migrations/0005_licensor_credentials.sql: licensors can now hold multiple rows per licensor_id (credential_label, rights tier, issued_by, revoked_at). Real structural finding: licensor_id couldn't simply become non-unique, since phase_manifests, extensions, and breach_records all FK to licensors(licensor_id), which only worked because that column used to be unique. Introduced licensor_identities (one row per tenant) as the new FK target for all four tables, with an ensure_licensor_identity trigger auto-creating the identity on first credential insert - so existing code (including every earlier test fixture) needed no changes. registry.py: Licensor gains credential_label/rights; RIGHTS_TIERS + has_right() ordinal helper (enforcement is Control Plane's job, T03/ T04, not this task's); issue_sub_credential/revoke_sub_credential (revocation via a SECURITY DEFINER function, matching set_extension_status's existing pattern - trf_app has no UPDATE grant on licensors); authenticate() rejects revoked credentials identically to unrecognized ones. Attribution scoped honestly: ledger_entry.schema.json stays unmodified (frozen Stage 0 surface, additionalProperties:false) - per-entry human attribution is a hosting-layer-only column (ledger_entries.submitted_by_token, ledger.get_ledger_attribution()), recorded alongside but never inside the signed entry payload. Narrower than "the signature names the human," but exactly the "(or an accompanying attributable field)" alternative this task's own description anticipated. All four Docker-gated test files that append Ledger entries needed migration 0005 added (append_entry's INSERT now references the new column). New tests/test_licensor_credentials.py (8 tests): multi- credential resolution, duplicate-label rejection, revocation and its idempotence, invalid-rights rejection, the has_right helper, per-entry attribution recorded and not leaking into exported ledger JSON, and DB-level UPDATE rejection. Full suite: 84 offline, 41 with Docker (up from 30); no stray containers left running.
155 lines
6.8 KiB
PL/PgSQL
155 lines
6.8 KiB
PL/PgSQL
-- 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;
|