feat(secrets): group-owned, env-scoped secrets + inherited resolution
Migration 0049 reshapes `secrets` to the same polymorphic-owner contract as `vars` (0048): a secret is owned by an app XOR an ancestor group, carries an `environment_scope`, and the old PK `(app_id, name)` becomes two partial-unique indexes `(owner, environment_scope, name)`. Existing rows backfill to `app_id` + scope `'*'`; the v1 AAD does NOT include the scope, so every current ciphertext keeps decrypting byte-for-byte. `SecretsRepo` is generalised to be owner+scope keyed (`SecretOwner` moves down from `secrets_service` and is re-exported for path stability). The SDK read path now goes through `SecretsRepo::resolve`, which reuses the shared `CHAIN_LEVELS_CTE` to walk app→ancestor-group→root, env-filters, and takes the nearest level (`@E` beating `*` within a level) — returning the winning owner so the value is decrypted under the AAD it was sealed with. Runtime injection stays anchored to `cx.app_id`: an app only ever resolves its own and its ancestors' secrets. All callers updated to owner+scope (`SecretOwner::App(_)`, scope `'*'`): the app-secrets admin API, the SDK service, and the apply email-secret path. The 21 secrets unit tests (incl. the app/group AAD-disjointness and cross-row swap proofs) stay green; the chain-walk was live-verified against Postgres (nearest-wins + env-filter). Admin API for group secrets and the masked-read endpoint land next (Step E). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
51
crates/manager-core/migrations/0049_group_secrets.sql
Normal file
51
crates/manager-core/migrations/0049_group_secrets.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- Phase 3 (v1.1.9): group-owned, environment-scoped secrets.
|
||||
--
|
||||
-- Until now `secrets` was strictly per-app: PK `(app_id, name)`, one
|
||||
-- envelope per app. Phase 3 makes secrets inheritable down the group tree
|
||||
-- (blueprint §11 bullet 3 / docs/design §3), exactly like `vars` (0048):
|
||||
-- a secret may be owned by an app OR an ancestor group, and a descendant
|
||||
-- app resolves the nearest one, environment-filtered.
|
||||
--
|
||||
-- Reshape (mirrors `vars`):
|
||||
-- * `group_id` — nullable FK→groups, CASCADE (a deleted group drops its
|
||||
-- secrets, same as its vars).
|
||||
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
|
||||
-- enforces "owned by an app XOR a group".
|
||||
-- * `environment_scope` — `'*'` (env-agnostic) or a concrete env name;
|
||||
-- the resolver filters on it. Existing rows backfill to `'*'`, so every
|
||||
-- current app secret stays env-agnostic and resolves unchanged.
|
||||
-- * PK `(app_id, name)` → two PARTIAL unique indexes, one per owner, both
|
||||
-- keyed `(owner, environment_scope, name)`.
|
||||
--
|
||||
-- CRYPTO INVARIANT (audit 2026-06-11 H-D1): the v1 AAD is
|
||||
-- `secret:{app_id}:{name}` for app secrets and `secret:group:{group_id}:{name}`
|
||||
-- for group secrets — it does NOT include `environment_scope`. Adding the
|
||||
-- column therefore leaves every existing ciphertext decryptable byte-for-byte;
|
||||
-- the app-owner AAD is unchanged and the group namespace is disjoint.
|
||||
|
||||
ALTER TABLE secrets
|
||||
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
ADD COLUMN environment_scope TEXT NOT NULL DEFAULT '*';
|
||||
|
||||
-- Drop the old composite PK first: `app_id` cannot lose NOT NULL while it
|
||||
-- is a primary-key column.
|
||||
ALTER TABLE secrets
|
||||
DROP CONSTRAINT secrets_pkey;
|
||||
|
||||
ALTER TABLE secrets
|
||||
ALTER COLUMN app_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE secrets
|
||||
ADD CONSTRAINT secrets_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||
|
||||
-- One secret per (owner, env, name). Partial so each owner column only
|
||||
-- constrains its own rows; the resolver and every upsert restate the
|
||||
-- predicate as the ON CONFLICT arbiter.
|
||||
CREATE UNIQUE INDEX secrets_app_uidx
|
||||
ON secrets (app_id, environment_scope, name) WHERE app_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX secrets_group_uidx
|
||||
ON secrets (group_id, environment_scope, name) WHERE group_id IS NOT NULL;
|
||||
|
||||
-- Owner lookup index for the group side (the app side keeps idx_secrets_app).
|
||||
CREATE INDEX idx_secrets_group ON secrets (group_id) WHERE group_id IS NOT NULL;
|
||||
Reference in New Issue
Block a user