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

@ -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,