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:

  1. Given forgejo requests forgejo-database-password, When the node evaluates, Then the result path is /run/agenix/forgejo_forgejo-database-password.
  2. Given owner = "forgejo" and mode = "440", When the node evaluates, Then age.secrets."forgejo_forgejo-database-password" has owner forgejo, group forgejo and mode 440.
  3. 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:

  1. Given a node with agenix secrets, When it evaluates, Then contracts.fileSecrets.defaultProvider is null.
  2. Given two applications, When they request secrets, Then each secret has its own <app>_<name> key.
  3. Given no request, When process runs, 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:

  1. Given a new forgejo-runner-token.age, When the runner node evaluates, Then every runner unit has the token ciphertext in its restartTriggers.

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:

  1. Given the in-tree groups, When secrets.nix evaluates, Then anubis-forgejo-key.age, forgejo-database-password.age and forgejo-email-password.age go to the host key of vm02116 (the forgejo machine), and forgejo-runner-token.age goes to the host keys of 192.168.51.100, 192.168.51.102, 192.168.51.103 and 192.168.51.104 (the forgejo-runner machines), each with the contributor keys and the cd key.
  2. Given a runner machine that runs another component, When recipients.nix evaluates, Then forgejo-runner-token.age does not go to the host key of that machine.
  3. Given a group that disables forgejo, When recipients.nix evaluates, Then no secret of forgejo has a recipient from that group.
  4. Given a consumer machine with no host key, When recipients.nix evaluates, Then the evaluation fails and names the machine.

Edge Cases

  • group defaults to owner. mode defaults to 0400.
  • process keeps only instances tagged secrets and drops applications with no secrets.
  • The routing policy can send one secret to another provider, because the per-instance route is a mkDefault.
  • 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 .age files keep the recipients that they were encrypted to until a person rekeys them, and test-secrets-match-encrypted-recipients fails 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: process MUST group the requests as { <app> = { <name> = <secret>; }; } and keep the application key.
  • FR-004: apply MUST return an empty module when no secret is requested.
  • FR-005: apply MUST otherwise import routing.nix and routing, and give the grouped secrets as data in fediversity.secrets.requests. The module routing.nix MUST import agenix.nix, set contracts.fileSecrets.want.<app>.<name>.request to { owner; group; mode; }, and route each instance to providers.agenix with mkDefault.
  • FR-006: The agenix provider MUST fulfill path = "${age.secretsDir}/<app>_<name>" and set age.secrets.<app>_<name> with the requested owner, group and mode.
  • FR-007: core/setups/hosts-common.nix MUST set "age".secrets = { } and add the process output to every node, for every deployment method.
  • FR-008: secrets.nix MUST encrypt each secret to all contributor keys, the cd key, and the host keys of its consumers.
  • FR-009: recipients.nix MUST find the consumers of a secret from the secrets requests of the components that the machines of the in-tree groups run, and MUST take each host key from core/keys/systems by the network.name of the machine. It MUST fail when a consumer has no host key.

Key Entities

  • fileSecrets contract: request { owner; group; mode; }, result { path; }.
  • agenix provider: fediversity.agenix.fileSecrets.<app>.<name> with file and ageName.
  • Recipient map: secrets.nix, read by the agenix CLI.
  • Consumer map: recipients.nix, { "<name>.age" = [ <host key> ]; }. It evaluates without pkgs, so the agenix CLI reads it with nix-instantiate --eval.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: nix-unit-resources-secrets passes (8 tests).
  • SC-002: resources-secrets-agenix passes.
  • SC-003: nix-unit-resources-secrets-recipients passes (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 .age files with the agenix CLI.

Known Deviations

None.