Implement TEN-WP-0011 security layer conformance
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
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:
parent
80961af91e
commit
672cf4da6e
40 changed files with 2285 additions and 361 deletions
|
|
@ -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)
|
||||
|
|
|
|||
106
src/tenant_engine/audit_core.py
Normal file
106
src/tenant_engine/audit_core.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
49
src/tenant_engine/stance.py
Normal file
49
src/tenant_engine/stance.py
Normal 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
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue