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; + }; +}