feat(terraform): support declarative subCAs and their Vault policies

We can now derive an infinite amount of subCAs as long as we do not
violate extensions constraints.

Additionally, we can build Vault policies specific to the PKI endpoint
without encoding the mountpoints.

Additionally, we can build Vault roles specific to the PKI endpoint
without encoding the mountpoints.

This adds an example of deep-derivation.

Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
This commit is contained in:
raito 2024-12-31 18:48:34 +01:00
parent efeeecb7e2
commit c9aa82ba49
9 changed files with 474 additions and 41 deletions

View file

@ -140,6 +140,7 @@
});
nixosConfigurations = (colmena.lib.makeHive self.outputs.colmena).nodes;
terraformConfiguration = forEachSystem' ({ terraformCfg, ... }: terraformCfg);
colmena = let
commonModules = [

View file

@ -36,6 +36,49 @@
signedCert = ../../pki/cacerts/ica1.crt;
};
ica2.enable = true;
ica2 = {
enable = true;
subCA = {
# Derive an infra CA from ICA2.
infra = {
enable = true;
enableVersioning = true;
cn = "Intermediate Infrastructure CA";
# For now, let's keep it rolling every year.
expiry.days = 365;
# Cannot emit any further CA from there.
extensions.pathlen = 0;
policies = {
ci = {
# This allows the CI to issue certificates for CI purposes.
# It should be a relative path.
"pki/issue/ci".capabilities = [ "read" "create" "update" ];
};
};
roles = {
ci = {
ttl = "6h";
max_ttl = "15d";
allowed_domains = [ "*.ofborg.infra.forkos" ];
allow_subdomains = true;
allow_glob_domains = true;
allow_wildcard_certificates = false;
ou = [ "Floral Systems Continuous Integration Systems" ];
};
};
# It's possible to continue the chain but we don't need that here.
# subCA.rabbitmq = {
# enable = true;
# enableVersioning = true;
# cn = "RabbitMQ infrastructure intermediate CA";
# expiry.days = 10;
# extensions.pathlen = 0;
# };
};
};
};
};
}

View file

@ -12,5 +12,6 @@ in
imports = [
./policy.nix
./sub-ca.nix
];
}

View file

@ -1,10 +1,9 @@
{ config, lib, ... }:
let
inherit (lib) tf mkOption mkEnableOption types mkIf optional;
inherit (lib) tf mkOption mkEnableOption types mkIf;
parentConf = config.infra.pki;
cfgIca1 = config.infra.pki.ica1;
cfg = config.infra.pki.ica2;
signId = "${cfg.resourceId}_by_ica1_v${toString cfgIca1.certVersion}";
hour_in_secs = 1 * 60 * 60;
year_in_secs = 365 * 24 * hour_in_secs;
in
@ -12,9 +11,33 @@ in
options.infra.pki.ica2 = {
enable = mkEnableOption "provision an ICA2";
enableVersioning = mkOption {
type = types.bool;
default = true;
readOnly = true;
};
name = mkOption {
type = types.str;
default = "ica2";
readOnly = true;
};
partialResourceId = mkOption {
type = types.str;
default = "v${toString cfg.version}_ica2_v${toString cfg.certVersion}";
readOnly = true;
};
resourceId = mkOption {
type = types.str;
default = "${parentConf.org.id}_v${toString cfg.version}_ica2_v${toString cfg.certVersion}";
default = "${parentConf.org.id}_${cfg.partialResourceId}";
readOnly = true;
};
signId = mkOption {
type = types.str;
default = "${cfg.resourceId}_by_ica1_v${toString cfgIca1.certVersion}";
readOnly = true;
};
@ -29,6 +52,12 @@ in
default = 1;
description = "Version number of the same ICA2 chain for the certificate itself";
};
subCA = mkOption {
type = types.lazyAttrsOf (types.submodule (import ./sub-ca-options.nix));
default = { };
description = "Sub-CAs derived from ICA2";
};
};
config = mkIf cfg.enable {
@ -52,7 +81,7 @@ in
inherit (parentConf.org) ou organization country locality province;
};
resource.vault_pki_secret_backend_root_sign_intermediate."${signId}" = {
resource.vault_pki_secret_backend_root_sign_intermediate."${cfg.signId}" = {
inherit (parentConf) provider;
depends_on = [
"vault_mount.${cfg.resourceId}"
@ -67,13 +96,13 @@ in
ttl = 1 * year_in_secs;
};
resource.vault_pki_secret_backend_intermediate_set_signed."${cfg.resourceId}_signed_cert" = mkIf (cfgIca1.signedCert != null) {
resource.vault_pki_secret_backend_intermediate_set_signed."${cfg.resourceId}_signed_cert" = {
inherit (parentConf) provider;
depends_on = [ "vault_pki_secret_backend_root_sign_intermediate.${signId}" ];
depends_on = [ "vault_pki_secret_backend_root_sign_intermediate.${cfg.signId}" ];
backend = tf.ref "vault_mount.${cfg.resourceId}.path";
certificate = ''
${tf.ref "vault_pki_secret_backend_root_sign_intermediate.${signId}.certificate"}
${tf.ref "vault_pki_secret_backend_root_sign_intermediate.${cfg.signId}.certificate"}
${builtins.readFile cfgIca1.signedCert}
'';
};

View file

@ -0,0 +1,36 @@
{ lib, ... }:
let
inherit (lib) mkOption types;
in
{
options = {
capabilities = mkOption {
type = types.listOf (types.enum [ "create" "read" "update" "patch" "delete" "list" "sudo" "deny" "subscribe" ]);
};
min_wrapping_ttl = mkOption {
type = types.nullOr types.int;
default = null;
};
max_wrapping_ttl = mkOption {
type = types.nullOr types.int;
default = null;
};
required_parameters = mkOption {
type = types.nullOr (types.listOf types.str);
default = null;
};
allowed_parameters = mkOption {
type = types.nullOr (types.attrsOf (types.listOf types.str));
default = null;
};
denied_parameters = mkOption {
type = types.nullOr (types.attrsOf (types.listOf types.str));
default = null;
};
};
}

View file

@ -1,38 +1,6 @@
{ config, lib, ... }:
let
inherit (lib) types mkOption mapAttrs concatStringsSep mapAttrsToList filterAttrs;
policyOpts = { ... }: {
options = {
capabilities = mkOption {
type = types.enum [ "create" "read" "update" "patch" "delete" "list" "sudo" "deny" "subscribe" ];
};
min_wrapping_ttl = mkOption {
type = types.nullOr types.int;
default = null;
};
max_wrapping_ttl = mkOption {
type = types.nullOr types.int;
default = null;
};
required_parameters = mkOption {
type = types.nullOr (types.listOf types.str);
default = null;
};
allowed_parameters = mkOption {
type = types.nullOr (types.attrsOf (types.listOf types.str));
default = null;
};
denied_parameters = mkOption {
type = types.nullOr (types.attrsOf (types.listOf types.str));
default = null;
};
};
};
cfg = config.infra.vault;
serializeRuleBody = body:
concatStringsSep "\n" (mapAttrsToList (name: value:
@ -52,8 +20,9 @@ let
in
{
options.infra.vault.policies = mkOption {
type = types.attrsOf (types.attrsOf (types.submodule policyOpts));
type = types.attrsOf (types.attrsOf (types.submodule (import ./policy-options.nix)));
description = "Vault policies, see https://developer.hashicorp.com/vault/docs/concepts/policies";
default = { };
};
config = {

View file

@ -0,0 +1,57 @@
{ name, lib, ... }:
let
inherit (lib) mkOption types;
in
{
options = {
name = mkOption {
type = types.str;
default = name;
internal = true;
readOnly = true;
};
issuer_ref = mkOption {
type = types.nullOr types.str;
internal = true;
default = null;
description = "Issuer reference for this role, defaults to the local sub-CA this role is written into";
};
ttl = mkOption {
type = types.str;
};
max_ttl = mkOption {
type = types.str;
};
allowed_domains = mkOption {
type = types.listOf types.str;
};
allow_subdomains = mkOption {
type = types.bool;
default = false;
};
allow_glob_domains = mkOption {
type = types.bool;
default = false;
};
allow_wildcard_certificates = mkOption {
type = types.bool;
default = false;
};
key_type = mkOption {
type = types.enum [ "ed25519" ];
default = "ed25519";
};
ou = mkOption {
type = types.listOf types.str;
};
};
}

View file

@ -0,0 +1,95 @@
# TODO: this file can generalize ICA2.
# ICA1 is more complicated due to being close to the bootstrap path (and offline parent).
# We still keep the nice names ICA1 & ICA2 as they are pretty standard.
{ name, config, lib, ... }:
let
inherit (lib) mkEnableOption mkOption types mkIf;
mkResourceId = { enableVersioning, certVersion, version, name }:
if enableVersioning then
"v${toString version}_${name}_v${toString certVersion}"
else
"unversioned_${name}";
in
{
options = {
enable = mkEnableOption "this sub-CA";
enableVersioning = mkEnableOption "versioning of this sub-CA";
version = mkOption {
type = types.int;
default = 1;
description = "Version number of this chain";
};
certVersion = mkOption {
type = types.int;
default = 1;
description = "Version number of this certificate";
};
partialResourceId = mkOption {
internal = true;
type = types.str;
};
name = mkOption {
readOnly = true;
type = types.str;
default = name;
description = "Name identifier for this CA";
};
cn = mkOption {
type = types.str;
description = "Common name prefix for this CA, versions may be added at the end";
};
keyType = mkOption {
# Give a good reason to extend this enum.
type = types.enum [ "ed25519" ];
default = "ed25519";
description = "Algorithm for the private key";
};
expiry.days = mkOption {
type = types.int;
default = 30;
description = "Expiry in days of this CA";
};
extensions = {
pathlen = mkOption {
type = types.int;
description = "Pathlen extension for this CA, e.g. how many sub-CA can this CA emit again?";
default = 0;
example = 1;
};
};
subCA = mkOption {
# Recursive type, let's go!
type = types.lazyAttrsOf (types.submodule (import ./sub-ca-options.nix));
default = {};
description = "Sub-CA of this CA";
};
policies = mkOption {
# TODO: Assert the inner attrs of has relative paths for the key type.
type = types.attrsOf (types.attrsOf (types.submodule (import ./policy-options.nix)));
default = { };
description = "PKI policies attached to this sub-CA";
};
roles = mkOption {
type = types.attrsOf (types.submodule (import ./role-options.nix));
default = { };
description = "PKI roles attached to this sub-CA";
};
};
config = mkIf config.enable {
partialResourceId = mkResourceId {
inherit (config) enableVersioning certVersion version name;
};
};
}

202
terraform/vault/sub-ca.nix Normal file
View file

@ -0,0 +1,202 @@
{ config, lib, ... }:
let
toplevelConfig = config;
inherit (lib) tf filterAttrs mapAttrs replaceChars addErrorContext concatMapAttrs mapAttrs';
# Create a Vault path for a certificate.
mkPath = { enableVersioning, name, version, certVersion, id }:
if enableVersioning then
"${id}/v${toString version}/${name}/v${toString certVersion}"
else
"${id}/unversioned/${name}";
mkCommonName = { enableVersioning, certVersion, prefix }:
if enableVersioning then
"${prefix} v${toString certVersion} "
else
"${prefix} unversioned ";
mkCsrCommonName = { parentConfig, chainVersion, enableVersioning, certVersion, prefix, name }:
if enableVersioning then
assert lib.assertMsg (parentConfig.enableVersioning) "Versioning requires all the chain to be versioned. You forgot to enable versioning on the parent of '${name}'!";
"${prefix} v${chainVersion}"
else
if parentConfig.enableVersioning then
"${prefix} v${toString parentConfig.chainVersion}.unversioned"
else
"${prefix} unversioned";
mkDescription = { enableVersioning, certVersion, version, name, displayName }:
if enableVersioning then
"PKI engine hosting v${toString version} ${name} v${toString certVersion} for ${displayName}"
else
"Unversioned PKI engine hosting ${name} for ${displayName}";
mkSignId = { parentConfig, enableVersioning, certVersion, resourceId }:
if enableVersioning then
"${resourceId}_by_${parentConfig.name}_v${toString certVersion}"
else
"${resourceId}_by_${parentConfig.name}_unversioned";
hour_in_secs = 1 * 60 * 60;
hour_in_days = 24;
day_in_secs = hour_in_days * hour_in_secs;
resourceIdAsPath = resourceId: replaceChars [ "_" ] [ "/" ] resourceId;
# Explore the subCA tree fragment and emit
bfs = f: parentCA: initialState:
let
immediateChildren = mapAttrs (name: _:
addErrorContext "while evaluating sub-CA configuration `${name}` in `${parentCA.name}`:"
(parentCA.subCA.${name} or { })
) parentCA.subCA;
in
concatMapAttrs (name: value:
let pairWithMore = f parentCA name value initialState;
in
(if builtins.length (builtins.attrValues pairWithMore.value) > 0 then { ${pairWithMore.name} = pairWithMore.value; } else { }) // bfs f value (pairWithMore.state or initialState))
(filterAttrs (_: subCA: subCA.enable) immediateChildren);
concatBfs = f: parentCA:
let
immediateChildren = mapAttrs (name: _:
addErrorContext "while evaluating sub-CA configuration `${name}` in `${parentCA.name}`:"
(parentCA.subCA.${name} or { })
) parentCA.subCA;
in
concatMapAttrs (name: value:
let attrs = f parentCA name value;
in
attrs // concatBfs f value)
(filterAttrs (_: subCA: subCA.enable) immediateChildren);
in
{
resource.vault_mount = bfs (parent: name: config: _:
{
name = "${toplevelConfig.infra.pki.org.id}_${config.partialResourceId}";
value = {
inherit (toplevelConfig.infra.pki) provider;
path = mkPath {
inherit (config) enableVersioning version certVersion name;
inherit (toplevelConfig.infra.pki.org) id;
};
type = "pki";
description = mkDescription {
inherit (config) enableVersioning version certVersion name;
displayName = config.cn;
};
default_lease_ttl_seconds = 1 * hour_in_secs;
max_lease_ttl_seconds = 365 * day_in_secs;
};
}) config.infra.pki.ica2 { };
resource.vault_pki_secret_backend_intermediate_cert_request = bfs (parentConfig: name: config: _:
let
resourceId = "${toplevelConfig.infra.pki.org.id}_${config.partialResourceId}";
in
{
name = resourceId;
value = {
inherit (toplevelConfig.infra.pki) provider;
depends_on = [ "vault_mount.${resourceId}" ];
backend = tf.ref "vault_mount.${resourceId}.path";
type = "internal";
common_name = mkCommonName {
inherit (config) enableVersioning certVersion;
prefix = config.cn;
};
key_type = config.keyType;
};
}) config.infra.pki.ica2 { };
resource.vault_pki_secret_backend_root_sign_intermediate = bfs (parentConfig: name: config: state:
let
parentResourceId = "${toplevelConfig.infra.pki.org.id}_${parentConfig.partialResourceId}";
resourceId = "${toplevelConfig.infra.pki.org.id}_${config.partialResourceId}";
chainVersion = "${toString config.certVersion}.${state.chainVersion}";
in
{
state = {
inherit chainVersion;
};
name = mkSignId {
inherit parentConfig resourceId;
inherit (config) enableVersioning certVersion;
};
value = {
inherit (toplevelConfig.infra.pki) provider;
depends_on = [
"vault_mount.${resourceId}"
"vault_pki_secret_backend_intermediate_cert_request.${resourceId}"
];
backend = tf.ref "vault_mount.${parentResourceId}.path";
csr = tf.ref "vault_pki_secret_backend_intermediate_cert_request.${resourceId}.csr";
common_name = mkCsrCommonName {
inherit parentConfig chainVersion;
inherit (config) enableVersioning certVersion name;
prefix = config.cn;
};
exclude_cn_from_sans = true;
inherit (toplevelConfig.infra.pki.org) ou organization country locality province;
max_path_length = config.extensions.pathlen;
ttl = config.expiry.days * day_in_secs;
};
}) config.infra.pki.ica2 {
chainVersion = "${toString config.infra.pki.ica2.certVersion}";
};
resource.vault_pki_secret_backend_intermediate_set_signed = bfs (parentConfig: name: config: _:
let
# We cannot inline it in the submodule as it depends `config.infra.pki.org.id`.
resourceId = "${toplevelConfig.infra.pki.org.id}_${config.partialResourceId}";
signId = mkSignId {
inherit parentConfig resourceId;
inherit (config) enableVersioning certVersion;
};
parentResourceId = "${toplevelConfig.infra.pki.org.id}_${parentConfig.partialResourceId}";
in
{
name = "${resourceId}_signed_cert";
value = {
inherit (toplevelConfig.infra.pki) provider;
# Here we wait upon the parent configuration to have its final chain ready.
# We wait for our own signature.
depends_on = [
"vault_pki_secret_backend_root_sign_intermediate.${signId}"
"vault_pki_secret_backend_intermediate_set_signed.${parentResourceId}_signed_cert"
];
backend = tf.ref "vault_mount.${resourceId}.path";
# The final chain is the concatenation of the previous chain plus our certificate.
certificate = ''
${tf.ref "vault_pki_secret_backend_root_sign_intermediate.${signId}.certificate"}
${tf.ref "vault_pki_secret_backend_intermediate_set_signed.${parentResourceId}_signed_cert.certificate"}
'';
};
}) config.infra.pki.ica2 { };
resource.vault_pki_secret_backend_role = concatBfs (parentConfig: name: config:
let
resourceId = "${toplevelConfig.infra.pki.org.id}_${config.partialResourceId}";
in
mapAttrs' (name: role:
{
name = "${resourceId}_${name}";
value = role // {
inherit (toplevelConfig.infra.pki) provider;
# Default issuer for this backend, i.e. the sub CA.
# TODO: make this the exact issuer ref we are using.
issuer_ref = "default";
backend = tf.ref "vault_mount.${resourceId}.path";
};
}
) config.roles) config.infra.pki.ica2;
# Generate the empty policy if there's nothing.
infra.vault.policies = concatBfs (parentConfig: name: config:
let
resourceId = "${toplevelConfig.infra.pki.org.id}_${config.partialResourceId}";
in
mapAttrs (name: value:
mapAttrs' (rulePath: value: {
name = "${resourceIdAsPath resourceId}/${rulePath}";
inherit value;
}) value
) config.policies)
config.infra.pki.ica2;
}