Record the verified principal type on approver entries (schema v4)

An entry stored subject_id, assurance and evidence_ref but nothing about
what kind of principal bound the approval, and subject_id is a naming
convention rather than a verified claim. /entries is not restricted by
principal type — only /consume is — and the approval-engine-operator
client holds approval:approve, so a service can supply approver evidence
today. Whether it may is gate-house doctrine; that it is legible is ours.

Add entries.principal_type, populate it from the verified token, surface
it on the object and the audit evidence path (not the claim, which stays
least-disclosure), and migrate v3 stores leaving legacy rows null rather
than back-filling a claim nobody made.

Also corrects two statements in the requirements issued to
informed-decision: agent tokens are not barred from approval:approve, and
an empty assurance object is accepted rather than refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyybaE7DUXrWYrhbnESCTe

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1275879@bnt-lap001
Assistant-Session: eb464208-f821-41b2-bc5a-a6c33d92a8ad
This commit is contained in:
tegwick 2026-09-09 14:09:54 +02:00
parent 8b8ada6c4a
commit 31da1af5e4
6 changed files with 160 additions and 20 deletions

View file

@ -346,3 +346,80 @@ def test_human_principal_cannot_consume(engine):
assert status == 200
assert claim["consumed"] is False
assert claim["valid_now"] is True
def test_entry_records_the_verified_principal_type(engine):
"""Approver evidence must say what kind of principal bound the approval.
`subject_id` alone cannot answer it: `user:alice` is a naming convention,
not a verified claim. The recorded value comes from the token and from
nowhere else, so an evidence reader can tell a human bind from a service
one without trusting a string's shape.
"""
from approval_engine.api import App
creator = Identity(
subject="agt-secrets-engine",
issuer="test",
audiences=("approval-engine",),
principal_type="service",
tenant="tenant:platform",
roles=frozenset(),
scopes=frozenset({"approval:create", "approval:read"}),
assurance={"level": "aal1"},
evidence_ref="service",
)
human = Identity(
subject="user:alice",
issuer="test",
audiences=("approval-engine",),
principal_type="human",
tenant="tenant:platform",
roles=frozenset(),
scopes=frozenset({"approval:approve"}),
assurance={"level": "aal2", "amr": ["pwd", "otp"]},
evidence_ref="human",
)
app = App(engine, StaticTokenAuthenticator({"creator": creator, "human": human}))
_, created = call(
app,
"POST",
"/v1/approvals",
{"binding": binding(), "validity": validity()},
authorization="Bearer creator",
)
_, approved = call(
app,
"POST",
f"/v1/approvals/{created['id']}/entries",
{"principal_type": "service"},
authorization="Bearer human",
)
entry = approved["entries"][0]
assert entry["subject_id"] == "user:alice"
assert entry["principal_type"] == "human"
_, claim = call(
app, "GET", f"/v1/approvals/{created['id']}/claim", authorization="Bearer creator"
)
assert "approvers" not in claim
def test_non_human_approver_is_recorded_as_such(app):
"""A service principal holding `approval:approve` is not refused — but it
is not silently indistinguishable from a human either.
Whether a non-human may supply approver evidence at all is approval
doctrine and belongs to gate-house; the `approval-engine-operator`
registration holds `approval:approve` today. This engine's obligation is
that the evidence chain records which it was.
"""
_, created = call(
app,
"POST",
"/v1/approvals",
{"binding": binding(), "validity": validity()},
)
_, approved = call(app, "POST", f"/v1/approvals/{created['id']}/entries", {})
assert approved["status"] == "approved"
assert approved["entries"][0]["principal_type"] == "service"

View file

@ -6,7 +6,7 @@ from pathlib import Path
import pytest
from approval_engine.errors import Conflict, Unprocessable
from approval_engine.store import Engine
from approval_engine.store import LATEST_SCHEMA_VERSION, Engine
from tests.conftest import FROZEN, approve, binding, validity
@ -174,8 +174,8 @@ def test_successor_inherits_the_pdp_path_declaration(engine):
assert successor.pdp_path is True
def test_v2_database_migrates_to_v3_preserving_approvals():
"""Schema v3 (GH-DEC-2026-008) must not disturb approvals issued under v2."""
def test_v2_database_migrates_forward_preserving_approvals():
"""A v2 store must upgrade to the current schema without disturbing approvals."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "v2.sqlite"
eng = Engine(path, clock=lambda: FROZEN)
@ -189,7 +189,7 @@ def test_v2_database_migrates_to_v3_preserving_approvals():
upgraded = Engine(path, clock=lambda: FROZEN)
version = int(upgraded._conn().execute("PRAGMA user_version").fetchone()[0])
assert version == 3
assert version == LATEST_SCHEMA_VERSION
survivor = upgraded.get(obj.id)
assert survivor.status == "approved"
assert survivor.pdp_digest == "sha256:" + "12" * 32
@ -197,4 +197,28 @@ def test_v2_database_migrates_to_v3_preserving_approvals():
# a digest that happens to be present
assert survivor.pdp_path is False
assert upgraded.claim(obj.id)["binding"]["pdp_path"] is False
# v4: a legacy entry carries no verified statement about the kind of
# principal that bound it, and must not be back-filled into one
assert survivor.entries[0]["principal_type"] is None
upgraded.close()
def test_v3_entries_migrate_to_v4_without_inventing_a_principal_type():
"""The v4 column is added; pre-v4 approver entries stay unclassified."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "v3.sqlite"
eng = Engine(path, clock=lambda: FROZEN)
obj = eng.create(binding(), validity())
eng.add_entry(obj.id, "user:alice", principal_type="human")
eng._conn().execute("ALTER TABLE entries DROP COLUMN principal_type")
eng._conn().execute("PRAGMA user_version=3")
eng._conn().commit()
eng.close()
upgraded = Engine(path, clock=lambda: FROZEN)
assert int(upgraded._conn().execute("PRAGMA user_version").fetchone()[0]) == 4
survivor = upgraded.get(obj.id)
assert survivor.status == "approved"
assert survivor.entries[0]["subject_id"] == "user:alice"
assert survivor.entries[0]["principal_type"] is None
upgraded.close()