emergent-security/src/emergent_security/decide.py

121 lines
5.1 KiB
Python

"""The decision function (canon A3).
`decide` is pure: it reads its inputs, never mutates them, holds no state and calls no
model. The same request, envelope, registry, tenant context and memory always produce
the same Decision.
"""
from __future__ import annotations
from typing import Iterable
from emergent_security.constitution import PLATFORM_TENANT_BOUND, ROOT
from emergent_security.delegation import Delegation, Grant, select_delegation
from emergent_security.envelope import DefaultEnvelope, Row
from emergent_security.memory import MemoryEntry
from emergent_security.model import Decision, Outcome, Request
from emergent_security.scales import RiskScales
from emergent_security.tenant import TenantContext
REQUIRED_SCOPE = ("level", "temperature", "phase", "tenant")
def decide(
request: Request,
*,
envelope: DefaultEnvelope,
scales: RiskScales,
registry: Iterable[Delegation] = (),
tenant: TenantContext | None = None,
memory: Iterable[MemoryEntry] = (),
) -> Decision:
"""Evaluate one privileged effect; the first matching step decides."""
def escalate(condition: str, route_to: str, **fields) -> Decision:
return Decision(request.request_id, request.decision_class, Outcome.ESCALATE,
blocking_condition=condition, route_to=route_to, **fields)
# 1. scope unresolved
unresolved = _unresolved_scope(request)
if unresolved:
return escalate("need_context", "delegator", missing=unresolved)
row = envelope.row(request.decision_class, request.attributes)
# 2. constitution or explicit prohibition
if row is not None and row.mode is Outcome.DENY:
return _deny(request, row.rules, envelope.id)
if tenant is not None and tenant.prohibition(request.decision_class):
return _deny(request, tenant.prohibition(request.decision_class), tenant.platform.id)
# 3. platform-root
if request.scope.get("level") == "L4" or (row is not None and row.mode is Outcome.ROOT_AUTHORIZATION_REQUIRED):
return Decision(request.request_id, request.decision_class, Outcome.ROOT_AUTHORIZATION_REQUIRED,
route_to="root_steward", rules=(ROOT,), source=envelope.id)
# 4. outside the platform tenant envelope
if tenant is not None:
outside = tenant.outside(request, scales)
if outside:
return escalate("outside_tenant_envelope", "platform", rules=(PLATFORM_TENANT_BOUND,), missing=outside)
route = "platform" if tenant is not None else "delegator"
# 5. delegation within the effective envelope
grant = _grant(request, row, envelope, registry, tenant, scales)
if grant is None:
return escalate("no_delegation", route, rules=row.rules if row else ())
# 6. negative memory
if any(entry.blocks(request) for entry in memory):
return escalate("negative_memory", route, source=grant.source)
# 7. evidence
missing_evidence = tuple(item for item in grant.evidence_required if item not in request.evidence)
if missing_evidence:
return escalate("missing_evidence", route, missing=missing_evidence, source=grant.source)
# 8. risk against the envelope's declared dimensions
exceeded = scales.exceeded(request.risk, grant.risk_envelope)
if exceeded:
return escalate("risk", route, missing=exceeded, source=grant.source)
# 9. tenant reserved the class for a human decision
if tenant is not None and tenant.requires_human(request.decision_class):
return escalate("tenant_human_decision", "tenant_admin", source=grant.source)
# 10. autonomous
return Decision(request.request_id, request.decision_class, grant.outcome,
rules=grant.rules, obligations=grant.obligations, source=grant.source)
def _unresolved_scope(request: Request) -> tuple[str, ...]:
missing = [name for name in ("principal", "decision_class") if not getattr(request, name)]
missing += [f"scope.{key}" for key in REQUIRED_SCOPE if request.scope.get(key) in (None, "")]
return tuple(missing)
def _deny(request: Request, rules: tuple[str, ...], source: str) -> Decision:
return Decision(request.request_id, request.decision_class, Outcome.DENY, rules=rules, source=source)
def _grant(
request: Request,
row: Row | None,
envelope: DefaultEnvelope,
registry: Iterable[Delegation],
tenant: TenantContext | None,
scales: RiskScales,
) -> Grant | None:
"""Registry first, then the platform tenant envelope, then the default envelope (A10)."""
delegation = select_delegation(registry, request)
if delegation is not None:
grant = delegation.as_grant()
elif tenant is not None:
grant = tenant.platform.grant_for(request.decision_class)
elif row is not None and row.mode is not None and row.mode.grants and envelope.applies_to(request.scope):
grant = Grant(source=envelope.id, outcome=row.mode, obligations=row.obligations,
evidence_required=row.evidence_required, risk_envelope=envelope.risk_envelope, rules=row.rules)
else:
return None
return grant.bounded_by(tenant.ceilings(scales), scales) if tenant is not None and grant else grant