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:
parent
04c604745b
commit
7986e62f31
10 changed files with 581 additions and 10 deletions
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue