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>
52 lines
2.4 KiB
SQL
52 lines
2.4 KiB
SQL
-- 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;
|