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

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