Files
PiCloud/crates/manager-core/migrations/0049_group_secrets.sql
MechaCat02 e6b4792389 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>
2026-06-24 21:48:33 +02:00

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;