152 lines
12 KiB
Text
152 lines
12 KiB
Text
|
|
let d = import "defaults.ncl" in
|
||
|
|
d.make_adr {
|
||
|
|
id = "adr-019",
|
||
|
|
title = "Per-File Recipient Routing for Tenant Isolation in lieu of Multi-Vault",
|
||
|
|
status = 'Accepted,
|
||
|
|
date = "2026-05-03",
|
||
|
|
|
||
|
|
context = "ADR-017 established per-project credential vaults as OCI artifacts in ZOT, encrypted with sops + age multi-recipient. The model held one recipient set per vault: every actor who could decrypt access.sops.yaml could decrypt every credential file inside the vault. Real projects (libre-wuji, the canonical multi-tenant example) need stronger separation — a single project hosts multiple clients with distinct services, AI agents that operate with restricted access, and developer/admin roles that occasionally overlap. Three concrete failure modes appear: (1) credential-of-clientB is visible to anyone who can open the vault even when only working on clientA — recipient lists are coarse and indiscriminate; (2) AI agents with read-only intent receive credentials whose blast radius exceeds their declared scope; (3) blast-radius limitation requires per-file recipient sets, which the original single-set model does not express. A naive answer is multi-vault — one vault_id per tenant or per environment — but it cascades through every layer (schema, helpers, recipes, dispatcher, migration) and creates an architectural debt without justifying a real isolation requirement (separate master keys, separate restic repos) for the typical case.",
|
||
|
|
|
||
|
|
decision = "Tenant isolation within a single project is expressed via sops creation_rules, declared in project.ncl::sops as recipient_groups (named lists of age public keys) and recipient_rules (path_regex to group-union mappings). The bootstrap recipe generates <vault_dir>/.sops.yaml from these declarations and sops natively encrypts each file with the union of declared groups. One vault_id per project remains the unit. Multi-vault is explicitly NOT implemented and remains out of scope until a project requires HARD isolation (separate master keys, separate restic repos, compliance-grade separation surviving accidental cross-decryption); such a case requires a future ADR. When recipient_rules are declared, project.ncl is the single source of truth: secrets-add-key and secrets-remove-key error out and direct the operator to edit project.ncl plus run secrets-rekey, which regenerates .sops.yaml and re-encrypts every *.sops.yaml file. Three adoption templates (single-team, multi-tenant, agent-first) ship in install/resources/templates/sops/ as copy-paste starting points; a project may adopt any pattern or none.",
|
||
|
|
|
||
|
|
rationale = [
|
||
|
|
{
|
||
|
|
claim = "Use sops creation_rules natively — do not invent a parallel routing layer",
|
||
|
|
detail = "sops already provides per-file recipient routing via creation_rules in .sops.yaml. Using it directly inherits all of sops' tooling (updatekeys, --decrypt with .sops.yaml discovery, --filename-override) and avoids a competing convention that would double the surface and break composability with sops itself.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
claim = "Preserve the single-vault structural invariants of ADR-017",
|
||
|
|
detail = "Single vault_id, single OCI artifact, single restic repo, single lock, single dispatcher subcommand surface. The schema delta is two optional fields (recipient_groups + recipient_rules). Most code paths require zero modification — additivity over existing helpers is the migration story for projects already on ADR-017.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
claim = "Project.ncl is the single source of truth for recipient sets",
|
||
|
|
detail = "Direct sops mutations via secrets-add-key and secrets-remove-key are forbidden in declarative mode (recipes error explicitly). The reason: any direct mutation diverges from project.ncl, and the next secrets-rekey would silently revert. Forcing edits through git via project.ncl makes recipient changes auditable and reproducible across machines.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
claim = "Honest trade-off: per-file routing protects against accidental cross-decryption, not against encrypted-byte visibility",
|
||
|
|
detail = "ClientA's lead with their .kage cannot decrypt clientB-*.sops.yaml files — sops rejects decryption when the recipient set excludes the actor. But all encrypted files are layers in the same OCI artifact; clientA SEES that clientB-* files exist. For HARD isolation (separate master keys, separate restic repos, compliance-grade separation surviving accidental cross-decryption), multi-vault remains the future option — a separate ADR captures that work when a real case requires it.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
claim = "Three adoption templates anchor common patterns without forcing them",
|
||
|
|
detail = "single-team, multi-tenant, and agent-first templates ship in install/resources/templates/sops/ as copy-paste starting points. The schema fields are optional with sensible defaults — a project may adopt any pattern, mix them, or skip templates entirely while still being a valid consumer of ADR-017 + ADR-019.",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
|
||
|
|
consequences = {
|
||
|
|
positive = [
|
||
|
|
"Tenant isolation in a single vault, single master key, single OCI artifact — adoption cost minimal.",
|
||
|
|
"Compatible with all existing ADR-017 enforcement (assert-actor-authorized, assert-target-in-scope, vault lock, impact analysis).",
|
||
|
|
"Migration from legacy single-set mode is additive: existing projects keep working, new fields opt them into per-file routing.",
|
||
|
|
"Defense in depth: actor scope (ops + namespaces) + recipient routing — both must permit an operation.",
|
||
|
|
],
|
||
|
|
negative = [
|
||
|
|
"Operators must understand sops creation_rules ordering (first match wins) — surfacing rule conflicts requires care.",
|
||
|
|
"secrets-add-key and secrets-remove-key behavior diverges between legacy and declarative modes, introducing a mode-aware UX.",
|
||
|
|
"Cross-tenant visibility of encrypted file paths in the OCI manifest — operationally clientB knows clientA exists in the vault.",
|
||
|
|
],
|
||
|
|
},
|
||
|
|
|
||
|
|
alternatives_considered = [
|
||
|
|
{
|
||
|
|
option = "Multi-vault: project.ncl::sops.vault_id (single string) becomes sops.vaults (record of named SopsConfigs)",
|
||
|
|
why_rejected ="Cascades through every layer (schema, helpers, recipes, dispatcher), forcing a 12-component refactor and a migration for every existing project to express what one optional schema field accomplishes via sops creation_rules. The HARD isolation it provides (separate master keys, separate restic repos) is rarely required; per-file routing covers the common case while leaving the door open for a future multi-vault ADR if a project genuinely needs filesystem-level separation.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
option = "Single recipient set + role-based decryption gating in helper code",
|
||
|
|
why_rejected ="Would require a custom layer over sops and reinvent recipient routing. sops already does it natively via creation_rules. Inventing a parallel mechanism doubles the surface and breaks composition with sops tooling (sops --decrypt, updatekeys).",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
option = "Externalize tenant credentials to an external secret manager (e.g. HashiCorp Vault)",
|
||
|
|
why_rejected ="Adds an external runtime dependency that contradicts the ADR-017 invariant of self-contained, distribution-via-OCI credentials. Reasonable for projects that already operate such a system, but inappropriate as the default ontoref pattern.",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
|
||
|
|
constraints = [
|
||
|
|
{
|
||
|
|
id = "rules-imply-groups-defined",
|
||
|
|
claim = "Every group referenced in recipient_rules must be declared in recipient_groups",
|
||
|
|
scope = "all projects with sops.recipient_rules non-empty",
|
||
|
|
severity = 'Hard,
|
||
|
|
check = {
|
||
|
|
tag = 'NuCmd,
|
||
|
|
cmd = "ore secrets audit --check recipient-routing-coherent",
|
||
|
|
},
|
||
|
|
rationale = "An undeclared group resolves to an empty recipient list, producing files encrypted to nobody. Catch at audit time before push.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id = "no-empty-group-on-active-rule",
|
||
|
|
claim = "A rule whose group union resolves to zero recipients is rejected at bootstrap and rekey",
|
||
|
|
scope = "all projects with sops.recipient_rules non-empty",
|
||
|
|
severity = 'Hard,
|
||
|
|
check = {
|
||
|
|
tag = 'NuCmd,
|
||
|
|
cmd = "ore secrets audit --check recipient-routing-coherent",
|
||
|
|
},
|
||
|
|
rationale = "Encrypting to zero recipients silently produces an unrecoverable file. Reject at the source rather than waiting for sops to fail at use time.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id = "declarative-mode-locks-direct-mutations",
|
||
|
|
claim = "secrets-add-key and secrets-remove-key recipes must error when project declares recipient_rules; canonical workflow is edit project.ncl + secrets-rekey",
|
||
|
|
scope = "all projects with sops.recipient_rules non-empty",
|
||
|
|
severity = 'Hard,
|
||
|
|
check = {
|
||
|
|
tag = 'Grep,
|
||
|
|
paths = ["justfiles/secrets.just"],
|
||
|
|
pattern = "HAS_RULES.*declarative",
|
||
|
|
must_be_empty = false,
|
||
|
|
},
|
||
|
|
rationale = "Direct sops mutations would diverge from project.ncl, and the next rekey would silently revert them. Forcing the rekey path keeps git as the single source of truth.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id = "every-vault-file-matches-a-rule",
|
||
|
|
claim = "Every *.sops.yaml under <vault_dir>/ must match at least one declared rule when recipient_rules is non-empty",
|
||
|
|
scope = "all projects with sops.recipient_rules non-empty",
|
||
|
|
severity = 'Hard,
|
||
|
|
check = {
|
||
|
|
tag = 'NuCmd,
|
||
|
|
cmd = "ore secrets audit --check recipient-routing-coverage",
|
||
|
|
},
|
||
|
|
rationale = "sops fails encryption with 'no matching creation rules found' for unmatched paths. Catch the mismatch at audit time, surface which path needs a rule (or which rule needs broadening).",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id = "multi-vault-not-implemented",
|
||
|
|
claim = "Projects must not declare a multi-vault structure (e.g. sops.vaults map). Multi-vault adoption requires a new ADR superseding or extending this one",
|
||
|
|
scope = "all projects with .ontoref/project.ncl",
|
||
|
|
severity = 'Hard,
|
||
|
|
check = {
|
||
|
|
tag = 'Grep,
|
||
|
|
pattern = "sops.vaults *=",
|
||
|
|
paths = [".ontoref/project.ncl"],
|
||
|
|
must_be_empty = true,
|
||
|
|
},
|
||
|
|
rationale = "Premature multi-vault implementation without a justifying use case generates schema and helper debt across 12+ components. The constraint is structural: any project that hits a real HARD-isolation requirement must capture it in a new ADR before adopting multi-vault.",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id = "templates-discoverable",
|
||
|
|
claim = "Three adoption templates (single-team, multi-tenant, agent-first) live under install/resources/templates/sops/ and are referenced by qa.ncl::credential-vault-templates",
|
||
|
|
scope = "ontoref protocol layer",
|
||
|
|
severity = 'Soft,
|
||
|
|
check = {
|
||
|
|
tag = 'FileExists,
|
||
|
|
paths = [
|
||
|
|
"install/resources/templates/sops/single-team/project.ncl.snippet",
|
||
|
|
"install/resources/templates/sops/multi-tenant/project.ncl.snippet",
|
||
|
|
"install/resources/templates/sops/agent-first/project.ncl.snippet",
|
||
|
|
],
|
||
|
|
},
|
||
|
|
rationale = "Without templates, adoption requires reading the schema, sops docs, and the FAQ — too high a friction. Templates are not mandatory but their absence is a soft signal of protocol decay.",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
|
||
|
|
related_adrs = [
|
||
|
|
"adr-017-registry-credential-vault-model",
|
||
|
|
"adr-015-mcp-tool-inventory-auto-derive",
|
||
|
|
],
|
||
|
|
|
||
|
|
ontology_check = {
|
||
|
|
decision_string = "tenant isolation within a single credential vault is expressed via sops creation_rules driven by project.ncl::sops.recipient_groups + recipient_rules; multi-vault is explicitly out of scope; project.ncl is the single source of truth for recipient sets and direct sops mutations are forbidden in declarative mode",
|
||
|
|
invariants_at_risk = ["protocol-not-runtime"],
|
||
|
|
verdict = 'Safe,
|
||
|
|
},
|
||
|
|
}
|