approval-engine/tests/test_revoker.py

161 lines
5.8 KiB
Python
Raw Normal View History

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