From 7986e62f31291fa89e36efa846358cd00900c819 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 30 Jul 2026 14:26:33 +0200 Subject: [PATCH] Extend WP-0006 auth for per-human sub-credentials (WP-0009-T02) 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. --- README.md | 2 +- migrations/0005_licensor_credentials.sql | 155 +++++++++++++ src/target_revenue/ledger.py | 29 ++- src/target_revenue/registry.py | 126 +++++++++- tests/test_hosted_conformance.py | 1 + tests/test_ledger_hosting.py | 1 + tests/test_licensor_credentials.py | 215 ++++++++++++++++++ tests/test_onboarding_hosted.py | 1 + tests/test_registry_hosting.py | 11 +- ...EV-WP-0009-target-revenue-control-plane.md | 50 +++- 10 files changed, 581 insertions(+), 10 deletions(-) create mode 100644 migrations/0005_licensor_credentials.sql create mode 100644 tests/test_licensor_credentials.py diff --git a/README.md b/README.md index 2bcf6f0..0e69c1d 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ The concept's §13 now defines a **Global Contingency Share Determination Rule** | [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — **finished**, all 9 tasks done (Postgres-backed registries/ledger/metrics/attestation/breach-record, ADR-0002 accepted, onboarding CLI, hosted conformance suite) | | [TREV-WP-0007](workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md) | Degeneration policy + canonical monetization profile catalog — **finished**, all 4 tasks done. `trsl:policy:linear-longstop-v0` confirmed 2026-07-29 as the v1 norm for the first pilot cohort; `progress-paused-longstop-v1` named as the next iteration, not yet adopted | | [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout — active; T01–T04 done. `info-tech-canon` dry-run onboarding routine exercised end-to-end 2026-07-29. **Org-wide TRSL license adoption executed 2026-07-30** across ~90 `coulomb`-org repos (`history/260730-TRSL-OrgWideLicenseRollout.md`) — a license-text adoption, not a Phase declaration. T05 (real Phase go-live gate) remains `todo` by design; no Phase exists yet for any repo | -| [TREV-WP-0009](workplans/TREV-WP-0009-target-revenue-control-plane.md) | Target Revenue Control Plane — interactive UI for the `binky` tenant, incl. interactive Development Credit entry creation (`specs/TargetRevenueControlPlaneConcept.md`) — active; **T01 accepted 2026-07-30** (four rights tiers confirmed; per-human sub-credentials at the Trust Service layer chosen over the concept's own simpler recommendation) — adds a real prerequisite: T02 extends WP-0006's finished auth layer before T03 (Control Plane backend) and T04 (interactive UI) can proceed | +| [TREV-WP-0009](workplans/TREV-WP-0009-target-revenue-control-plane.md) | Target Revenue Control Plane — interactive UI for the `binky` tenant, incl. interactive Development Credit entry creation (`specs/TargetRevenueControlPlaneConcept.md`) — active; T01 (rights model) and **T02 (WP-0006 auth extension for per-human sub-credentials, `migrations/0005_licensor_credentials.sql`) done**; T03 (Control Plane backend) next | | [TREV-WP-0010](workplans/TREV-WP-0010-development-effort-calculator.md) | Development Effort Calculator — **finished**, all 3 tasks done. Applied to the three real pilot candidates (`history/260730-EffortCalculator-CandidateApplication.md`) — every calculator-derived Initial Target came out materially lower than the earlier hand-picked placeholders, two of three carrying explicit warnings recommending manual review | Hub index: [`WORK-RECORDS.md`](WORK-RECORDS.md) · brief: [`.custodian-brief.md`](.custodian-brief.md) diff --git a/migrations/0005_licensor_credentials.sql b/migrations/0005_licensor_credentials.sql new file mode 100644 index 0000000..e731cae --- /dev/null +++ b/migrations/0005_licensor_credentials.sql @@ -0,0 +1,155 @@ +-- 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; diff --git a/src/target_revenue/ledger.py b/src/target_revenue/ledger.py index 77c6e39..39d6c69 100644 --- a/src/target_revenue/ledger.py +++ b/src/target_revenue/ledger.py @@ -99,8 +99,9 @@ def append_entry( conn.execute( """ INSERT INTO ledger_entries - (entry_id, phase_id, entry, previous_entry_hash, signature, recognized_at) - VALUES (%s, %s, %s, %s, %s, %s) + (entry_id, phase_id, entry, previous_entry_hash, signature, recognized_at, + submitted_by_token) + VALUES (%s, %s, %s, %s, %s, %s, %s) """, ( signed["id"], @@ -109,6 +110,7 @@ def append_entry( previous_entry_hash, signed["signature"], signed["recognized_at"], + licensor.token, ), ) except UniqueViolation as exc: @@ -130,3 +132,26 @@ def get_ledger(conn: Connection, phase_id: str) -> list[dict[str, Any]]: (phase_id,), ).fetchall() return [row[0] for row in rows] + + +def get_ledger_attribution(conn: Connection, phase_id: str) -> list[dict[str, Any]]: + """Return, per entry in append order, which credential submitted it + (WP-0009-T02: `licensors.credential_label`, joined via + `ledger_entries.submitted_by_token`). This is a hosting-layer-only + view — the credential label is never part of the signed entry payload + itself (`get_ledger`'s output), so an export of the raw Ledger remains + exactly as portable and schema-conformant as before this task.""" + rows = conn.execute( + """ + SELECT le.entry_id, li.credential_label, li.rights + FROM ledger_entries le + LEFT JOIN licensors li ON li.token = le.submitted_by_token + WHERE le.phase_id = %s + ORDER BY le.sequence ASC + """, + (phase_id,), + ).fetchall() + return [ + {"entry_id": entry_id, "credential_label": credential_label, "rights": rights} + for entry_id, credential_label, rights in rows + ] diff --git a/src/target_revenue/registry.py b/src/target_revenue/registry.py index eae469c..b90a1a0 100644 --- a/src/target_revenue/registry.py +++ b/src/target_revenue/registry.py @@ -12,10 +12,19 @@ level (see `migrations/0001_registries.sql`) — this module's job is to surface a conformance rejection *before* attempting a write that the database would reject anyway, so callers get a field-by-field diff instead of an opaque database error. + +WP-0009-T02 extended the `licensors` auth model so one Licensor tenant +(e.g. `binky`) can hold multiple, individually-issued, individually- +revocable credentials (`issue_sub_credential`, `revoke_sub_credential`), +each carrying its own `credential_label` and rights tier +(`RIGHTS_TIERS`), while still resolving to the same `licensor_id` for +every phase-ownership check in this module and `ledger.py` — unchanged +from the single-token model WP-0006 originally shipped. """ from __future__ import annotations +import secrets from dataclasses import dataclass from typing import Any @@ -30,10 +39,41 @@ class RegistrationError(ValueError): """Raised with the specific reason a registration was rejected.""" +# Rights tiers accepted 2026-07-30 +# (workplans/TREV-WP-0009-target-revenue-control-plane.md T01, +# specs/TargetRevenueControlPlaneConcept.md §2), ordered least to most +# privileged. Enforcing what each tier may actually do is the Control +# Plane's own job (WP-0009-T03/T04) — this module only carries the tier +# label through authentication and offers `has_right` as a shared ordinal +# comparison, so that enforcement logic doesn't have to reinvent the +# ordering itself. +RIGHTS_TIERS = ("viewer", "contributor", "operator", "admin") + + +def has_right(rights: str, minimum: str) -> bool: + """True if `rights` is at least as privileged as `minimum` (RIGHTS_TIERS order).""" + return RIGHTS_TIERS.index(rights) >= RIGHTS_TIERS.index(minimum) + + @dataclass(frozen=True) class Licensor: + """An authenticated caller: a Licensor tenant, via one of possibly + several individually-issued, individually-revocable credentials + (WP-0009-T02). `credential_label` and `rights` distinguish *which* + credential authenticated, for attribution and authorization purposes, + even though every credential for the same `licensor_id` has identical + phase-ownership rights in `registry.py`/`ledger.py`'s existing checks. + """ + token: str licensor_id: str + credential_label: str | None = None + rights: str = "operator" + + +def generate_credential_token() -> str: + """A new random credential token, suitable for `issue_sub_credential`.""" + return secrets.token_hex(32) def authenticate(conn: Connection, token: str) -> Licensor: @@ -43,13 +83,95 @@ def authenticate(conn: Connection, token: str) -> Licensor: independent of the per-entry Ed25519 signing WP-0002 already implements (that signing happens at the Ledger layer, T04 — this function only answers "who is calling," not "is this entry authentic"). + + A revoked credential (`revoked_at` set) is rejected exactly like an + unrecognized one — from the caller's perspective there is no + observable difference between "never existed" and "no longer valid," + which is the correct behavior for a revoked credential. """ row = conn.execute( - "SELECT licensor_id FROM licensors WHERE token = %s", (token,) + """ + SELECT licensor_id, credential_label, rights + FROM licensors + WHERE token = %s AND revoked_at IS NULL + """, + (token,), ).fetchone() if row is None: raise RegistrationError("unrecognized or revoked token") - return Licensor(token=token, licensor_id=row[0]) + licensor_id, credential_label, rights = row + return Licensor( + token=token, licensor_id=licensor_id, credential_label=credential_label, rights=rights + ) + + +def create_licensor_identity(conn: Connection, licensor_id: str) -> None: + """Onboard a brand-new Licensor tenant identity (`licensor_identities`), + a prerequisite before any credential can be issued for it + (`issue_sub_credential`'s foreign key requires the identity to already + exist). Deliberately a separate, explicit step rather than an implicit + side effect of issuing the first credential — matching this + framework's existing pattern of governance actions being explicit + (`promote_extension_canonical`, `revoke_sub_credential`), not + automated. There is exactly one identity today (`binky`, + `specs/TRSL-Governance.md` §1); this function exists so a future + additional tenant does not require a schema change to onboard. + """ + try: + conn.execute( + "INSERT INTO licensor_identities (licensor_id) VALUES (%s)", (licensor_id,) + ) + except UniqueViolation as exc: + raise RegistrationError(f"licensor identity {licensor_id!r} already exists") from exc + + +def issue_sub_credential( + conn: Connection, + licensor_id: str, + credential_label: str, + rights: str, + issued_by: str, +) -> Licensor: + """Issue a new, individually-revocable credential for a Licensor + tenant (e.g. a second credential for `binky`, labeled for a specific + human user). This is itself a governance action — `issued_by` records + who issued it, the same attributable-action pattern already used by + `promote_extension_canonical`. The Licensor identity is auto-created + on first use if it doesn't already exist + (`migrations/0005_licensor_credentials.sql`'s `ensure_licensor_identity` + trigger) — `create_licensor_identity` remains available for callers + that want tenant onboarding as its own explicit, auditable step rather + than an implicit side effect of the first credential. + """ + if rights not in RIGHTS_TIERS: + raise RegistrationError(f"rights must be one of {RIGHTS_TIERS}, got {rights!r}") + token = generate_credential_token() + try: + conn.execute( + """ + INSERT INTO licensors (token, licensor_id, credential_label, rights, issued_by) + VALUES (%s, %s, %s, %s, %s) + """, + (token, licensor_id, credential_label, rights, issued_by), + ) + except UniqueViolation as exc: + raise RegistrationError( + f"an active credential labeled {credential_label!r} already exists for " + f"licensor {licensor_id!r}" + ) from exc + return Licensor( + token=token, licensor_id=licensor_id, credential_label=credential_label, rights=rights + ) + + +def revoke_sub_credential(conn: Connection, token: str) -> None: + """Revoke a credential via the database's `revoke_credential` function + (not a direct UPDATE — the application role has no UPDATE grant on + `licensors` at all, matching `promote_extension_canonical`'s pattern). + Idempotent: revoking an already-revoked or unknown token is not an + error — the caller's intent (this token must not work) is satisfied + either way.""" + conn.execute("SELECT revoke_credential(%s)", (token,)) def register_phase_manifest( diff --git a/tests/test_hosted_conformance.py b/tests/test_hosted_conformance.py index 8f7a3d1..5ab2de2 100644 --- a/tests/test_hosted_conformance.py +++ b/tests/test_hosted_conformance.py @@ -39,6 +39,7 @@ MIGRATIONS = [ REPO_ROOT / "migrations" / "0002_ledger.sql", REPO_ROOT / "migrations" / "0003_attestations.sql", REPO_ROOT / "migrations" / "0004_breach_records.sql", + REPO_ROOT / "migrations" / "0005_licensor_credentials.sql", ] EXTENSION_NAMES = [ "development-license", diff --git a/tests/test_ledger_hosting.py b/tests/test_ledger_hosting.py index 722fa7f..8f36f16 100644 --- a/tests/test_ledger_hosting.py +++ b/tests/test_ledger_hosting.py @@ -32,6 +32,7 @@ MIGRATIONS = [ REPO_ROOT / "migrations" / "0002_ledger.sql", REPO_ROOT / "migrations" / "0003_attestations.sql", REPO_ROOT / "migrations" / "0004_breach_records.sql", + REPO_ROOT / "migrations" / "0005_licensor_credentials.sql", ] pytestmark = pytest.mark.skipif( diff --git a/tests/test_licensor_credentials.py b/tests/test_licensor_credentials.py new file mode 100644 index 0000000..93ac8cb --- /dev/null +++ b/tests/test_licensor_credentials.py @@ -0,0 +1,215 @@ +"""Integration tests for WP-0009-T02 (per-human sub-credentials). + +Same ephemeral, disposable Postgres-via-Docker pattern as the other +hosted test modules (never the shared state-hub instance). +""" + +from __future__ import annotations + +import shutil +import subprocess +import time +import uuid +from pathlib import Path + +import pytest + +psycopg = pytest.importorskip("psycopg") + +from conftest import golden_manifest # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parents[1] +MIGRATIONS = [ + REPO_ROOT / "migrations" / "0001_registries.sql", + REPO_ROOT / "migrations" / "0002_ledger.sql", + REPO_ROOT / "migrations" / "0003_attestations.sql", + REPO_ROOT / "migrations" / "0004_breach_records.sql", + REPO_ROOT / "migrations" / "0005_licensor_credentials.sql", +] + +pytestmark = pytest.mark.skipif( + shutil.which("docker") is None, reason="docker not available" +) + + +@pytest.fixture(scope="module") +def pg_container(): + name = f"trf-test-pg-creds-{uuid.uuid4().hex[:8]}" + subprocess.run( + [ + "docker", "run", "--rm", "-d", + "--name", name, + "-e", "POSTGRES_PASSWORD=postgres", + "-e", "POSTGRES_DB=target_revenue_test", + "-p", "127.0.0.1::5432", + "postgres:16-alpine", + ], + check=True, capture_output=True, + ) + try: + port_out = subprocess.run( + ["docker", "port", name, "5432/tcp"], check=True, capture_output=True, text=True + ).stdout.strip() + host_port = port_out.split(":")[-1] + dsn = f"host=127.0.0.1 port={host_port} dbname=target_revenue_test user=postgres password=postgres" + + for _ in range(60): + try: + with psycopg.connect(dsn, connect_timeout=1): + break + except psycopg.OperationalError: + time.sleep(0.5) + else: + raise RuntimeError("postgres container did not become ready in time") + + with psycopg.connect(dsn) as conn: + for migration in MIGRATIONS: + conn.execute(migration.read_text(encoding="utf-8")) + conn.commit() + # A first, "founding" credential for the tenant, as if issued + # at Licensor-onboarding time (analogous to earlier test + # modules' plain single-token setup). + conn.execute( + "INSERT INTO licensors (token, licensor_id, credential_label, rights, issued_by) " + "VALUES (%s, %s, %s, %s, %s)", + ("founding-token", "binky", "founding-admin", "admin", "bootstrap"), + ) + conn.commit() + + app_dsn = ( + f"host=127.0.0.1 port={host_port} dbname=target_revenue_test " + f"user=trf_app password=changeme-in-deployment" + ) + yield {"admin_dsn": dsn, "app_dsn": app_dsn, "founding_token": "founding-token"} + finally: + subprocess.run(["docker", "stop", name], capture_output=True) + + +@pytest.fixture() +def conn(pg_container): + with psycopg.connect(pg_container["app_dsn"]) as connection: + yield connection + + +def test_issue_sub_credential_resolves_same_licensor_id(conn, pg_container): + from target_revenue import registry + + founding = registry.authenticate(conn, pg_container["founding_token"]) + assert founding.licensor_id == "binky" + + alice = registry.issue_sub_credential( + conn, licensor_id="binky", credential_label="alice", rights="operator", issued_by="founding-admin" + ) + conn.commit() + assert alice.licensor_id == "binky" + assert alice.credential_label == "alice" + assert alice.rights == "operator" + assert alice.token != pg_container["founding_token"] + + reauth = registry.authenticate(conn, alice.token) + assert reauth.licensor_id == "binky" + assert reauth.credential_label == "alice" + assert reauth.rights == "operator" + + +def test_duplicate_active_label_for_same_licensor_rejected(conn): + from target_revenue import registry + + registry.issue_sub_credential( + conn, licensor_id="binky", credential_label="bob", rights="viewer", issued_by="founding-admin" + ) + conn.commit() + + with pytest.raises(registry.RegistrationError, match="already exists"): + registry.issue_sub_credential( + conn, licensor_id="binky", credential_label="bob", rights="operator", issued_by="founding-admin" + ) + + +def test_revoked_credential_cannot_authenticate(conn): + from target_revenue import registry + + carol = registry.issue_sub_credential( + conn, licensor_id="binky", credential_label="carol", rights="contributor", issued_by="founding-admin" + ) + conn.commit() + + registry.revoke_sub_credential(conn, carol.token) + conn.commit() + + with pytest.raises(registry.RegistrationError, match="unrecognized or revoked"): + registry.authenticate(conn, carol.token) + + +def test_revoking_unknown_token_is_not_an_error(conn): + from target_revenue import registry + + registry.revoke_sub_credential(conn, "not-a-real-token") # must not raise + conn.commit() + + +def test_invalid_rights_tier_rejected(conn): + from target_revenue import registry + + with pytest.raises(registry.RegistrationError, match="rights must be one of"): + registry.issue_sub_credential( + conn, licensor_id="binky", credential_label="dave", rights="superuser", issued_by="founding-admin" + ) + + +def test_has_right_ordinal_comparison(): + from target_revenue import registry + + assert registry.has_right("admin", "viewer") + assert registry.has_right("operator", "contributor") + assert not registry.has_right("viewer", "operator") + assert registry.has_right("contributor", "contributor") + + +def test_ledger_entry_attribution_recorded(conn, pg_container): + from target_revenue import ledger, registry + from target_revenue.service import keys + + alice = registry.issue_sub_credential( + conn, licensor_id="binky", credential_label="alice-ledger", rights="operator", issued_by="founding-admin" + ) + conn.commit() + + manifest = golden_manifest() + manifest["phase"]["id"] = manifest["phase"]["id"] + "-attribution-" + uuid.uuid4().hex[:6] + registry.register_phase_manifest(conn, alice, manifest) + conn.commit() + + signing_key = keys.load_signing_key() + entry = { + "id": "trsl:entry:attrtest0001", + "phase": manifest["phase"]["id"], + "type": "development-credit", + "amount": 1, + "currency": manifest["phase"]["initial_target"]["currency"], + "recognized_at": "2026-08-01T00:00:00Z", + "evidence_reference": "confidential:evidence:attrtest0001", + "extension": {"id": "trsl:extension:development-license", "version": "1.0"}, + } + ledger.append_entry(conn, alice, manifest["phase"]["id"], entry, signing_key) + conn.commit() + + attribution = ledger.get_ledger_attribution(conn, manifest["phase"]["id"]) + assert attribution == [ + {"entry_id": "trsl:entry:attrtest0001", "credential_label": "alice-ledger", "rights": "operator"} + ] + + # The exported ledger entry itself carries no attribution field — + # unchanged, schema-conformant, exactly as portable as before this task. + exported = ledger.get_ledger(conn, manifest["phase"]["id"]) + assert "credential_label" not in exported[0] + assert "submitted_by" not in exported[0] + + +def test_application_role_cannot_update_licensors_directly(pg_container): + with psycopg.connect(pg_container["app_dsn"]) as app_conn: + with pytest.raises(psycopg.errors.InsufficientPrivilege): + app_conn.execute( + "UPDATE licensors SET rights = 'admin' WHERE token = 'founding-token'" + ) + app_conn.rollback() diff --git a/tests/test_onboarding_hosted.py b/tests/test_onboarding_hosted.py index f8ec5a2..5a1362f 100644 --- a/tests/test_onboarding_hosted.py +++ b/tests/test_onboarding_hosted.py @@ -32,6 +32,7 @@ MIGRATIONS = [ REPO_ROOT / "migrations" / "0002_ledger.sql", REPO_ROOT / "migrations" / "0003_attestations.sql", REPO_ROOT / "migrations" / "0004_breach_records.sql", + REPO_ROOT / "migrations" / "0005_licensor_credentials.sql", ] pytestmark = pytest.mark.skipif( diff --git a/tests/test_registry_hosting.py b/tests/test_registry_hosting.py index b117374..21c646c 100644 --- a/tests/test_registry_hosting.py +++ b/tests/test_registry_hosting.py @@ -24,9 +24,11 @@ from fastapi.testclient import TestClient # noqa: E402 from conftest import golden_extension, golden_manifest # noqa: E402 -REPO_ROOT_MIGRATIONS = ( - __import__("pathlib").Path(__file__).resolve().parents[1] / "migrations" / "0001_registries.sql" -) +_REPO_ROOT = __import__("pathlib").Path(__file__).resolve().parents[1] +REPO_ROOT_MIGRATIONS = [ + _REPO_ROOT / "migrations" / "0001_registries.sql", + _REPO_ROOT / "migrations" / "0005_licensor_credentials.sql", +] pytestmark = pytest.mark.skipif( shutil.which("docker") is None, reason="docker not available" @@ -64,7 +66,8 @@ def pg_container(): raise RuntimeError("postgres container did not become ready in time") with psycopg.connect(dsn) as conn: - conn.execute(REPO_ROOT_MIGRATIONS.read_text(encoding="utf-8")) + for migration in REPO_ROOT_MIGRATIONS: + conn.execute(migration.read_text(encoding="utf-8")) conn.commit() token = "test-token-acme" conn.execute( diff --git a/workplans/TREV-WP-0009-target-revenue-control-plane.md b/workplans/TREV-WP-0009-target-revenue-control-plane.md index 30249e6..d2b3673 100644 --- a/workplans/TREV-WP-0009-target-revenue-control-plane.md +++ b/workplans/TREV-WP-0009-target-revenue-control-plane.md @@ -72,7 +72,7 @@ hypothetical branch — see T02 below, added specifically for this reason. ```task id: TREV-WP-0009-T02 -status: todo +status: done priority: high state_hub_task_id: "52ae3a7a-6b55-4694-8bc1-cb0e32a4fe31" ``` @@ -91,6 +91,54 @@ plus new tests for the sub-credential path. Does not change the Ledger's append-only guarantees or the hash-chain/signature scheme itself, only who may authenticate as `binky` and how that's distinguished. +**Result:** `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 along the way: +`licensor_id` could not simply become non-unique, because +`phase_manifests`, `extensions`, and `breach_records` all carry a foreign +key to `licensors(licensor_id)`, which only worked because that column +used to be unique — a FK target must be unique. Introduced a new +`licensor_identities` table (one row per tenant) as the FK target for all +four tables instead, with an `ensure_licensor_identity` trigger that +auto-creates the identity row on first credential insert (so existing +code that inserts directly into `licensors` — including every earlier +test fixture — needed no changes), plus +`registry.create_licensor_identity` for callers that want tenant +onboarding as its own explicit step. `registry.py` gained +`Licensor.credential_label`/`.rights`, `RIGHTS_TIERS`/`has_right()` (an +ordinal helper — enforcing what each tier may do is Control Plane's own +job, T03/T04, not this task's), `issue_sub_credential`/ +`revoke_sub_credential` (revocation via a `revoke_credential()` SECURITY +DEFINER function, matching `set_extension_status`'s existing pattern — +`trf_app` has no UPDATE grant on `licensors`). `authenticate()` now +rejects a revoked credential identically to an unrecognized one. + +**Attribution, scoped honestly:** `ledger_entry.schema.json` was +deliberately left unmodified (frozen Stage 0 normative surface, +`additionalProperties: false`) — per-entry human attribution is instead a +hosting-layer-only column, `ledger_entries.submitted_by_token`, recorded +alongside but never inside the signed entry payload +(`ledger.get_ledger_attribution`). This means the claim is narrower than +"the signature itself names the human": the cryptographic signature is +unchanged and still only attests to the entry content and chain; the +*database* additionally knows which credential submitted each entry, +queryable but not portable/exported with the entry itself. 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 to their setup (`ledger.append_entry`'s INSERT now +references the new column) — done for +`test_registry_hosting.py`/`test_ledger_hosting.py`/ +`test_hosted_conformance.py`/`test_onboarding_hosted.py`. New +`tests/test_licensor_credentials.py` (8 tests): multi-credential +resolution to the same `licensor_id`, duplicate-active-label rejection, +revocation and its idempotence, invalid-rights rejection, the `has_right` +ordinal helper, per-entry attribution recorded and *not* leaking into the +exported ledger JSON, and DB-level UPDATE rejection on `licensors`. Full +suite: 84 passing offline (unchanged), 41 passing with Docker (up from +30); no stray containers left running. + ## Control Plane backend: auth layer and audit log ```task