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