Skip to content

feat(store): scope and encrypt user secrets at rest (RIG-3655 T2) - #1118

Merged
trunk-io[bot] merged 2 commits into
compass-server/rig-3655-t1-envelopefrom
compass-server/rig-3655-t2-store
Sep 12, 2026
Merged

trunk-io[bot] merged 2 commits into
compass-server/rig-3655-t1-envelopefrom
compass-server/rig-3655-t2-store

Conversation

@rigel-mintaka

@rigel-mintaka rigel-mintaka commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

This PR is part of a stack containing 7 PRs:

  1. main
  2. docs(server): scope user secrets per tenant, user, and agent (RIG-3655) #1110
  3. feat(envelope): AES-256-GCM seam for user-secret values (RIG-3655 T1) #1111
  4. "feat(store): scope and encrypt user secrets at rest (RIG-3655 T2)" (this PR)
  5. feat(secrets): add the DB-backed StoreResolver (RIG-3655 T3) #1138
  6. docs(server): require admin for tenant-scoped secret writes (RIG-3655) #1141
  7. feat(server): resolve the at-rest master key at boot (RIG-3655 T4) #1146
  8. test(server): prove the armed forge-secret boot path with the real resolver (RIG-3656) #1155

T2 of the user-secret store: the secrets table stops being a names-only
registry and becomes the store of record for scoped, encrypted values.

Scope is three tiers -- 0 tenant, 1 user, 2 agent -- so the primary key becomes
(name, scope_kind, scope_id). A tenant row is a real shared value, and a user
or agent row of the same name shadows it. secrets_scope_shape enforces that a
tenant row carries an empty scope_id and a user/agent row does not, so a
malformed coordinate cannot reach a row.

scope_id carries no foreign key: a tenant row's empty string could not satisfy
one. The referential check moves to the store door, which resolves the id
against user_accounts or agent_accounts inside the write transaction.

SecretRecordsForAgent collapses the precedence in SQL -- DISTINCT ON (name)
under ORDER BY name, scope_kind DESC, with the user tier reached through
agent_accounts.owner_user_id. Doing it here rather than in Go means a shadowed
row never leaves Postgres, so the resolver never decrypts a value it would
discard, and the precedence lives in one ORDER BY instead of a merge a later
writer can reorder.

The value columns land nullable. T2 keeps the value-free InsertSecret path
alive for its T5 caller, and that path writes no value, so an unconditional NOT
NULL here would fail every live SetSecret at runtime. T5 tightens them with
the cutover.

secrets stays in the tenant_tables RLS array. Scope is an additional filter
inside a tenant, never a replacement for row-level security -- the catalog-driven
RLS test still reports it enabled and forced.

Tests are red-first and mutation-checked: inverting the ORDER BY direction,
swapping the case-folding predicate for a byte-exact one, and stubbing out the
door validator each turn the suite red, so the precedence, near-miss rejection,
and door-validation assertions can all genuinely fail. The battery also covers
cross-user and cross-agent isolation, one tenant row resolving for two agents
under different owners, and a same-name-different-scope upsert inserting a
second row rather than overwriting the first.

The two DeleteSecretDeclaration callsites in go/server/secrets_service.go
take the tenant coordinate as a marked T5 placeholder. Whether a user may write
a tenant-scoped row is an open authorization question for Matt; T5 owns the
write surface once it is ruled.

Refs RIG-3655

Co-authored-by: Matt Wilkinson matt@rigel.build

@linear-code

linear-code Bot commented Sep 11, 2026

Copy link
Copy Markdown

RIG-3655

@rigel-mintaka
rigel-mintaka added this pull request to stack #1112 September 11, 2026 20:53
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown

Compass engineering docs preview: https://compass-server-rig-3655-t2-s.compass-eng-docs.pages.dev

Deployed from compass-server/rig-3655-t2-store at 3b5c78f.

rigel-mintaka and others added 2 commits September 11, 2026 22:38
T2 of the user-secret store: the `secrets` table stops being a names-only
registry and becomes the store of record for scoped, encrypted values.

Scope is three tiers -- 0 tenant, 1 user, 2 agent -- so the primary key becomes
`(name, scope_kind, scope_id)`. A tenant row is a real shared value, and a user
or agent row of the same name shadows it. `secrets_scope_shape` enforces that a
tenant row carries an empty `scope_id` and a user/agent row does not, so a
malformed coordinate cannot reach a row.

`scope_id` carries no foreign key: a tenant row's empty string could not satisfy
one. The referential check moves to the store door, which resolves the id
against `user_accounts` or `agent_accounts` inside the write transaction.

