Schema v9 stores who revoked an approval and an optional bounded reason. The bounded-execution carrier example is proven in process. APPROVAL-WP-0005, 0006, and 0007 are blocked because they are not current work. Task statuses stay as they were, apart from T03 and T04, which are done in source. Assistant: grok Assistant-Session: 01a1b5fd-0c33-7ba1-b383-53937d525595
160 lines
5.8 KiB
Python
160 lines
5.8 KiB
Python
"""APPROVAL-WP-0007-T03: verified revoker evidence on the object and the event.
|
|
|
|
The revoker is whoever holds approval:revoke. They are not required to be
|
|
the bound principal. The body cannot name the revoker.
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from approval_engine.api import App, call
|
|
from approval_engine.auth import Identity, StaticTokenAuthenticator
|
|
from approval_engine.errors import Conflict, Unprocessable
|
|
from approval_engine.store import Engine, LATEST_SCHEMA_VERSION
|
|
from tests.conftest import FROZEN, approve, binding, validity
|
|
|
|
AAL2 = {"level": "aal2", "amr": ["pwd", "otp"], "source": "key-cape"}
|
|
|
|
|
|
def _identity(subject, principal_type="human", assurance=None):
|
|
return Identity(
|
|
subject=subject,
|
|
issuer="https://keycape.example",
|
|
audiences=("approval-engine",),
|
|
principal_type=principal_type,
|
|
tenant="tenant:platform",
|
|
roles=frozenset(),
|
|
scopes=frozenset({"approval:create", "approval:read", "approval:approve",
|
|
"approval:revoke", "approval:consume"}),
|
|
assurance=assurance or dict(AAL2),
|
|
evidence_ref=f"test-identity:{subject}",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def people(engine):
|
|
return App(engine, StaticTokenAuthenticator({
|
|
"executor": _identity("service:nk-bind-executor", "service", {"level": "aal1", "source": "key-cape"}),
|
|
"founder": _identity("uid=founder,ou=people,dc=netkingdom,dc=local"),
|
|
"holder": _identity("admin"),
|
|
}))
|
|
|
|
|
|
def _issued(app):
|
|
status, created = call(
|
|
app, "POST", "/v1/approvals",
|
|
{"binding": binding(actor="service:nk-bind-executor", principal="admin"),
|
|
"validity": validity(), "human_control": True},
|
|
authorization="Bearer executor",
|
|
)
|
|
assert status == 201
|
|
status, _ = call(
|
|
app, "POST", f"/v1/approvals/{created['id']}/entries", {},
|
|
authorization="Bearer holder",
|
|
)
|
|
assert status == 200
|
|
return created["id"]
|
|
|
|
|
|
def _revocation(engine, approval_id):
|
|
payload = engine._conn().execute(
|
|
"SELECT payload_json FROM outbox WHERE approval_id=? AND class='revocation'",
|
|
(approval_id,),
|
|
).fetchone()[0]
|
|
return json.loads(payload)
|
|
|
|
|
|
def test_http_records_the_token_revoker_not_the_body(people, engine):
|
|
aid = _issued(people)
|
|
status, body = call(
|
|
people, "POST", f"/v1/approvals/{aid}/revoke",
|
|
{"reason": "pre-start kill switch", "subject": "admin", "principal_type": "human"},
|
|
authorization="Bearer founder",
|
|
)
|
|
assert status == 422
|
|
assert body["error"] == "unprocessable"
|
|
|
|
status, revoked = call(
|
|
people, "POST", f"/v1/approvals/{aid}/revoke",
|
|
{"reason": "pre-start kill switch"},
|
|
authorization="Bearer founder",
|
|
)
|
|
assert status == 200
|
|
assert revoked["status"] == "revoked"
|
|
assert revoked["revoked_by"] == {
|
|
"subject": "uid=founder,ou=people,dc=netkingdom,dc=local",
|
|
"principal_type": "human",
|
|
"assurance": AAL2,
|
|
"reason": "pre-start kill switch",
|
|
}
|
|
assert revoked["revoked_by"]["subject"] != revoked["binding"]["principal"]
|
|
event = _revocation(engine, aid)
|
|
assert event["action"] == "approval.revocation"
|
|
assert event["actor"] == "service:nk-bind-executor"
|
|
assert event["details"]["revoked_by"] == revoked["revoked_by"]
|
|
assert event["details"]["binding_digest"] == revoked["binding"]["digest"]
|
|
status, claim = call(people, "GET", f"/v1/approvals/{aid}/claim", authorization="Bearer founder")
|
|
assert status == 200
|
|
assert claim["reason_code"] == "revoked"
|
|
assert "revoked_by" not in claim
|
|
|
|
|
|
def test_reason_is_optional_and_bounded(people):
|
|
aid = _issued(people)
|
|
status, revoked = call(
|
|
people, "POST", f"/v1/approvals/{aid}/revoke", {},
|
|
authorization="Bearer founder",
|
|
)
|
|
assert status == 200
|
|
assert "reason" not in revoked["revoked_by"]
|
|
|
|
other = _issued(people)
|
|
status, body = call(
|
|
people, "POST", f"/v1/approvals/{other}/revoke",
|
|
{"reason": "x" * 201},
|
|
authorization="Bearer founder",
|
|
)
|
|
assert status == 422
|
|
status, body = call(
|
|
people, "POST", f"/v1/approvals/{other}/revoke",
|
|
{"reason": "two\nlines"},
|
|
authorization="Bearer founder",
|
|
)
|
|
assert status == 422
|
|
status, still = call(people, "GET", f"/v1/approvals/{other}", authorization="Bearer founder")
|
|
assert still["status"] == "approved"
|
|
assert "revoked_by" not in still
|
|
|
|
|
|
def test_internal_revoke_omits_evidence_and_stays_terminal(engine):
|
|
obj = approve(engine)
|
|
revoked = engine.revoke(obj.id)
|
|
assert revoked.status == "revoked"
|
|
assert "revoked_by" not in revoked.as_dict()
|
|
event = _revocation(engine, obj.id)
|
|
assert "revoked_by" not in event["details"]
|
|
with pytest.raises(Conflict):
|
|
engine.revoke(obj.id, revoker=_identity("uid=founder,ou=people,dc=netkingdom,dc=local"))
|
|
with pytest.raises(Unprocessable, match="reason"):
|
|
engine.revoke(obj.id, reason="too late")
|
|
|
|
|
|
def test_legacy_rows_migrate_without_a_revoker(tmp_path):
|
|
path = tmp_path / "v8.sqlite"
|
|
eng = Engine(path, clock=lambda: FROZEN)
|
|
obj = approve(eng)
|
|
eng.revoke(obj.id)
|
|
eng._conn().execute("ALTER TABLE approvals DROP COLUMN revoked_by_subject")
|
|
eng._conn().execute("ALTER TABLE approvals DROP COLUMN revoked_by_principal_type")
|
|
eng._conn().execute("ALTER TABLE approvals DROP COLUMN revoked_by_assurance")
|
|
eng._conn().execute("ALTER TABLE approvals DROP COLUMN revocation_reason")
|
|
eng._conn().execute("PRAGMA user_version=8")
|
|
eng._conn().commit()
|
|
eng.close()
|
|
upgraded = Engine(path, clock=lambda: FROZEN)
|
|
survivor = upgraded.get(obj.id)
|
|
assert survivor.status == "revoked"
|
|
assert "revoked_by" not in survivor.as_dict()
|
|
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9
|
|
upgraded.close()
|