156 lines
6.8 KiB
MySQL
156 lines
6.8 KiB
MySQL
|
|
-- 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;
|