informed-decision/tests/test_policy_client.py

115 lines
6 KiB
Python
Raw Normal View History

import copy
from dataclasses import replace
from datetime import datetime, timezone
import json
import time
import pytest
from informed_decision.http_transport import TransportError
from informed_decision.oidc import HumanSession
from informed_decision.policy import CONTRACT, PolicyClient, PolicyError, build_request, submitted_digest
from informed_decision.provenance import Claim, Route
from test_skeleton import make_memo
PIN = "sha256:" + "b" * 64
STAMP = lambda t: datetime.fromtimestamp(t, timezone.utc).isoformat()
def human():
return HumanSession("human-fixture", Claim("tenant:platform", Route.REGISTRATION),
Claim("human", Route.AUTHENTICATION), {"at": int(time.time()) - 20, "level": "aal2", "mfa": True,
"methods": ["pwd", "otp"], "source": "key-cape"}, time.time()+600, "synthetic-browser-token", roles=("Reviewer",))
def envelope(request):
now = time.time()
return {"id": "decision-fixture", "contract_version": CONTRACT, "request_id": request["id"],
"effect": "allow", "matched_policy_version": "v1", "subject": copy.deepcopy(request["subject"]),
"resource": copy.deepcopy(request["resource"]),
"binding": {**copy.deepcopy({k:request[k] for k in ("tenant","subject","action","resource","context")}),
"request_digest": "sha256:" + "e" * 64, "submitted_request_digest": submitted_digest(request)},
"lifetime": {"kind":"ttl", "not_before":STAMP(now-1), "expires_at":STAMP(now+60)},
"provenance": {"evaluator":"flex-auth/local", "mode":"standalone", "policy_package":"informed-decision.fixture",
"policy_version":"v1", "policy_package_digest":PIN, "registry_snapshot_digest":"sha256:"+"c"*64,
"decision_time":STAMP(now)}}
class PolicyTransport:
def __init__(self): self.calls=[]; self.change=lambda d: None; self.status=200
def request(self, method, url, *, headers=None, body=None):
self.calls.append((method,url,headers,json.loads(body)))
data=envelope(json.loads(body)); self.change(data)
return self.status, data
def policy(transport=None):
return PolicyClient("https://policy.test", lambda:"synthetic-workload-token", package="informed-decision.fixture",
version="v1", package_digest=PIN, transport=transport or PolicyTransport())
def request():
return build_request(human(), make_memo(approval_binding_digest="sha256:"+"a"*64), "read", "v1")
def test_request_preserves_identity_routes_and_excludes_presentation_claims():
req=request(); attributes=req["subject"]["attributes"]
assert attributes["tenant_source"]=="registration-supplied" and attributes["principal_type_source"]=="authentication-derived"
assert attributes["roles"]==["Reviewer"]
assert set(req["context"])=={"memo_version","approval_id","approval_binding_digest"}
assert "synthetic-browser-token" not in json.dumps(req)
assert not any(k in json.dumps(req) for k in ["view_hash","presentation_id","acked_highlight_ids"])
def test_fresh_exact_enriched_decision_and_separate_workload_identity():
client=policy();req=request();observed=client.check(req)
observed.require_current(time.time())
data=json.loads(observed.decision_json)
assert data["binding"]["request_digest"]!=data["binding"]["submitted_request_digest"]
assert client.transport.calls[0][2]["Authorization"]=="Bearer synthetic-workload-token"
assert observed.usable_until<=time.time()+30
with pytest.raises(PolicyError):observed.require_current(time.time()+31)
@pytest.mark.parametrize("mutate", [
lambda d:d.update(contract_version="unknown"), lambda d:d.update(request_id="other"),
lambda d:d["binding"].pop("submitted_request_digest"),
lambda d:d["binding"].update(submitted_request_digest="sha256:"+"0"*64),
lambda d:d["binding"].update(action="accept"), lambda d:d["binding"]["subject"].update(id="other"),
lambda d:d["binding"]["resource"].update(system="other"),
lambda d:d["binding"]["context"].update(memo_version=99),
lambda d:d["binding"].update(tenant="tenant:other"), lambda d:d["resource"].update(id="memo:other"),
lambda d:d["provenance"].update(policy_package="other"), lambda d:d["provenance"].update(policy_version="other"),
lambda d:d["provenance"].update(policy_package_digest="sha256:"+"0"*64),
lambda d:d.update(matched_policy_version="other"), lambda d:d["provenance"].pop("registry_snapshot_digest"),
lambda d:d["provenance"].update(decision_time=STAMP(time.time()-300)),
lambda d:d["lifetime"].update(expires_at=STAMP(time.time()-1)),
lambda d:d["lifetime"].update(not_before=STAMP(time.time()+300)),
lambda d:d["lifetime"].update(kind="forever"), lambda d:d.update(lifetime={}),
lambda d:d.update(obligations=[{"type":"unimplemented"}]), lambda d:d.update(obligations={}),
lambda d:d.update(obligations=None), lambda d:d.update(effect="redact"),
lambda d:d.update(effect="audit_only"), lambda d:d.update(effect="not_applicable"),
lambda d:d.update(effect="deny"), lambda d:d.update(diagnostics={"bad":float("nan")}),
])
def test_unusable_decision_never_allows(mutate):
transport=PolicyTransport();transport.change=mutate
observed=policy(transport).check(request())
with pytest.raises(PolicyError):observed.require_current(time.time())
@pytest.mark.parametrize("status", [400,401,403,404,409,500,503])
def test_upstream_errors_never_persist_response_body(status):
transport=PolicyTransport();transport.status=status
transport.change=lambda d:d.update(reason="secret-sentinel",diagnostics={"token":"secret-sentinel"})
observed=policy(transport).check(request())
assert observed.decision_json is None and "secret-sentinel" not in repr(observed)
with pytest.raises(PolicyError):observed.require_current(time.time())
def test_transport_and_missing_caller_have_typed_observations():
class Broken:
def request(self,*a,**kw):raise TransportError("secret-sentinel")
client=policy(Broken());assert client.check(request()).outcome=="policy_unavailable"
client.token_provider=lambda:None
assert client.check(request()).outcome=="caller_unavailable"