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.
This commit is contained in:
tegwick 2026-07-30 14:26:33 +02:00
parent 04c604745b
commit 7986e62f31
10 changed files with 581 additions and 10 deletions

View file

@ -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; T01T04 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)

View file

@ -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;

View file

@ -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
]

View file

@ -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(

View file

@ -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",

View file

@ -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(

View file

@ -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()

View file

@ -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(

View file

@ -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(

View file

@ -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