121 lines
3.8 KiB
Python
121 lines
3.8 KiB
Python
|
|
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)
|
||
|
|
)
|