TEN-WP-0002 T01-T03: service skeleton, domain model, storage layer
Python 3.12 + FastAPI, pyproject.toml + Makefile mirroring
qonto-assistant's exactly. src/tenant_engine/{domain,store,app,main}.py:
- domain.py: Tenant, CapabilityRole (PLTF/IAM/VEN/CUS), RoleGrant,
PlanAssignment, create_role_grant() enforcing ADR-0014's invariants.
Refinement made while implementing: platform_default grants are valid
for trial-grouped tenants OR the reserved tenant:platform/tenant:coulomb
tenants (their baseline roles were never purchased either) -- the task
spec only named the trial case.
- store.py: TenantStore Protocol + InMemoryTenantStore, every mutation
emits a DomainEvent per the boundary contract's Audit Correlation
Contract.
- app.py/main.py: FastAPI factory + /health endpoint, verified live on
127.0.0.1:8090.
29 tests passing, including non-exclusive role coexistence (CUS+VEN
simultaneously) and append-only revoke semantics.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
7eb21c05b8
commit
0770ce82d9
11 changed files with 774 additions and 3 deletions
1
src/tenant_engine/__init__.py
Normal file
1
src/tenant_engine/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
__version__ = "0.1.0"
|
||||
19
src/tenant_engine/app.py
Normal file
19
src/tenant_engine/app.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from tenant_engine import __version__
|
||||
from tenant_engine.store import InMemoryTenantStore, TenantStore
|
||||
|
||||
|
||||
def create_app(*, store: TenantStore | None = None) -> FastAPI:
|
||||
store = store or InMemoryTenantStore()
|
||||
|
||||
app = FastAPI(title="tenant-engine", version=__version__)
|
||||
app.state.store = store
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok", "service": "tenant-engine", "version": __version__}
|
||||
|
||||
return app
|
||||
170
src/tenant_engine/domain.py
Normal file
170
src/tenant_engine/domain.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
# ADR-0013: onboarding-risk / entity-shape grouping. Orthogonal to
|
||||
# capability role (below) -- neither axis constrains the other except
|
||||
# through guardrail policy, which is a reserved, unimplemented concern.
|
||||
GROUPINGS = frozenset(
|
||||
{
|
||||
"trial",
|
||||
"friendly",
|
||||
"single",
|
||||
"small",
|
||||
"medium",
|
||||
"large",
|
||||
"enterprise",
|
||||
"consumer",
|
||||
"family",
|
||||
"community",
|
||||
"association",
|
||||
"agentic",
|
||||
}
|
||||
)
|
||||
|
||||
# tenant:platform and tenant:coulomb predate the grouping taxonomy and stay
|
||||
# reserved, ungrouped identifiers (ADR-0013's Tenant Claim rationale).
|
||||
RESERVED_IDENTIFIERS = frozenset({"tenant:platform", "tenant:coulomb"})
|
||||
|
||||
|
||||
class InvalidTenantIdentifierError(ValueError):
|
||||
"""A tenant identifier does not match `tenant:<grouping>:<name>` or a reserved form."""
|
||||
|
||||
|
||||
class InvalidGrantError(ValueError):
|
||||
"""A role grant violates a domain invariant (ADR-0014)."""
|
||||
|
||||
|
||||
class CapabilityRole(str, Enum):
|
||||
"""ADR-0014: non-exclusive capability roles a tenant may hold."""
|
||||
|
||||
PLTF = "PLTF"
|
||||
IAM = "IAM"
|
||||
VEN = "VEN"
|
||||
CUS = "CUS"
|
||||
|
||||
|
||||
GrantReason = Literal["plan_assignment", "manual_grant", "platform_default"]
|
||||
|
||||
|
||||
def parse_tenant_identifier(identifier: str) -> tuple[str | None, str]:
|
||||
"""Return (grouping, name). grouping is None only for reserved identifiers."""
|
||||
if identifier in RESERVED_IDENTIFIERS:
|
||||
return None, identifier.split(":", 1)[1]
|
||||
|
||||
parts = identifier.split(":")
|
||||
if len(parts) != 3 or parts[0] != "tenant":
|
||||
raise InvalidTenantIdentifierError(
|
||||
f"Malformed tenant identifier: {identifier!r} (expected tenant:<grouping>:<name>)"
|
||||
)
|
||||
_, grouping, name = parts
|
||||
if grouping not in GROUPINGS:
|
||||
raise InvalidTenantIdentifierError(f"Unknown tenant grouping: {grouping!r}")
|
||||
if not name:
|
||||
raise InvalidTenantIdentifierError("Tenant name segment is empty")
|
||||
return grouping, name
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Tenant:
|
||||
tenant_id: str
|
||||
identifier: str
|
||||
grouping: str | None
|
||||
|
||||
@classmethod
|
||||
def create(cls, *, tenant_id: str, identifier: str) -> "Tenant":
|
||||
grouping, _name = parse_tenant_identifier(identifier)
|
||||
return cls(tenant_id=tenant_id, identifier=identifier, grouping=grouping)
|
||||
|
||||
@property
|
||||
def is_reserved(self) -> bool:
|
||||
return self.grouping is None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoleGrant:
|
||||
"""Audited role grant record -- the Tenant Role & Plan Grant Contract shape.
|
||||
|
||||
Append-only: revocation produces a new record via `revoke()`, it never
|
||||
deletes the original.
|
||||
"""
|
||||
|
||||
grant_id: str
|
||||
tenant_id: str
|
||||
role: CapabilityRole
|
||||
grant_reason: GrantReason
|
||||
plan_id: str | None
|
||||
granted_by: str
|
||||
granted_at: datetime
|
||||
correlation_id: str
|
||||
revoked_at: datetime | None = None
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.revoked_at is None
|
||||
|
||||
def revoke(self, *, at: datetime) -> "RoleGrant":
|
||||
if self.revoked_at is not None:
|
||||
raise InvalidGrantError(f"Grant {self.grant_id!r} is already revoked")
|
||||
return replace(self, revoked_at=at)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlanAssignment:
|
||||
"""A tenant's current plan, referenced by id only -- never resolved or
|
||||
|
||||
duplicated locally. Plan term definitions belong to adaptive-pricing.
|
||||
"""
|
||||
|
||||
tenant_id: str
|
||||
plan_id: str
|
||||
assigned_at: datetime
|
||||
|
||||
|
||||
def create_role_grant(
|
||||
*,
|
||||
tenant: Tenant,
|
||||
grant_id: str,
|
||||
role: CapabilityRole,
|
||||
grant_reason: GrantReason,
|
||||
plan_id: str | None,
|
||||
granted_by: str,
|
||||
correlation_id: str,
|
||||
granted_at: datetime,
|
||||
) -> RoleGrant:
|
||||
"""Construct a RoleGrant, enforcing ADR-0014's domain invariants.
|
||||
|
||||
- `plan_assignment` grants always require a `plan_id`.
|
||||
- `platform_default` grants never carry a `plan_id`, and are only valid
|
||||
for `trial`-grouped tenants (ADR-0014: trial may hold any role,
|
||||
unrestricted, for showcase purposes) or the reserved, ungrouped
|
||||
`tenant:platform`/`tenant:coulomb` tenants (their baseline roles were
|
||||
never purchased either).
|
||||
- `manual_grant` carries no grouping restriction and an optional
|
||||
`plan_id`.
|
||||
"""
|
||||
if grant_reason == "plan_assignment" and plan_id is None:
|
||||
raise InvalidGrantError("plan_assignment grants require a plan_id")
|
||||
|
||||
if grant_reason == "platform_default":
|
||||
if plan_id is not None:
|
||||
raise InvalidGrantError("platform_default grants must not carry a plan_id")
|
||||
if tenant.grouping not in (None, "trial"):
|
||||
raise InvalidGrantError(
|
||||
"platform_default is only valid for trial-grouped or reserved tenants, "
|
||||
f"got grouping={tenant.grouping!r}"
|
||||
)
|
||||
|
||||
return RoleGrant(
|
||||
grant_id=grant_id,
|
||||
tenant_id=tenant.tenant_id,
|
||||
role=role,
|
||||
grant_reason=grant_reason,
|
||||
plan_id=plan_id,
|
||||
granted_by=granted_by,
|
||||
granted_at=granted_at,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
13
src/tenant_engine/main.py
Normal file
13
src/tenant_engine/main.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uvicorn
|
||||
|
||||
from tenant_engine.app import create_app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
uvicorn.run(create_app(), host="127.0.0.1", port=8090)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
120
src/tenant_engine/store.py
Normal file
120
src/tenant_engine/store.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from tenant_engine.domain import CapabilityRole, PlanAssignment, RoleGrant, Tenant
|
||||
|
||||
|
||||
class TenantNotFoundError(KeyError):
|
||||
pass
|
||||
|
||||
|
||||
class TenantAlreadyExistsError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class GrantNotFoundError(KeyError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DomainEvent:
|
||||
"""Boundary contract's Audit Correlation Contract, in event form."""
|
||||
|
||||
event_type: str
|
||||
tenant_id: str
|
||||
at: datetime
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
class TenantStore(Protocol):
|
||||
"""Swappable persistence seam -- domain/ and api/ depend on this, not a backend."""
|
||||
|
||||
def create_tenant(self, tenant: Tenant) -> None: ...
|
||||
|
||||
def get_tenant(self, tenant_id: str) -> Tenant: ...
|
||||
|
||||
def grant_role(self, grant: RoleGrant) -> None: ...
|
||||
|
||||
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant: ...
|
||||
|
||||
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]: ...
|
||||
|
||||
def assign_plan(self, assignment: PlanAssignment) -> None: ...
|
||||
|
||||
def events(self) -> list[DomainEvent]: ...
|
||||
|
||||
|
||||
class InMemoryTenantStore:
|
||||
def __init__(self) -> None:
|
||||
self._tenants: dict[str, Tenant] = {}
|
||||
self._grants: dict[str, dict[str, RoleGrant]] = {}
|
||||
self._plans: dict[str, PlanAssignment] = {}
|
||||
self._events: list[DomainEvent] = []
|
||||
|
||||
def create_tenant(self, tenant: Tenant) -> None:
|
||||
if tenant.tenant_id in self._tenants:
|
||||
raise TenantAlreadyExistsError(tenant.tenant_id)
|
||||
self._tenants[tenant.tenant_id] = tenant
|
||||
self._grants[tenant.tenant_id] = {}
|
||||
self._emit(
|
||||
"tenant_created",
|
||||
tenant.tenant_id,
|
||||
{"identifier": tenant.identifier, "grouping": tenant.grouping},
|
||||
)
|
||||
|
||||
def get_tenant(self, tenant_id: str) -> Tenant:
|
||||
try:
|
||||
return self._tenants[tenant_id]
|
||||
except KeyError:
|
||||
raise TenantNotFoundError(tenant_id) from None
|
||||
|
||||
def grant_role(self, grant: RoleGrant) -> None:
|
||||
self.get_tenant(grant.tenant_id)
|
||||
self._grants[grant.tenant_id][grant.grant_id] = grant
|
||||
self._emit(
|
||||
"role_granted",
|
||||
grant.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:
|
||||
self.get_tenant(tenant_id)
|
||||
try:
|
||||
grant = self._grants[tenant_id][grant_id]
|
||||
except KeyError:
|
||||
raise GrantNotFoundError(grant_id) from None
|
||||
revoked = grant.revoke(at=at)
|
||||
self._grants[tenant_id][grant_id] = revoked
|
||||
self._emit(
|
||||
"role_revoked",
|
||||
tenant_id,
|
||||
{"grant_id": grant_id, "role": revoked.role.value},
|
||||
)
|
||||
return revoked
|
||||
|
||||
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]:
|
||||
self.get_tenant(tenant_id)
|
||||
return frozenset(
|
||||
grant.role for grant in self._grants.get(tenant_id, {}).values() if grant.active
|
||||
)
|
||||
|
||||
def assign_plan(self, assignment: PlanAssignment) -> None:
|
||||
self.get_tenant(assignment.tenant_id)
|
||||
self._plans[assignment.tenant_id] = assignment
|
||||
self._emit("plan_assigned", assignment.tenant_id, {"plan_id": assignment.plan_id})
|
||||
|
||||
def events(self) -> list[DomainEvent]:
|
||||
return list(self._events)
|
||||
|
||||
def _emit(self, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None:
|
||||
self._events.append(
|
||||
DomainEvent(event_type=event_type, tenant_id=tenant_id, at=datetime.now(UTC), payload=payload)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue