115 lines
6 KiB
Python
115 lines
6 KiB
Python
|
|
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"
|