Implement USER-WP-0024 security layer conformance
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 36s

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:
tegwick 2026-08-29 12:53:16 +02:00
parent c7b6148a70
commit 4349758608
22 changed files with 1242 additions and 89 deletions

View file

@ -11,6 +11,13 @@ from user_engine.domain import (
AuthorizationDecision,
AuthorizationEffect,
AuthorizationRequest,
utc_now,
)
from user_engine.pep_stance import (
ALLOW_BINDING,
ALLOW_LIFETIME,
DEFAULT_STANCE_SCOPE,
UNREACHABLE_STANCE,
)
@ -86,16 +93,28 @@ class FlexAuthHTTPAdapter:
for item in body.get("obligations", ())
if isinstance(item, dict) and item.get("type")
)
lifetime = ALLOW_LIFETIME if effect in {
AuthorizationEffect.ALLOW,
AuthorizationEffect.AUDIT_ONLY,
} else None
issued_at = utc_now() if lifetime is not None else None
return AuthorizationDecision(
effect=effect,
decision_id=decision_id,
reason=reason,
obligations=obligations,
binding=ALLOW_BINDING,
lifetime=lifetime,
issued_at=issued_at,
)
except (HTTPError, URLError, TimeoutError, OSError, ValueError, KeyError, TypeError):
return AuthorizationDecision(
effect=AuthorizationEffect.DENY,
decision_id=None,
reason="authorization service unavailable",
stance_applied=UNREACHABLE_STANCE,
stance_scope=DEFAULT_STANCE_SCOPE,
binding=ALLOW_BINDING,
)
def batch_check(

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import copy
import os
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Iterable, Iterator, Mapping, cast
@ -426,18 +427,25 @@ class InMemoryUserEngineStore:
class LocalAuthorizationCheckPort:
"""Deterministic local authorization adapter.
"""Test and standalone authorization double. Not a production PDP.
Rules are action-specific. The default is allow so isolated tests and local
demos can focus on user-engine behavior while still exercising the port.
Construction fails when a production flex-auth caller token is configured,
so ``create_application()`` cannot assemble this adapter by mistake.
"""
standalone_double = True
def __init__(
self,
*,
default_effect: AuthorizationEffect = AuthorizationEffect.ALLOW,
action_effects: dict[str, AuthorizationEffect] | None = None,
) -> None:
if os.environ.get("USER_ENGINE_FLEX_AUTH_TOKEN_FILE"):
raise RuntimeError(
"LocalAuthorizationCheckPort cannot be constructed in a "
"production runtime (USER_ENGINE_FLEX_AUTH_TOKEN_FILE is set)"
)
self.default_effect = default_effect
self.action_effects = action_effects or {}
self.requests: list[AuthorizationRequest] = []
@ -445,7 +453,7 @@ class LocalAuthorizationCheckPort:
def check(self, request: AuthorizationRequest) -> AuthorizationDecision:
self.requests.append(request)
effect = self.action_effects.get(request.action, self.default_effect)
return AuthorizationDecision(effect=effect, reason="local")
return AuthorizationDecision.for_standalone(effect, reason="local")
def batch_check(
self, requests: Iterable[AuthorizationRequest]

View file

@ -8,7 +8,7 @@ these shapes instead of putting domain rules in infrastructure code.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from enum import StrEnum
from typing import Any, Mapping
from uuid import uuid4
@ -656,9 +656,14 @@ class AuthorizationRequest:
@dataclass(frozen=True)
class AuthorizationDecision:
effect: AuthorizationEffect
decision_id: str = field(default_factory=lambda: new_id("dec"))
decision_id: str | None = None
reason: str | None = None
obligations: tuple[str, ...] = ()
binding: str = "request"
lifetime: timedelta | None = None
issued_at: datetime | None = None
stance_applied: str | None = None
stance_scope: str | None = None
@property
def allowed(self) -> bool:
@ -667,6 +672,33 @@ class AuthorizationDecision:
AuthorizationEffect.AUDIT_ONLY,
}
def expired(self, now: datetime | None = None) -> bool:
if self.lifetime is None or self.issued_at is None:
return False
return (now or utc_now()) > self.issued_at + self.lifetime
@classmethod
def for_standalone(
cls,
effect: AuthorizationEffect,
*,
reason: str,
obligations: tuple[str, ...] = (),
) -> AuthorizationDecision:
allowed = effect in {
AuthorizationEffect.ALLOW,
AuthorizationEffect.AUDIT_ONLY,
}
issued = utc_now() if allowed else None
return cls(
effect=effect,
reason=reason,
obligations=obligations,
binding="standalone",
lifetime=timedelta(hours=1) if allowed else None,
issued_at=issued,
)
@dataclass(frozen=True)
class AuditRecord:

View file

@ -0,0 +1,88 @@
"""Local evidence classification under security-layer-model §9.6.
An append-only local audit and outbox prove the records they hold were
not altered after arrival. They do not prove that an event never sent
did not happen. Completeness is not claimed.
"""
from __future__ import annotations
from datetime import timedelta
from typing import Iterable
from user_engine.domain import AuditRecord, OutboxEvent
HEARTBEAT_EVENT_TYPE = "user_engine.evidence.heartbeat"
HEARTBEAT_INTERVAL = timedelta(hours=1)
# Low-volume classes whose absence can be mistaken for "it was allowed"
# or "the membership still holds". Cadence is a heartbeat, not a rate.
LOAD_BEARING_OUTBOX_TYPES = frozenset(
{
"account.status_changed",
"tenant_account.status_changed",
"prepared_account.revoked",
"family_invitation.revoked",
}
)
LOAD_BEARING_AUDIT_SUMMARIES = frozenset(
{
"authorization denied",
"authorization denied (stance fail_closed)",
}
)
ATTRIBUTIVE_OUTBOX_TYPES = frozenset(
{
"user.created",
"user.self_service_profile_updated",
"identity.linked",
"membership.added",
"profile.value_set",
"application.registered",
"application.bound",
"catalog.published",
"registration.started",
"registration.factor_verified",
"registration.completed",
"access_profile.registered",
"active_access_context.selected",
}
)
def classify_outbox(event_type: str) -> str:
if event_type == HEARTBEAT_EVENT_TYPE:
return "heartbeat"
if event_type in LOAD_BEARING_OUTBOX_TYPES:
return "load-bearing"
return "attributive"
def classify_audit(summary: str | None) -> str:
if summary in LOAD_BEARING_AUDIT_SUMMARIES or (
summary is not None and summary.startswith("authorization denied")
):
return "load-bearing"
return "attributive"
def load_bearing_counts(
audit_records: Iterable[AuditRecord],
outbox_events: Iterable[OutboxEvent],
) -> dict[str, int]:
counts: dict[str, int] = {
"authorization_denied": 0,
"account.status_changed": 0,
"tenant_account.status_changed": 0,
"prepared_account.revoked": 0,
"family_invitation.revoked": 0,
}
for record in audit_records:
if classify_audit(record.summary) == "load-bearing":
counts["authorization_denied"] += 1
for event in outbox_events:
if event.event_type in counts:
counts[event.event_type] += 1
return counts

View file

@ -0,0 +1,134 @@
"""Minimal YAML mapping loader for layer and PEP-stance declarations.
Stdlib only. Handles the subset this repository actually writes: nested
maps, lists of scalars, lists of maps, quoted strings, booleans, null,
integers, and empty lists. Not a general YAML implementation.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
def load_mapping(path: Path) -> dict[str, Any]:
data = load_mapping_text(path.read_text())
if not isinstance(data, dict):
raise ValueError(f"{path} did not parse as a mapping")
return data
def load_mapping_text(text: str) -> dict[str, Any]:
data = _parse(text)
if not isinstance(data, dict):
raise ValueError("YAML text did not parse as a mapping")
return data
def _parse(text: str) -> Any:
lines: list[tuple[int, str]] = []
for raw in text.splitlines():
stripped = raw.split("#", 1)[0].rstrip()
if not stripped:
continue
indent = len(raw) - len(raw.lstrip(" "))
lines.append((indent, stripped.lstrip(" ")))
value, _ = _parse_block(lines, 0, 0)
return value
def _parse_block(
lines: list[tuple[int, str]], index: int, indent: int
) -> tuple[Any, int]:
if index >= len(lines):
return {}, index
current_indent, content = lines[index]
if current_indent < indent:
return {}, index
if content.startswith("- "):
return _parse_list(lines, index, current_indent)
return _parse_map(lines, index, current_indent)
def _parse_map(
lines: list[tuple[int, str]], index: int, indent: int
) -> tuple[dict[str, Any], int]:
result: dict[str, Any] = {}
while index < len(lines):
current_indent, content = lines[index]
if current_indent < indent:
break
if current_indent > indent:
raise ValueError(f"unexpected indent at {content!r}")
if content.startswith("- "):
break
key, separator, remainder = content.partition(":")
if not separator:
raise ValueError(f"expected key: value, got {content!r}")
key = _parse_scalar(key.strip())
if not isinstance(key, str):
key = str(key)
remainder = remainder.strip()
index += 1
if remainder in ("", "|", ">"):
if index < len(lines) and lines[index][0] > indent:
value, index = _parse_block(lines, index, lines[index][0])
else:
value = None
else:
value = _parse_scalar(remainder)
result[key] = value
return result, index
def _parse_list(
lines: list[tuple[int, str]], index: int, indent: int
) -> tuple[list[Any], int]:
result: list[Any] = []
while index < len(lines):
current_indent, content = lines[index]
if current_indent < indent:
break
if current_indent > indent:
raise ValueError(f"unexpected indent at {content!r}")
if not content.startswith("- "):
break
item = content[2:].strip()
index += 1
if not item:
if index < len(lines) and lines[index][0] > indent:
value, index = _parse_block(lines, index, lines[index][0])
else:
value = None
result.append(value)
continue
if ":" in item and not item.startswith(("'", '"')):
key, _, remainder = item.partition(":")
mapping: dict[str, Any] = {key.strip(): _parse_scalar(remainder.strip())}
if index < len(lines) and lines[index][0] > indent:
nested, index = _parse_block(lines, index, lines[index][0])
if isinstance(nested, dict):
mapping.update(nested)
result.append(mapping)
else:
result.append(_parse_scalar(item))
return result, index
def _parse_scalar(text: str) -> Any:
if text in ("", "~", "null", "Null", "NULL"):
return None
if text in ("true", "True"):
return True
if text in ("false", "False"):
return False
if text == "[]":
return []
if text == "{}":
return {}
if len(text) >= 2 and text[0] == text[-1] and text[0] in "'\"":
return text[1:-1]
try:
return int(text)
except ValueError:
return text

View file

@ -0,0 +1,22 @@
"""Published unreachable-engine stance. Must equal pep-stance.yaml."""
from __future__ import annotations
from datetime import timedelta
UNREACHABLE_STANCE = "fail_closed"
ALLOW_BINDING = "request"
ALLOW_LIFETIME = timedelta(seconds=30)
VERDICT_CACHING = "none"
STANCE = {
"z0-experimental": UNREACHABLE_STANCE,
"z1-operational": UNREACHABLE_STANCE,
"z2-protected": UNREACHABLE_STANCE,
"z2-continuity": UNREACHABLE_STANCE,
"z3-critical": UNREACHABLE_STANCE,
"unknown": UNREACHABLE_STANCE,
"not-applicable": UNREACHABLE_STANCE,
}
DEFAULT_STANCE_SCOPE = "unknown"

View file

@ -19,6 +19,7 @@ from user_engine.domain import (
ApplicationBinding,
AttributeDefinition,
AuditRecord,
AuthorizationDecision,
AuthorizationRequest,
CanonEntityReference,
CanonRelationshipReference,
@ -68,6 +69,10 @@ from user_engine.errors import (
NotFoundError,
ValidationError,
)
from user_engine.evidence import (
HEARTBEAT_EVENT_TYPE,
load_bearing_counts,
)
from user_engine.ports import (
AuthorizationCheckPort,
FactorVerificationAdapter,
@ -2894,6 +2899,37 @@ class UserEngineService:
issues=issues,
)
def record_evidence_heartbeat(
self, *, correlation_id: str | None = None
) -> OutboxEvent:
"""Emit a positive evidence claim that can itself go missing.
Counts load-bearing denials and revocations already on the local
trail. Does not claim the trail is complete.
"""
correlation_id = correlation_id or new_id("corr")
counts = load_bearing_counts(
self.store.audit_log(), self.store.pending_outbox()
)
event = OutboxEvent(
event_id=new_id("evt"),
event_type=HEARTBEAT_EVENT_TYPE,
aggregate_id="user-engine:evidence",
payload={
"completeness_claimed": False,
"form": "heartbeat",
"bound": (
"the archive proves the records it holds were not "
"altered or truncated after arrival"
),
"load_bearing": counts,
},
tenant=PLATFORM_TENANT,
correlation_id=correlation_id,
)
self.store.append_outbox(event)
return event
def structured_log_context(
self,
*,
@ -4242,22 +4278,75 @@ class UserEngineService:
),
)
decision = self.authorization.check(request)
if not decision.allowed:
self.store.append_audit(
AuditRecord(
audit_id=new_id("aud"),
actor=actor,
if decision.allowed:
if decision.lifetime is None:
self._record_authorization_denial(
actor,
action=action,
subject=resource_id,
resource_id=resource_id,
tenant=tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
application_id=application_id,
summary="authorization denied",
decision=AuthorizationDecision(
effect=decision.effect,
reason="authorization allow has no lifetime",
),
summary="authorization denied (allow has no lifetime)",
)
raise AuthorizationDenied("authorization allow has no lifetime")
if decision.expired():
self._record_authorization_denial(
actor,
action=action,
resource_id=resource_id,
tenant=tenant,
correlation_id=correlation_id,
application_id=application_id,
decision=decision,
summary="authorization denied (allow expired)",
)
raise AuthorizationDenied("authorization allow expired")
return decision
summary = "authorization denied"
if decision.stance_applied:
summary = f"authorization denied (stance {decision.stance_applied})"
self._record_authorization_denial(
actor,
action=action,
resource_id=resource_id,
tenant=tenant,
correlation_id=correlation_id,
application_id=application_id,
decision=decision,
summary=summary,
)
raise AuthorizationDenied(decision.reason or "authorization denied")
def _record_authorization_denial(
self,
actor: Actor,
*,
action: str,
resource_id: str,
tenant: str,
correlation_id: str,
application_id: str | None,
decision: AuthorizationDecision,
summary: str,
) -> None:
self.store.append_audit(
AuditRecord(
audit_id=new_id("aud"),
actor=actor,
action=action,
subject=resource_id,
tenant=tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
application_id=application_id,
summary=summary,
)
raise AuthorizationDenied(decision.reason or "authorization denied")
return decision
)
def _record_mutation(
self,

View file

@ -61,7 +61,7 @@ class StaticAuthorizationCheckPort:
def check(self, request: AuthorizationRequest) -> AuthorizationDecision:
self.requests.append(request)
return AuthorizationDecision(effect=self.effect, reason="fixture")
return AuthorizationDecision.for_standalone(self.effect, reason="fixture")
def batch_check(
self, requests: Iterable[AuthorizationRequest]

View file

@ -125,8 +125,8 @@ class ScenarioAuthorizationHarness:
effect = self.action_effects.get(request.action, self.default_effect)
if _cross_tenant_denied(request) or _assurance_denied(request):
effect = AuthorizationEffect.DENY
return AuthorizationDecision(
effect=effect,
return AuthorizationDecision.for_standalone(
effect,
reason="scenario",
obligations=self.action_obligations.get(request.action, ()),
)