`SecretRecordsForAgent` collapses the precedence in SQL -- `DISTINCT ON (name)`
under `ORDER BY name, scope_kind DESC`, with the user tier reached through
`agent_accounts.owner_user_id`. Doing it here rather than in Go means a shadowed
row never leaves Postgres, so the resolver never decrypts a value it would
discard, and the precedence lives in one ORDER BY instead of a merge a later
writer can reorder.

The value columns land nullable. T2 keeps the value-free `InsertSecret` path
alive for its T5 caller, and that path writes no value, so an unconditional NOT
NULL here would fail every live `SetSecret` at runtime. T5 tightens them with
the cutover.

`secrets` stays in the `tenant_tables` RLS array. Scope is an additional filter
inside a tenant, never a replacement for row-level security -- the catalog-driven
RLS test still reports it enabled and forced.

Tests are red-first and mutation-checked: inverting the ORDER BY direction,
swapping the case-folding predicate for a byte-exact one, and stubbing out the
door validator each turn the suite red, so the precedence, near-miss rejection,
and door-validation assertions can all genuinely fail. The battery also covers
cross-user and cross-agent isolation, one tenant row resolving for two agents
under different owners, and a same-name-different-scope upsert inserting a
second row rather than overwriting the first.

The two `DeleteSecretDeclaration` callsites in `go/server/secrets_service.go`
take the tenant coordinate as a marked T5 placeholder. Whether a user may write
a tenant-scoped row is an open authorization question for Matt; T5 owns the
write surface once it is ruled.

Refs RIG-3655

Co-authored-by: Matt Wilkinson <matt@rigel.build>
…655)

Review findings across T1 and T2. They ship as one commit because the
UserSecretAAD signature change and the T2 callsites that must follow it cannot
be separated without leaving a commit that does not compile.

The master key leaked through fmt and slog. The type's claim that unexported
bytes made it unloggable was false: fmt reads unexported fields reflectively, so
`%v` rendered the key as decimal bytes and slog's TextHandler logged the whole
32-byte array. Measured before the fix: slog emitted
`masterKey="{k:[171 171 ...]}"`. Key now carries String, GoString and LogValue,
and every vector renders REDACTED. The doc comment now names the real mechanism
-- unexported fields stop encoding/json and external access, the three methods
stop fmt and slog.

The test that was supposed to prove this passed vacuously. It searched for the
HEX needle "abab" while fmt emits decimal for %v, so it matched nothing it was
meant to catch. The needles are now built from the actual renderings and the
matrix covers both slog handlers. Removing the three methods reddens it.

The AAD's injectivity claim was false for a field containing a NUL: an in-field
NUL is indistinguishable from a separator, so
`("t",1,"a","b\x00c",1)` and `("t",1,"a\x00b","c",1)` produced identical bytes.
Matt ruled reject over re-encoding, matching every secret store surveyed -- none
permits a NUL in a name, and POSIX env names cannot contain one at all because
they are C strings. UserSecretAAD now returns an error naming the offending
field, never its value. The byte format is unchanged, which matters because the
AAD is bound into every ciphertext and a format change after rows exist would
force a re-encrypt.

The zero-value Key was a usable all-zero AES key, so a wiring bug that skipped
NewKey would have encrypted every secret under a publicly known key. It now
fails closed with ErrUnsetKey, kept distinct from ErrDecrypt because a missing
key is a wiring fault, not a tamper.

The read-side authorization gate had no test. SecretRecordsForAgent decides
which secrets an agent receives, and its gate for an unknown principal is
entirely the INNER JOIN on agent_accounts -- but every existing test passed a
real agent id, so a regression to LEFT JOIN would have handed every
tenant-scoped secret to an unknown caller with the suite still green. A test now
asserts zero rows for a user account and for a non-existent id; switching the
join to LEFT reddens it.

pgCheckViolation was declared but never referenced, while its comment called it
the backstop for a bypassed door guard. The door did not validate the delivery
range, so an out-of-range value reached the CHECK and surfaced as CodeInternal
instead of the ErrInvalidArgument the method documents. The door now mirrors the
CHECK and the SQLSTATE maps to ErrInvalidArgument. DeclareSecret has the same
gap; it is pre-existing and left alone.

Refs RIG-3655

Co-authored-by: Matt Wilkinson <matt@rigel.build>
@trunk-io
trunk-io Bot merged commit c34e417 into main Sep 12, 2026
19 of 29 checks passed
@trunk-io
trunk-io Bot deleted the compass-server/rig-3655-t2-store branch September 12, 2026 18:36
@trunk-io

trunk-io Bot commented Sep 12, 2026

Copy link
Copy Markdown

This pull request was merged into main as part of stacked PR 1155.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants