user-engine/src/user_engine/evidence.py
tegwick 4349758608
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
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
2026-08-29 12:53:16 +02:00

88 lines
2.5 KiB
Python

"""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