Secret Contract

Table of Contents

Motivation
Contract Reference
Usage

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.

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.

Motivation

Let’s provide the ldap SHB module option ldapUserPasswordFile with a secret managed by sops-nix.

Without the secret contract, configuring the option would look like so:

sops.secrets."ldap/user_password" = {
  mode = "0440";
  owner = "lldap";
  group = "lldap";
  restartUnits = [ "lldap.service" ];
  sopsFile = ./secrets.yaml;
};

shb.ldap.userPassword.result = config.sops.secrets."ldap/user_password".result;

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.

Now, with this contract, a layer on top of sops is added which is found under shb.sops. The configuration then becomes:

shb.sops.secrets."ldap/user_password" = {
  request = config.shb.ldap.userPassword.request;
  settings.sopsFile = ./secrets.yaml;
};

shb.ldap.userPassword.result = config.shb.sops.secrets."ldap/user_password".result;

The issue is now gone as the responsibility falls on the module maintainer for describing how the secret should be provided.

If taking advantage of the sops.defaultSopsFile option like so:

sops.defaultSopsFile = ./secrets.yaml;

Then the snippet above is even more simplified:

shb.sops.secrets."ldap/user_password".request = config.shb.ldap.userPassword.request;

shb.ldap.userPassword.result = config.shb.sops.secrets."ldap/user_password".result;

Contract Reference

These are all the options that are expected to exist for this contract to be respected.

shb.contracts.secret

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.

Type: submodule

Declared by:

<selfhostblocks/modules/contracts/secret/dummyModule.nix>
shb.contracts.secret.request

Request part of the secret contract.

Options set by the requester module enforcing some properties the secret should have.

Type: submodule

Default: ""

Declared by:

<selfhostblocks/modules/contracts/secret/dummyModule.nix>
shb.contracts.secret.request.group

Linux group owning the secret file.

Type: string

Default: "root"

Declared by:

<selfhostblocks/modules/contracts/secret/dummyModule.nix>
shb.contracts.secret.request.mode

Mode of the secret file.

Type: string

Default: "0400"

Declared by:

<selfhostblocks/modules/contracts/secret/dummyModule.nix>
shb.contracts.secret.request.owner

Linux user owning the secret file.

Type: string

Default: "root"

Declared by:

<selfhostblocks/modules/contracts/secret/dummyModule.nix>
shb.contracts.secret.request.restartUnits

Systemd units to restart after the secret is updated.

Type: list of string

Default: [ ]

Declared by:

<selfhostblocks/modules/contracts/secret/dummyModule.nix>
shb.contracts.secret.result

Result part of the secret contract.

Options set by the provider module that indicates where the secret can be found.

Type: submodule

Default:

{
  path = "/run/secrets/secret";
}

Declared by:

<selfhostblocks/modules/contracts/secret/dummyModule.nix>
shb.contracts.secret.result.path

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.

Type: path

Default: "/run/secrets/secret"

Declared by:

<selfhostblocks/modules/contracts/secret/dummyModule.nix>
shb.contracts.secret.settings

Optional attribute set with options specific to the provider.

Type: anything

Declared by:

<selfhostblocks/modules/contracts/secret/dummyModule.nix>

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

Here is an example module requesting two secrets through the secret contract.

{ config, ... }:
let
  inherit (lib) mkOption;
  inherit (lib.types) submodule;
in
{
  options = {
    myservice = mkOption {
      type = submodule {
        options = {
          adminPassword = contracts.secret.mkRequester {
            owner = "myservice";
            group = "myservice";
            mode = "0440";
            restartUnits = [ "myservice.service" ];
          };
          databasePassword = contracts.secret.mkRequester {
            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

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.

{ config, ... }:
let
  inherit (lib) mkOption;
  inherit (lib.types) attrsOf submodule;

  contracts = pkgs.callPackage ./contracts {};
in
{
  options.secretservice.secret = mkOption {
    description = "Secret following the secret contract.";
    default = {};
    type = attrsOf (submodule ({ name, options, ... }: {
      options = contracts.secret.mkProvider {
        settings = mkOption {
          description = ''
            Settings specific to the secrets provider.
          '';

          type = submodule {
            options = {
              secretFile = lib.mkOption {
                description = "File containing the encrypted secret.";
                type = lib.types.path;
              };
            };
          };
        };

        resultCfg = {
          path = "/run/secrets/${name}";
          pathText = "/run/secrets/<name>";
        };
      };
    }));
  };

  config = {
    // ...
  };
}

End User

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. And then they will give back the result to the requester module myservice.

secretservice.secret."adminPassword" = {
  request = myservice.adminPasswor".request;
  settings.secretFile = ./secret.yaml;
};
myservice.adminPassword.result = secretservice.secret."adminPassword".result;

secretservice.secret."databasePassword" = {
  request = myservice.databasePassword.request;
  settings.secretFile = ./secret.yaml;
};
myservice.databasePassword.result = secretservice.service."databasePassword".result;

Assuming the secretservice module accepts default options, the above snippet could be reduced to:

secretservice.default.secretFile = ./secret.yaml;

secretservice.secret."adminPassword".request = myservice.adminPasswor".request;
myservice.adminPassword.result = secretservice.secret."adminPassword".result;

secretservice.secret."databasePassword".request = myservice.databasePassword.request;
myservice.databasePassword.result = secretservice.service."databasePassword".result;

The plumbing of request from the requester to the provider and then the result from the provider back to the requester is quite explicit in this snippet.