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
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
88
src/user_engine/evidence.py
Normal file
88
src/user_engine/evidence.py
Normal 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
|
||||
134
src/user_engine/layer_yaml.py
Normal file
134
src/user_engine/layer_yaml.py
Normal 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
|
||||
22
src/user_engine/pep_stance.py
Normal file
22
src/user_engine/pep_stance.py
Normal 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"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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, ()),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue