SECURITY: enforce passport status — revocation now takes effect live
All checks were successful
check / gate (push) Successful in 21s

A revoked passport returns HTTP 200, status=revoked, band=unproven,
allowed_actions=[] (verified live 2026-08-13). resolve_passport keyed refusal
only on HTTP 4xx and band=="untrusted", so it returned band 'unproven' and the
agent was seated. Revocation was NOT enforced on the live auth path at all — and
now that agent auth actually works, a revoked agent could authenticate and act.

Extracts decide_trust(body) -> (band, actions) | raise. Only status=="active"
is allowed; revoked/suspended/frozen/unknown all refuse, fail-closed on the
field that carries the most consequential fact about an identity. The agent call
site turns that into a clean 403 passport_revoked.

This is the REAL revocation gate — the token cannot be un-issued, but its
standing is re-checked on every request, so revocation takes effect on the next
call with no webhook required. The Eternitas webhook remains useful for
invalidating locally-issued credentials/grants (G6.3, not built yet), but it was
never the primary gate and its being unwired is no longer a live exposure.

Behavioral tests: revoked body refused, active accepted, unknown/missing status
fails closed.

Co-Authored-By: Claude (Fable 5) <noreply@anthropic.com>
This commit is contained in:
Grant Whitmer
2026-08-13 23:18:16 -04:00
parent c96d1d3102
commit 587fb05265
2 changed files with 76 additions and 3 deletions

View File

@@ -168,3 +168,32 @@ def test_every_throttled_action_has_a_configured_base():
s = Settings()
for action, field in ACTION_BASE.items():
assert getattr(s, field) > 0, f"{action} has no positive base rate"
# ---- revocation enforced on the live trust path (not just the webhook) ----
def test_revoked_passport_is_refused_by_trust_decision():
"""A revoked passport returns HTTP 200, status=revoked, band=unproven,
allowed=[] (verified live 2026-08-13). The decision must refuse it — the
old code returned band 'unproven' and seated the agent."""
from api.app.auth import PassportNotInGoodStanding, decide_trust
revoked = {"status": "revoked", "band": "unproven", "allowed_actions": []}
with pytest.raises(PassportNotInGoodStanding):
decide_trust(revoked, "ET26-NJQT-QMR0")
def test_active_passport_is_accepted_by_trust_decision():
from api.app.auth import decide_trust
active = {"status": "active", "band": "gold", "allowed_actions": ["read", "send"]}
band, actions = decide_trust(active, "ET26-1EF9-VJAN")
assert band == "gold" and actions == ("read", "send")
def test_unknown_or_missing_status_fails_closed():
from api.app.auth import PassportNotInGoodStanding, decide_trust
for body in ({"band": "gold"}, {"status": "suspended"}, {"status": "frozen"},
{"status": ""}, {}):
with pytest.raises(PassportNotInGoodStanding):
decide_trust(body, "ET26-X")