Implement tenant update and reversible retirement API (TEN-WP-0005 T01-T04)
Add an explicit tenant lifecycle (active/retired), allow-listed mutable
metadata, record versioning, and lifecycle timestamps to the tenant authority.
- domain: TenantLifecycle, with_metadata/retire/reactivate, immutability and
transition invariants. Identifier stays immutable -- it is the IAM Profile
`tenant` claim key-cape mints into tokens.
- store: mutate_tenant() commits idempotency replay, version CAS, mutation,
and audit event together; durable receipts survive restart. Retired tenants
refuse new grants and plan changes but keep their history.
- sqlite: forward-only idempotent migration; existing rows default to active
at version 1. Reads now take the write lock -- the concurrent-writer test
caught unguarded reads on the shared connection observing mid-transaction
state as a spurious tenant_not_found.
- api: GET/PATCH /tenants/{id}, POST retire|reactivate. Idempotency-Key and
If-Match required, distinct flex-auth actions per operation, stable error
schema, redacted 503s.
- docs/tenant-lifecycle-api.md: consumer contract for user-engine.
Implemented against SQLite, not PostgreSQL as the workplan assumed --
TEN-WP-0004 shipped SQLite on a PVC as the production store.
124 tests pass (was 66); no breaking change to existing endpoints.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
7dcccafc03
commit
d6fd73bd42
10 changed files with 1625 additions and 28 deletions
|
|
@ -1,10 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi import FastAPI, Header, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from tenant_engine import __version__
|
||||
from tenant_engine.authz import (
|
||||
|
|
@ -16,21 +18,27 @@ from tenant_engine.authz import (
|
|||
from tenant_engine.config import Settings
|
||||
from tenant_engine.domain import (
|
||||
CapabilityRole,
|
||||
EmptyUpdateError,
|
||||
GrantReason,
|
||||
ImmutableFieldError,
|
||||
InvalidGrantError,
|
||||
InvalidLifecycleTransitionError,
|
||||
InvalidTenantIdentifierError,
|
||||
PlanAssignment,
|
||||
Tenant,
|
||||
TenantRetiredError,
|
||||
create_role_grant,
|
||||
)
|
||||
from tenant_engine.flex_auth import FlexAuthCheckClient
|
||||
from tenant_engine.store import (
|
||||
GrantNotFoundError,
|
||||
IdempotencyConflictError,
|
||||
InMemoryTenantStore,
|
||||
StoreUnavailableError,
|
||||
TenantAlreadyExistsError,
|
||||
TenantNotFoundError,
|
||||
TenantStore,
|
||||
VersionConflictError,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -38,6 +46,8 @@ class CreateTenantRequest(BaseModel):
|
|||
tenant_id: str
|
||||
identifier: str
|
||||
actor: str
|
||||
display_name: str | None = None
|
||||
contact_email: str | None = None
|
||||
|
||||
|
||||
class GrantRoleRequest(BaseModel):
|
||||
|
|
@ -60,6 +70,53 @@ class AssignPlanRequest(BaseModel):
|
|||
actor: str
|
||||
|
||||
|
||||
class TenantMetadata(BaseModel):
|
||||
"""The allow-list, expressed in the schema itself.
|
||||
|
||||
`extra="forbid"` is what turns "unknown fields fail closed" into a
|
||||
contract consumers can see in the OpenAPI document rather than a rule
|
||||
they discover from a 400.
|
||||
"""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
display_name: str | None = None
|
||||
contact_email: str | None = None
|
||||
|
||||
|
||||
class UpdateTenantRequest(BaseModel):
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
metadata: TenantMetadata
|
||||
actor: str
|
||||
reason: str = Field(min_length=1)
|
||||
correlation_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
class LifecycleRequest(BaseModel):
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
actor: str
|
||||
reason: str = Field(min_length=1)
|
||||
correlation_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
class LifecycleError(Exception):
|
||||
"""A lifecycle-endpoint failure rendered in the stable error schema.
|
||||
|
||||
Carries only strings this service chose. Store and policy exceptions are
|
||||
mapped to one of these at the boundary, never reflected verbatim -- a 503
|
||||
must not leak a database path or a flex-auth policy name to a consumer.
|
||||
"""
|
||||
|
||||
def __init__(self, status_code: int, error_code: str, detail: str, correlation_id: str) -> None:
|
||||
super().__init__(f"{status_code} {error_code}")
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code
|
||||
self.detail = detail
|
||||
self.correlation_id = correlation_id
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
store: TenantStore | None = None,
|
||||
|
|
@ -81,10 +138,38 @@ def create_app(
|
|||
content={"error_code": "write_denied", "action": exc.action, "detail": exc.reason},
|
||||
)
|
||||
|
||||
@app.exception_handler(LifecycleError)
|
||||
async def handle_lifecycle_error(_: Request, exc: LifecycleError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error_code": exc.error_code,
|
||||
"detail": exc.detail,
|
||||
"correlation_id": exc.correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok", "service": "tenant-engine", "version": __version__}
|
||||
|
||||
# -- Authoritative tenant record read (TEN-WP-0005) -------------------
|
||||
# user-engine's platform operator UI reads this before offering an edit,
|
||||
# and echoes the returned ETag back as If-Match on the mutation.
|
||||
|
||||
@app.get("/tenants/{tenant_id}")
|
||||
async def get_tenant(tenant_id: str, response: Response) -> dict:
|
||||
try:
|
||||
tenant = store.get_tenant(tenant_id)
|
||||
except TenantNotFoundError as exc:
|
||||
raise LifecycleError(404, "tenant_not_found", "tenant_not_found", "") from exc
|
||||
except StoreUnavailableError as exc:
|
||||
raise LifecycleError(
|
||||
503, "tenant_authority_unavailable", "tenant_authority_unavailable", ""
|
||||
) from exc
|
||||
response.headers["ETag"] = _etag(tenant.version)
|
||||
return _tenant_response(tenant)
|
||||
|
||||
# -- Cache-read API (key-cape, at token issuance) --------------------
|
||||
|
||||
@app.get("/tenants/{tenant_id}/roles")
|
||||
|
|
@ -110,13 +195,21 @@ def create_app(
|
|||
async def create_tenant(payload: CreateTenantRequest) -> dict:
|
||||
authorizer.authorize(action="tenant.create", tenant_id=payload.tenant_id, actor=payload.actor)
|
||||
try:
|
||||
tenant = Tenant.create(tenant_id=payload.tenant_id, identifier=payload.identifier)
|
||||
tenant = Tenant.create(
|
||||
tenant_id=payload.tenant_id,
|
||||
identifier=payload.identifier,
|
||||
display_name=payload.display_name,
|
||||
contact_email=payload.contact_email,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
store.create_tenant(tenant)
|
||||
except InvalidTenantIdentifierError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except TenantAlreadyExistsError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"tenant_id": tenant.tenant_id, "identifier": tenant.identifier, "grouping": tenant.grouping}
|
||||
# Superset of the pre-lifecycle response (tenant_id/identifier/grouping
|
||||
# are unchanged), so existing create clients keep working.
|
||||
return _tenant_response(tenant)
|
||||
|
||||
@app.post("/tenants/{tenant_id}/roles/grant", status_code=201)
|
||||
async def grant_role(tenant_id: str, payload: GrantRoleRequest) -> dict:
|
||||
|
|
@ -136,6 +229,8 @@ def create_app(
|
|||
store.grant_role(grant)
|
||||
except TenantNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
||||
except TenantRetiredError as exc:
|
||||
raise HTTPException(status_code=409, detail="tenant_retired") from exc
|
||||
except InvalidGrantError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"grant_id": grant.grant_id, "tenant_id": tenant_id, "role": grant.role.value}
|
||||
|
|
@ -162,11 +257,216 @@ def create_app(
|
|||
)
|
||||
except TenantNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
||||
except TenantRetiredError as exc:
|
||||
raise HTTPException(status_code=409, detail="tenant_retired") from exc
|
||||
return {"tenant_id": tenant_id, "plan_id": payload.plan_id}
|
||||
|
||||
# -- Lifecycle write API (TEN-WP-0005) --------------------------------
|
||||
# Update, retire, and reactivate are separately authorized actions, not
|
||||
# one "tenant.write": user-engine's operator UI can be permitted to
|
||||
# rename a tenant without thereby being permitted to retire it.
|
||||
|
||||
@app.patch("/tenants/{tenant_id}")
|
||||
async def update_tenant(
|
||||
tenant_id: str,
|
||||
payload: UpdateTenantRequest,
|
||||
response: Response,
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
) -> dict:
|
||||
changes = payload.metadata.model_dump(exclude_unset=True)
|
||||
return _lifecycle_mutation(
|
||||
store=store,
|
||||
authorizer=authorizer,
|
||||
action="tenant.update",
|
||||
tenant_id=tenant_id,
|
||||
actor=payload.actor,
|
||||
reason=payload.reason,
|
||||
correlation_id=payload.correlation_id,
|
||||
idempotency_key=idempotency_key,
|
||||
if_match=if_match,
|
||||
response=response,
|
||||
event_type="tenant_updated",
|
||||
extra_fingerprint=changes,
|
||||
mutate=lambda tenant, at: tenant.with_metadata(changes, at=at),
|
||||
)
|
||||
|
||||
@app.post("/tenants/{tenant_id}/retire")
|
||||
async def retire_tenant(
|
||||
tenant_id: str,
|
||||
payload: LifecycleRequest,
|
||||
response: Response,
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
) -> dict:
|
||||
return _lifecycle_mutation(
|
||||
store=store,
|
||||
authorizer=authorizer,
|
||||
action="tenant.retire",
|
||||
tenant_id=tenant_id,
|
||||
actor=payload.actor,
|
||||
reason=payload.reason,
|
||||
correlation_id=payload.correlation_id,
|
||||
idempotency_key=idempotency_key,
|
||||
if_match=if_match,
|
||||
response=response,
|
||||
event_type="tenant_retired",
|
||||
extra_fingerprint={},
|
||||
mutate=lambda tenant, at: tenant.retire(at=at),
|
||||
)
|
||||
|
||||
@app.post("/tenants/{tenant_id}/reactivate")
|
||||
async def reactivate_tenant(
|
||||
tenant_id: str,
|
||||
payload: LifecycleRequest,
|
||||
response: Response,
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
) -> dict:
|
||||
return _lifecycle_mutation(
|
||||
store=store,
|
||||
authorizer=authorizer,
|
||||
action="tenant.reactivate",
|
||||
tenant_id=tenant_id,
|
||||
actor=payload.actor,
|
||||
reason=payload.reason,
|
||||
correlation_id=payload.correlation_id,
|
||||
idempotency_key=idempotency_key,
|
||||
if_match=if_match,
|
||||
response=response,
|
||||
event_type="tenant_reactivated",
|
||||
extra_fingerprint={},
|
||||
mutate=lambda tenant, at: tenant.reactivate(at=at),
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _etag(version: int) -> str:
|
||||
return f'"{version}"'
|
||||
|
||||
|
||||
def _parse_if_match(if_match: str | None, correlation_id: str) -> int:
|
||||
if if_match is None:
|
||||
raise LifecycleError(
|
||||
428, "if_match_required", "If-Match header is required", correlation_id
|
||||
)
|
||||
candidate = if_match.strip()
|
||||
if candidate.startswith("W/"):
|
||||
candidate = candidate[2:]
|
||||
candidate = candidate.strip('"')
|
||||
if candidate == "*" or not candidate.isdigit():
|
||||
# `*` would mean "whatever version is current", which is precisely
|
||||
# the unconditional write this endpoint exists to prevent.
|
||||
raise LifecycleError(
|
||||
400, "invalid_if_match", "If-Match must be a record version ETag", correlation_id
|
||||
)
|
||||
return int(candidate)
|
||||
|
||||
|
||||
def _tenant_response(tenant: Tenant) -> dict:
|
||||
return {
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"identifier": tenant.identifier,
|
||||
"grouping": tenant.grouping,
|
||||
"display_name": tenant.display_name,
|
||||
"contact_email": tenant.contact_email,
|
||||
"lifecycle": tenant.lifecycle.value,
|
||||
"version": tenant.version,
|
||||
"created_at": _iso(tenant.created_at),
|
||||
"updated_at": _iso(tenant.updated_at),
|
||||
"retired_at": _iso(tenant.retired_at),
|
||||
"reactivated_at": _iso(tenant.reactivated_at),
|
||||
}
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def _lifecycle_mutation(
|
||||
*,
|
||||
store: TenantStore,
|
||||
authorizer: WriteAuthorizer,
|
||||
action: str,
|
||||
tenant_id: str,
|
||||
actor: str,
|
||||
reason: str,
|
||||
correlation_id: str,
|
||||
idempotency_key: str | None,
|
||||
if_match: str | None,
|
||||
response: Response,
|
||||
event_type: str,
|
||||
extra_fingerprint: dict,
|
||||
mutate,
|
||||
) -> dict:
|
||||
"""Shared spine for update/retire/reactivate: authorize, then CAS.
|
||||
|
||||
Authorization runs before the store is touched at all, so an unauthorized
|
||||
caller cannot use timing or error codes to probe which tenants exist.
|
||||
"""
|
||||
if idempotency_key is None:
|
||||
raise LifecycleError(
|
||||
400, "idempotency_key_required", "Idempotency-Key header is required", correlation_id
|
||||
)
|
||||
expected_version = _parse_if_match(if_match, correlation_id)
|
||||
|
||||
authorizer.authorize(action=action, tenant_id=tenant_id, actor=actor)
|
||||
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps(
|
||||
{
|
||||
"action": action,
|
||||
"tenant": tenant_id,
|
||||
"version": expected_version,
|
||||
**extra_fingerprint,
|
||||
},
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
).encode()
|
||||
).hexdigest()
|
||||
|
||||
now = datetime.now(UTC)
|
||||
try:
|
||||
tenant, replayed = store.mutate_tenant(
|
||||
tenant_id=tenant_id,
|
||||
expected_version=expected_version,
|
||||
mutate=lambda current: mutate(current, now),
|
||||
event_type=event_type,
|
||||
evidence={"actor": actor, "reason": reason, "correlation_id": correlation_id},
|
||||
idempotency_key=idempotency_key,
|
||||
request_fingerprint=fingerprint,
|
||||
)
|
||||
except TenantNotFoundError as exc:
|
||||
raise LifecycleError(404, "tenant_not_found", "tenant_not_found", correlation_id) from exc
|
||||
except IdempotencyConflictError as exc:
|
||||
raise LifecycleError(
|
||||
409,
|
||||
"idempotency_key_conflict",
|
||||
"Idempotency-Key was reused for a different request",
|
||||
correlation_id,
|
||||
) from exc
|
||||
except VersionConflictError as exc:
|
||||
raise LifecycleError(
|
||||
409,
|
||||
"version_conflict",
|
||||
f"record version is {exc.actual}, not {exc.expected}",
|
||||
correlation_id,
|
||||
) from exc
|
||||
except (InvalidLifecycleTransitionError, TenantRetiredError) as exc:
|
||||
raise LifecycleError(409, "invalid_lifecycle_transition", str(exc), correlation_id) from exc
|
||||
except (ImmutableFieldError, EmptyUpdateError) as exc:
|
||||
raise LifecycleError(400, "invalid_update", str(exc), correlation_id) from exc
|
||||
except StoreUnavailableError as exc:
|
||||
raise LifecycleError(
|
||||
503, "tenant_authority_unavailable", "tenant_authority_unavailable", correlation_id
|
||||
) from exc
|
||||
|
||||
response.headers["ETag"] = _etag(tenant.version)
|
||||
response.headers["Idempotent-Replay"] = "true" if replayed else "false"
|
||||
return _tenant_response(tenant)
|
||||
|
||||
|
||||
def _build_authorizer(settings: Settings) -> WriteAuthorizer:
|
||||
if settings.flex_auth_base_url is None:
|
||||
return DefaultDenyWriteAuthorizer()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ _RESOURCE_TYPES: dict[str, str] = {
|
|||
"tenant.role.grant": "role-grant",
|
||||
"tenant.role.revoke": "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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,41 @@ class InvalidGrantError(ValueError):
|
|||
"""A role grant violates a domain invariant (ADR-0014)."""
|
||||
|
||||
|
||||
class InvalidLifecycleTransitionError(ValueError):
|
||||
"""A lifecycle transition is not legal from the tenant's current state."""
|
||||
|
||||
|
||||
class ImmutableFieldError(ValueError):
|
||||
"""An update tried to change a field that is immutable by contract."""
|
||||
|
||||
|
||||
class EmptyUpdateError(ValueError):
|
||||
"""An update carried no allow-listed field changes."""
|
||||
|
||||
|
||||
class TenantRetiredError(ValueError):
|
||||
"""A mutation was attempted on a retired tenant that only active tenants allow."""
|
||||
|
||||
|
||||
class TenantLifecycle(str, Enum):
|
||||
"""TEN-WP-0005: tenant existence is reversible, never hard-deleted.
|
||||
|
||||
Retirement suspends a tenant's ability to take on new capability or plan
|
||||
state; it deliberately preserves the tenant record, its grant history,
|
||||
and its plan history so audit correlation and recovery stay intact.
|
||||
"""
|
||||
|
||||
ACTIVE = "active"
|
||||
RETIRED = "retired"
|
||||
|
||||
|
||||
# The only tenant fields a PATCH may change. tenant_id, identifier, and
|
||||
# grouping are immutable: the identifier is the IAM Profile `tenant` claim
|
||||
# value that key-cape mints into tokens and flex-auth authorizes against, so
|
||||
# mutating it would silently invalidate every issued token referencing it.
|
||||
MUTABLE_METADATA_FIELDS = frozenset({"display_name", "contact_email"})
|
||||
|
||||
|
||||
class CapabilityRole(str, Enum):
|
||||
"""ADR-0014: non-exclusive capability roles a tenant may hold."""
|
||||
|
||||
|
|
@ -73,16 +108,99 @@ class Tenant:
|
|||
tenant_id: str
|
||||
identifier: str
|
||||
grouping: str | None
|
||||
# -- TEN-WP-0005 lifecycle and mutable metadata. All default so that
|
||||
# pre-lifecycle construction sites (and migrated rows) keep working.
|
||||
display_name: str | None = None
|
||||
contact_email: str | None = None
|
||||
lifecycle: TenantLifecycle = TenantLifecycle.ACTIVE
|
||||
version: int = 1
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
retired_at: datetime | None = None
|
||||
reactivated_at: datetime | None = None
|
||||
|
||||
@classmethod
|
||||
def create(cls, *, tenant_id: str, identifier: str) -> "Tenant":
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
identifier: str,
|
||||
display_name: str | None = None,
|
||||
contact_email: str | None = None,
|
||||
created_at: datetime | None = None,
|
||||
) -> "Tenant":
|
||||
grouping, _name = parse_tenant_identifier(identifier)
|
||||
return cls(tenant_id=tenant_id, identifier=identifier, grouping=grouping)
|
||||
return cls(
|
||||
tenant_id=tenant_id,
|
||||
identifier=identifier,
|
||||
grouping=grouping,
|
||||
display_name=display_name,
|
||||
contact_email=contact_email,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_reserved(self) -> bool:
|
||||
return self.grouping is None
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.lifecycle is TenantLifecycle.ACTIVE
|
||||
|
||||
def with_metadata(self, changes: dict[str, object], *, at: datetime) -> "Tenant":
|
||||
"""Apply an allow-listed metadata change, bumping the record version.
|
||||
|
||||
Fails closed on anything ambiguous: unknown fields, attempts to change
|
||||
an immutable field, an empty change set, or a no-op change set. A no-op
|
||||
is rejected rather than silently accepted so a caller never reads a
|
||||
version bump as evidence that a value actually changed.
|
||||
"""
|
||||
if self.lifecycle is not TenantLifecycle.ACTIVE:
|
||||
raise InvalidLifecycleTransitionError(
|
||||
"metadata of a retired tenant cannot be updated; reactivate first"
|
||||
)
|
||||
|
||||
unknown = set(changes) - MUTABLE_METADATA_FIELDS
|
||||
immutable = unknown & {"tenant_id", "identifier", "grouping", "version", "lifecycle"}
|
||||
if immutable:
|
||||
raise ImmutableFieldError(f"immutable field(s): {', '.join(sorted(immutable))}")
|
||||
if unknown:
|
||||
raise ImmutableFieldError(f"unknown field(s): {', '.join(sorted(unknown))}")
|
||||
if not changes:
|
||||
raise EmptyUpdateError("update carried no fields")
|
||||
if all(getattr(self, field) == value for field, value in changes.items()):
|
||||
raise EmptyUpdateError("update would not change any field")
|
||||
|
||||
return replace(self, version=self.version + 1, updated_at=at, **changes) # type: ignore[arg-type]
|
||||
|
||||
def retire(self, *, at: datetime) -> "Tenant":
|
||||
if self.lifecycle is TenantLifecycle.RETIRED:
|
||||
raise InvalidLifecycleTransitionError("tenant is already retired")
|
||||
return replace(
|
||||
self,
|
||||
lifecycle=TenantLifecycle.RETIRED,
|
||||
version=self.version + 1,
|
||||
updated_at=at,
|
||||
retired_at=at,
|
||||
)
|
||||
|
||||
def reactivate(self, *, at: datetime) -> "Tenant":
|
||||
"""Return the tenant to active. Deliberately narrow: it restores the
|
||||
tenant's ability to receive new grants and plan changes, and does not
|
||||
resurrect revoked grants or invent plan state -- those stay exactly as
|
||||
retirement left them.
|
||||
"""
|
||||
if self.lifecycle is TenantLifecycle.ACTIVE:
|
||||
raise InvalidLifecycleTransitionError("tenant is already active")
|
||||
return replace(
|
||||
self,
|
||||
lifecycle=TenantLifecycle.ACTIVE,
|
||||
version=self.version + 1,
|
||||
updated_at=at,
|
||||
reactivated_at=at,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoleGrant:
|
||||
|
|
|
|||
|
|
@ -2,15 +2,26 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
|
||||
from tenant_engine.domain import CapabilityRole, PlanAssignment, RoleGrant, Tenant
|
||||
from tenant_engine.domain import (
|
||||
CapabilityRole,
|
||||
PlanAssignment,
|
||||
RoleGrant,
|
||||
Tenant,
|
||||
TenantLifecycle,
|
||||
TenantRetiredError,
|
||||
)
|
||||
from tenant_engine.store import (
|
||||
DomainEvent,
|
||||
GrantNotFoundError,
|
||||
IdempotencyConflictError,
|
||||
TenantAlreadyExistsError,
|
||||
TenantNotFoundError,
|
||||
VersionConflictError,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -40,14 +51,50 @@ 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 idempotency_receipts (
|
||||
tenant_id TEXT NOT NULL, idempotency_key TEXT NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL, result TEXT NOT NULL,
|
||||
recorded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, idempotency_key)
|
||||
);
|
||||
""")
|
||||
self._migrate_tenant_lifecycle()
|
||||
|
||||
def _migrate_tenant_lifecycle(self) -> None:
|
||||
"""TEN-WP-0005-T02: forward-only, idempotent lifecycle migration.
|
||||
|
||||
Existing rows default to `active` at version 1 with their identifiers,
|
||||
grants, and plan assignments untouched -- nothing is rewritten, only
|
||||
columns are added. Fresh and pre-existing databases take this same
|
||||
path, so there is no second schema definition to drift out of sync.
|
||||
"""
|
||||
existing = {row["name"] for row in self._db.execute("PRAGMA table_info(tenants)")}
|
||||
additions = {
|
||||
"display_name": "TEXT",
|
||||
"contact_email": "TEXT",
|
||||
"lifecycle": "TEXT NOT NULL DEFAULT 'active'",
|
||||
"version": "INTEGER NOT NULL DEFAULT 1",
|
||||
"created_at": "TEXT",
|
||||
"updated_at": "TEXT",
|
||||
"retired_at": "TEXT",
|
||||
"reactivated_at": "TEXT",
|
||||
}
|
||||
for column, spec in additions.items():
|
||||
if column not in existing:
|
||||
self._db.execute(f"ALTER TABLE tenants ADD COLUMN {column} {spec}")
|
||||
|
||||
def create_tenant(self, tenant: Tenant) -> None:
|
||||
with self._lock, self._db:
|
||||
try:
|
||||
self._db.execute(
|
||||
"INSERT INTO tenants VALUES (?, ?, ?)",
|
||||
(tenant.tenant_id, tenant.identifier, tenant.grouping),
|
||||
"""INSERT INTO tenants (tenant_id, identifier, grouping_name, display_name,
|
||||
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)),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise TenantAlreadyExistsError(tenant.identifier) from exc
|
||||
|
|
@ -56,15 +103,83 @@ class SQLiteTenantStore:
|
|||
})
|
||||
|
||||
def get_tenant(self, tenant_id: str) -> Tenant:
|
||||
row = self._db.execute(
|
||||
"SELECT * FROM tenants WHERE tenant_id = ? OR identifier = ?", (tenant_id, tenant_id)
|
||||
).fetchone()
|
||||
# Reads take the same lock as writes. One sqlite3 connection is shared
|
||||
# across request threads (check_same_thread=False), so an unguarded
|
||||
# SELECT can execute while another thread sits inside BEGIN IMMEDIATE
|
||||
# and observe a row that is not there yet -- caught by the concurrent
|
||||
# writer test, which failed with a spurious tenant_not_found.
|
||||
with self._lock:
|
||||
row = self._db.execute(
|
||||
"SELECT * FROM tenants WHERE tenant_id = ? OR identifier = ?",
|
||||
(tenant_id, tenant_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TenantNotFoundError(tenant_id)
|
||||
return Tenant(row["tenant_id"], row["identifier"], row["grouping_name"])
|
||||
return _tenant(row)
|
||||
|
||||
def mutate_tenant(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
expected_version: int,
|
||||
mutate: Callable[[Tenant], Tenant],
|
||||
event_type: str,
|
||||
evidence: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
request_fingerprint: str,
|
||||
) -> tuple[Tenant, bool]:
|
||||
tenant = self.get_tenant(tenant_id)
|
||||
with self._lock:
|
||||
# BEGIN IMMEDIATE takes the write lock before the read below, so a
|
||||
# concurrent writer cannot slip a version bump between the check
|
||||
# and the swap. Receipt lookup precedes the version check: a real
|
||||
# retry replays the original If-Match, which is stale by then.
|
||||
self._db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
receipt = self._db.execute(
|
||||
"SELECT request_fingerprint, result FROM idempotency_receipts "
|
||||
"WHERE tenant_id = ? AND idempotency_key = ?",
|
||||
(tenant.tenant_id, idempotency_key),
|
||||
).fetchone()
|
||||
if receipt is not None:
|
||||
if receipt["request_fingerprint"] != request_fingerprint:
|
||||
raise IdempotencyConflictError(idempotency_key)
|
||||
replayed = _tenant(json.loads(receipt["result"]))
|
||||
self._db.rollback() # read-only path: release the write lock
|
||||
return replayed, True
|
||||
|
||||
current = _tenant(
|
||||
self._db.execute(
|
||||
"SELECT * FROM tenants WHERE tenant_id = ?", (tenant.tenant_id,)
|
||||
).fetchone()
|
||||
)
|
||||
if current.version != expected_version:
|
||||
raise VersionConflictError(expected=expected_version, actual=current.version)
|
||||
|
||||
updated = mutate(current)
|
||||
self._db.execute(
|
||||
"""UPDATE tenants SET display_name = ?, contact_email = ?, lifecycle = ?,
|
||||
version = ?, updated_at = ?, retired_at = ?, reactivated_at = ?
|
||||
WHERE tenant_id = ?""",
|
||||
(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()),
|
||||
)
|
||||
self._emit(event_type, updated.tenant_id, {**evidence, "version": updated.version})
|
||||
except BaseException:
|
||||
self._db.rollback()
|
||||
raise
|
||||
self._db.commit()
|
||||
return updated, False
|
||||
|
||||
def grant_role(self, grant: RoleGrant) -> 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
|
|
@ -78,10 +193,11 @@ class SQLiteTenantStore:
|
|||
|
||||
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
|
||||
tenant = self.get_tenant(tenant_id)
|
||||
row = self._db.execute(
|
||||
"SELECT * FROM grants WHERE tenant_id = ? AND grant_id = ?",
|
||||
(tenant.tenant_id, grant_id),
|
||||
).fetchone()
|
||||
with self._lock:
|
||||
row = self._db.execute(
|
||||
"SELECT * FROM grants WHERE tenant_id = ? AND grant_id = ?",
|
||||
(tenant.tenant_id, grant_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise GrantNotFoundError(grant_id)
|
||||
grant = self._grant(row).revoke(at=at)
|
||||
|
|
@ -94,14 +210,16 @@ class SQLiteTenantStore:
|
|||
|
||||
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]:
|
||||
tenant = self.get_tenant(tenant_id)
|
||||
rows = self._db.execute(
|
||||
"SELECT role FROM grants WHERE tenant_id = ? AND revoked_at IS NULL",
|
||||
(tenant.tenant_id,),
|
||||
).fetchall()
|
||||
with self._lock:
|
||||
rows = self._db.execute(
|
||||
"SELECT role FROM grants WHERE tenant_id = ? AND revoked_at IS NULL",
|
||||
(tenant.tenant_id,),
|
||||
).fetchall()
|
||||
return frozenset(CapabilityRole(row["role"]) for row in rows)
|
||||
|
||||
def assign_plan(self, assignment: PlanAssignment) -> None:
|
||||
tenant = self.get_tenant(assignment.tenant_id)
|
||||
self._require_active(tenant, "assign a plan")
|
||||
with self._lock, self._db:
|
||||
self._db.execute(
|
||||
"INSERT OR REPLACE INTO plans VALUES (?, ?, ?)",
|
||||
|
|
@ -110,9 +228,16 @@ class SQLiteTenantStore:
|
|||
self._emit("plan_assigned", tenant.tenant_id, {"plan_id": assignment.plan_id})
|
||||
|
||||
def events(self) -> list[DomainEvent]:
|
||||
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 self._db.execute("SELECT * FROM events ORDER BY seq")]
|
||||
for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def _require_active(tenant: Tenant, what: str) -> None:
|
||||
if tenant.lifecycle is not TenantLifecycle.ACTIVE:
|
||||
raise TenantRetiredError(f"cannot {what} on a retired tenant")
|
||||
|
||||
def _emit(self, event_type: str, tenant_id: str, payload: dict) -> None:
|
||||
now = datetime.now().astimezone()
|
||||
|
|
@ -125,3 +250,45 @@ class SQLiteTenantStore:
|
|||
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 _dt(value: str | None) -> datetime | None:
|
||||
return datetime.fromisoformat(value) if value else None
|
||||
|
||||
|
||||
def _tenant(row: sqlite3.Row | dict) -> Tenant:
|
||||
"""Map a tenants row -- or a receipt's JSON snapshot -- to a Tenant."""
|
||||
return Tenant(
|
||||
tenant_id=row["tenant_id"],
|
||||
identifier=row["identifier"],
|
||||
grouping=row["grouping_name"],
|
||||
display_name=row["display_name"],
|
||||
contact_email=row["contact_email"],
|
||||
lifecycle=TenantLifecycle(row["lifecycle"]),
|
||||
version=row["version"],
|
||||
created_at=_dt(row["created_at"]),
|
||||
updated_at=_dt(row["updated_at"]),
|
||||
retired_at=_dt(row["retired_at"]),
|
||||
reactivated_at=_dt(row["reactivated_at"]),
|
||||
)
|
||||
|
||||
|
||||
def _row(tenant: Tenant) -> dict:
|
||||
"""Inverse of `_tenant`, for durable idempotency receipts."""
|
||||
return {
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"identifier": tenant.identifier,
|
||||
"grouping_name": tenant.grouping,
|
||||
"display_name": tenant.display_name,
|
||||
"contact_email": tenant.contact_email,
|
||||
"lifecycle": tenant.lifecycle.value,
|
||||
"version": tenant.version,
|
||||
"created_at": _iso(tenant.created_at),
|
||||
"updated_at": _iso(tenant.updated_at),
|
||||
"retired_at": _iso(tenant.retired_at),
|
||||
"reactivated_at": _iso(tenant.reactivated_at),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from tenant_engine.domain import CapabilityRole, PlanAssignment, RoleGrant, Tenant
|
||||
from tenant_engine.domain import (
|
||||
CapabilityRole,
|
||||
PlanAssignment,
|
||||
RoleGrant,
|
||||
Tenant,
|
||||
TenantLifecycle,
|
||||
TenantRetiredError,
|
||||
)
|
||||
|
||||
|
||||
class TenantNotFoundError(KeyError):
|
||||
pass
|
||||
|
||||
|
||||
class VersionConflictError(RuntimeError):
|
||||
"""The caller's `If-Match` version is not the tenant's current version."""
|
||||
|
||||
def __init__(self, *, expected: int, actual: int) -> None:
|
||||
super().__init__(f"expected version {expected}, current version is {actual}")
|
||||
self.expected = expected
|
||||
self.actual = actual
|
||||
|
||||
|
||||
class IdempotencyConflictError(RuntimeError):
|
||||
"""An `Idempotency-Key` was reused for a materially different request."""
|
||||
|
||||
|
||||
class TenantAlreadyExistsError(ValueError):
|
||||
pass
|
||||
|
||||
|
|
@ -63,6 +84,31 @@ class TenantStore(Protocol):
|
|||
|
||||
def events(self) -> list[DomainEvent]: ...
|
||||
|
||||
def mutate_tenant(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
expected_version: int,
|
||||
mutate: Callable[[Tenant], Tenant],
|
||||
event_type: str,
|
||||
evidence: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
request_fingerprint: str,
|
||||
) -> tuple[Tenant, bool]:
|
||||
"""Atomically compare-and-swap a tenant record. Returns (tenant, replayed).
|
||||
|
||||
One method carries all four concerns -- idempotency replay, version
|
||||
CAS, the mutation itself, and the audit event -- because they have to
|
||||
commit or fail together. Splitting them across store calls would leave
|
||||
a window where a crash yields a bumped version with no receipt (a
|
||||
retry then double-applies) or a receipt with no mutation.
|
||||
|
||||
Order matters: a replayed `idempotency_key` short-circuits *before*
|
||||
the version check, because a genuine retry of an already-applied
|
||||
mutation necessarily carries a now-stale `If-Match`.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryTenantStore:
|
||||
def __init__(self) -> None:
|
||||
|
|
@ -71,6 +117,8 @@ class InMemoryTenantStore:
|
|||
self._grants: dict[str, dict[str, RoleGrant]] = {}
|
||||
self._plans: dict[str, PlanAssignment] = {}
|
||||
self._events: list[DomainEvent] = []
|
||||
# (tenant_id, idempotency_key) -> (request_fingerprint, result snapshot)
|
||||
self._receipts: dict[tuple[str, str], tuple[str, Tenant]] = {}
|
||||
|
||||
def create_tenant(self, tenant: Tenant) -> None:
|
||||
if tenant.tenant_id in self._tenants:
|
||||
|
|
@ -91,6 +139,7 @@ class InMemoryTenantStore:
|
|||
|
||||
def grant_role(self, grant: RoleGrant) -> None:
|
||||
resolved = self._resolve(grant.tenant_id)
|
||||
self._require_active(resolved, "grant a role")
|
||||
self._grants[resolved][grant.grant_id] = grant
|
||||
self._emit(
|
||||
"role_granted",
|
||||
|
|
@ -126,12 +175,48 @@ class InMemoryTenantStore:
|
|||
|
||||
def assign_plan(self, assignment: PlanAssignment) -> 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})
|
||||
|
||||
def events(self) -> list[DomainEvent]:
|
||||
return list(self._events)
|
||||
|
||||
def mutate_tenant(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
expected_version: int,
|
||||
mutate: Callable[[Tenant], Tenant],
|
||||
event_type: str,
|
||||
evidence: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
request_fingerprint: str,
|
||||
) -> tuple[Tenant, bool]:
|
||||
resolved = self._resolve(tenant_id)
|
||||
|
||||
receipt = self._receipts.get((resolved, idempotency_key))
|
||||
if receipt is not None:
|
||||
fingerprint, snapshot = receipt
|
||||
if fingerprint != request_fingerprint:
|
||||
raise IdempotencyConflictError(idempotency_key)
|
||||
return snapshot, True
|
||||
|
||||
current = self._tenants[resolved]
|
||||
if current.version != expected_version:
|
||||
raise VersionConflictError(expected=expected_version, actual=current.version)
|
||||
|
||||
updated = mutate(current)
|
||||
self._tenants[resolved] = updated
|
||||
self._receipts[(resolved, idempotency_key)] = (request_fingerprint, updated)
|
||||
self._emit(event_type, resolved, {**evidence, "version": updated.version})
|
||||
return updated, False
|
||||
|
||||
def _require_active(self, resolved_id: str, what: str) -> None:
|
||||
tenant = self._tenants[resolved_id]
|
||||
if tenant.lifecycle is not TenantLifecycle.ACTIVE:
|
||||
raise TenantRetiredError(f"cannot {what} on a retired tenant")
|
||||
|
||||
def _resolve(self, tenant_id: str) -> str:
|
||||
"""Resolve an internal tenant_id or a profile identifier to the
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue