From 47b96f06a15ac2e80037deea2b032d4c5489e09a Mon Sep 17 00:00:00 2001
From: ibizaman <ibizapeanut@gmail.com>
Date: Sat, 21 Sep 2024 06:30:18 +0200
Subject: [PATCH] add secret contract and use it in ldap block

---
 docs/contracts.md                        |   5 +
 docs/default.nix                         |   5 +
 modules/blocks/ldap.nix                  |  28 +++++
 modules/contracts/default.nix            |   1 +
 modules/contracts/secret.nix             |  38 ++++++
 modules/contracts/secret/docs/default.md | 150 +++++++++++++++++++++++
 modules/contracts/secret/dummyModule.nix |  10 ++
 7 files changed, 237 insertions(+)
 create mode 100644 modules/contracts/secret.nix
 create mode 100644 modules/contracts/secret/docs/default.md
 create mode 100644 modules/contracts/secret/dummyModule.nix

diff --git a/docs/contracts.md b/docs/contracts.md
index d241b90..919ef84 100644
--- a/docs/contracts.md
+++ b/docs/contracts.md
@@ -23,6 +23,7 @@ Provided contracts are:
   Two implementations are provided: self-signed and Let's Encrypt.
 - [Backup contract](contracts-backup.html) to backup directories.
   This contract allows to backup multiple times the same directories for extra protection.
+- [Secret contract](contracts-secret.html) to provide secrets that are deployed outside of the Nix store.
 
 ```{=include=} chapters html:into-file=//contracts-ssl.html
 modules/contracts/ssl/docs/default.md
@@ -32,6 +33,10 @@ modules/contracts/ssl/docs/default.md
 modules/contracts/backup/docs/default.md
 ```
 
+```{=include=} chapters html:into-file=//contracts-secret.html
+modules/contracts/secret/docs/default.md
+```
+
 ## Why do we need this new concept? {#contracts-why}
 
 Currently in nixpkgs, every module needing access to a shared resource must implement the logic
diff --git a/docs/default.nix b/docs/default.nix
index e411647..b76fed5 100644
--- a/docs/default.nix
+++ b/docs/default.nix
@@ -160,6 +160,11 @@ in stdenv.mkDerivation {
         '@OPTIONS_JSON@' \
        ${individualModuleOptionsDocs [ ../modules/contracts/backup/dummyModule.nix ]}/share/doc/nixos/options.json
 
+    substituteInPlace ./modules/contracts/secret/docs/default.md \
+      --replace \
+        '@OPTIONS_JSON@' \
+       ${individualModuleOptionsDocs [ ../modules/contracts/secret/dummyModule.nix ]}/share/doc/nixos/options.json
+
     substituteInPlace ./modules/contracts/ssl/docs/default.md \
       --replace \
         '@OPTIONS_JSON@' \
diff --git a/modules/blocks/ldap.nix b/modules/blocks/ldap.nix
index 20a1040..ffc8d75 100644
--- a/modules/blocks/ldap.nix
+++ b/modules/blocks/ldap.nix
@@ -57,6 +57,34 @@ in
       description = "File containing the JWT secret.";
     };
 
+    secret = {
+      ldapUserPasswordFile = lib.mkOption {
+        type = contracts.secret;
+        description = ''
+          Secret configuration for the file containing the LDAP admin user password.
+        '';
+        default = {
+          mode = "0440";
+          owner = "lldap";
+          group = "lldap";
+          restartUnits = [ "lldap.service" ];
+        };
+      };
+
+      jwtSecretFile = lib.mkOption {
+        type = contracts.secret;
+        description = ''
+          Secret configuration for the file containing the JWT secret.
+        '';
+        default = {
+          mode = "0440";
+          owner = "lldap";
+          group = "lldap";
+          restartUnits = [ "lldap.service" ];
+        };
+      };
+    };
+
     restrictAccessIPRange = lib.mkOption {
       type = lib.types.nullOr lib.types.str;
       description = "Set a local network range to restrict access to the UI to only those IPs.";
diff --git a/modules/contracts/default.nix b/modules/contracts/default.nix
index e748008..7fb8cdb 100644
--- a/modules/contracts/default.nix
+++ b/modules/contracts/default.nix
@@ -2,5 +2,6 @@
 {
   backup = import ./backup.nix { inherit lib; };
   mount = import ./mount.nix { inherit lib; };
+  secret = import ./secret.nix { inherit lib; };
   ssl = import ./ssl.nix { inherit lib; };
 }
diff --git a/modules/contracts/secret.nix b/modules/contracts/secret.nix
new file mode 100644
index 0000000..217da53
--- /dev/null
+++ b/modules/contracts/secret.nix
@@ -0,0 +1,38 @@
+{ lib, ... }:
+lib.types.submodule {
+  freeformType = lib.types.anything;
+
+  options = {
+    mode = lib.mkOption {
+      description = ''
+        Mode of the secret file.
+      '';
+      type = lib.types.str;
+      default = "0400";
+    };
+
+    owner = lib.mkOption {
+      description = ''
+        Linux user owning the secret file.
+      '';
+      type = lib.types.str;
+      default = "root";
+    };
+
+    group = lib.mkOption {
+      description = ''
+        Linux group owning the secret file.
+      '';
+      type = lib.types.str;
+      default = "root";
+    };
+
+    restartUnits = lib.mkOption {
+      description = ''
+        Systemd units to restart after the secret is updated.
+      '';
+      type = lib.types.listOf lib.types.str;
+      default = [];
+    };
+  };
+}
diff --git a/modules/contracts/secret/docs/default.md b/modules/contracts/secret/docs/default.md
new file mode 100644
index 0000000..44fb517
--- /dev/null
+++ b/modules/contracts/secret/docs/default.md
@@ -0,0 +1,150 @@
+# Secret Contract {#secret-contract}
+
+This NixOS contract represents a secret file
+that must be created out of band, from outside the nix store,
+and that must be placed in an expected location with expected permission.
+
+It is a contract between a service that needs a secret
+and a service that will provide the secret.
+All options in this contract should be set by the former.
+The latter will then use the values of those options to know where to produce the file.
+
+## Contract Reference {#secret-contract-options}
+
+These are all the options that are expected to exist for this contract to be respected.
+
+```{=include=} options
+id-prefix: contracts-secret-options-
+list-id: selfhostblocks-options
+source: @OPTIONS_JSON@
+```
+
+## Usage {#secret-contract-usage}
+
+A service that needs access to a secret will provide one or more `secret` option.
+
+Here is an example module defining two `secret` options:
+
+```nix
+{
+  options = {
+    myservice.secret = lib.mkOption {
+      type = lib.types.submodule {
+        options = {
+          adminPassword = lib.mkOption {
+            type = contracts.secret;
+            readOnly = true;
+            default = {
+              owner = "myservice";
+              group = "myservice";
+              mode = "0440";
+              restartUnits = [ "myservice.service" ];
+            };
+          };
+          databasePassword = lib.mkOption {
+            type = contracts.secret;
+            readOnly = true;
+            default = {
+              owner = "myservice";
+              restartUnits = [ "myservice.service" "mysql.service" ];
+            };
+          };
+        };
+      };
+    };
+  };
+};
+```
+
+As you can see, NixOS modules are a bit abused to make contracts work.
+Default values are set as well as the `readOnly` attribute to ensure those values stay as defined.
+
+Now, on the other side we have a service that uses these `secret` options and provides the secrets
+Let's assume such a module is available under the `secretservice` option
+and that one can create multiple instances under `secretservice.instances`.
+Then, to actually provide the secrets defined above, one would write:
+
+```nix
+secretservice.instances.adminPassword = myservice.secret.adminPassword // {
+  enable = true;
+
+  secretFile = ./secret.yaml;
+
+  # ... Other options specific to secretservice.
+};
+
+secretservice.instances.databasePassword = myservice.secret.databasePassword // {
+  enable = true;
+
+  secretFile = ./secret.yaml;
+
+  # ... Other options specific to secretservice.
+};
+```
+
+Assuming the `secretservice` module accepts default options,
+the above snippet could be reduced to:
+
+```nix
+secretservice.default.secretFile = ./secret.yaml;
+
+secretservice.instances.adminPassword = myservice.secret.adminPassword;
+secretservice.instances.databasePassword = myservice.secret.databasePassword;
+```
+
+### With sops-nix {#secret-contract-usage-sopsnix}
+
+For a concrete example, let's provide the [ldap SHB module][ldap-module] option `ldapUserPasswordFile`
+with a secret managed by [sops-nix][].
+
+[ldap-module]: TODO
+[sops-nix]: TODO
+
+Without the secret contract, configuring the option would look like so:
+
+```nix
+sops.secrets."ldap/user_password" = {
+  sopsFile = ./secrets.yaml;
+  mode = "0440";
+  owner = "lldap";
+  group = "lldap";
+  restartUnits = [ "lldap.service" ];
+};
+
+shb.ldap.ldapUserPasswordFile = config.sops.secrets."ldap/user_password".path;
+```
+
+We can already see the problem here.
+How does the end user know what values to give to the
+`mode`, `owner`, `group` and `restartUnits` options?
+If lucky, the documentation of the option would tell them
+or more likely, they will need to figure it out by looking
+at the module source code. Not a great user experience.
+
+Now, with this contract, the configuration becomes:
+
+```nix
+sops.secrets."ldap/user_password" = config.shb.ldap.secret.ldapUserPasswordFile // {
+  sopsFile = ./secrets.yaml;
+};
+
+shb.ldap.ldapUserPasswordFile = config.sops.secrets."ldap/user_password".path;
+```
+
+The issue is now gone.
+The module maintainer is now in charge of describing
+how the module expects the secret to be provided.
+
+If taking advantage of the `sops.defaultSopsFile` option like so:
+
+```nix
+sops.defaultSopsFile = ./secrets.yaml;
+```
+
+Then the snippet above is even more simplified:
+
+```nix
+sops.secrets."ldap/user_password" = config.shb.ldap.secret.ldapUserPasswordFile;
+
+shb.ldap.ldapUserPasswordFile = config.sops.secrets."ldap/user_password".path;
+```
diff --git a/modules/contracts/secret/dummyModule.nix b/modules/contracts/secret/dummyModule.nix
new file mode 100644
index 0000000..d884b89
--- /dev/null
+++ b/modules/contracts/secret/dummyModule.nix
@@ -0,0 +1,10 @@
+{ pkgs, lib, ... }:
+let
+  contracts = pkgs.callPackage ../. {};
+in
+{
+  options.shb.contracts.secret = lib.mkOption {
+    description = "Contract for secrets.";
+    type = contracts.secret;
+  };
+}