feat(guests): give guests only the host-decrypted secrets they name #33

Merged
alexion merged 1 commits from task-0007-guest-secrets-placement into main 2026-07-25 21:07:13 -04:00
Owner

Summary

Adds a secrets placement option to the guest builder: a Host sets guests.<path>.secrets, a list of secret names.
For each name the builder declares sops.secrets.<name> on the Host — so the Host is the sole decryptor, from the sops files it already holds — and bind-mounts the decrypted file into the guest read-only at the same /run/secrets/<name> path it occupies on a Host.
A service inside the guest reads its credentials at a predictable location, while the guest carries no age key and decrypts nothing itself.

Ownership needs no new code: the container already runs in the Host's uid and gid space one to one (privateUsers = "no"), so the decrypted file's Host owner is its owner inside the guest.

A build-time assertion rejects an in-guest path claimed by both a mounts entry and a secret, since the two attribute sets merge and the collision would otherwise resolve silently in the secret's favour.

No Host commits a secrets placement (the only Host, neogaia, is a laptop carrying no service that names one), matching the mounts/networking/storage foundations.
Verified via nixosConfigurations.neogaia.extendModules: the resolved sops.secrets, the container's read-only bindMounts entry, a null interior sops.age.keyFile, and an empty interior sops.secrets.
The full Host toplevel was built with the sample guest naming a real key; nix flake check passes on the committed tree.

Deviation: none from the task plan. sops-nix validates at build time that each named key exists in the Host's sops files, so a name absent from those files fails the build clearly rather than at activation — a stronger guarantee than the task required, noted for reviewers.

Review

Risk

Overall: HIGH

  • Blast radius — Low: changes one guest builder; merges two attrsets into bindMounts, no other callers touched.
  • Reversibility — High: the option enters a published module interface, and once a Host declares secrets and services depend on /run/secrets/<name>, removing it breaks consumers.
  • Test coverage — High: no automated test around the secret-mount wiring (matches the spec's manual-verification decision, but untested in-diff).
  • Sensitive domain — High: bind-mounts decrypted plaintext secrets across a container boundary and relies on uid identity mapping for ownership.
  • Size & complexity — Low: ~50 lines, straightforward map/listToAttrs, no tangled control flow.
  • Runtime criticality — Medium: guest containers are production-facing, but the feature is dormant until a Host opts in.

Standards — unaddressed

  • userMounts comment is a "what" comment (lib.nix): kept deliberately, since it documents the non-obvious keying (the attribute key is the in-guest path). A judgement call; the duplication, cross-file narration, fragment, and prose-semicolon findings were fixed in the diff.

Spec — unaddressed

  • A non-root guest service can't read a default 0400 root:root secret (spec "Secrets"): by design and recorded in the task's Implementation Notes. The secrets field stays a list of names, which the spec treats as its interface; the operator sets sops.secrets.<name>.owner on the Host when a non-root service needs the file, and it merges cleanly with the builder's stub declaration.
  • Runtime, not exercised by nix eval (spec testing decisions): the mount source /run/secrets/<name> is a sops-managed symlink, so real reads inside the guest — symlink resolution at container start and secret-decryption-before-container ordering — are verified manually on the target Host, per the spec's decision to verify real reads there.

— Claude

## Summary Adds a `secrets` placement option to the guest builder: a Host sets `guests.<path>.secrets`, a list of secret names. For each name the builder declares `sops.secrets.<name>` on the Host — so the Host is the sole decryptor, from the sops files it already holds — and bind-mounts the decrypted file into the guest read-only at the same `/run/secrets/<name>` path it occupies on a Host. A service inside the guest reads its credentials at a predictable location, while the guest carries no age key and decrypts nothing itself. Ownership needs no new code: the container already runs in the Host's uid and gid space one to one (`privateUsers = "no"`), so the decrypted file's Host owner is its owner inside the guest. A build-time assertion rejects an in-guest path claimed by both a `mounts` entry and a secret, since the two attribute sets merge and the collision would otherwise resolve silently in the secret's favour. No Host commits a `secrets` placement (the only Host, `neogaia`, is a laptop carrying no service that names one), matching the mounts/networking/storage foundations. Verified via `nixosConfigurations.neogaia.extendModules`: the resolved `sops.secrets`, the container's read-only `bindMounts` entry, a null interior `sops.age.keyFile`, and an empty interior `sops.secrets`. The full Host toplevel was built with the sample guest naming a real key; `nix flake check` passes on the committed tree. **Deviation:** none from the task plan. sops-nix validates at build time that each named key exists in the Host's sops files, so a name absent from those files fails the build clearly rather than at activation — a stronger guarantee than the task required, noted for reviewers. ## Review ### Risk **Overall: HIGH** - Blast radius — Low: changes one guest builder; merges two attrsets into `bindMounts`, no other callers touched. - Reversibility — High: the option enters a published module interface, and once a Host declares `secrets` and services depend on `/run/secrets/<name>`, removing it breaks consumers. - Test coverage — High: no automated test around the secret-mount wiring (matches the spec's manual-verification decision, but untested in-diff). - Sensitive domain — High: bind-mounts decrypted plaintext secrets across a container boundary and relies on uid identity mapping for ownership. - Size & complexity — Low: ~50 lines, straightforward `map`/`listToAttrs`, no tangled control flow. - Runtime criticality — Medium: guest containers are production-facing, but the feature is dormant until a Host opts in. ### Standards — unaddressed - **`userMounts` comment is a "what" comment** (`lib.nix`): kept deliberately, since it documents the non-obvious keying (the attribute key is the in-guest path). A judgement call; the duplication, cross-file narration, fragment, and prose-semicolon findings were fixed in the diff. ### Spec — unaddressed - **A non-root guest service can't read a default `0400 root:root` secret** (spec "Secrets"): by design and recorded in the task's Implementation Notes. The `secrets` field stays a list of names, which the spec treats as its interface; the operator sets `sops.secrets.<name>.owner` on the Host when a non-root service needs the file, and it merges cleanly with the builder's stub declaration. - **Runtime, not exercised by `nix eval`** (spec testing decisions): the mount source `/run/secrets/<name>` is a sops-managed symlink, so real reads inside the guest — symlink resolution at container start and secret-decryption-before-container ordering — are verified manually on the target Host, per the spec's decision to verify real reads there. — Claude
alexion added 1 commit 2026-07-25 20:47:33 -04:00
Add a `secrets` placement option to the guest builder: a list of secret
names. The host declares each as one of its own sops secrets, so the host
is the sole decryptor from the sops files it already holds, and the
decrypted file is bind-mounted into the guest read-only at the same
`/run/secrets/<name>` path it occupies on a host. A service inside the
guest reads its credentials at a predictable location while the guest
carries no age key and decrypts nothing itself.

Ownership needs no new code: the container already runs in the host's uid
and gid space one to one, so the decrypted file's host owner is its owner
inside the guest.

Assert that no in-guest path is claimed by both a mount and a secret, so
the attribute-set merge fails loudly rather than resolving a collision
silently in the secret's favour.
alexion merged commit d637d3e7f6 into main 2026-07-25 21:07:13 -04:00
alexion deleted branch task-0007-guest-secrets-placement 2026-07-25 21:07:13 -04:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: alexion/dotfiles#33