1
0
Fork 0

update secret contract (#311)

This makes the secret contract better (IMNSHO):

- Improves documentation, explains better the reasoning behind the
contract.
- Makes it easier to create an option implementing the secret contract.
This commit is contained in:
Pierre Penninckx 2024-10-01 23:01:00 +02:00 committed by GitHub
parent fb890645bf
commit 5a0ae36c85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 289 additions and 177 deletions

View file

@ -15,6 +15,8 @@
- `shb.authelia.oidcClients.secret` -> `shb.authelia.oidcClients.client_secret`
- `shb.authelia.ldapEndpoint` -> `shb.authelia.ldapHostname` and `shb.authelia.ldapPort`
- Make Nextcloud automatically disable maintenance mode upon service restart.
- `shb.ldap.ldapUserPasswordFile` -> `shb.ldap.ldapUserPassword.result.path`
- `shb.ldap.jwtSecretFile` -> `shb.ldap.jwtSecret.result.path`
## User Facing Backwards Compatible Changes

View file

@ -47,43 +47,21 @@ in
default = 17170;
};
ldapUserPasswordFile = lib.mkOption {
type = lib.types.path;
description = "File containing the LDAP admin user password.";
};
jwtSecretFile = lib.mkOption {
type = lib.types.path;
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 = {
ldapUserPassword = contracts.secret.mkOption {
description = "LDAP admin user secret.";
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 = {
jwtSecret = contracts.secret.mkOption {
description = "JWT secret.";
mode = "0440";
owner = "lldap";
group = "lldap";
restartUnits = [ "lldap.service" ];
};
};
};
restrictAccessIPRange = lib.mkOption {
type = lib.types.nullOr lib.types.str;
@ -174,8 +152,8 @@ in
enable = true;
environment = {
LLDAP_JWT_SECRET_FILE = toString cfg.jwtSecretFile;
LLDAP_LDAP_USER_PASS_FILE = toString cfg.ldapUserPasswordFile;
LLDAP_JWT_SECRET_FILE = toString cfg.jwtSecret.result.path;
LLDAP_LDAP_USER_PASS_FILE = toString cfg.ldapUserPassword.result.path;
RUST_LOG = lib.mkIf cfg.debug "debug";
};

View file

@ -1,5 +1,44 @@
{ lib, ... }:
lib.types.submodule {
{
mkOption =
{ description,
mode ? "0400",
owner ? "root",
group ? "root",
restartUnits ? [],
}: lib.mkOption {
inherit description;
type = lib.types.submodule {
options = {
request = lib.mkOption {
default = {
inherit mode owner group restartUnits;
};
readOnly = true;
description = ''
Options set by the requester module
enforcing some properties the secret should have.
Use the `contracts.secret.mkOption` function to
create a secret option for a requester module.
See the [requester usage section](contracts-secret.html#secret-contract-usage-requester) for an example.
Some providers will need more options to be defined and this is allowed.
These extra options will be set by the user.
For example, the `sops` implementation requires to be given
the sops key in which the secret is encrypted.
`request` options are set read-only
because they must be set through option defaults,
they shouldn't be changed in the `config` section.
This would otherwise lead to infinite recursion
during evaluation.
This is handled automatically when using the `contracts.secret.mkOption` function.
'';
type = lib.types.submodule {
freeformType = lib.types.anything;
options = {
@ -8,7 +47,7 @@ lib.types.submodule {
Mode of the secret file.
'';
type = lib.types.str;
default = "0400";
default = mode;
};
owner = lib.mkOption {
@ -16,7 +55,7 @@ lib.types.submodule {
Linux user owning the secret file.
'';
type = lib.types.str;
default = "root";
default = owner;
};
group = lib.mkOption {
@ -24,7 +63,7 @@ lib.types.submodule {
Linux group owning the secret file.
'';
type = lib.types.str;
default = "root";
default = group;
};
restartUnits = lib.mkOption {
@ -32,7 +71,31 @@ lib.types.submodule {
Systemd units to restart after the secret is updated.
'';
type = lib.types.listOf lib.types.str;
default = [];
default = restartUnits;
};
};
};
};
result = lib.mkOption {
description = ''
Options set by the provider module that indicates where the secret can be found.
'';
type = lib.types.submodule {
options = {
path = lib.mkOption {
type = lib.types.path;
description = ''
Path to the file containing the secret generated out of band.
This path will exist after deploying to a target host,
it is not available through the nix store.
'';
};
};
};
};
};
};
};
}

View file

@ -1,100 +1,15 @@
# Secret Contract {#secret-contract}
This NixOS contract represents a secret file
that must be created out of band, from outside the nix store,
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.
More formally, this contract is made between a requester module - the one needing a secret -
and a provider module - the one creating the secret and making it available.
## Contract Reference {#secret-contract-options}
## Problem Statement {#secret-contract-problem}
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`
Let's provide the [ldap SHB module][ldap-module] option `ldapUserPasswordFile`
with a secret managed by [sops-nix][].
[ldap-module]: TODO
@ -114,24 +29,26 @@ sops.secrets."ldap/user_password" = {
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
The problem this contract intends to fix is how to ensure
the end user knows 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.
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 // {
sops.secrets."ldap/user_password" = config.shb.ldap.secret.ldapUserPassword.request // {
sopsFile = ./secrets.yaml;
};
shb.ldap.ldapUserPasswordFile = config.sops.secrets."ldap/user_password".path;
shb.ldap.ldapUserPassword.result.path = config.sops.secrets."ldap/user_password".path;
```
The issue is now gone.
The issue is now gone at the expense of some plumbing.
The module maintainer is now in charge of describing
how the module expects the secret to be provided.
@ -144,7 +61,148 @@ sops.defaultSopsFile = ./secrets.yaml;
Then the snippet above is even more simplified:
```nix
sops.secrets."ldap/user_password" = config.shb.ldap.secret.ldapUserPasswordFile;
sops.secrets."ldap/user_password" = config.shb.ldap.secret.ldapUserPassword.request;
shb.ldap.ldapUserPasswordFile = config.sops.secrets."ldap/user_password".path;
shb.ldap.ldapUserPassword.result.path = config.sops.secrets."ldap/user_password".path;
```
## 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 contract involves 3 parties:
- The implementer of a requester module.
- The implementer of a provider module.
- The end user which sets up the requester module and picks a provider implementation.
The usage of this contract is similarly separated into 3 sections.
### Requester Module {#secret-contract-usage-requester}
Here is an example module requesting two secrets through the `secret` contract.
```nix
{ config, ... }:
{
options = {
myservice = lib.mkOption {
type = lib.types.submodule {
options = {
adminPassword = contracts.secret.mkOption {
owner = "myservice";
group = "myservice";
mode = "0440";
restartUnits = [ "myservice.service" ];
};
databasePassword = contracts.secret.mkOption {
owner = "myservice";
# group defaults to "root"
# mode defaults to "0400"
restartUnits = [ "myservice.service" "mysql.service" ];
};
};
};
};
};
config = {
// Do something with the secrets, available at:
// config.myservice.adminPassword.result.path
// config.myservice.databasePassword.result.path
};
};
```
### Provider Module {#secret-contract-usage-provider}
Now, on the other side, we have a module that uses those options and provides a secret.
Let's assume such a module is available under the `secretservice` option
and that one can create multiple instances.
```nix
{ config, ... }:
{
options = {
secretservice = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
mode = lib.mkOption {
description = "Mode of the secret file.";
type = lib.types.str;
};
owner = lib.mkOption {
description = "Linux user owning the secret file.";
type = lib.types.str;
};
group = lib.mkOption {
description = "Linux group owning the secret file.";
type = lib.types.str;
};
restartUnits = lib.mkOption {
description = "Systemd units to restart after the secret is updated.";
type = lib.types.listOf lib.types.str;
};
path = lib.mkOption {
description = "Path where the secret file will be located.";
type = lib.types.str;
};
// The contract allows more options to be defined to accomodate specific implementations.
secretFile = lib.mkOption {
description = "File containing the encrypted secret.";
type = lib.types.path;
};
};
});
};
};
}
```
### End User {#secret-contract-usage-enduser}
The end user's responsibility is now to do some plumbing.
They will setup the provider module - here `secretservice` - with the options set by the requester module,
while also setting other necessary options to satisfy the provider service.
```nix
secretservice.adminPassword = myservice.secret.adminPassword.request // {
secretFile = ./secret.yaml;
};
secretservice.databasePassword = myservice.secret.databasePassword.request // {
secretFile = ./secret.yaml;
};
```
Assuming the `secretservice` module accepts default options,
the above snippet could be reduced to:
```nix
secretservice.default.secretFile = ./secret.yaml;
secretservice.adminPassword = myservice.secret.adminPassword.request;
secretservice.databasePassword = myservice.secret.databasePassword.request;
```
Then they will setup the requester module - here `myservice` - with the result of the provider module.
```nix
myservice.secret.adminPassword.result.path = secretservice.adminPassword.result.path;
myservice.secret.databasePassword.result.path = secretservice.adminPassword.result.path;
```

View file

@ -3,8 +3,19 @@ let
contracts = pkgs.callPackage ../. {};
in
{
options.shb.contracts.secret = lib.mkOption {
description = "Contract for secrets.";
type = contracts.secret;
options.shb.contracts.secret = contracts.secret.mkOption {
description = ''
Contract for secrets between a requester module
and a provider module.
The requester communicates to the provider
some properties the secret should have
through the `request` options.
The provider reads from the `request` options
and creates the secret as requested.
It then communicates to the requester where the secret can be found
through the `result` options.
'';
};
}

View file

@ -32,8 +32,8 @@ in
dcdomain = "dc=example,dc=com";
subdomain = "ldap";
domain = "machine.com";
ldapUserPasswordFile = pkgs.writeText "user_password" ldapAdminPassword;
jwtSecretFile = pkgs.writeText "jwt_secret" "securejwtsecret";
ldapUserPassword.result.path = pkgs.writeText "user_password" ldapAdminPassword;
jwtSecret.result.path = pkgs.writeText "jwt_secret" "securejwtsecret";
};
shb.authelia = {

View file

@ -23,8 +23,8 @@ in
dcdomain = "dc=example,dc=com";
subdomain = "ldap";
domain = "example.com";
ldapUserPasswordFile = pkgs.writeText "user_password" "securepw";
jwtSecretFile = pkgs.writeText "jwt_secret" "securejwtsecret";
ldapUserPassword.result.path = pkgs.writeText "user_password" "securepw";
jwtSecret.result.path = pkgs.writeText "jwt_secret" "securejwtsecret";
debug = true;
};
networking.firewall.allowedTCPPorts = [ 80 ]; # nginx port

View file

@ -154,8 +154,8 @@ in
ldapPort = 3890;
webUIListenPort = 17170;
dcdomain = "dc=example,dc=com";
ldapUserPasswordFile = pkgs.writeText "ldapUserPassword" "ldapUserPassword";
jwtSecretFile = pkgs.writeText "jwtSecret" "jwtSecret";
ldapUserPassword.result.path = pkgs.writeText "ldapUserPassword" "ldapUserPassword";
jwtSecret.result.path = pkgs.writeText "jwtSecret" "jwtSecret";
};
};

View file

@ -57,7 +57,7 @@ let
host = "127.0.0.1";
port = config.shb.ldap.ldapPort;
dcdomain = config.shb.ldap.dcdomain;
adminPasswordFile = config.shb.ldap.ldapUserPasswordFile;
adminPasswordFile = config.shb.ldap.ldapUserPassword.result.path;
};
};
};

View file

@ -43,7 +43,7 @@ let
host = "127.0.0.1";
port = config.shb.ldap.ldapPort;
dcdomain = config.shb.ldap.dcdomain;
passwordFile = config.shb.ldap.ldapUserPasswordFile;
passwordFile = config.shb.ldap.ldapUserPassword.result.path;
};
};
};

View file

@ -152,7 +152,7 @@ let
port = config.shb.ldap.ldapPort;
dcdomain = config.shb.ldap.dcdomain;
adminName = "admin";
adminPasswordFile = config.shb.ldap.ldapUserPasswordFile;
adminPasswordFile = config.shb.ldap.ldapUserPassword.result.path;
userGroup = "nextcloud_user";
};
};