approval-engine/tests/test_pep.py
tegwick 6d18f62a90 Set the approval store tenant to exact tenant:platform
Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management, administration and services tenant, with no alias
to platform or tenant:coulomb and no implicit cross-tenant grant. This closes
the collision recorded in 5c87ba8, where the manifest served --tenant platform
while the requested registrations issued tenant:coulomb.

The store tenant is now exactly tenant:platform in the manifest, the CLI
default, and the Engine default, and the requested client registrations ask for
the same spelling. Exact JWT/store equality is retained: no mapping table, no
normalisation, no prefix handling.

Moving the defaults rather than only the manifest is deliberate. A default of
platform under a sanctioned value of tenant:platform is a trap, because a serve
that omits --tenant would come up healthy and then refuse every authenticated
call -- the exact failure this decision exists to prevent.

That default change broke ten tests whose identity fixtures hard-coded
platform. This is the hazard flex-auth reported as FLEX-DEC-2026-008: fixtures
that all carry one tenant prove nothing about the tenant field. Fixtures are
aligned to the exact spelling, and the field is now varied rather than merely
present. test_near_miss_tenant_spellings_are_forbidden refuses platform,
tenant:coulomb, case variants, whitespace variants and empty against a
tenant:platform store; test_exact_sanctioned_tenant_is_admitted pins the other
half so a reject-everything bug cannot pass it. 111 tests pass.

Also records the credential-independent half of the GLAS-WP-0015 image request:
the image builds non-root uid 10001 off the pinned base, carries schema v3 and
the new tenant default, migrates and verifies a fresh store to schema_version 3
with integrity ok, and refuses production without a persistent database or
authenticated audit delivery. No scan was run -- no scanner is installed here --
and no release digest exists, so T01 and T03 both stay open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM5HnEAhokxdfcPqBNpT7D

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715850@bnt-lap001
Assistant-Session: eb557e93-7cb1-45d0-9e57-7d15b3edc60e
2026-09-06 22:33:50 +02:00

296 lines
9.3 KiB
Python

import json
import threading
from io import BytesIO
from urllib.error import HTTPError, URLError
from wsgiref.simple_server import make_server
import pytest
from approval_engine.api import App, call
from approval_engine.auth import Identity, StaticTokenAuthenticator
from approval_engine.pep import (
ApprovalHTTPClient,
ApprovalProtocolError,
ProtectedActionHarness,
)
from approval_engine.store import Engine
from tests.conftest import FROZEN, binding, validity
DIGEST = "sha256:" + "ab" * 32
OTHER = "sha256:" + "cd" * 32
class Client:
def __init__(self, claim=None, consume=None, failure=None):
self.claim_result = claim or {"valid_now": True, "consumed": False}
self.consume_result = consume
self.failure = failure
self.calls = []
def claim(self, approval_id):
self.calls.append("claim")
if self.failure == "claim":
raise ApprovalProtocolError("down")
return self.claim_result
def consume(self, approval_id, digest, decision_id):
self.calls.append("consume")
if self.failure == "consume":
raise ApprovalProtocolError("conflict")
return self.consume_result or {"status": "consumed", "request_digest": digest}
class Response:
def __init__(self, body, status=200):
self.body = json.dumps(body).encode()
self.status = status
def getcode(self):
return self.status
def read(self, _size):
return self.body
def close(self):
pass
def _token(tmp_path, value="test-token"):
path = tmp_path / "token"
path.write_text(value)
return path
def allow(_claim):
return {"effect": "ALLOW", "decision_id": "decision:1", "request_digest": DIGEST}
def test_http_client_rereads_mounted_token(tmp_path):
token = _token(tmp_path, "first")
seen = []
def opener(request, timeout):
seen.append((request.get_header("Authorization"), timeout))
return Response({"valid_now": True, "consumed": False})
client = ApprovalHTTPClient("http://approval-engine:8080", token, opener=opener)
client.claim("approval-1")
token.write_text("second")
client.claim("approval-1")
assert [item[0] for item in seen] == ["Bearer first", "Bearer second"]
def test_side_effect_occurs_only_after_claim_decision_and_consume():
client = Client()
order = client.calls
result = ProtectedActionHarness(client).execute(
"approval:1",
DIGEST,
lambda claim: (order.append("decision"), allow(claim))[1],
lambda: (order.append("side-effect"), "dry-run-only")[1],
)
assert result == "dry-run-only"
assert order == ["claim", "decision", "consume", "side-effect"]
@pytest.mark.parametrize("failure", ["claim", "consume"])
def test_unavailable_or_conflicting_engine_prevents_side_effect(failure):
client = Client(failure=failure)
effects = []
with pytest.raises(ApprovalProtocolError):
ProtectedActionHarness(client).execute(
"approval:1", DIGEST, allow, lambda: effects.append("called")
)
assert effects == []
def test_deny_or_digest_mismatch_prevents_consume_and_side_effect():
for decision in (
{"effect": "DENY", "decision_id": "decision:1", "request_digest": DIGEST},
{"effect": "ALLOW", "decision_id": "decision:1", "request_digest": OTHER},
):
client = Client()
effects = []
with pytest.raises(ApprovalProtocolError):
ProtectedActionHarness(client).execute(
"approval:1",
DIGEST,
lambda _claim, value=decision: value,
lambda: effects.append("called"),
)
assert client.calls == ["claim"]
assert effects == []
def test_consume_http_errors_fail_closed(tmp_path):
def opener(request, timeout):
raise HTTPError(
request.full_url,
409,
"conflict",
hdrs=None,
fp=BytesIO(b'{"error":"conflict"}'),
)
client = ApprovalHTTPClient("http://approval.test", _token(tmp_path), opener=opener)
with pytest.raises(ApprovalProtocolError, match="conflict"):
client.consume("approval-1", DIGEST, "decision:1")
def test_unreachable_engine_fails_closed(tmp_path):
def opener(request, timeout):
raise URLError("down")
client = ApprovalHTTPClient("http://approval.test", _token(tmp_path), opener=opener)
with pytest.raises(ApprovalProtocolError, match="unreachable"):
client.claim("approval-1")
def test_consume_rejects_non_canonical_digest_before_http(tmp_path):
client = ApprovalHTTPClient(
"http://approval.test",
_token(tmp_path),
opener=lambda *_args, **_kwargs: pytest.fail("must not HTTP"),
)
with pytest.raises(ApprovalProtocolError, match="canonical request digest"):
client.consume("approval-1", "not-a-digest", "decision:1")
def test_consume_does_not_treat_decision_shaped_payload_as_permission(tmp_path):
def opener(request, timeout):
if request.get_method() == "GET":
return Response({"valid_now": True, "consumed": False})
return Response(
{
"status": "consumed",
"request_digest": DIGEST,
"effect": "ALLOW",
}
)
client = ApprovalHTTPClient("http://approval.test", _token(tmp_path), opener=opener)
effects = []
with pytest.raises(ApprovalProtocolError, match="not a permission"):
ProtectedActionHarness(client).execute(
"approval-1", DIGEST, allow, lambda: effects.append("called")
)
assert effects == []
@pytest.fixture
def live_http(tmp_path):
engine = Engine(tmp_path / "approval.sqlite", clock=lambda: FROZEN)
identity = Identity(
subject="agt-secrets-engine",
issuer="https://keycape.example",
audiences=("approval-engine",),
principal_type="service",
tenant="tenant:platform",
roles=frozenset({"secrets-engine"}),
scopes=frozenset(
{
"approval:create",
"approval:read",
"approval:approve",
"approval:revoke",
"approval:supersede",
"approval:consume",
"approval:observe",
"approval:emit",
}
),
assurance={"level": "aal1", "methods": ["test"], "source": "test"},
evidence_ref="test-identity:test-token",
)
app = App(engine, StaticTokenAuthenticator({"test-token": identity}))
server = make_server("127.0.0.1", 0, app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}", app
finally:
server.shutdown()
thread.join(timeout=2)
engine.close()
def _approved(app):
_, created = call(
app,
"POST",
"/v1/approvals",
{"binding": binding(), "validity": validity()},
)
aid = created["id"]
call(app, "POST", f"/v1/approvals/{aid}/entries", {})
return aid
def test_live_http_pep_sequence_consumes_before_callback(live_http, tmp_path):
base_url, app = live_http
aid = _approved(app)
client = ApprovalHTTPClient(base_url, _token(tmp_path))
order = []
result = ProtectedActionHarness(client).execute(
aid,
DIGEST,
lambda claim: (
order.append("decision"),
{
"effect": "ALLOW",
"decision_id": "decision:live",
"request_digest": DIGEST,
},
)[1],
lambda: (order.append("side-effect"), "dry-run-only")[1],
)
assert result == "dry-run-only"
assert order == ["decision", "side-effect"]
retry = client.consume(aid, DIGEST, "decision:live")
assert retry["status"] == "consumed"
assert retry["idempotent"] is True
assert retry["request_digest"] == DIGEST
status, claim = call(app, "GET", f"/v1/approvals/{aid}/claim")
assert status == 200
assert claim["consumed"] is True
assert claim["valid_now"] is False
def test_live_http_conflict_and_spent_approval_prevent_side_effect(live_http, tmp_path):
base_url, app = live_http
aid = _approved(app)
client = ApprovalHTTPClient(base_url, _token(tmp_path))
ProtectedActionHarness(client).execute(
aid,
DIGEST,
lambda _claim: {
"effect": "ALLOW",
"decision_id": "decision:live",
"request_digest": DIGEST,
},
lambda: "first",
)
effects = []
with pytest.raises(ApprovalProtocolError, match="conflict"):
client.consume(aid, OTHER, "decision:other")
with pytest.raises(ApprovalProtocolError, match="not valid for use"):
ProtectedActionHarness(client).execute(
aid,
DIGEST,
lambda _claim: pytest.fail("must not decide"),
lambda: effects.append("called"),
)
assert effects == []
def test_live_http_unavailability_prevents_side_effect(tmp_path):
client = ApprovalHTTPClient("http://127.0.0.1:1", _token(tmp_path), timeout_seconds=0.2)
effects = []
with pytest.raises(ApprovalProtocolError, match="unreachable"):
ProtectedActionHarness(client).execute(
"approval-missing",
DIGEST,
allow,
lambda: effects.append("called"),
)
assert effects == []