Implement USER-WP-0024 security layer conformance
Declare Engine/PIP machine-readably, publish a total fail-closed PEP stance map, stop minting local decision ids on engine-unavailable DENY, bind allows to a 30s request lifetime, confine the local authorization double, classify evidence and emit a denial/revocation heartbeat, and prove access-control facts remain claims. Assistant: grok Assistant-Session: 01a04cea-f0d6-7ab3-9ffd-881eb6bea6cb
This commit is contained in:
parent
c7b6148a70
commit
4349758608
22 changed files with 1242 additions and 89 deletions
230
tests/test_evidence.py
Normal file
230
tests/test_evidence.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import unittest
|
||||
from dataclasses import fields
|
||||
from datetime import timedelta
|
||||
|
||||
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
|
||||
from user_engine.domain import (
|
||||
AccessControlFact,
|
||||
AccessMembershipRequirement,
|
||||
AccessProfile,
|
||||
ActiveAccessContext,
|
||||
AccountStatus,
|
||||
AuthorizationDecision,
|
||||
AuthorizationEffect,
|
||||
)
|
||||
from user_engine.evidence import (
|
||||
HEARTBEAT_EVENT_TYPE,
|
||||
classify_audit,
|
||||
classify_outbox,
|
||||
load_bearing_counts,
|
||||
)
|
||||
from user_engine.service import AccessControlFactExport, AccessProfileSelection, UserEngineService
|
||||
from user_engine.testing.fixtures import (
|
||||
FixtureIdentityClaimsAdapter,
|
||||
human_actor_claims,
|
||||
sample_application,
|
||||
sample_application_binding,
|
||||
sample_catalog,
|
||||
)
|
||||
|
||||
|
||||
_FORBIDDEN_DECISION_FIELDS = frozenset(
|
||||
{"effect", "allowed", "decision", "decision_id", "deny", "allow"}
|
||||
)
|
||||
|
||||
|
||||
class EvidenceClassificationTests(unittest.TestCase):
|
||||
def test_denials_and_revocations_are_load_bearing(self):
|
||||
self.assertEqual(classify_audit("authorization denied"), "load-bearing")
|
||||
self.assertEqual(
|
||||
classify_audit("authorization denied (stance fail_closed)"),
|
||||
"load-bearing",
|
||||
)
|
||||
self.assertEqual(classify_outbox("account.status_changed"), "load-bearing")
|
||||
self.assertEqual(classify_outbox("prepared_account.revoked"), "load-bearing")
|
||||
self.assertEqual(classify_outbox("user.created"), "attributive")
|
||||
self.assertEqual(classify_outbox(HEARTBEAT_EVENT_TYPE), "heartbeat")
|
||||
|
||||
def test_heartbeat_counts_load_bearing_events_and_does_not_claim_completeness(self):
|
||||
service, _, _ = _service()
|
||||
session = service.me(_claims(), correlation_id="corr-me")
|
||||
service.set_account_status(
|
||||
session.actor,
|
||||
session.user.user_id,
|
||||
AccountStatus.DISABLED,
|
||||
correlation_id="corr-disable",
|
||||
)
|
||||
deny = LocalAuthorizationCheckPort(default_effect=AuthorizationEffect.DENY)
|
||||
denied = UserEngineService(
|
||||
store=service.store,
|
||||
identity_adapter=FixtureIdentityClaimsAdapter(),
|
||||
authorization=deny,
|
||||
)
|
||||
from user_engine.errors import AuthorizationDenied
|
||||
|
||||
with self.assertRaises(AuthorizationDenied):
|
||||
denied.create_user(
|
||||
session.actor,
|
||||
display_name="x",
|
||||
primary_email=None,
|
||||
correlation_id="corr-denied",
|
||||
)
|
||||
|
||||
event = service.record_evidence_heartbeat(correlation_id="corr-heartbeat")
|
||||
self.assertEqual(event.event_type, HEARTBEAT_EVENT_TYPE)
|
||||
self.assertFalse(event.payload["completeness_claimed"])
|
||||
self.assertEqual(event.payload["form"], "heartbeat")
|
||||
self.assertGreaterEqual(event.payload["load_bearing"]["account.status_changed"], 1)
|
||||
self.assertGreaterEqual(event.payload["load_bearing"]["authorization_denied"], 1)
|
||||
self.assertIn("were not altered or truncated after arrival", event.payload["bound"])
|
||||
self.assertNotIn("complete", event.payload["bound"])
|
||||
|
||||
counts = load_bearing_counts(service.store.audit_log(), service.store.pending_outbox())
|
||||
self.assertEqual(counts["account.status_changed"], 1)
|
||||
|
||||
def test_allow_without_lifetime_is_denied(self):
|
||||
from user_engine.errors import AuthorizationDenied
|
||||
|
||||
class _StandingGrant:
|
||||
def check(self, request):
|
||||
return AuthorizationDecision(effect=AuthorizationEffect.ALLOW, reason="standing")
|
||||
|
||||
store = InMemoryUserEngineStore()
|
||||
service = UserEngineService(
|
||||
store=store,
|
||||
identity_adapter=FixtureIdentityClaimsAdapter(),
|
||||
authorization=_StandingGrant(),
|
||||
)
|
||||
with self.assertRaisesRegex(AuthorizationDenied, "no lifetime"):
|
||||
service.me(_claims(), correlation_id="corr-standing")
|
||||
self.assertEqual(store.audit_log()[-1].summary, "authorization denied (allow has no lifetime)")
|
||||
self.assertIsNone(store.audit_log()[-1].decision_id)
|
||||
|
||||
def test_expired_allow_is_denied(self):
|
||||
from user_engine.errors import AuthorizationDenied
|
||||
|
||||
class _StaleAllow:
|
||||
def check(self, request):
|
||||
decision = AuthorizationDecision.for_standalone(
|
||||
AuthorizationEffect.ALLOW, reason="stale"
|
||||
)
|
||||
return AuthorizationDecision(
|
||||
effect=decision.effect,
|
||||
reason=decision.reason,
|
||||
binding=decision.binding,
|
||||
lifetime=timedelta(seconds=1),
|
||||
issued_at=decision.issued_at - timedelta(seconds=5),
|
||||
)
|
||||
|
||||
store = InMemoryUserEngineStore()
|
||||
service = UserEngineService(
|
||||
store=store,
|
||||
identity_adapter=FixtureIdentityClaimsAdapter(),
|
||||
authorization=_StaleAllow(),
|
||||
)
|
||||
with self.assertRaisesRegex(AuthorizationDenied, "expired"):
|
||||
service.me(_claims(), correlation_id="corr-stale")
|
||||
|
||||
|
||||
class AccessControlFactsAreClaimsTests(unittest.TestCase):
|
||||
def test_claim_shapes_have_no_authorization_effect(self):
|
||||
for model in (AccessControlFact, AccessProfile, ActiveAccessContext):
|
||||
names = {item.name for item in fields(model)}
|
||||
overlap = names & _FORBIDDEN_DECISION_FIELDS
|
||||
self.assertFalse(
|
||||
overlap,
|
||||
f"{model.__name__} carries decision fields {overlap}",
|
||||
)
|
||||
|
||||
def test_hat_selection_and_export_never_return_allow_or_deny(self):
|
||||
service, _, _ = _service()
|
||||
session = _bootstrap(service)
|
||||
service.add_membership(
|
||||
session.actor,
|
||||
session.user.user_id,
|
||||
tenant="tenant:coulomb",
|
||||
scope_type="realm",
|
||||
scope_id="realm:citadel",
|
||||
kind="operator",
|
||||
correlation_id="corr-realm-membership",
|
||||
)
|
||||
profile = service.register_access_profile(
|
||||
session.actor,
|
||||
AccessProfile(
|
||||
tenant="tenant:coulomb",
|
||||
display_name="Operator",
|
||||
hat="operator",
|
||||
realm_id="realm:citadel",
|
||||
membership_requirements=(
|
||||
AccessMembershipRequirement(
|
||||
scope_type="realm",
|
||||
scope_id="realm:citadel",
|
||||
kind="operator",
|
||||
),
|
||||
),
|
||||
),
|
||||
correlation_id="corr-profile-register",
|
||||
)
|
||||
selection = service.select_active_hat(
|
||||
session.actor,
|
||||
session.user.user_id,
|
||||
profile.access_profile_id,
|
||||
correlation_id="corr-select-hat",
|
||||
)
|
||||
export = service.export_access_control_facts(
|
||||
session.actor,
|
||||
tenant="tenant:coulomb",
|
||||
user_id=session.user.user_id,
|
||||
correlation_id="corr-export-facts",
|
||||
)
|
||||
self.assertIsInstance(selection, AccessProfileSelection)
|
||||
self.assertIsInstance(export, AccessControlFactExport)
|
||||
self._assert_no_decision_payload(selection)
|
||||
self._assert_no_decision_payload(export)
|
||||
for fact in export.facts:
|
||||
self._assert_no_decision_payload(fact)
|
||||
|
||||
def _assert_no_decision_payload(self, value) -> None:
|
||||
blob = repr(value).lower()
|
||||
self.assertNotIn("authorizationeffect", blob)
|
||||
self.assertNotIn("effect=allow", blob)
|
||||
self.assertNotIn("effect=deny", blob)
|
||||
if hasattr(value, "__dict__") or hasattr(value, "__dataclass_fields__"):
|
||||
names = {item.name for item in fields(type(value))}
|
||||
self.assertFalse(names & _FORBIDDEN_DECISION_FIELDS)
|
||||
|
||||
|
||||
def _service():
|
||||
store = InMemoryUserEngineStore()
|
||||
service = UserEngineService(
|
||||
store=store,
|
||||
identity_adapter=FixtureIdentityClaimsAdapter(),
|
||||
authorization=LocalAuthorizationCheckPort(),
|
||||
)
|
||||
return service, store, None
|
||||
|
||||
|
||||
def _bootstrap(service: UserEngineService):
|
||||
session = service.me(_claims(), correlation_id="corr-me")
|
||||
service.register_application(
|
||||
session.actor,
|
||||
sample_application(),
|
||||
binding=sample_application_binding(),
|
||||
correlation_id="corr-app",
|
||||
)
|
||||
service.publish_catalog(
|
||||
session.actor,
|
||||
sample_catalog(),
|
||||
correlation_id="corr-catalog",
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
def _claims():
|
||||
claims = human_actor_claims(subject="ada", tenant="tenant:coulomb")
|
||||
claims["roles"] = ["tenant-admin"]
|
||||
return claims
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
187
tests/test_layer_conformance.py
Normal file
187
tests/test_layer_conformance.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import ast
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from user_engine.adapters.flex_auth import FlexAuthHTTPAdapter
|
||||
from user_engine.adapters.local import LocalAuthorizationCheckPort
|
||||
from user_engine.domain import AuthorizationEffect, utc_now
|
||||
from user_engine.layer_yaml import load_mapping, load_mapping_text
|
||||
from user_engine.pep_stance import (
|
||||
ALLOW_BINDING,
|
||||
ALLOW_LIFETIME,
|
||||
STANCE,
|
||||
UNREACHABLE_STANCE,
|
||||
VERDICT_CACHING,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "check_layer_conformance.py"
|
||||
LAYER = ROOT / "layer.yaml"
|
||||
STANCE_FILE = ROOT / "pep-stance.yaml"
|
||||
RUNTIME = ROOT / "src" / "user_engine" / "runtime.py"
|
||||
|
||||
|
||||
class LayerDeclarationTests(unittest.TestCase):
|
||||
def test_declares_engine_pip_in_own_voice(self):
|
||||
data = load_mapping(LAYER)
|
||||
self.assertEqual(data["repository"], "user-engine")
|
||||
self.assertEqual(data["layer"], "engine")
|
||||
self.assertEqual(data["role"], "pip")
|
||||
self.assertEqual(data["tooling_contacts"], [])
|
||||
self.assertEqual(data["pep_stance"], "pep-stance.yaml")
|
||||
self.assertTrue(data["pep_shape"])
|
||||
self.assertEqual(data["declared_by"], "INTENT.md")
|
||||
|
||||
def test_intent_frontmatter_matches_declaration(self):
|
||||
text = (ROOT / "INTENT.md").read_text()
|
||||
front = text.split("---", 2)[1]
|
||||
intent = load_mapping_text(front)
|
||||
decl = load_mapping(LAYER)
|
||||
self.assertEqual(intent["layer"].lower(), decl["layer"])
|
||||
self.assertEqual(intent["role"].lower(), decl["role"])
|
||||
|
||||
def test_own_store_is_declared(self):
|
||||
data = load_mapping(LAYER)
|
||||
self.assertTrue(data["own_store"])
|
||||
self.assertEqual(data["own_store"][0]["import_root"], "psycopg")
|
||||
|
||||
def test_checker_passes_on_the_real_tree(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=ROOT,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_checker_catches_an_undeclared_tooling_client(self):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"check_layer_conformance", SCRIPT
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fake_src = Path(directory) / "src" / "user_engine"
|
||||
fake_src.mkdir(parents=True)
|
||||
(fake_src / "secrets.py").write_text(
|
||||
"import hvac\n\n\ndef leak():\n hvac.Client().read('secret')\n"
|
||||
)
|
||||
original_src = module.SRC
|
||||
try:
|
||||
module.SRC = fake_src
|
||||
hits = module.scan()
|
||||
finally:
|
||||
module.SRC = original_src
|
||||
self.assertTrue(hits, "a direct OpenBao client was not detected")
|
||||
self.assertEqual(hits[0][1], "hvac")
|
||||
|
||||
|
||||
class PepStanceTests(unittest.TestCase):
|
||||
def test_published_map_equals_shipped_behaviour(self):
|
||||
published = load_mapping(STANCE_FILE)
|
||||
self.assertEqual(published["stance"], STANCE)
|
||||
self.assertEqual(published["allow_binding"], ALLOW_BINDING)
|
||||
self.assertEqual(
|
||||
timedelta(seconds=published["allow_lifetime_seconds"]), ALLOW_LIFETIME
|
||||
)
|
||||
self.assertEqual(published["verdict_caching"], VERDICT_CACHING)
|
||||
self.assertTrue(
|
||||
all(value == UNREACHABLE_STANCE for value in published["stance"].values())
|
||||
)
|
||||
|
||||
def test_stance_is_total_over_the_zone_model(self):
|
||||
required = {
|
||||
"z0-experimental",
|
||||
"z1-operational",
|
||||
"z2-protected",
|
||||
"z2-continuity",
|
||||
"z3-critical",
|
||||
"unknown",
|
||||
"not-applicable",
|
||||
}
|
||||
self.assertEqual(required, set(STANCE))
|
||||
|
||||
def test_flex_auth_unavailable_applies_fail_closed_without_decision_id(self):
|
||||
from urllib.error import URLError
|
||||
|
||||
adapter = FlexAuthHTTPAdapter(base_url="http://flex-auth")
|
||||
with patch(
|
||||
"user_engine.adapters.flex_auth.urlopen", side_effect=URLError("down")
|
||||
):
|
||||
decision = adapter.check(_request())
|
||||
self.assertEqual(decision.effect, AuthorizationEffect.DENY)
|
||||
self.assertIsNone(decision.decision_id)
|
||||
self.assertEqual(decision.stance_applied, UNREACHABLE_STANCE)
|
||||
self.assertEqual(decision.reason, "authorization service unavailable")
|
||||
|
||||
def test_flex_auth_allow_carries_published_lifetime(self):
|
||||
import io
|
||||
import json
|
||||
|
||||
class _Response(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
self.close()
|
||||
|
||||
body = _Response(json.dumps({"id": "decision:1", "effect": "allow"}).encode())
|
||||
adapter = FlexAuthHTTPAdapter(base_url="http://flex-auth")
|
||||
with patch("user_engine.adapters.flex_auth.urlopen", return_value=body):
|
||||
decision = adapter.check(_request())
|
||||
self.assertEqual(decision.effect, AuthorizationEffect.ALLOW)
|
||||
self.assertEqual(decision.decision_id, "decision:1")
|
||||
self.assertEqual(decision.binding, ALLOW_BINDING)
|
||||
self.assertEqual(decision.lifetime, ALLOW_LIFETIME)
|
||||
self.assertIsNotNone(decision.issued_at)
|
||||
self.assertFalse(decision.expired(now=utc_now()))
|
||||
|
||||
def test_production_runtime_does_not_import_local_authorization(self):
|
||||
tree = ast.parse(RUNTIME.read_text())
|
||||
imported = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
imported.update(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.Import):
|
||||
imported.update(alias.name for alias in node.names)
|
||||
self.assertIn("FlexAuthHTTPAdapter", imported)
|
||||
self.assertNotIn("LocalAuthorizationCheckPort", imported)
|
||||
self.assertNotIn("StaticAuthorizationCheckPort", imported)
|
||||
|
||||
def test_local_authorization_port_cannot_construct_with_production_token(self):
|
||||
with patch.dict(
|
||||
os.environ, {"USER_ENGINE_FLEX_AUTH_TOKEN_FILE": "/var/run/token"}
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "cannot be constructed"):
|
||||
LocalAuthorizationCheckPort()
|
||||
|
||||
|
||||
def _request():
|
||||
from user_engine.domain import Actor, AuthorizationRequest, PrincipalType
|
||||
|
||||
actor = Actor(
|
||||
issuer="https://issuer",
|
||||
subject="subject-1",
|
||||
tenant="tenant-a",
|
||||
principal_type=PrincipalType.HUMAN,
|
||||
audience=("user-engine",),
|
||||
)
|
||||
return AuthorizationRequest(
|
||||
actor=actor,
|
||||
resource_type="user-engine:user",
|
||||
resource_id="user-1",
|
||||
action="user.update",
|
||||
tenant="tenant-a",
|
||||
correlation_id="corr-1",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -44,6 +44,8 @@ class PlatformAdapterTests(unittest.TestCase):
|
|||
decision = FlexAuthHTTPAdapter(base_url="http://flex-auth").check(_request())
|
||||
self.assertEqual(decision.effect, AuthorizationEffect.DENY)
|
||||
self.assertEqual(decision.reason, "authorization service unavailable")
|
||||
self.assertIsNone(decision.decision_id)
|
||||
self.assertEqual(decision.stance_applied, "fail_closed")
|
||||
|
||||
def test_flex_auth_reads_rotating_caller_token_for_each_decision(self):
|
||||
with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") as token_file:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue