Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 29 additions & 23 deletions go/internal/secrets/secrets.go
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
// Package secrets is the Server-side secret resolve surface for the agent
// container runtime (RIG-1327 T3). It wraps SecretSpec resolution behind a
// Resolver interface and owns the resolve-surface value types the Runner fetch
// (T4) and materializer (T5) consume.
// container runtime. It wraps secret resolution behind a Resolver interface and
// owns the resolve-surface value types the Runner fetch path and the container
// materializer consume.
//
// The split of concerns:
// - internal/store owns the persisted NAMES registry (SecretDeclaration) —
// which secrets are declared and how each is delivered/routed, never a
// value.
// - this package reads that registry, generates the SecretSpec manifest the
// resolver resolves against, calls SecretSpec to resolve the actual values
// from the configured provider (keyring/1Password/Vault/…), and hands back
// ResolvedSecrets (name + value + content-hash version + delivery/kind).
// - internal/store owns two separate registries. The secrets table holds user
// secrets: one row carries the declaration (name, delivery/routing) AND the
// encrypted value columns. Boot/server secret names live in the physically
// separate server_secrets table, declaration only — so the inject-all
// container path, which reads secrets, can never see them by construction.
// - this package reads those registries and hands back ResolvedSecrets (name +
// value + content-hash version + delivery/kind): SpecResolver by generating
// a manifest and resolving names against the provider, StoreResolver by
// decrypting the user rows.
//
// It maps store enums to its own resolve-surface enums at this edge, exactly as
// the comms service maps store↔proto (store/types.go) — so store stays a leaf
// and the two evolve independently. The dependency runs one way: secrets →
// store (no cycle).
//
// Values live only in the provider and this process's memory during a resolve;
// they are never persisted by Compass and never logged. Every value-bearing
// type here redacts under %s/%v/%#v (the store.Credentials pattern).
// Value locations differ by kind. User secret values are persisted in the
// Postgres secrets table as AES-256-GCM ciphertext (the internal/envelope seam
// encrypts them; StoreResolver is the DB-backed resolver over those rows).
// Boot/server secret values are never persisted by Compass — they stay in the
// configured provider (keyring/1Password/Vault/…) and are read through
// SpecResolver. Either way a value is never logged: every value-bearing type
// here redacts under %s/%v/%#v (the store.Credentials pattern).
package secrets

