APPROVAL-WP-0007: record the verified revoker and park the inactive plans
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
This commit is contained in:
parent
f41a9ca82c
commit
4aa0b75449
17 changed files with 599 additions and 36 deletions
|
|
@ -159,7 +159,7 @@ def test_legacy_v7_rows_migrate_without_the_flag(tmp_path):
|
|||
survivor = upgraded.get(obj.id)
|
||||
assert survivor.actor_consumes is False
|
||||
assert "actor_consumes" not in upgraded.claim(obj.id)["binding"]
|
||||
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8
|
||||
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9
|
||||
upgraded.consume(obj.id, obj.binding_digest, consumer=_identity("svc-other"))
|
||||
upgraded.close()
|
||||
|
||||
|
|
|
|||
200
tests/test_bounded_execution_carrier.py
Normal file
200
tests/test_bounded_execution_carrier.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""APPROVAL-WP-0007-T04: synthetic bounded-execution carrier path.
|
||||
|
||||
The request is the published example. Nothing here contacts a live service.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from approval_engine.api import App, call
|
||||
from approval_engine.auth import Identity, StaticTokenAuthenticator
|
||||
from approval_engine.store import Engine
|
||||
from tests.conftest import FROZEN
|
||||
|
||||
EXAMPLE = json.loads(
|
||||
(Path(__file__).parents[1] / "examples" / "bounded-execution-approval-request.json").read_text()
|
||||
)
|
||||
ADMISSION_DIGEST = EXAMPLE["binding"]["target"]["admission_digest"]
|
||||
OTHER_DIGEST = "sha256:" + "ab" * 32
|
||||
APPROVER = "uid=admin,ou=people,dc=netkingdom,dc=local"
|
||||
FOUNDER = "uid=founder,ou=people,dc=netkingdom,dc=local"
|
||||
|
||||
|
||||
def _identity(subject, principal_type, level):
|
||||
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={"level": level, "amr": ["pwd", "otp"], "source": "key-cape"},
|
||||
evidence_ref=f"test-identity:{subject}",
|
||||
)
|
||||
|
||||
|
||||
def _app(engine):
|
||||
return App(engine, StaticTokenAuthenticator({
|
||||
"executor": _identity(EXAMPLE["binding"]["actor"], "service", "aal1"),
|
||||
"aal1": _identity(APPROVER, "human", "aal1"),
|
||||
"human": _identity(APPROVER, "human", "aal2"),
|
||||
"founder": _identity(FOUNDER, "human", "aal2"),
|
||||
"other": _identity("service:someone-else", "service", "aal1"),
|
||||
}))
|
||||
|
||||
|
||||
def test_synthetic_bounded_execution_carrier():
|
||||
clock = [FROZEN]
|
||||
engine = Engine(":memory:", clock=lambda: clock[0])
|
||||
app = _app(engine)
|
||||
try:
|
||||
request = json.loads(json.dumps(EXAMPLE))
|
||||
assert request["binding"]["action"] == "bounded-execution"
|
||||
assert request["human_control"] is True and request["pdp_path"] is False
|
||||
assert set(request["binding"]["target"]) == {"admission_id", "admission_digest"}
|
||||
|
||||
status, created = call(
|
||||
app, "POST", "/v1/approvals", request, authorization="Bearer executor",
|
||||
)
|
||||
assert status == 201
|
||||
aid = created["id"]
|
||||
assert created["status"] == "requested"
|
||||
assert created["binding"]["actor"] == request["binding"]["actor"]
|
||||
assert created["binding"]["target"] == request["binding"]["target"]
|
||||
|
||||
status, refused = call(
|
||||
app, "POST", f"/v1/approvals/{aid}/entries", {}, authorization="Bearer aal1",
|
||||
)
|
||||
assert status == 403 and refused["error"] == "forbidden"
|
||||
|
||||
status, approved = call(
|
||||
app, "POST", f"/v1/approvals/{aid}/entries", {}, authorization="Bearer human",
|
||||
)
|
||||
assert status == 200 and approved["status"] == "approved"
|
||||
assert approved["entries"][0]["subject_id"] == APPROVER
|
||||
assert approved["entries"][0]["principal_type"] == "human"
|
||||
assert json.loads(approved["entries"][0]["assurance"])["level"] == "aal2"
|
||||
assert APPROVER != request["binding"]["principal"]
|
||||
|
||||
status, fetched = call(
|
||||
app, "GET", f"/v1/approvals/{aid}", authorization="Bearer executor",
|
||||
)
|
||||
assert status == 200
|
||||
for field in ("action", "actor", "principal", "purpose", "target"):
|
||||
assert fetched["binding"][field] == request["binding"][field]
|
||||
assert fetched["binding"]["digest"] == created["binding"]["digest"]
|
||||
assert fetched["binding"]["human_control"] is True
|
||||
assert fetched["binding"]["pdp_path"] is False
|
||||
assert fetched["binding"]["required_assurance"] == {"level": "aal2"}
|
||||
assert fetched["binding"]["actor_consumes"] is True
|
||||
assert fetched["validity"] == request["validity"]
|
||||
|
||||
status, other = call(
|
||||
app, "POST", f"/v1/approvals/{aid}/consume",
|
||||
{"request_digest": ADMISSION_DIGEST, "decision_id": "run-1"},
|
||||
authorization="Bearer other",
|
||||
)
|
||||
assert status == 403 and other["error"] == "forbidden"
|
||||
|
||||
status, spent = call(
|
||||
app, "POST", f"/v1/approvals/{aid}/consume",
|
||||
{"request_digest": ADMISSION_DIGEST, "decision_id": "run-1"},
|
||||
authorization="Bearer executor",
|
||||
)
|
||||
assert status == 200
|
||||
assert spent["status"] == "consumed"
|
||||
assert spent["request_digest"] == ADMISSION_DIGEST
|
||||
assert spent["idempotent"] is False
|
||||
|
||||
status, again = call(
|
||||
app, "POST", f"/v1/approvals/{aid}/consume",
|
||||
{"request_digest": ADMISSION_DIGEST, "decision_id": "run-1"},
|
||||
authorization="Bearer executor",
|
||||
)
|
||||
assert status == 200 and again["idempotent"] is True
|
||||
assert again["request_digest"] == ADMISSION_DIGEST
|
||||
assert again["consumed_at"] == spent["consumed_at"]
|
||||
|
||||
status, conflict = call(
|
||||
app, "POST", f"/v1/approvals/{aid}/consume",
|
||||
{"request_digest": OTHER_DIGEST, "decision_id": "run-2"},
|
||||
authorization="Bearer executor",
|
||||
)
|
||||
assert status == 409 and conflict["error"] == "conflict"
|
||||
|
||||
# Revoke before consume, by someone who is not the bound principal.
|
||||
status, second = call(
|
||||
app, "POST", "/v1/approvals", request, authorization="Bearer executor",
|
||||
)
|
||||
assert status == 201
|
||||
second_id = second["id"]
|
||||
assert call(
|
||||
app, "POST", f"/v1/approvals/{second_id}/entries", {},
|
||||
authorization="Bearer human",
|
||||
)[0] == 200
|
||||
status, revoked = call(
|
||||
app, "POST", f"/v1/approvals/{second_id}/revoke",
|
||||
{"reason": "founder stopped the run before consume"},
|
||||
authorization="Bearer founder",
|
||||
)
|
||||
assert status == 200 and revoked["status"] == "revoked"
|
||||
assert revoked["revoked_by"]["subject"] == FOUNDER
|
||||
assert revoked["revoked_by"]["subject"] != request["binding"]["principal"]
|
||||
status, blocked = call(
|
||||
app, "POST", f"/v1/approvals/{second_id}/consume",
|
||||
{"request_digest": ADMISSION_DIGEST, "decision_id": "run-revoked"},
|
||||
authorization="Bearer executor",
|
||||
)
|
||||
assert status == 409 and blocked["error"] == "conflict"
|
||||
|
||||
# Consumption outside the window is refused. Issue and approve while
|
||||
# the clock is inside, then move it onto the exclusive expiry.
|
||||
short = json.loads(json.dumps(request))
|
||||
short["validity"] = {
|
||||
"not_before": "2026-08-29T11:00:00+00:00",
|
||||
"expires_at": "2026-08-29T12:30:00+00:00",
|
||||
}
|
||||
status, third = call(
|
||||
app, "POST", "/v1/approvals", short, authorization="Bearer executor",
|
||||
)
|
||||
assert status == 201
|
||||
third_id = third["id"]
|
||||
assert call(
|
||||
app, "POST", f"/v1/approvals/{third_id}/entries", {},
|
||||
authorization="Bearer human",
|
||||
)[0] == 200
|
||||
clock[0] = datetime(2026, 8, 29, 12, 30, tzinfo=timezone.utc)
|
||||
status, late = call(
|
||||
app, "POST", f"/v1/approvals/{third_id}/consume",
|
||||
{"request_digest": ADMISSION_DIGEST, "decision_id": "run-late"},
|
||||
authorization="Bearer executor",
|
||||
)
|
||||
assert status == 409 and "window" in late["message"]
|
||||
|
||||
early = json.loads(json.dumps(request))
|
||||
early["validity"] = {
|
||||
"not_before": "2026-08-29T13:00:00+00:00",
|
||||
"expires_at": "2026-08-29T15:00:00+00:00",
|
||||
}
|
||||
clock[0] = FROZEN
|
||||
status, fourth = call(
|
||||
app, "POST", "/v1/approvals", early, authorization="Bearer executor",
|
||||
)
|
||||
assert status == 201
|
||||
fourth_id = fourth["id"]
|
||||
assert call(
|
||||
app, "POST", f"/v1/approvals/{fourth_id}/entries", {},
|
||||
authorization="Bearer human",
|
||||
)[0] == 200
|
||||
status, soon = call(
|
||||
app, "POST", f"/v1/approvals/{fourth_id}/consume",
|
||||
{"request_digest": ADMISSION_DIGEST, "decision_id": "run-early"},
|
||||
authorization="Bearer executor",
|
||||
)
|
||||
assert status == 409 and "window" in soon["message"]
|
||||
finally:
|
||||
engine.close()
|
||||
|
|
@ -144,7 +144,7 @@ def test_legacy_v4_rows_are_not_reclassified_from_human_entries(tmp_path):
|
|||
assert survivor.entries[0]["principal_type"] == "human"
|
||||
assert survivor.human_control is False
|
||||
assert upgraded.claim(obj.id)["binding"]["human_control"] is False
|
||||
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8
|
||||
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9
|
||||
upgraded.close()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -159,5 +159,5 @@ def test_legacy_v6_rows_migrate_to_null_floor(tmp_path):
|
|||
survivor = upgraded.get(obj.id)
|
||||
assert survivor.status == "approved" and survivor.required_assurance is None
|
||||
assert upgraded.claim(obj.id)["valid_now"] is True
|
||||
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8
|
||||
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9
|
||||
upgraded.close()
|
||||
|
|
|
|||
160
tests/test_revoker.py
Normal file
160
tests/test_revoker.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""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()
|
||||
|
|
@ -79,7 +79,7 @@ def test_v5_migration_preserves_unknown_provenance_and_existing_evidence(tmp_pat
|
|||
with pytest.raises(StoreUnavailable, match="migrate"):
|
||||
Engine(path, auto_migrate=False)
|
||||
engine = Engine(path, clock=lambda: FROZEN)
|
||||
assert engine.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8
|
||||
assert engine.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9
|
||||
assert engine.get(obj.id).entries[0]["tenant_source"] is None
|
||||
assert engine.get(obj.id).binding_digest == digest
|
||||
assert engine.get(obj.id).human_control is True
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue