feat: add sops

We've previously used SOPS for our secrets management and liked it. We
did, however, find the configuration was a bit annoying to do. In aid of
this, we've made a SOPS module that we find a little easier to make the
sort of configurations we want without creating so much mess.

We haven't set up scalpel/equivalent yet - we intend to avoid it if at
all possible. It isn't necessarily out-of-scope but it isn't included in
our current SOPS plans.

Change-Id: I35b9c7e94c12a4f1360833026efe06803d59626e
Reviewed-on: https://git.clicks.codes/c/Infra/NixFiles/+/725
Reviewed-by: Samuel Shuert <coded@clicks.codes>
Tested-by: Samuel Shuert <coded@clicks.codes>
diff --git a/modules/nixos/clicks/secrets/default.nix b/modules/nixos/clicks/secrets/default.nix
new file mode 100644
index 0000000..19f01b4
--- /dev/null
+++ b/modules/nixos/clicks/secrets/default.nix
@@ -0,0 +1,138 @@
+# SPDX-FileCopyrightText: 2024 Clicks Codes
+#
+# SPDX-License-Identifier: GPL-3.0-only
+
+{
+  lib,
+  pkgs,
+  config,
+  ...
+}:
+let
+  cfg = config.clicks.security.sops;
+
+  guessFormat =
+    extension:
+    if extension == "json" then
+      "json"
+    else if extension == "yaml" || extension == "yml" then
+      "yaml"
+    else if extension == "env" then
+      "dotenv"
+    else if extension == "ini" then
+      "ini"
+    else
+      "binary";
+
+  getExtension =
+    filePath:
+    let
+      pathParts = builtins.split ''\.'' (builtins.toString filePath);
+      numPathParts = builtins.length pathParts;
+    in
+    builtins.elemAt pathParts (numPathParts - 1);
+in
+{
+  options.clicks.secrets =
+    let
+      generateNonBinarySopsPaths =
+        file: keys:
+        lib.lists.forEach keys (key: {
+          name = key;
+          value = config.sops.secrets."${lib.clicks.secrets.name file}:${key}".path;
+        });
+    in
+    lib.mkOption {
+      type = lib.types.attrsOf (
+        lib.types.submodule (
+          { ... }@submodule:
+          {
+            options = {
+              file = lib.mkOption {
+                type = lib.types.pathInStore;
+                description = "The store path to your secrets file";
+              };
+              group = lib.mkOption {
+                type = lib.types.str;
+                description = "The user the secret should be owned by.";
+                default = "root";
+              };
+              keys = lib.mkOption {
+                type = lib.types.nullOr (lib.types.listOf lib.types.str);
+                description = "List of keys to pull from the structured data.";
+                default = null;
+              };
+              neededForUsers = lib.mkEnableOption "This secret is needed for users";
+              paths = lib.mkOption {
+                type = lib.types.nullOr (lib.types.attrsOf lib.types.str);
+                description = "Automatically populated with the SOPS paths to your keys, null if you are using binary secrets";
+                default =
+                  if guessFormat (getExtension submodule.config.file) != "binary" then
+                    builtins.listToAttrs (generateNonBinarySopsPaths submodule.config.file submodule.config.keys)
+                  else
+                    null;
+              };
+              path = lib.mkOption {
+                type = lib.types.nullOr lib.types.str;
+                description = "Populated automatically with the SOPS path of the secret, null if you are using non binary secrets";
+                default =
+                  if guessFormat (getExtension submodule.config.file) == "binary" then
+                    config.sops.secrets.${lib.clicks.secrets.name submodule.config.file}.path
+                  else
+                    null;
+              };
+            };
+          }
+        )
+      );
+      description = "";
+      default = { };
+    };
+
+  config =
+    let
+      generateBinarySopsSecret = secret: {
+        name = lib.clicks.secrets.name secret.value.file;
+        value = {
+          mode = "0400";
+          owner = config.users.users.root.name;
+          group = config.users.groups.${secret.value.group}.name;
+          sopsFile = secret.value.file;
+          format = guessFormat (getExtension secret.value.file);
+          inherit (secret.value) neededForUsers;
+        };
+      };
+
+      generateNonBinarySopsSecrets =
+        secret:
+        lib.lists.forEach secret.value.keys (key: {
+          name = "${lib.clicks.secrets.name secret.value.file}:${key}";
+          value = {
+            mode = "0040";
+            owner = config.users.users.root.name;
+            group = config.users.groups.${secret.value.group}.name;
+            sopsFile = secret.value.file;
+            format = guessFormat (getExtension secret.value.file);
+            inherit (secret.value) neededForUsers;
+            inherit key;
+          };
+        });
+
+      secretsAsList = lib.attrsets.attrsToList config.clicks.secrets;
+
+      secretsAsSops = lib.pipe secretsAsList [
+        (map (
+          secret:
+          if guessFormat (getExtension secret.value.file) == "binary" then
+            generateBinarySopsSecret secret
+          else
+            generateNonBinarySopsSecrets secret
+        ))
+        lib.flatten
+        builtins.listToAttrs
+      ];
+    in
+    {
+      sops.secrets = secretsAsSops;
+    };
+}