Feature Specification: Resource secrets
Created: 2026-10-07
Status: Implemented
Input: Reconstructed from the code at 093e68b6f (core/resources/secrets/default.nix, core/resources/secrets/agenix.nix, core/resources/secrets/secrets.nix, core/resources/secrets/recipients.nix, core/resources/secrets/recipients-test.nix, core/resources/secrets/test.nix, core/resources/secrets/agenix-test.nix, core/resources/secrets/README.md, core/setups/hosts-common.nix, core/components/dev/forgejo/default.nix, core/components/dev/forgejo-runner/default.nix), pull requests #1184, #1206, #1238 and #1255.
User Scenarios & Testing (mandatory)
The secrets resource puts secrets that are encrypted in the repository on
a node. It wraps the fileSecrets contract and routes its own secrets to the
agenix provider. A consumer reads the file path from
config.contracts.fileSecrets.results.<app>.<name>.path. Secrets that the
deployment generates use the generateFiles resource,
which reaches the same contract without this resource. The shared resource
rules are in the resource family spec.
User Story 1 - Provide a stored secret to an application (Priority: P1)
A component needs a secret that a person creates, for example a database
password or a registration token. The component requests
"age".secrets.secrets.<name> = { owner; group; mode; file; }, where file is
the encrypted .age source. The resource has no default source, because it
holds the secrets of no deployment. The policy wants
contracts.fileSecrets.want.<app>.<name> and routes it to agenix. agenix
decrypts file to /run/agenix/<app>_<name> at activation.
Why this priority: the forgejo and forgejo-runner components cannot
start without their stored secrets.
Independent Test: nix-unit-resources-secrets, tests
test-result-path-is-agenix-runtime, test-secret-routed-to-agenix,
test-request-owner-group-mode-flow and test-age-file-from-request.
resources-secrets-agenix checks owner, group, mode and content at the
result path on a running node.
Acceptance Scenarios:
- Given
forgejorequestsforgejo-database-password, When the node evaluates, Then the result path is/run/agenix/forgejo_forgejo-database-password. - Given
owner = "forgejo"andmode = "440", When the node evaluates, Thenage.secrets."forgejo_forgejo-database-password"has ownerforgejo, groupforgejoand mode440. - Given a request with
file = <path>/forgejo-database-password.age, When the node evaluates, Then the source is that file.
User Story 2 - Leave other secret providers alone (Priority: P1)
A node can also consume fileSecrets from another provider, for example
vars through generateFiles. This resource routes each
of its secrets per instance with mkDefault. It sets no node-wide
defaultProvider. When no component requests a secret, apply returns an
empty module and does not import agenix.
Why this priority: a node-wide default would take over the routing of
other fileSecrets consumers on the node.
Independent Test: nix-unit-resources-secrets, tests
test-no-node-wide-default-provider and test-second-consumer-flat-key.
Acceptance Scenarios:
- Given a node with agenix secrets, When it evaluates, Then
contracts.fileSecrets.defaultProviderisnull. - Given two applications, When they request secrets, Then each secret has its own
<app>_<name>key. - Given no request, When
processruns, Then the module has no imports.
User Story 3 - Roll out a rotated secret (Priority: P3)
A person re-encrypts a secret file with a new value. The result path does
not change, so no unit changes. A consumer that must see the new value adds
the ciphertext to its own restartTriggers. forgejo-runner does this for
each runner unit.
Why this priority: without a trigger, a rotated secret reaches the consumer only at its next restart.
Independent Test: nix-unit-resources-secrets, test
test-rotating-a-secret-reaches-its-consumer.
Acceptance Scenarios:
- Given a new
forgejo-runner-token.age, When the runner node evaluates, Then every runner unit has the token ciphertext in itsrestartTriggers.
User Story 4 - Encrypt a secret to the machines that consume it (Priority: P2)
A person runs the agenix CLI, which reads the recipients of each .age
file from secrets.nix. secrets.nix gives each secret all contributor keys,
the cd key, and the host keys from recipients.nix. recipients.nix folds
the components modules and the machines of each in-tree group
(core/setups/groups). A machine runs machine.component or <machine key>.
The component gets the configuration that its group states, or
{ enable = true; } when the group states none. When the requests that its
implementation returns include a secrets request with file = <dir>/<name>.age,
the machine is a consumer of <name>.age. Its host key is
core/keys/systems/<machine.network.name>. The host keys of a secret are in
the order of the network names. The OpenPGP key of security@fediversity.eu
and its passphrase have no consumer, and secrets.nix gives them only the
contributor keys.
Why this priority: the .age files are encrypted to a fixed recipient
set. A recipient list that a person writes by hand can give a secret to a
machine that does not consume it, or keep it from a machine that does.
Independent Test: nix-unit-resources-secrets-recipients, tests
test-secrets-match-encrypted-recipients,
test-each-age-file-has-recipients,
test-machine-without-consumer-loses-host-key,
test-machine-with-consumer-gets-host-key,
test-disabled-component-has-no-recipients and
test-consumer-without-host-key-fails.
Acceptance Scenarios:
- Given the in-tree groups, When
secrets.nixevaluates, Thenanubis-forgejo-key.age,forgejo-database-password.ageandforgejo-email-password.agego to the host key ofvm02116(theforgejomachine), andforgejo-runner-token.agegoes to the host keys of192.168.51.100,192.168.51.102,192.168.51.103and192.168.51.104(theforgejo-runnermachines), each with the contributor keys and thecdkey. - Given a runner machine that runs another component, When
recipients.nixevaluates, Thenforgejo-runner-token.agedoes not go to the host key of that machine. - Given a group that disables
forgejo, Whenrecipients.nixevaluates, Then no secret offorgejohas a recipient from that group. - Given a consumer machine with no host key, When
recipients.nixevaluates, Then the evaluation fails and names the machine.
Edge Cases
groupdefaults toowner.modedefaults to0400.processkeeps only instances taggedsecretsand drops applications with no secrets.- The
routingpolicy can send one secret to another provider, because the per-instance route is amkDefault. - The node can decrypt a secret only when its host key is a recipient in
secrets.nix. - A change to the roster or the components of a group can change the recipients of a secret. The
.agefiles keep the recipients that they were encrypted to until a person rekeys them, andtest-secrets-match-encrypted-recipientsfails until then.
Requirements (mandatory)
Functional Requirements
- FR-001: The request MUST accept
secrets, an attribute set of{ owner; group; mode; file; }. - FR-002: The policy MUST accept
routing, a deferred module (default{ }). - FR-003:
processMUST group the requests as{ <app> = { <name> = <secret>; }; }and keep the application key. - FR-004:
applyMUST return an empty module when no secret is requested. - FR-005:
applyMUST otherwise importrouting.nixandrouting, and give the grouped secrets as data infediversity.secrets.requests. The modulerouting.nixMUST importagenix.nix, setcontracts.fileSecrets.want.<app>.<name>.requestto{ owner; group; mode; }, and route each instance toproviders.agenixwithmkDefault. - FR-006: The
agenixprovider MUST fulfillpath = "${age.secretsDir}/<app>_<name>"and setage.secrets.<app>_<name>with the requested owner, group and mode. - FR-007:
core/setups/hosts-common.nixMUST set"age".secrets = { }and add theprocessoutput to every node, for every deployment method. - FR-008:
secrets.nixMUST encrypt each secret to all contributor keys, thecdkey, and the host keys of its consumers. - FR-009:
recipients.nixMUST find the consumers of a secret from thesecretsrequests of the components that the machines of the in-tree groups run, and MUST take each host key fromcore/keys/systemsby thenetwork.nameof the machine. It MUST fail when a consumer has no host key.
Key Entities
fileSecretscontract: request{ owner; group; mode; }, result{ path; }.agenixprovider:fediversity.agenix.fileSecrets.<app>.<name>withfileandageName.- Recipient map:
secrets.nix, read by theagenixCLI. - Consumer map:
recipients.nix,{ "<name>.age" = [ <host key> ]; }. It evaluates withoutpkgs, so theagenixCLI reads it withnix-instantiate --eval.
Success Criteria (mandatory)
Measurable Outcomes
- SC-001:
nix-unit-resources-secretspasses (8 tests). - SC-002:
resources-secrets-agenixpasses. - SC-003:
nix-unit-resources-secrets-recipientspasses (6 tests).
Assumptions
- Each consuming node has an SSH host key that is a recipient of its secrets.
- A person edits and re-encrypts the
.agefiles with theagenixCLI.
Known Deviations
None.