Skip to content

Secrets as data

yen never reads a secret. It plans which host gets which encrypted file, as data that a secrets tool (agenix, sops-nix) reads. A secret is an instance of a data kind; its owners are host refs.

modules/kinds.nix
# A secret is data: nothing spawns it. A host's `secrets` sink only takes secret refs.
{ yen, lib, ... }:
{
yen.kinds.fleet = {
spawns.host = _: yen.host;
sinks.out.reduce = yen.lib.reduce.merge;
};
yen.kinds.secret.fields = {
file = lib.types.str; # the encrypted file in the repo
owners = yen.lib.is lib.isList; # host refs
};
yen.kinds.host.sinks.secrets = {
reduce = yen.lib.reduce.set;
of = yen.kinds.secret;
# the plan a secrets tool reads: agenix, sops-nix, or your own
out =
{ host, secrets, ... }:
{
secretsPlan.${host.name} = map (s: {
inherit (s) name file;
path = "/run/secrets/${s.name}";
}) secrets;
};
};
yen.fleet.main = { };
yen.flake = { out, ... }: out;
}
modules/data.nix
{ yen, ... }:
{
yen.host.web = { };
yen.host.db = { };
yen.secret.tls = {
file = "secrets/tls.age";
owners = [ yen.host.web ];
};
yen.secret.db-password = {
file = "secrets/db-password.age";
owners = [
yen.host.web
yen.host.db
];
};
yen.secret.old-token = {
file = "secrets/old-token.age";
owners = [ ];
};
}

One component delivers each secret to its owners. Another reports secrets that nobody owns:

modules/deliver.nix
{ yen, lib, ... }:
{
# each host gets the secrets that name it
yen.aspects.deliver = {
at = yen.kinds.host;
cycle =
{ scope, ... }:
{
secrets = lib.attrValues (lib.filterAttrs (_: s: lib.any (yen.lib.same scope) s.owners) yen.secret);
};
};
# a secret that no host owns is an error, not a silent leftover
yen.aspects.orphans = {
at = yen.kinds.fleet;
errors = map (s: "secret ${s.name} (${s.file}) has no owner") (
lib.filter (s: s.owners == [ ]) (lib.attrValues yen.secret)
);
};
}
result
{
db = [
{
file = "secrets/db-password.age";
name = "db-password";
path = "/run/secrets/db-password";
}
];
web = [
{
file = "secrets/db-password.age";
name = "db-password";
path = "/run/secrets/db-password";
}
{
file = "secrets/tls.age";
name = "tls";
path = "/run/secrets/tls";
}
];
}

The sink’s of is the secret kind, so only a ref passes. A name is refused, with the scope and the aspect:

variants/by-name.nix
# A secret by name, not by ref: `of` refuses it.
{ yen, ... }:
{
yen.aspects.shortcut = {
at = yen.host.db;
secrets = "tls";
};
}
result
[
"sink secrets: element of aspect shortcut from fleet:main/host:db fails `of`"
"secret old-token (secrets/old-token.age) has no owner"
]
Contribute Community Sponsor