Implement TEN-WP-0011 security layer conformance
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s

Engine/PIP declaration is now checkable (layer.yaml plus a Tooling-client
scan). Writes persist a decision record or the published fail-closed
stance, live-lookup freshness is published, events_for is tenant-scoped,
and mutation evidence drains to audit-core from a local outbox without
blocking the mutation.

Sender registration is requested as AUDIT-IN-0002. Boundary-contract
amendment is requested as NET-IN-0002.

Assistant: grok
Assistant-Session: 01a04cea-e5e8-7081-a0fc-808ebbc35fa9
This commit is contained in:
tegwick 2026-08-29 13:02:51 +02:00
parent 80961af91e
commit 672cf4da6e
40 changed files with 2285 additions and 361 deletions

View file

@ -9,7 +9,9 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from tenant_engine import __version__
from tenant_engine.audit_core import AuditCoreClient
from tenant_engine.authz import (
AuthorizationOutcome,
DefaultDenyWriteAuthorizer,
FlexAuthWriteAuthorizer,
WriteAuthorizationDeniedError,
@ -44,6 +46,7 @@ from tenant_engine.guardrail import (
)
from tenant_engine.guardrail.serde import UNLIMITED_TOKEN
from tenant_engine.store import (
AuthorizationRecord,
GrantNotFoundError,
IdempotencyConflictError,
InMemoryTenantStore,
@ -185,9 +188,11 @@ def create_app(
app = FastAPI(title="tenant-engine", version=__version__)
app.state.store = store
app.state.authorizer = authorizer
app.state.audit_core = _build_audit_client(settings)
@app.exception_handler(WriteAuthorizationDeniedError)
async def handle_denied(_: Request, exc: WriteAuthorizationDeniedError) -> JSONResponse:
_persist_authz(store, exc.outcome)
return JSONResponse(
status_code=403,
content={"error_code": "write_denied", "action": exc.action, "detail": exc.reason},
@ -233,7 +238,7 @@ def create_app(
response: Response,
actor: str = Query(min_length=1),
) -> dict:
authorizer.authorize(action="tenant.read", tenant_id=tenant_id, actor=actor)
_authorize(authorizer, store, action="tenant.read", tenant_id=tenant_id, actor=actor)
try:
tenant = store.get_tenant(tenant_id)
except TenantNotFoundError as exc:
@ -249,7 +254,7 @@ def create_app(
@app.get("/tenants/{tenant_id}/roles")
async def cache_read_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict:
authorizer.authorize(action="tenant.role.read", tenant_id=tenant_id, actor=actor)
_authorize(authorizer, store, action="tenant.role.read", tenant_id=tenant_id, actor=actor)
return _read_roles(store, tenant_id)
# -- Live-lookup API (flex-auth, for aal2-class decisions) -----------
@ -261,7 +266,9 @@ def create_app(
@app.get("/tenants/{tenant_id}/roles/live")
async def live_lookup_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict:
authorizer.authorize(action="tenant.role.read.live", tenant_id=tenant_id, actor=actor)
_authorize(
authorizer, store, action="tenant.role.read.live", tenant_id=tenant_id, actor=actor
)
return _read_roles(store, tenant_id)
# -- Write API (grant/revoke/plan mutation) ---------------------------
@ -270,7 +277,13 @@ def create_app(
@app.post("/tenants", status_code=201)
async def create_tenant(payload: CreateTenantRequest) -> dict:
authorizer.authorize(action="tenant.create", tenant_id=payload.tenant_id, actor=payload.actor)
outcome = _authorize(
authorizer,
store,
action="tenant.create",
tenant_id=payload.tenant_id,
actor=payload.actor,
)
try:
tenant = Tenant.create(
tenant_id=payload.tenant_id,
@ -279,7 +292,8 @@ def create_app(
contact_email=payload.contact_email,
created_at=datetime.now(UTC),
)
store.create_tenant(tenant)
store.create_tenant(tenant, authz=outcome.as_payload())
_drain_outbox(store, app.state.audit_core)
except InvalidTenantIdentifierError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except TenantAlreadyExistsError as exc:
@ -290,7 +304,9 @@ def create_app(
@app.post("/tenants/{tenant_id}/roles/grant", status_code=201)
async def grant_role(tenant_id: str, payload: GrantRoleRequest) -> dict:
authorizer.authorize(action="tenant.role.grant", tenant_id=tenant_id, actor=payload.actor)
outcome = _authorize(
authorizer, store, action="tenant.role.grant", tenant_id=tenant_id, actor=payload.actor
)
try:
tenant = store.get_tenant(tenant_id)
grant = create_role_grant(
@ -303,7 +319,8 @@ def create_app(
correlation_id=payload.correlation_id,
granted_at=datetime.now(UTC),
)
store.grant_role(grant)
store.grant_role(grant, authz=outcome.as_payload())
_drain_outbox(store, app.state.audit_core)
except TenantNotFoundError as exc:
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
except TenantRetiredError as exc:
@ -314,9 +331,17 @@ def create_app(
@app.post("/tenants/{tenant_id}/roles/revoke")
async def revoke_role(tenant_id: str, payload: RevokeRoleRequest) -> dict:
authorizer.authorize(action="tenant.role.revoke", tenant_id=tenant_id, actor=payload.actor)
outcome = _authorize(
authorizer, store, action="tenant.role.revoke", tenant_id=tenant_id, actor=payload.actor
)
try:
revoked = store.revoke_role(tenant_id=tenant_id, grant_id=payload.grant_id, at=datetime.now(UTC))
revoked = store.revoke_role(
tenant_id=tenant_id,
grant_id=payload.grant_id,
at=datetime.now(UTC),
authz=outcome.as_payload(),
)
_drain_outbox(store, app.state.audit_core)
except TenantNotFoundError as exc:
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
except GrantNotFoundError as exc:
@ -327,11 +352,17 @@ def create_app(
@app.post("/tenants/{tenant_id}/plan")
async def assign_plan(tenant_id: str, payload: AssignPlanRequest) -> dict:
authorizer.authorize(action="tenant.plan.assign", tenant_id=tenant_id, actor=payload.actor)
outcome = _authorize(
authorizer, store, action="tenant.plan.assign", tenant_id=tenant_id, actor=payload.actor
)
try:
store.assign_plan(
PlanAssignment(tenant_id=tenant_id, plan_id=payload.plan_id, assigned_at=datetime.now(UTC))
PlanAssignment(
tenant_id=tenant_id, plan_id=payload.plan_id, assigned_at=datetime.now(UTC)
),
authz=outcome.as_payload(),
)
_drain_outbox(store, app.state.audit_core)
except TenantNotFoundError as exc:
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
except TenantRetiredError as exc:
@ -366,6 +397,7 @@ def create_app(
event_type="tenant_updated",
extra_fingerprint=changes,
mutate=lambda tenant, at: tenant.with_metadata(changes, at=at),
audit_core=app.state.audit_core,
)
@app.post("/tenants/{tenant_id}/retire")
@ -390,6 +422,7 @@ def create_app(
event_type="tenant_retired",
extra_fingerprint={},
mutate=lambda tenant, at: tenant.retire(at=at),
audit_core=app.state.audit_core,
)
@app.post("/tenants/{tenant_id}/reactivate")
@ -414,6 +447,7 @@ def create_app(
event_type="tenant_reactivated",
extra_fingerprint={},
mutate=lambda tenant, at: tenant.reactivate(at=at),
audit_core=app.state.audit_core,
)
# -- Reclassification (TEN-WP-0010) -----------------------------------
@ -443,6 +477,7 @@ def create_app(
event_type="tenant_grouping_changed",
extra_fingerprint={"grouping": payload.grouping},
mutate=lambda tenant, at: tenant.with_grouping(payload.grouping, at=at),
audit_core=app.state.audit_core,
)
# -- Guardrail API (TEN-WP-0006) --------------------------------------
@ -451,7 +486,9 @@ def create_app(
@app.get("/tenants/{tenant_id}/guardrails")
async def read_guardrails(tenant_id: str, actor: str) -> dict:
authorizer.authorize(action="tenant.guardrail.read", tenant_id=tenant_id, actor=actor)
_authorize(
authorizer, store, action="tenant.guardrail.read", tenant_id=tenant_id, actor=actor
)
try:
tenant = store.get_tenant(tenant_id)
overrides = store.guardrail_overrides(tenant_id)
@ -487,9 +524,7 @@ def create_app(
try:
value = load_limit(payload.limit.model_dump())
except (InvalidLimitError, ValueError) as exc:
raise LifecycleError(
400, "invalid_limit", str(exc), payload.correlation_id
) from exc
raise LifecycleError(400, "invalid_limit", str(exc), payload.correlation_id) from exc
return _guardrail_mutation(
store=store,
authorizer=authorizer,
@ -502,6 +537,7 @@ def create_app(
idempotency_key=idempotency_key,
if_match=if_match,
response=response,
audit_core=app.state.audit_core,
)
@app.delete("/tenants/{tenant_id}/guardrails/{limit_key}")
@ -525,6 +561,7 @@ def create_app(
idempotency_key=idempotency_key,
if_match=if_match,
response=response,
audit_core=app.state.audit_core,
)
return app
@ -553,6 +590,7 @@ def _guardrail_mutation(
idempotency_key: str | None,
if_match: str | None,
response: Response,
audit_core: AuditCoreClient | None = None,
) -> dict:
"""Shared spine for setting and clearing an override.
@ -566,7 +604,9 @@ def _guardrail_mutation(
)
expected_version = _parse_if_match(if_match, correlation_id)
authorizer.authorize(action="tenant.guardrail.set", tenant_id=tenant_id, actor=actor)
outcome = _authorize(
authorizer, store, action="tenant.guardrail.set", tenant_id=tenant_id, actor=actor
)
fingerprint = hashlib.sha256(
json.dumps(
@ -585,9 +625,9 @@ def _guardrail_mutation(
# Derived, not random: a genuine retry must produce the same change_id so
# the replay path returns the original audit record rather than minting a
# second one for a mutation that happened once.
change_id = hashlib.sha256(
f"{tenant_id}:{limit_key}:{idempotency_key}".encode()
).hexdigest()[:32]
change_id = hashlib.sha256(f"{tenant_id}:{limit_key}:{idempotency_key}".encode()).hexdigest()[
:32
]
try:
tenant, change, replayed = store.set_guardrail_override(
@ -602,7 +642,9 @@ def _guardrail_mutation(
idempotency_key=idempotency_key,
request_fingerprint=fingerprint,
at=datetime.now(UTC),
authz=outcome.as_payload(),
)
_drain_outbox(store, audit_core)
except UnknownLimitKeyError as exc:
raise LifecycleError(404, "unknown_limit_key", "unknown_limit_key", correlation_id) from exc
except TenantNotFoundError as exc:
@ -622,9 +664,7 @@ def _guardrail_mutation(
correlation_id,
) from exc
except TenantRetiredError as exc:
raise LifecycleError(
409, "guardrail_loosening_denied", str(exc), correlation_id
) from exc
raise LifecycleError(409, "guardrail_loosening_denied", str(exc), correlation_id) from exc
except (InvalidLimitError, ConflictingLimitError) as exc:
raise LifecycleError(400, "invalid_limit", str(exc), correlation_id) from exc
except StoreUnavailableError as exc:
@ -704,6 +744,7 @@ def _lifecycle_mutation(
event_type: str,
extra_fingerprint: dict,
mutate,
audit_core: AuditCoreClient | None = None,
) -> dict:
"""Shared spine for update/retire/reactivate: authorize, then CAS.
@ -716,7 +757,7 @@ def _lifecycle_mutation(
)
expected_version = _parse_if_match(if_match, correlation_id)
authorizer.authorize(action=action, tenant_id=tenant_id, actor=actor)
outcome = _authorize(authorizer, store, action=action, tenant_id=tenant_id, actor=actor)
fingerprint = hashlib.sha256(
json.dumps(
@ -738,10 +779,16 @@ def _lifecycle_mutation(
expected_version=expected_version,
mutate=lambda current: mutate(current, now),
event_type=event_type,
evidence={"actor": actor, "reason": reason, "correlation_id": correlation_id},
evidence={
"actor": actor,
"reason": reason,
"correlation_id": correlation_id,
**outcome.as_payload(),
},
idempotency_key=idempotency_key,
request_fingerprint=fingerprint,
)
_drain_outbox(store, audit_core)
except TenantNotFoundError as exc:
raise LifecycleError(404, "tenant_not_found", "tenant_not_found", correlation_id) from exc
except IdempotencyConflictError as exc:
@ -787,6 +834,69 @@ def _build_authorizer(settings: Settings) -> WriteAuthorizer:
return FlexAuthWriteAuthorizer(client=client)
def _build_audit_client(settings: Settings) -> AuditCoreClient | None:
if not settings.audit_core_base_url:
return None
return AuditCoreClient(
base_url=settings.audit_core_base_url,
timeout_seconds=settings.audit_core_timeout_seconds,
token_file=settings.audit_core_token_file,
)
def _authorize(
authorizer: WriteAuthorizer,
store: TenantStore,
*,
action: str,
tenant_id: str,
actor: str,
) -> AuthorizationOutcome:
try:
outcome = authorizer.authorize(action=action, tenant_id=tenant_id, actor=actor)
except WriteAuthorizationDeniedError:
raise
_persist_authz(store, outcome)
return outcome
def _persist_authz(store: TenantStore, outcome: AuthorizationOutcome) -> None:
recorder = getattr(store, "record_authorization", None)
if recorder is None:
return
recorder(
AuthorizationRecord(
action=outcome.action,
tenant_id=outcome.tenant_id,
actor=outcome.actor,
allowed=outcome.allowed,
source=outcome.source,
reason=outcome.reason,
at=datetime.now(UTC),
decision_id=outcome.decision_id,
request_digest=outcome.request_digest,
effect=outcome.effect,
stance=outcome.stance,
)
)
def _drain_outbox(store: TenantStore, client: AuditCoreClient | None) -> None:
"""Best-effort drain. Never fails the mutation (attributive, non-blocking)."""
if client is None:
return
pending = getattr(store, "pending_outbox", None)
mark = getattr(store, "mark_outbox", None)
if pending is None or mark is None:
return
try:
for row in pending():
result = client.post_event(row.envelope)
mark(row.event_id, status=result.status, detail=result.detail)
except Exception:
return
def _read_roles(store: TenantStore, tenant_id: str) -> dict:
try:
roles = store.active_roles(tenant_id)

View file

@ -0,0 +1,106 @@
"""Attributive emission to audit-core from the local outbox.
Trade (statute §9.6): mutation evidence is attributive. Emission is
atomic with the local outbox (crash between mutation and insert is
prevented). Drain to audit-core is after commit and MUST NOT fail a
mutation. Completeness is not claimed. See docs/evidence-emission.md.
This module has no SQL against audit-core's store and no credential
for it beyond a sender token used to POST /v1/events. The external
copy cannot be rewritten through tenant-engine's runtime database
credential because we do not hold that credential.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from uuid import uuid4
import httpx
SCHEMA_VERSION = "audit-core.event.v1alpha1"
SOURCE = "tenant-engine"
def new_event_id() -> str:
return str(uuid4())
def envelope_for(
*,
event_id: str,
event_type: str,
tenant_id: str,
observed_at: str,
payload: dict[str, Any],
) -> dict[str, Any]:
return {
"schema_version": SCHEMA_VERSION,
"event_id": event_id,
"observed_at": observed_at,
"tenant": tenant_id,
"scope": "tenant-engine",
"source": SOURCE,
"actor": payload.get("actor") or payload.get("granted_by") or payload.get("changed_by"),
"action": event_type,
"resource": f"tenant:{tenant_id}",
"outcome": "recorded",
"reason": payload.get("reason") or payload.get("authorization_reason"),
"details": payload,
}
@dataclass(frozen=True, slots=True)
class DeliveryResult:
event_id: str
status: str # delivered | duplicate | retry | dead | skipped
http_status: int | None = None
detail: str = ""
class AuditCoreClient:
"""POST /v1/events. The only audit-core surface this engine holds."""
def __init__(
self,
*,
base_url: str,
timeout_seconds: float = 3.0,
token_file: str | None = None,
transport: httpx.BaseTransport | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.token_file = token_file
self._client = httpx.Client(
base_url=self.base_url,
timeout=httpx.Timeout(timeout_seconds),
transport=transport,
)
def post_event(self, envelope: dict[str, Any]) -> DeliveryResult:
event_id = str(envelope.get("event_id") or "")
headers: dict[str, str] = {"Content-Type": "application/json"}
if self.token_file:
try:
token = open(self.token_file, encoding="utf-8").read().strip()
except OSError as exc:
return DeliveryResult(event_id, "retry", None, f"token_unreadable:{exc}")
if not token:
return DeliveryResult(event_id, "retry", None, "token_empty")
headers["Authorization"] = f"Bearer {token}"
try:
response = self._client.post("/v1/events", json=envelope, headers=headers)
except (httpx.HTTPError, OSError) as exc:
return DeliveryResult(event_id, "retry", None, f"unreachable:{exc.__class__.__name__}")
if response.status_code in (200, 202):
status = "duplicate" if response.status_code == 200 else "delivered"
return DeliveryResult(event_id, status, response.status_code)
if response.status_code in (400, 409):
return DeliveryResult(event_id, "dead", response.status_code, "rejected")
if response.status_code in (401, 403):
return DeliveryResult(event_id, "retry", response.status_code, "unauthorized")
return DeliveryResult(event_id, "retry", response.status_code, "unavailable")
def close(self) -> None:
self._client.close()

View file

@ -1,12 +1,11 @@
from __future__ import annotations
from typing import Protocol
from dataclasses import dataclass
from typing import Any, Protocol
from tenant_engine.flex_auth import CheckRequest, FlexAuthCheckClient, new_request_id
from tenant_engine.stance import FAIL_CLOSED
# TEN-WP-0003-T01/T02: action names here must match FLEX-WP-0008-T01's
# resource/action vocabulary exactly -- the two repos coordinate on these
# strings, neither invents its own.
_RESOURCE_TYPES: dict[str, str] = {
"tenant.read": "tenant",
"tenant.create": "tenant",
@ -15,63 +14,88 @@ _RESOURCE_TYPES: dict[str, str] = {
"tenant.role.read": "role-grant",
"tenant.role.read.live": "role-grant",
"tenant.plan.assign": "plan-assignment",
# TEN-WP-0005: lifecycle actions are distinct so policy can separate a
# metadata edit from a retirement.
"tenant.update": "tenant",
"tenant.retire": "tenant",
"tenant.reactivate": "tenant",
# TEN-WP-0006: reading a ceiling and changing one are separate privileges.
# A PDP needs the read; almost nothing needs the write.
"tenant.guardrail.read": "guardrail",
"tenant.guardrail.set": "guardrail",
# TEN-WP-0010: reclassification moves a tenant's spend ceiling, so it is
# separable from a metadata edit rather than folded into tenant.update.
"tenant.grouping.set": "tenant",
}
@dataclass(frozen=True, slots=True)
class AuthorizationOutcome:
"""The record companion §5 requires on every protected action.
Either a decision (source="decision", decision_id set) or the
application of the published fail-closed stance (source="stance").
"""
action: str
tenant_id: str
actor: str
allowed: bool
source: str
reason: str
decision_id: str | None = None
request_digest: str | None = None
effect: str | None = None
stance: str | None = None
def as_payload(self) -> dict[str, Any]:
return {
"authorization_source": self.source,
"authorization_decision_id": self.decision_id,
"authorization_request_digest": self.request_digest,
"authorization_effect": self.effect,
"authorization_stance": self.stance,
"authorization_reason": self.reason,
}
class WriteAuthorizationDeniedError(Exception):
def __init__(self, action: str, reason: str = "denied") -> None:
super().__init__(f"{action}: {reason}")
self.action = action
self.reason = reason
def __init__(self, outcome: AuthorizationOutcome) -> None:
super().__init__(f"{outcome.action}: {outcome.reason}")
self.action = outcome.action
self.reason = outcome.reason
self.outcome = outcome
class WriteAuthorizer(Protocol):
"""The single seam every write endpoint calls before mutating anything.
"""The single seam every write (and authorized read) calls before the store."""
Per the boundary contract, tenant-engine never self-authorizes writes --
flex-auth is meant to gate them. A real flex-auth integration is an
explicit non-goal of TEN-WP-0002; this Protocol exists so swapping one in
later touches this one seam, not every endpoint.
"""
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
"""Raise WriteAuthorizationDeniedError if the write is not authorized."""
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
"""Return an allow record, or raise WriteAuthorizationDeniedError."""
...
class DefaultDenyWriteAuthorizer:
"""Deny every write. The correct default until a real authorizer exists."""
"""Apply the published fail-closed stance when access-engine is unset."""
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
raise WriteAuthorizationDeniedError(
action, "no flex-auth integration configured (default-deny stub)"
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
outcome = AuthorizationOutcome(
action=action,
tenant_id=tenant_id,
actor=actor,
allowed=False,
source="stance",
reason="no flex-auth integration configured (default-deny stub)",
stance=FAIL_CLOSED,
)
raise WriteAuthorizationDeniedError(outcome)
class FlexAuthWriteAuthorizer:
"""Gates writes through flex-auth's POST /v1/check (FLEX-WP-0008).
"""Gates calls through flex-auth's POST /v1/check.
Until FLEX-WP-0008's policy package exists for tenant-engine, every
check resolves to deny -- that's the correct fail-closed behavior, not
a bug in this client (see flex_auth.FlexAuthCheckClient).
Does not cache verdicts. A previous allow cannot authorize a later
request every call is a new check.
"""
def __init__(self, *, client: FlexAuthCheckClient) -> None:
self._client = client
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
resource_type = _RESOURCE_TYPES.get(action, "tenant")
request = CheckRequest(
request_id=new_request_id(),
@ -82,5 +106,19 @@ class FlexAuthWriteAuthorizer:
resource_id=tenant_id,
resource_type=resource_type,
)
if not self._client.is_allowed(request):
raise WriteAuthorizationDeniedError(action, "denied by flex-auth policy check")
result = self._client.check(request)
outcome = AuthorizationOutcome(
action=action,
tenant_id=tenant_id,
actor=actor,
allowed=result.allowed,
source=result.source,
reason=result.reason,
decision_id=result.decision_id,
request_digest=result.request_digest,
effect=result.effect,
stance=result.stance,
)
if not result.allowed:
raise WriteAuthorizationDeniedError(outcome)
return outcome

View file

@ -13,15 +13,25 @@ class Settings:
database_path: str | None = None
database_url_file: str | None = None
flex_auth_token_file: str | None = None
audit_core_base_url: str | None = None
audit_core_token_file: str | None = None
audit_core_timeout_seconds: float = 3.0
@classmethod
def from_env(cls) -> "Settings":
return cls(
flex_auth_base_url=os.getenv("TENANT_ENGINE_FLEX_AUTH_URL") or None,
flex_auth_timeout_seconds=float(os.getenv("TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS", "3")),
flex_auth_timeout_seconds=float(
os.getenv("TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS", "3")
),
host=os.getenv("TENANT_ENGINE_HOST", "127.0.0.1"),
port=int(os.getenv("TENANT_ENGINE_HTTP_PORT", "8090")),
database_path=os.getenv("TENANT_ENGINE_DATABASE_PATH") or None,
database_url_file=os.getenv("TENANT_ENGINE_DATABASE_URL_FILE") or None,
flex_auth_token_file=os.getenv("TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE") or None,
audit_core_base_url=os.getenv("TENANT_ENGINE_AUDIT_CORE_URL") or None,
audit_core_token_file=os.getenv("TENANT_ENGINE_AUDIT_CORE_TOKEN_FILE") or None,
audit_core_timeout_seconds=float(
os.getenv("TENANT_ENGINE_AUDIT_CORE_TIMEOUT_SECONDS", "3")
),
)

View file

@ -193,9 +193,7 @@ class Tenant:
# resolve guardrails through the reserved profile. Giving one a
# grouping would silently move the platform's own identity onto
# the grouping ladder.
raise ImmutableFieldError(
"reserved tenants are ungrouped and cannot be reclassified"
)
raise ImmutableFieldError("reserved tenants are ungrouped and cannot be reclassified")
if self.lifecycle is not TenantLifecycle.ACTIVE:
raise InvalidLifecycleTransitionError(
"grouping of a retired tenant cannot be changed; reactivate first"

View file

@ -1,12 +1,15 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import Any
from uuid import uuid4
import httpx
# flex-auth's DecisionEnvelope schema (schemas/decision_envelope.schema.json)
# allows five effects; only "allow" authorizes anything.
from tenant_engine.stance import FAIL_CLOSED
ALLOW_EFFECT = "allow"
@ -45,15 +48,35 @@ class CheckRequest:
"context": self.context,
}
def digest(self) -> str:
canonical = json.dumps(self.to_json(), sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode()).hexdigest()
@dataclass(frozen=True, slots=True)
class CheckResult:
"""What the authorizer keeps after POST /v1/check.
`is_allowed` used to drop the envelope. Companion §5 / statute §6.4
require the decision record (or a recorded stance) to survive.
"""
allowed: bool
source: str
reason: str
request_digest: str
decision_id: str | None = None
effect: str | None = None
stance: str | None = None
class FlexAuthCheckClient:
"""Client for flex-auth's POST /v1/check.
Fail-closed by construction: every non-"allow" effect, every non-2xx
response, every malformed body, and every transport failure (timeout,
connection error) resolves to `False` from `is_allowed()`. Nothing
raises past this boundary -- callers (the WriteAuthorizer seam) get a
plain deny, not an exception to handle inconsistently.
Fail-closed by construction. Every non-allow effect, every non-2xx,
every malformed body, and every transport failure resolves to a
CheckResult with allowed=False. Nothing raises past this boundary.
Verdicts are never cached: every call is a new POST.
"""
def __init__(
@ -73,33 +96,85 @@ class FlexAuthCheckClient:
transport=transport,
)
def is_allowed(self, request: CheckRequest) -> bool:
def check(self, request: CheckRequest) -> CheckResult:
digest = request.digest()
try:
headers: dict[str, str] = {}
if self.bearer_token_file:
# Projected ServiceAccount tokens rotate. Read on each check
# instead of pinning the token for the lifetime of the process.
with open(self.bearer_token_file, encoding="utf-8") as token_file:
token = token_file.read().strip()
if not token:
return False
return CheckResult(
allowed=False,
source="stance",
reason="caller_token_empty",
request_digest=digest,
stance=FAIL_CLOSED,
)
headers["Authorization"] = f"Bearer {token}"
response = self._client.post("/v1/check", json=request.to_json(), headers=headers)
except (httpx.HTTPError, OSError):
return False
return CheckResult(
allowed=False,
source="stance",
reason="access_engine_unreachable",
request_digest=digest,
stance=FAIL_CLOSED,
)
if response.status_code != 200:
return False
return CheckResult(
allowed=False,
source="stance",
reason=f"access_engine_http_{response.status_code}",
request_digest=digest,
stance=FAIL_CLOSED,
)
try:
envelope = response.json()
except ValueError:
return False
return CheckResult(
allowed=False,
source="stance",
reason="access_engine_malformed_body",
request_digest=digest,
stance=FAIL_CLOSED,
)
if not isinstance(envelope, dict):
return False
return CheckResult(
allowed=False,
source="stance",
reason="access_engine_malformed_body",
request_digest=digest,
stance=FAIL_CLOSED,
)
return envelope.get("effect") == ALLOW_EFFECT
effect = envelope.get("effect")
decision_id = envelope.get("id")
if not isinstance(decision_id, str):
decision_id = None
if effect == ALLOW_EFFECT:
return CheckResult(
allowed=True,
source="decision",
reason="allow",
request_digest=digest,
decision_id=decision_id,
effect=ALLOW_EFFECT,
)
return CheckResult(
allowed=False,
source="decision",
reason="denied_by_flex_auth_policy_check",
request_digest=digest,
decision_id=decision_id,
effect=str(effect) if effect is not None else None,
)
def is_allowed(self, request: CheckRequest) -> bool:
return self.check(request).allowed
def close(self) -> None:
self._client.close()

View file

@ -19,6 +19,7 @@ except ImportError: # pragma: no cover - exercised by the deployment guard
ConnectionPool = None
PoolTimeout = None
from tenant_engine.audit_core import envelope_for, new_event_id
from tenant_engine.domain import (
CapabilityRole,
PlanAssignment,
@ -35,9 +36,11 @@ from tenant_engine.guardrail import (
load_limit,
)
from tenant_engine.store import (
AuthorizationRecord,
DomainEvent,
GrantNotFoundError,
IdempotencyConflictError,
OutboxRow,
StoreUnavailableError,
TenantAlreadyExistsError,
TenantNotFoundError,
@ -118,7 +121,7 @@ class PostgresTenantStore:
with self._pool.connection() as conn:
conn.execute("SELECT 1").fetchone()
def create_tenant(self, tenant: Tenant) -> None:
def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None:
try:
with self._pool.connection() as conn, conn.transaction():
conn.execute(
@ -144,7 +147,7 @@ class PostgresTenantStore:
conn,
"tenant_created",
tenant.tenant_id,
{"identifier": tenant.identifier, "grouping": tenant.grouping},
{"identifier": tenant.identifier, "grouping": tenant.grouping, **(authz or {})},
)
except StoreUnavailableError as exc:
if isinstance(exc.__cause__, UniqueViolation):
@ -204,15 +207,13 @@ class PostgresTenantStore:
updated.tenant_id,
),
)
self._record_receipt(
conn, updated, idempotency_key, request_fingerprint
)
self._record_receipt(conn, updated, idempotency_key, request_fingerprint)
self._emit(
conn, event_type, updated.tenant_id, {**evidence, "version": updated.version}
)
return updated, False
def grant_role(self, grant: RoleGrant) -> None:
def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None:
tenant = self.get_tenant(grant.tenant_id)
self._require_active(tenant, "grant a role")
with self._pool.connection() as conn, conn.transaction():
@ -248,10 +249,13 @@ class PostgresTenantStore:
"role": grant.role.value,
"grant_reason": grant.grant_reason,
"correlation_id": grant.correlation_id,
**(authz or {}),
},
)
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
def revoke_role(
self, *, tenant_id: str, grant_id: str, at: datetime, authz: dict[str, Any] | None = None
) -> RoleGrant:
tenant = self.get_tenant(tenant_id)
with self._pool.connection() as conn, conn.transaction():
row = conn.execute(
@ -266,7 +270,7 @@ class PostgresTenantStore:
conn,
"role_revoked",
tenant.tenant_id,
{"grant_id": grant_id, "role": grant.role.value},
{"grant_id": grant_id, "role": grant.role.value, **(authz or {})},
)
return grant
@ -279,7 +283,9 @@ class PostgresTenantStore:
).fetchall()
return frozenset(CapabilityRole(row["role"]) for row in rows)
def assign_plan(self, assignment: PlanAssignment) -> None:
def assign_plan(
self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None
) -> None:
tenant = self.get_tenant(assignment.tenant_id)
self._require_active(tenant, "assign a plan")
with self._pool.connection() as conn, conn.transaction():
@ -290,17 +296,89 @@ class PostgresTenantStore:
(tenant.tenant_id, assignment.plan_id, assignment.assigned_at),
)
self._emit(
conn, "plan_assigned", tenant.tenant_id, {"plan_id": assignment.plan_id}
conn,
"plan_assigned",
tenant.tenant_id,
{"plan_id": assignment.plan_id, **(authz or {})},
)
def events(self) -> list[DomainEvent]:
def events_for(self, tenant_id: str) -> list[DomainEvent]:
tenant = self.get_tenant(tenant_id)
with self._pool.connection() as conn:
rows = conn.execute("SELECT * FROM events ORDER BY seq").fetchall()
rows = conn.execute(
"SELECT * FROM events WHERE tenant_id = %s ORDER BY seq",
(tenant.tenant_id,),
).fetchall()
return [
DomainEvent(row["event_type"], row["tenant_id"], row["at"], row["payload"])
for row in rows
]
def record_authorization(self, record: AuthorizationRecord) -> None:
with self._pool.connection() as conn, conn.transaction():
conn.execute(
"""INSERT INTO authz_records
(action, tenant_id, actor, allowed, source, reason, at,
decision_id, request_digest, effect, stance)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(
record.action,
record.tenant_id,
record.actor,
record.allowed,
record.source,
record.reason,
record.at,
record.decision_id,
record.request_digest,
record.effect,
record.stance,
),
)
def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]:
keys = [tenant_id]
try:
keys.append(self.get_tenant(tenant_id).tenant_id)
except TenantNotFoundError:
pass
placeholders = ",".join(["%s"] * len(keys))
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM authz_records WHERE tenant_id IN ({placeholders}) ORDER BY seq",
keys,
).fetchall()
return [_authz_row(row) for row in rows]
def pending_outbox(self) -> list[OutboxRow]:
with self._pool.connection() as conn:
rows = conn.execute(
"""SELECT * FROM audit_outbox
WHERE delivered_at IS NULL AND dead_at IS NULL
ORDER BY created_at"""
).fetchall()
return [_outbox_row(row) for row in rows]
def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None:
with self._pool.connection() as conn, conn.transaction():
if status in {"delivered", "duplicate"}:
conn.execute(
"""UPDATE audit_outbox SET delivered_at = %s, last_error = NULL
WHERE event_id = %s""",
(datetime.now(UTC), event_id),
)
elif status == "dead":
conn.execute(
"UPDATE audit_outbox SET dead_at = %s, last_error = %s WHERE event_id = %s",
(datetime.now(UTC), detail, event_id),
)
else:
conn.execute(
"""UPDATE audit_outbox SET attempts = attempts + 1, last_error = %s
WHERE event_id = %s""",
(detail, event_id),
)
def guardrail_overrides(self, tenant_id: str) -> dict[str, LimitValue]:
tenant = self.get_tenant(tenant_id)
with self._pool.connection() as conn:
@ -333,6 +411,7 @@ class PostgresTenantStore:
idempotency_key: str,
request_fingerprint: str,
at: datetime,
authz: dict[str, Any] | None = None,
) -> tuple[Tenant, GuardrailChange | None, bool]:
DEFAULT_REGISTRY.get(limit_key)
with self._pool.connection() as conn, conn.transaction():
@ -428,6 +507,7 @@ class PostgresTenantStore:
"cleared": value is None,
"correlation_id": correlation_id,
"version": updated.version,
**(authz or {}),
},
)
return updated, change, False
@ -467,9 +547,24 @@ class PostgresTenantStore:
@staticmethod
def _emit(conn: Any, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None:
event_id = str(payload.get("event_id") or new_event_id())
payload = {**payload, "event_id": event_id}
at = datetime.now(UTC)
conn.execute(
"INSERT INTO events (event_type, tenant_id, at, payload) VALUES (%s, %s, %s, %s)",
(event_type, tenant_id, datetime.now(UTC), Jsonb(payload)),
(event_type, tenant_id, at, Jsonb(payload)),
)
envelope = envelope_for(
event_id=event_id,
event_type=event_type,
tenant_id=tenant_id,
observed_at=at.isoformat(),
payload=payload,
)
conn.execute(
"""INSERT INTO audit_outbox (event_id, tenant_id, envelope, created_at)
VALUES (%s, %s, %s, %s)""",
(event_id, tenant_id, Jsonb(envelope), at),
)
@ -501,9 +596,7 @@ def _row(tenant: Tenant) -> dict[str, Any]:
"created_at": tenant.created_at.isoformat() if tenant.created_at else None,
"updated_at": tenant.updated_at.isoformat() if tenant.updated_at else None,
"retired_at": tenant.retired_at.isoformat() if tenant.retired_at else None,
"reactivated_at": (
tenant.reactivated_at.isoformat() if tenant.reactivated_at else None
),
"reactivated_at": (tenant.reactivated_at.isoformat() if tenant.reactivated_at else None),
}
@ -539,3 +632,32 @@ def _change(row: Mapping[str, Any]) -> GuardrailChange:
correlation_id=row["correlation_id"],
changed_at=_dt(row["changed_at"]),
)
def _authz_row(row: Mapping[str, Any]) -> AuthorizationRecord:
return AuthorizationRecord(
action=row["action"],
tenant_id=row["tenant_id"],
actor=row["actor"],
allowed=bool(row["allowed"]),
source=row["source"],
reason=row["reason"],
at=_dt(row["at"]),
decision_id=row["decision_id"],
request_digest=row["request_digest"],
effect=row["effect"],
stance=row["stance"],
)
def _outbox_row(row: Mapping[str, Any]) -> OutboxRow:
return OutboxRow(
event_id=row["event_id"],
tenant_id=row["tenant_id"],
envelope=row["envelope"],
created_at=_dt(row["created_at"]),
attempts=row["attempts"],
last_error=row["last_error"],
delivered_at=_dt(row["delivered_at"]) if row["delivered_at"] else None,
dead_at=_dt(row["dead_at"]) if row["dead_at"] else None,
)

View file

@ -8,6 +8,7 @@ from datetime import datetime
from threading import RLock
from typing import Any
from tenant_engine.audit_core import envelope_for, new_event_id
from tenant_engine.domain import (
CapabilityRole,
PlanAssignment,
@ -24,9 +25,11 @@ from tenant_engine.guardrail import (
load_limit,
)
from tenant_engine.store import (
AuthorizationRecord,
DomainEvent,
GrantNotFoundError,
IdempotencyConflictError,
OutboxRow,
TenantAlreadyExistsError,
TenantNotFoundError,
VersionConflictError,
@ -62,6 +65,19 @@ class SQLiteTenantStore:
seq INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL,
tenant_id TEXT NOT NULL, at TEXT NOT NULL, payload TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS authz_records (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL, tenant_id TEXT NOT NULL, actor TEXT NOT NULL,
allowed INTEGER NOT NULL, source TEXT NOT NULL, reason TEXT NOT NULL,
at TEXT NOT NULL, decision_id TEXT, request_digest TEXT,
effect TEXT, stance TEXT
);
CREATE TABLE IF NOT EXISTS audit_outbox (
event_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
envelope TEXT NOT NULL, created_at TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT,
delivered_at TEXT, dead_at TEXT
);
CREATE TABLE IF NOT EXISTS idempotency_receipts (
tenant_id TEXT NOT NULL, idempotency_key TEXT NOT NULL,
request_fingerprint TEXT NOT NULL, result TEXT NOT NULL,
@ -110,7 +126,7 @@ class SQLiteTenantStore:
with self._lock:
self._db.execute("SELECT 1").fetchone()
def create_tenant(self, tenant: Tenant) -> None:
def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None:
with self._lock, self._db:
try:
self._db.execute(
@ -118,16 +134,31 @@ class SQLiteTenantStore:
contact_email, lifecycle, version, created_at, updated_at,
retired_at, reactivated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(tenant.tenant_id, tenant.identifier, tenant.grouping, tenant.display_name,
tenant.contact_email, tenant.lifecycle.value, tenant.version,
_iso(tenant.created_at), _iso(tenant.updated_at),
_iso(tenant.retired_at), _iso(tenant.reactivated_at)),
(
tenant.tenant_id,
tenant.identifier,
tenant.grouping,
tenant.display_name,
tenant.contact_email,
tenant.lifecycle.value,
tenant.version,
_iso(tenant.created_at),
_iso(tenant.updated_at),
_iso(tenant.retired_at),
_iso(tenant.reactivated_at),
),
)
except sqlite3.IntegrityError as exc:
raise TenantAlreadyExistsError(tenant.identifier) from exc
self._emit("tenant_created", tenant.tenant_id, {
"identifier": tenant.identifier, "grouping": tenant.grouping,
})
self._emit(
"tenant_created",
tenant.tenant_id,
{
"identifier": tenant.identifier,
"grouping": tenant.grouping,
**(authz or {}),
},
)
def get_tenant(self, tenant_id: str) -> Tenant:
# Reads take the same lock as writes. One sqlite3 connection is shared
@ -189,15 +220,27 @@ class SQLiteTenantStore:
contact_email = ?, lifecycle = ?, version = ?, updated_at = ?,
retired_at = ?, reactivated_at = ?
WHERE tenant_id = ?""",
(updated.grouping, updated.display_name, updated.contact_email,
updated.lifecycle.value, updated.version, _iso(updated.updated_at),
_iso(updated.retired_at), _iso(updated.reactivated_at),
updated.tenant_id),
(
updated.grouping,
updated.display_name,
updated.contact_email,
updated.lifecycle.value,
updated.version,
_iso(updated.updated_at),
_iso(updated.retired_at),
_iso(updated.reactivated_at),
updated.tenant_id,
),
)
self._db.execute(
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",
(updated.tenant_id, idempotency_key, request_fingerprint,
json.dumps(_row(updated)), datetime.now().astimezone().isoformat()),
(
updated.tenant_id,
idempotency_key,
request_fingerprint,
json.dumps(_row(updated)),
datetime.now().astimezone().isoformat(),
),
)
self._emit(event_type, updated.tenant_id, {**evidence, "version": updated.version})
except BaseException:
@ -251,6 +294,7 @@ class SQLiteTenantStore:
idempotency_key: str,
request_fingerprint: str,
at: datetime,
authz: dict[str, Any] | None = None,
) -> tuple[Tenant, GuardrailChange | None, bool]:
tenant = self.get_tenant(tenant_id)
DEFAULT_REGISTRY.get(limit_key)
@ -274,8 +318,11 @@ class SQLiteTenantStore:
replayed = _tenant(json.loads(receipt["result"]))
self._db.rollback()
prior = next(
(c for c in self.guardrail_changes(tenant.tenant_id)
if c.change_id == change_id),
(
c
for c in self.guardrail_changes(tenant.tenant_id)
if c.change_id == change_id
),
None,
)
return replayed, prior, True
@ -317,16 +364,29 @@ class SQLiteTenantStore:
dumped = dump_limit(value)
self._db.execute(
"INSERT OR REPLACE INTO guardrail_overrides VALUES (?, ?, ?, ?, ?, ?)",
(current.tenant_id, limit_key, dumped["kind"], dumped["amount"],
dumped["currency"], dumped["period"]),
(
current.tenant_id,
limit_key,
dumped["kind"],
dumped["amount"],
dumped["currency"],
dumped["period"],
),
)
self._db.execute(
"INSERT INTO guardrail_changes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(change.change_id, change.tenant_id, change.limit_key,
json.dumps(dump_limit(change.previous)) if change.previous else None,
json.dumps(dump_limit(change.current)) if change.current else None,
change.changed_by, change.reason, change.correlation_id, _iso(at)),
(
change.change_id,
change.tenant_id,
change.limit_key,
json.dumps(dump_limit(change.previous)) if change.previous else None,
json.dumps(dump_limit(change.current)) if change.current else None,
change.changed_by,
change.reason,
change.correlation_id,
_iso(at),
),
)
updated = replace(current, version=current.version + 1, updated_at=at)
@ -336,35 +396,65 @@ class SQLiteTenantStore:
)
self._db.execute(
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",
(updated.tenant_id, idempotency_key, request_fingerprint,
json.dumps(_row(updated)), datetime.now().astimezone().isoformat()),
(
updated.tenant_id,
idempotency_key,
request_fingerprint,
json.dumps(_row(updated)),
datetime.now().astimezone().isoformat(),
),
)
self._emit(
"guardrail_changed",
updated.tenant_id,
{
"limit_key": limit_key,
"change_id": change_id,
"cleared": value is None,
"correlation_id": correlation_id,
"version": updated.version,
**(authz or {}),
},
)
self._emit("guardrail_changed", updated.tenant_id, {
"limit_key": limit_key, "change_id": change_id,
"cleared": value is None, "correlation_id": correlation_id,
"version": updated.version,
})
except BaseException:
self._db.rollback()
raise
self._db.commit()
return updated, change, False
def grant_role(self, grant: RoleGrant) -> None:
def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None:
tenant = self.get_tenant(grant.tenant_id)
self._require_active(tenant, "grant a role")
with self._lock, self._db:
self._db.execute(
"INSERT OR REPLACE INTO grants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(grant.grant_id, tenant.tenant_id, grant.role.value, grant.grant_reason,
grant.plan_id, grant.granted_by, grant.granted_at.isoformat(),
grant.correlation_id, grant.revoked_at.isoformat() if grant.revoked_at else None),
(
grant.grant_id,
tenant.tenant_id,
grant.role.value,
grant.grant_reason,
grant.plan_id,
grant.granted_by,
grant.granted_at.isoformat(),
grant.correlation_id,
grant.revoked_at.isoformat() if grant.revoked_at else None,
),
)
self._emit(
"role_granted",
tenant.tenant_id,
{
"grant_id": grant.grant_id,
"role": grant.role.value,
"grant_reason": grant.grant_reason,
"correlation_id": grant.correlation_id,
**(authz or {}),
},
)
self._emit("role_granted", tenant.tenant_id, {"grant_id": grant.grant_id,
"role": grant.role.value, "grant_reason": grant.grant_reason,
"correlation_id": grant.correlation_id})
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
def revoke_role(
self, *, tenant_id: str, grant_id: str, at: datetime, authz: dict[str, Any] | None = None
) -> RoleGrant:
tenant = self.get_tenant(tenant_id)
with self._lock:
row = self._db.execute(
@ -375,10 +465,14 @@ class SQLiteTenantStore:
raise GrantNotFoundError(grant_id)
grant = self._grant(row).revoke(at=at)
with self._lock, self._db:
self._db.execute("UPDATE grants SET revoked_at = ? WHERE grant_id = ?",
(at.isoformat(), grant_id))
self._emit("role_revoked", tenant.tenant_id,
{"grant_id": grant_id, "role": grant.role.value})
self._db.execute(
"UPDATE grants SET revoked_at = ? WHERE grant_id = ?", (at.isoformat(), grant_id)
)
self._emit(
"role_revoked",
tenant.tenant_id,
{"grant_id": grant_id, "role": grant.role.value, **(authz or {})},
)
return grant
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]:
@ -390,7 +484,9 @@ class SQLiteTenantStore:
).fetchall()
return frozenset(CapabilityRole(row["role"]) for row in rows)
def assign_plan(self, assignment: PlanAssignment) -> None:
def assign_plan(
self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None
) -> None:
tenant = self.get_tenant(assignment.tenant_id)
self._require_active(tenant, "assign a plan")
with self._lock, self._db:
@ -398,14 +494,94 @@ class SQLiteTenantStore:
"INSERT OR REPLACE INTO plans VALUES (?, ?, ?)",
(tenant.tenant_id, assignment.plan_id, assignment.assigned_at.isoformat()),
)
self._emit("plan_assigned", tenant.tenant_id, {"plan_id": assignment.plan_id})
self._emit(
"plan_assigned",
tenant.tenant_id,
{"plan_id": assignment.plan_id, **(authz or {})},
)
def events(self) -> list[DomainEvent]:
def events_for(self, tenant_id: str) -> list[DomainEvent]:
tenant = self.get_tenant(tenant_id)
with self._lock:
rows = self._db.execute("SELECT * FROM events ORDER BY seq").fetchall()
return [DomainEvent(row["event_type"], row["tenant_id"],
datetime.fromisoformat(row["at"]), json.loads(row["payload"]))
for row in rows]
rows = self._db.execute(
"SELECT * FROM events WHERE tenant_id = ? ORDER BY seq", (tenant.tenant_id,)
).fetchall()
return [
DomainEvent(
row["event_type"],
row["tenant_id"],
datetime.fromisoformat(row["at"]),
json.loads(row["payload"]),
)
for row in rows
]
def record_authorization(self, record: AuthorizationRecord) -> None:
with self._lock, self._db:
self._db.execute(
"""INSERT INTO authz_records
(action, tenant_id, actor, allowed, source, reason, at,
decision_id, request_digest, effect, stance)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
record.action,
record.tenant_id,
record.actor,
1 if record.allowed else 0,
record.source,
record.reason,
record.at.isoformat(),
record.decision_id,
record.request_digest,
record.effect,
record.stance,
),
)
def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]:
keys = {tenant_id}
try:
keys.add(self.get_tenant(tenant_id).tenant_id)
except TenantNotFoundError:
pass
placeholders = ",".join("?" * len(keys))
with self._lock:
rows = self._db.execute(
f"SELECT * FROM authz_records WHERE tenant_id IN ({placeholders}) ORDER BY seq",
tuple(keys),
).fetchall()
return [_authz_row(row) for row in rows]
def pending_outbox(self) -> list[OutboxRow]:
with self._lock:
rows = self._db.execute(
"""SELECT * FROM audit_outbox
WHERE delivered_at IS NULL AND dead_at IS NULL
ORDER BY created_at"""
).fetchall()
return [_outbox_row(row) for row in rows]
def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None:
now = datetime.now().astimezone().isoformat()
with self._lock, self._db:
if status in {"delivered", "duplicate"}:
self._db.execute(
"""UPDATE audit_outbox
SET delivered_at = ?, last_error = NULL WHERE event_id = ?""",
(now, event_id),
)
elif status == "dead":
self._db.execute(
"UPDATE audit_outbox SET dead_at = ?, last_error = ? WHERE event_id = ?",
(now, detail, event_id),
)
else:
self._db.execute(
"""UPDATE audit_outbox
SET attempts = attempts + 1, last_error = ?
WHERE event_id = ?""",
(detail, event_id),
)
@staticmethod
def _require_active(tenant: Tenant, what: str) -> None:
@ -413,22 +589,74 @@ class SQLiteTenantStore:
raise TenantRetiredError(f"cannot {what} on a retired tenant")
def _emit(self, event_type: str, tenant_id: str, payload: dict) -> None:
event_id = str(payload.get("event_id") or new_event_id())
payload = {**payload, "event_id": event_id}
now = datetime.now().astimezone()
self._db.execute("INSERT INTO events(event_type,tenant_id,at,payload) VALUES(?,?,?,?)",
(event_type, tenant_id, now.isoformat(), json.dumps(payload)))
self._db.execute(
"INSERT INTO events(event_type,tenant_id,at,payload) VALUES(?,?,?,?)",
(event_type, tenant_id, now.isoformat(), json.dumps(payload)),
)
envelope = envelope_for(
event_id=event_id,
event_type=event_type,
tenant_id=tenant_id,
observed_at=now.isoformat(),
payload=payload,
)
self._db.execute(
"""INSERT INTO audit_outbox (event_id, tenant_id, envelope, created_at)
VALUES (?, ?, ?, ?)""",
(event_id, tenant_id, json.dumps(envelope), now.isoformat()),
)
@staticmethod
def _grant(row: sqlite3.Row) -> RoleGrant:
return RoleGrant(row["grant_id"], row["tenant_id"], CapabilityRole(row["role"]),
row["grant_reason"], row["plan_id"], row["granted_by"],
datetime.fromisoformat(row["granted_at"]), row["correlation_id"],
datetime.fromisoformat(row["revoked_at"]) if row["revoked_at"] else None)
return RoleGrant(
row["grant_id"],
row["tenant_id"],
CapabilityRole(row["role"]),
row["grant_reason"],
row["plan_id"],
row["granted_by"],
datetime.fromisoformat(row["granted_at"]),
row["correlation_id"],
datetime.fromisoformat(row["revoked_at"]) if row["revoked_at"] else None,
)
def _iso(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _authz_row(row: sqlite3.Row) -> AuthorizationRecord:
return AuthorizationRecord(
action=row["action"],
tenant_id=row["tenant_id"],
actor=row["actor"],
allowed=bool(row["allowed"]),
source=row["source"],
reason=row["reason"],
at=datetime.fromisoformat(row["at"]),
decision_id=row["decision_id"],
request_digest=row["request_digest"],
effect=row["effect"],
stance=row["stance"],
)
def _outbox_row(row: sqlite3.Row) -> OutboxRow:
return OutboxRow(
event_id=row["event_id"],
tenant_id=row["tenant_id"],
envelope=json.loads(row["envelope"]),
created_at=datetime.fromisoformat(row["created_at"]),
attempts=row["attempts"],
last_error=row["last_error"],
delivered_at=datetime.fromisoformat(row["delivered_at"]) if row["delivered_at"] else None,
dead_at=datetime.fromisoformat(row["dead_at"]) if row["dead_at"] else None,
)
def _dt(value: str | None) -> datetime | None:
return datetime.fromisoformat(value) if value else None

View file

@ -0,0 +1,49 @@
"""Published unreachable-engine stance, loaded from pep-stance.yaml.
The map MUST equal shipped behaviour. DefaultDenyWriteAuthorizer and
transport-failure deny both apply `fail_closed`. A published map that may
drift from this module is worse than none (§6.4 obligation 3).
"""
from __future__ import annotations
from pathlib import Path
STANCE_PATH = Path(__file__).resolve().parents[2] / "pep-stance.yaml"
# Shipped behaviour. pep-stance.yaml must equal this dict. Keep the two
# in lockstep — tests/test_layer_conformance.py compares them.
SHIPPED_STANCE: dict[str, str] = {
"unset": "fail_closed",
"unreachable": "fail_closed",
"non_allow": "fail_closed",
"unknown": "fail_closed",
}
FAIL_CLOSED = "fail_closed"
def shipped_stance() -> dict[str, str]:
return dict(SHIPPED_STANCE)
def published_stance(text: str | None = None) -> dict[str, str]:
"""Parse the `stance:` map from pep-stance.yaml without a YAML runtime dep."""
raw = text if text is not None else STANCE_PATH.read_text(encoding="utf-8")
in_map = False
parsed: dict[str, str] = {}
for line in raw.splitlines():
stripped = line.strip()
if stripped.startswith("stance:"):
in_map = True
continue
if in_map:
if not stripped or stripped.startswith("#"):
continue
if not line.startswith(" ") and not line.startswith("\t"):
break
if ":" not in stripped:
continue
key, value = stripped.split(":", 1)
parsed[key.strip()] = value.split("#", 1)[0].strip()
return parsed

View file

@ -5,6 +5,7 @@ from dataclasses import dataclass, replace
from datetime import UTC, datetime
from typing import Any, Protocol
from tenant_engine.audit_core import envelope_for, new_event_id
from tenant_engine.domain import (
CapabilityRole,
PlanAssignment,
@ -64,6 +65,37 @@ class DomainEvent:
payload: dict[str, Any]
@dataclass(frozen=True, slots=True)
class AuthorizationRecord:
"""PEP reconstructability: every authorize attempt, including denies."""
action: str
tenant_id: str
actor: str
allowed: bool
source: str
reason: str
at: datetime
decision_id: str | None = None
request_digest: str | None = None
effect: str | None = None
stance: str | None = None
@dataclass(frozen=True, slots=True)
class OutboxRow:
"""Local audit-core outbox. Drain is after commit and non-blocking."""
event_id: str
tenant_id: str
envelope: dict[str, Any]
created_at: datetime
attempts: int = 0
last_error: str | None = None
delivered_at: datetime | None = None
dead_at: datetime | None = None
class TenantStore(Protocol):
"""Swappable persistence seam -- domain/ and api/ depend on this, not a backend.
@ -77,19 +109,31 @@ class TenantStore(Protocol):
tenant_roles claim, KEY-WP-0005-T02).
"""
def create_tenant(self, tenant: Tenant) -> None: ...
def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None: ...
def get_tenant(self, tenant_id: str) -> Tenant: ...
def grant_role(self, grant: RoleGrant) -> None: ...
def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None: ...
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant: ...
def revoke_role(
self, *, tenant_id: str, grant_id: str, at: datetime, authz: dict[str, Any] | None = None
) -> RoleGrant: ...
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]: ...
def assign_plan(self, assignment: PlanAssignment) -> None: ...
def assign_plan(
self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None
) -> None: ...
def events(self) -> list[DomainEvent]: ...
def events_for(self, tenant_id: str) -> list[DomainEvent]: ...
def record_authorization(self, record: AuthorizationRecord) -> None: ...
def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]: ...
def pending_outbox(self) -> list[OutboxRow]: ...
def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None: ...
def mutate_tenant(
self,
@ -202,12 +246,14 @@ class InMemoryTenantStore:
self._grants: dict[str, dict[str, RoleGrant]] = {}
self._plans: dict[str, PlanAssignment] = {}
self._events: list[DomainEvent] = []
self._authz: list[AuthorizationRecord] = []
self._outbox: dict[str, OutboxRow] = {}
# (tenant_id, idempotency_key) -> (request_fingerprint, result snapshot)
self._receipts: dict[tuple[str, str], tuple[str, Tenant]] = {}
self._overrides: dict[str, dict[str, LimitValue]] = {}
self._guardrail_changes: list[GuardrailChange] = []
def create_tenant(self, tenant: Tenant) -> None:
def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None:
if tenant.tenant_id in self._tenants:
raise TenantAlreadyExistsError(tenant.tenant_id)
if tenant.identifier in self._by_identifier:
@ -218,13 +264,13 @@ class InMemoryTenantStore:
self._emit(
"tenant_created",
tenant.tenant_id,
{"identifier": tenant.identifier, "grouping": tenant.grouping},
{"identifier": tenant.identifier, "grouping": tenant.grouping, **(authz or {})},
)
def get_tenant(self, tenant_id: str) -> Tenant:
return self._tenants[self._resolve(tenant_id)]
def grant_role(self, grant: RoleGrant) -> None:
def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None:
resolved = self._resolve(grant.tenant_id)
self._require_active(resolved, "grant a role")
self._grants[resolved][grant.grant_id] = grant
@ -236,10 +282,18 @@ class InMemoryTenantStore:
"role": grant.role.value,
"grant_reason": grant.grant_reason,
"correlation_id": grant.correlation_id,
**(authz or {}),
},
)
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
def revoke_role(
self,
*,
tenant_id: str,
grant_id: str,
at: datetime,
authz: dict[str, Any] | None = None,
) -> RoleGrant:
resolved = self._resolve(tenant_id)
try:
grant = self._grants[resolved][grant_id]
@ -250,7 +304,7 @@ class InMemoryTenantStore:
self._emit(
"role_revoked",
resolved,
{"grant_id": grant_id, "role": revoked.role.value},
{"grant_id": grant_id, "role": revoked.role.value, **(authz or {})},
)
return revoked
@ -260,14 +314,48 @@ class InMemoryTenantStore:
grant.role for grant in self._grants.get(resolved, {}).values() if grant.active
)
def assign_plan(self, assignment: PlanAssignment) -> None:
def assign_plan(
self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None
) -> None:
resolved = self._resolve(assignment.tenant_id)
self._require_active(resolved, "assign a plan")
self._plans[resolved] = assignment
self._emit("plan_assigned", resolved, {"plan_id": assignment.plan_id})
self._emit(
"plan_assigned",
resolved,
{"plan_id": assignment.plan_id, **(authz or {})},
)
def events(self) -> list[DomainEvent]:
return list(self._events)
def events_for(self, tenant_id: str) -> list[DomainEvent]:
resolved = self._resolve(tenant_id)
return [event for event in self._events if event.tenant_id == resolved]
def record_authorization(self, record: AuthorizationRecord) -> None:
self._authz.append(record)
def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]:
try:
resolved = self._resolve(tenant_id)
except TenantNotFoundError:
resolved = tenant_id
return [row for row in self._authz if row.tenant_id in {tenant_id, resolved}]
def pending_outbox(self) -> list[OutboxRow]:
return [
row for row in self._outbox.values() if row.delivered_at is None and row.dead_at is None
]
def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None:
row = self._outbox.get(event_id)
if row is None:
return
now = datetime.now(UTC)
if status in {"delivered", "duplicate"}:
self._outbox[event_id] = replace(row, delivered_at=now, last_error=None)
elif status == "dead":
self._outbox[event_id] = replace(row, dead_at=now, last_error=detail)
else:
self._outbox[event_id] = replace(row, attempts=row.attempts + 1, last_error=detail)
def mutate_tenant(
self,
@ -320,6 +408,7 @@ class InMemoryTenantStore:
idempotency_key: str,
request_fingerprint: str,
at: datetime,
authz: dict[str, Any] | None = None,
) -> tuple[Tenant, GuardrailChange | None, bool]:
resolved = self._resolve(tenant_id)
@ -342,9 +431,7 @@ class InMemoryTenantStore:
raise VersionConflictError(expected=expected_version, actual=current.version)
overrides = self._overrides.setdefault(resolved, {})
guard_guardrail_write(
tenant=current, overrides=overrides, limit_key=limit_key, value=value
)
guard_guardrail_write(tenant=current, overrides=overrides, limit_key=limit_key, value=value)
previous = overrides.get(limit_key)
change = GuardrailChange(
@ -377,6 +464,7 @@ class InMemoryTenantStore:
"cleared": value is None,
"correlation_id": correlation_id,
"version": updated.version,
**(authz or {}),
},
)
return updated, change, False
@ -405,6 +493,19 @@ class InMemoryTenantStore:
return resolved
def _emit(self, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None:
event_id = str(payload.get("event_id") or new_event_id())
payload = {**payload, "event_id": event_id}
at = datetime.now(UTC)
self._events.append(
DomainEvent(event_type=event_type, tenant_id=tenant_id, at=datetime.now(UTC), payload=payload)
DomainEvent(event_type=event_type, tenant_id=tenant_id, at=at, payload=payload)
)
envelope = envelope_for(
event_id=event_id,
event_type=event_type,
tenant_id=tenant_id,
observed_at=at.isoformat(),
payload=payload,
)
self._outbox[event_id] = OutboxRow(
event_id=event_id, tenant_id=tenant_id, envelope=envelope, created_at=at
)