import (
Expand All @@ -32,7 +38,7 @@ import (
)

// DeliveryKind is how a resolved secret is delivered into a container — the
// load-bearing file-vs-env split that fixes how it rotates (T5/T6). It mirrors
// load-bearing file-vs-env split that fixes how it rotates. It mirrors
// store.SecretDelivery; the two are mapped at this package's edge.
type DeliveryKind uint8

Expand All @@ -45,7 +51,7 @@ const (
DeliveryEnv
)

// SecretKind is the routing class the T5 materializer switches on. It mirrors
// SecretKind is the routing class the materializer switches on. It mirrors
// store.SecretKind; the two are mapped at this package's edge.
type SecretKind uint8

Expand All @@ -62,8 +68,8 @@ const (

// nameGrammar is SecretSpec's env-var-name grammar. A declared secret name must
// match it: it becomes both a manifest key and, downstream, a path segment
// under $HOME/.compass/secrets/ and a token in a root-adjacent setup script
// (T5). Validated at the store door (store.UpsertSecret) and re-checked here as
// under $HOME/.compass/secrets/ and a token in a root-adjacent setup script.
// Validated at the store door (store.UpsertSecret) and re-checked here as
// defense in depth before a name is ever emitted into a generated manifest.
var nameGrammar = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)

Expand Down Expand Up @@ -112,15 +118,15 @@ func ValidateProfile(profile string) error {
// of the value, hex-encoded. The registry stores no values and SecretSpec
// resolve returns values (not versions), so a content hash is the only
// deterministic version producer. A same-value re-set hashes identically, so
// T6's rotation diff sees no change and does nothing — correct, since nothing
// the rotation diff sees no change and does nothing — correct, since nothing
// the container holds is stale.
//
// It is NEVER logged: String/GoString redact the value AND omit the version, so
// the hash cannot serve as an offline confirmation oracle for a low-entropy
// secret. T6 diffs the struct field directly, not a log line, so dropping it
// from the log surface costs nothing. (A keyed hash — HMAC under a server key —
// is a post-MVP defense-in-depth option, redundant once the version is unlogged;
// it would amend the frozen SHA-256 algorithm, so it is deferred, not folded.)
// secret. The rotation diff reads the struct field directly, not a log line, so
// dropping it from the log surface costs nothing. (A keyed hash — HMAC under a
// server key — is a defense-in-depth option, redundant once the version is
// unlogged; it would amend the frozen SHA-256 algorithm, so it is deferred.)
func Version(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
Expand All @@ -134,7 +140,7 @@ type ResolvedSecret struct {
Name string
Value string
Version string
// Delivery is the file-vs-env split (T5/T6 rotation shape).
// Delivery is the file-vs-env split (rotation shape).
Delivery DeliveryKind
// Kind is the materializer routing class.
Kind SecretKind
Expand Down
20 changes: 10 additions & 10 deletions go/internal/store/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
)

// SecretDelivery is how a declared secret is delivered into a container — the
// load-bearing file-vs-env split that determines how it rotates (T5/T6). Stored
// load-bearing file-vs-env split that determines how it rotates. Stored
// as the small int the secrets resolve surface uses (secrets.DeliveryKind),
// mapped at that package's edge like every other store↔proto enum (types.go).
type SecretDelivery int32
Expand All @@ -24,7 +24,7 @@ const (
SecretDeliveryEnv SecretDelivery = 1
)

// SecretKind is the routing class the T5 materializer switches on: a generic
// SecretKind is the routing class the materializer switches on: a generic
// declared secret, a provider (LLM) credential that rides the OMP SDK auth
// surface, or a gh credential placed into ~/.config/gh/hosts.yml.
type SecretKind int32
Expand All @@ -44,18 +44,18 @@ const (
// secretNamePattern is SecretSpec's env-var-name grammar. A declared name is
// validated against it at the store door (UpsertSecret) — before it can reach
// a row — because it later becomes a path segment under $HOME/.compass/secrets/
// and a line in a root-adjacent setup script (T5): constrained at the door, not
// and a line in a root-adjacent setup script: constrained at the door, not
// escaped downstream. The identical grammar is re-exported and re-checked by
// internal/secrets (defense in depth at materialization).
var secretNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)

// SecretDeclaration is one names-only registry row: a declared secret's name,
// how it is delivered/routed, and who declared it — NEVER its value. The value
// lives only in the SecretSpec provider; the Server resolves it at fetch time
// (internal/secrets) and never persists it.
// SecretDeclaration is the value-free view of a user-secret row: the name, how
// it is delivered/routed, and who declared it. The struct carries no value, but
// the underlying row does — the value is AES-256-GCM ciphertext beside it, read
// and decrypted through StoreResolver rather than resolved from a provider.
type SecretDeclaration struct {
Name string
// Delivery is the file-vs-env split (T5/T6 rotation shape).
// Delivery is the file-vs-env split that fixes how the secret rotates.
Delivery SecretDelivery
// Kind is the materializer routing class.
Kind SecretKind
Expand All @@ -65,7 +65,7 @@ type SecretDeclaration struct {
// else "").
Host string
// DeclaredBy is the account that declared the secret (write path is
// user-only, enforced at the T7 RPC edge).
// user-only, enforced at the RPC edge).
DeclaredBy AccountID
CreatedAt time.Time
UpdatedAt time.Time
Expand All @@ -77,7 +77,7 @@ type SecretDeclaration struct {
// and no provider, a generic row (kind=0) neither. A caller that violates it
// gets an actionable ErrInvalidArgument here rather than a raw constraint
// violation from the INSERT — and an out-of-invariant row can never reach the
// T5 materializer, where an empty provider id would silently misroute.
// materializer, where an empty provider id would silently misroute.
func validateKindRouting(kind SecretKind, provider, host string) error {
switch kind {
case SecretKindGeneric:
Expand Down
45 changes: 22 additions & 23 deletions go/server/secrets_service.go
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
//go:build unix

// The SecretsService implementation — the account-facing side of the compass.v1
// secrets contract (RIG-1327 T7). It sits beside CompassService/CommsService on
// the same account doors (socket + dev + network), behind the bearer + admin-gate
// secrets contract. It sits beside CompassService/CommsService on the same
// account doors (socket + dev + network), behind the bearer + admin-gate
// interceptors that classify the three procedures authenticatedOpen (admin_gate.go):
// the door admits any authenticated account and THIS handler enforces the fine
// authz the frozen record pins.
//
// - SetSecret / DeleteSecret are USER-ONLY (record §911-927): an agent-token
// caller is CodePermissionDenied, the same fail-closed posture as the
// admin-gated IssueToken. This is the load-bearing regression the record
// calls out (§927).
// - ListSecrets is open to user AND agent (record §904-910): the Setup agent
// drives it. It returns value-free SecretStatus — never a value, and never
// resolves values to compute is_set.
// - SetSecret / DeleteSecret are user-only: an agent-token caller is
// CodePermissionDenied (the requireUser gate), the same fail-closed posture
// as the admin-gated IssueToken. A tenant-scoped write additionally requires
// an admin (D8), at the coordinate the D9 selector resolves.
// - ListSecrets is open to user AND agent: the Setup agent drives it. It
// returns value-free SecretStatus — never a value, and never resolves values
// to compute is_set.
//
// A successful Set/Delete bumps the secrets version (a fire-and-forget hub push
// to live sessions, secretsSignaler) so live containers re-fetch (T6 cleanup).
// A secret value is never logged here (it is [debug_redact] on the wire; the
// server side keeps the same posture).
// to live sessions, secretsSignaler) so live containers re-fetch. A secret value
// is never logged here (it is [debug_redact] on the wire; the server side keeps
// the same posture).
package server

import (
Expand Down Expand Up @@ -91,8 +91,8 @@ var errNoResolver = errors.New("no secret resolver configured on this server")
var errNoServerResolver = errors.New("no server secret resolver configured on this server")

// SetSecret writes a user secret's declaration and encrypted value in ONE atomic
// upsert at the caller-resolved scope coordinate. USER-ONLY (record §911-927): an
// agent-token caller is CodePermissionDenied. `value` is never logged.
// upsert at the caller-resolved scope coordinate. User-only: an agent-token
// caller is CodePermissionDenied. `value` is never logged.
//
// The declaration and the value are the same row now (A1), so the write is a
// single StoreResolver.Upsert transaction — the old declare-then-Set-then-rollback
Expand Down Expand Up @@ -142,7 +142,7 @@ func (s *secretsService) SetSecret(
}

// ListSecrets returns the value-free status of every declared secret. Open to
// USER AND AGENT (record §904-910): the Setup agent drives it, so no kind
// USER AND AGENT (the Setup agent drives it), so no kind
// restriction. It reads the declaration registry and maps each row to a
// SecretStatus — NEVER a value (SecretStatus has no value field), and never
// resolves values to compute is_set.
Expand Down Expand Up @@ -182,7 +182,7 @@ func (s *secretsService) ListSecrets(

// DeleteSecret removes a user secret's row (declaration and value are the same
// row post-A1) at the caller-resolved coordinate, then bumps the secrets version.
// USER-ONLY (record §915-918): an agent-token caller is CodePermissionDenied. A
// User-only: an agent-token caller is CodePermissionDenied. A
// name that was never declared at that coordinate is CodeNotFound. A reserved-
// prefix name is rejected CodeInvalidArgument ahead of any store call — the F1
// name partition keeps reserved names out of the user table, so a delete on one
Expand Down Expand Up @@ -288,13 +288,12 @@ func (s *secretsService) requireCaller(ctx context.Context) (store.AccountID, er

// requireUser returns the authenticated caller id AND role only when the caller
// is a USER account; an agent account is CodePermissionDenied (the user-only
// write gate, record §919-927 — the same fail-closed posture as admin-gated
// IssueToken). No caller is CodeUnauthenticated (fail closed). The account kind
// is read from the store (an agent account has the Agent subtype set; a user
// does not — IsAgent). The role is returned so a handler can gate a tenant-scope
// write (D9) without a second GetAccount; a caller with no user payload (the
// reserved system account) is the least-privilege member, so it cannot pass the
// admin gate.
// write gate — the same fail-closed posture as admin-gated IssueToken). No
// caller is CodeUnauthenticated (fail closed). The account kind is read from the
// store (an agent account has the Agent subtype set; a user does not — IsAgent).
// The role is returned so a handler can gate a tenant-scope write (D8) without a
// second GetAccount; a caller with no user payload (the reserved system account)
// is the least-privilege member, so it cannot pass the admin gate.
func (s *secretsService) requireUser(ctx context.Context) (store.AccountID, store.UserRole, error) {
callerID, err := s.requireCaller(ctx)
if err != nil {
Expand Down
Loading