-- WP-0009-T02: per-human sub-credentials for a single Licensor identity. -- Depends on migrations/0001_registries.sql (licensors, phase_manifests, -- extensions) and migrations/0002_ledger.sql (ledger_entries). -- -- Supersedes 0001's implicit one-token-per-licensor assumption (its -- `licensors.licensor_id UNIQUE` constraint) so a single Licensor (e.g. -- `binky`) can issue multiple, individually-labeled, individually- -- revocable credentials to different human users, each still resolving -- to the same `licensor_id` for phase-ownership checks (registry.py, -- ledger.py) unchanged. -- -- Structural note: `phase_manifests.licensor_id` and -- `extensions.licensor_id` both carry a foreign key to -- `licensors(licensor_id)`, which only worked because that column used -- to be UNIQUE. Once one `licensor_id` can have many `licensors` rows -- (many credentials), that FK target is no longer valid — a FK must -- reference a unique/PK column. This migration introduces a dedicated -- `licensor_identities` table (one row per tenant, e.g. `binky`) as the -- new FK target for all three tables, and repoints the existing -- constraints at it before relaxing `licensors.licensor_id`'s uniqueness. -- -- Does NOT change ledger_entry.schema.json or the signed entry payload — -- that schema is frozen Stage 0 normative surface -- (specs/TargetLedgerSpecification.md), additionalProperties:false, and -- is out of this task's scope. Per-entry human attribution is instead a -- hosting-layer-only column (`ledger_entries.submitted_by_token`) -- alongside the already-signed entry, not inside its cryptographically -- signed content — an honest, narrower guarantee than "the signature -- itself names the human," which would require reopening WP-0002's -- shipped schema. BEGIN; CREATE TABLE IF NOT EXISTS licensor_identities ( licensor_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now() ); -- Backfill: every licensor_id already present becomes a formal identity. INSERT INTO licensor_identities (licensor_id) SELECT DISTINCT licensor_id FROM licensors ON CONFLICT (licensor_id) DO NOTHING; -- Repoint phase_manifests/extensions at licensor_identities before -- relaxing licensors.licensor_id's uniqueness below. ALTER TABLE phase_manifests DROP CONSTRAINT IF EXISTS phase_manifests_licensor_id_fkey; ALTER TABLE phase_manifests ADD CONSTRAINT phase_manifests_licensor_id_fkey FOREIGN KEY (licensor_id) REFERENCES licensor_identities(licensor_id); ALTER TABLE extensions DROP CONSTRAINT IF EXISTS extensions_licensor_id_fkey; ALTER TABLE extensions ADD CONSTRAINT extensions_licensor_id_fkey FOREIGN KEY (licensor_id) REFERENCES licensor_identities(licensor_id); -- breach_records (migrations/0004_breach_records.sql) also references -- licensors(licensor_id) via published_by — repoint it too. Guarded so -- this migration still applies cleanly against a database that doesn't -- have 0004 applied (breach_records is optional/independent). DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'breach_records') THEN ALTER TABLE breach_records DROP CONSTRAINT IF EXISTS breach_records_published_by_fkey; ALTER TABLE breach_records ADD CONSTRAINT breach_records_published_by_fkey FOREIGN KEY (published_by) REFERENCES licensor_identities(licensor_id); END IF; END $$; -- Now safe: drop the old one-credential-per-tenant uniqueness and have -- `licensors` itself reference the identity table instead of being its -- own FK target. ALTER TABLE licensors DROP CONSTRAINT IF EXISTS licensors_licensor_id_key; ALTER TABLE licensors DROP CONSTRAINT IF EXISTS licensors_licensor_id_fkey; ALTER TABLE licensors ADD CONSTRAINT licensors_licensor_id_fkey FOREIGN KEY (licensor_id) REFERENCES licensor_identities(licensor_id); ALTER TABLE licensors ADD COLUMN IF NOT EXISTS credential_label text, ADD COLUMN IF NOT EXISTS rights text NOT NULL DEFAULT 'operator' CHECK (rights IN ('viewer', 'contributor', 'operator', 'admin')), ADD COLUMN IF NOT EXISTS issued_by text, ADD COLUMN IF NOT EXISTS revoked_at timestamptz; -- A credential_label is unique per Licensor tenant (not globally) among -- currently-active credentials — two different Licensors may each have -- their own "alice", and a revoked "alice" does not block reissuing a -- new active credential with the same label later. CREATE UNIQUE INDEX IF NOT EXISTS licensors_licensor_id_label_idx ON licensors (licensor_id, credential_label) WHERE credential_label IS NOT NULL AND revoked_at IS NULL; -- Auto-create the identity row on first credential for a licensor_id, so -- existing and future code that inserts directly into `licensors` (e.g. -- test fixtures, or `registry.issue_sub_credential`'s own INSERT) doesn't -- need to remember a separate "declare the tenant first" step for the -- common case. `registry.create_licensor_identity` remains available for -- callers that want that declaration as its own explicit, auditable step. CREATE OR REPLACE FUNCTION ensure_licensor_identity() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN INSERT INTO licensor_identities (licensor_id) VALUES (NEW.licensor_id) ON CONFLICT (licensor_id) DO NOTHING; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS licensors_ensure_identity ON licensors; CREATE TRIGGER licensors_ensure_identity BEFORE INSERT ON licensors FOR EACH ROW EXECUTE FUNCTION ensure_licensor_identity(); -- Per-entry attribution: which credential (hence which human) submitted -- each Ledger entry, recorded alongside — not inside — the entry's own -- signed JSON payload. -- Guarded like breach_records above: this migration must also apply -- cleanly against a database that only has 0001 (e.g. a deployment or -- test fixture that hosts registries but not yet the Ledger). DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ledger_entries') THEN ALTER TABLE ledger_entries ADD COLUMN IF NOT EXISTS submitted_by_token text REFERENCES licensors(token); END IF; END $$; -- Revocation is a governance action, not an ordinary application UPDATE — -- same pattern as set_extension_status() in migrations/0001_registries.sql. -- trf_app has no UPDATE grant on `licensors` at all; this SECURITY -- DEFINER function is the only sanctioned way to revoke a credential. CREATE OR REPLACE FUNCTION revoke_credential( p_token text ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN UPDATE licensors SET revoked_at = now() WHERE token = p_token AND revoked_at IS NULL; -- Idempotent: revoking an already-revoked or unknown token is not an -- error — the caller's intent (this token must not work) is already -- satisfied either way, so no FOUND check here. END; $$; GRANT SELECT, INSERT ON licensor_identities TO trf_app; GRANT EXECUTE ON FUNCTION revoke_credential(text) TO trf_app; COMMIT;