user-engine/src/user_engine/evidence.py

89 lines
2.5 KiB
Python
Raw Normal View History

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