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