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>
2026-07-23 22:01:23 +02:00
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from tenant_engine.domain import CapabilityRole, PlanAssignment, Tenant, create_role_grant
|
|
|
|
|
from tenant_engine.store import (
|
|
|
|
|
GrantNotFoundError,
|
|
|
|
|
InMemoryTenantStore,
|
|
|
|
|
TenantAlreadyExistsError,
|
|
|
|
|
TenantNotFoundError,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-29 13:02:51 +02:00
|
|
|
def _store_with_tenant(
|
|
|
|
|
*, grouping: str = "friendly", name: str = "binky"
|
|
|
|
|
) -> tuple[InMemoryTenantStore, Tenant]:
|
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>
2026-07-23 22:01:23 +02:00
|
|
|
store = InMemoryTenantStore()
|
|
|
|
|
tenant = Tenant.create(tenant_id=f"t-{name}", identifier=f"tenant:{grouping}:{name}")
|
|
|
|
|
store.create_tenant(tenant)
|
|
|
|
|
return store, tenant
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_and_get_tenant() -> None:
|
|
|
|
|
store, tenant = _store_with_tenant()
|
|
|
|
|
assert store.get_tenant(tenant.tenant_id) == tenant
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_duplicate_tenant_raises() -> None:
|
|
|
|
|
store, tenant = _store_with_tenant()
|
|
|
|
|
with pytest.raises(TenantAlreadyExistsError):
|
|
|
|
|
store.create_tenant(tenant)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_unknown_tenant_raises() -> None:
|
|
|
|
|
store = InMemoryTenantStore()
|
|
|
|
|
with pytest.raises(TenantNotFoundError):
|
|
|
|
|
store.get_tenant("does-not-exist")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_grant_and_active_roles() -> None:
|
|
|
|
|
store, tenant = _store_with_tenant()
|
|
|
|
|
grant = create_role_grant(
|
|
|
|
|
tenant=tenant,
|
|
|
|
|
grant_id="g-1",
|
|
|
|
|
role=CapabilityRole.CUS,
|
|
|
|
|
grant_reason="manual_grant",
|
|
|
|
|
plan_id=None,
|
|
|
|
|
granted_by="ops",
|
|
|
|
|
correlation_id="corr-1",
|
|
|
|
|
granted_at=datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
store.grant_role(grant)
|
|
|
|
|
|
|
|
|
|
assert store.active_roles(tenant.tenant_id) == frozenset({CapabilityRole.CUS})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_revoke_removes_role_from_active_set_but_keeps_record() -> None:
|
|
|
|
|
store, tenant = _store_with_tenant()
|
|
|
|
|
grant = create_role_grant(
|
|
|
|
|
tenant=tenant,
|
|
|
|
|
grant_id="g-1",
|
|
|
|
|
role=CapabilityRole.VEN,
|
|
|
|
|
grant_reason="manual_grant",
|
|
|
|
|
plan_id=None,
|
|
|
|
|
granted_by="ops",
|
|
|
|
|
correlation_id="corr-1",
|
|
|
|
|
granted_at=datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
store.grant_role(grant)
|
|
|
|
|
revoked = store.revoke_role(tenant_id=tenant.tenant_id, grant_id="g-1", at=datetime.now(UTC))
|
|
|
|
|
|
|
|
|
|
assert revoked.revoked_at is not None
|
|
|
|
|
assert store.active_roles(tenant.tenant_id) == frozenset()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_revoke_unknown_grant_raises() -> None:
|
|
|
|
|
store, tenant = _store_with_tenant()
|
|
|
|
|
with pytest.raises(GrantNotFoundError):
|
|
|
|
|
store.revoke_role(tenant_id=tenant.tenant_id, grant_id="missing", at=datetime.now(UTC))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_exclusive_roles_coexist() -> None:
|
|
|
|
|
store, tenant = _store_with_tenant()
|
|
|
|
|
for role in (CapabilityRole.CUS, CapabilityRole.VEN):
|
|
|
|
|
store.grant_role(
|
|
|
|
|
create_role_grant(
|
|
|
|
|
tenant=tenant,
|
|
|
|
|
grant_id=f"g-{role.value}",
|
|
|
|
|
role=role,
|
|
|
|
|
grant_reason="manual_grant",
|
|
|
|
|
plan_id=None,
|
|
|
|
|
granted_by="ops",
|
|
|
|
|
correlation_id="corr-1",
|
|
|
|
|
granted_at=datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-29 13:02:51 +02:00
|
|
|
assert store.active_roles(tenant.tenant_id) == frozenset(
|
|
|
|
|
{CapabilityRole.CUS, CapabilityRole.VEN}
|
|
|
|
|
)
|
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>
2026-07-23 22:01:23 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_assign_plan() -> None:
|
|
|
|
|
store, tenant = _store_with_tenant()
|
2026-08-29 13:02:51 +02:00
|
|
|
store.assign_plan(
|
|
|
|
|
PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC))
|
|
|
|
|
)
|
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>
2026-07-23 22:01:23 +02:00
|
|
|
|
2026-08-29 13:02:51 +02:00
|
|
|
events = store.events_for(tenant.tenant_id)
|
|
|
|
|
assert any(
|
|
|
|
|
event.event_type == "plan_assigned" and event.payload["plan_id"] == "plan-x"
|
|
|
|
|
for event in events
|
|
|
|
|
)
|
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>
2026-07-23 22:01:23 +02:00
|
|
|
|
|
|
|
|
|
2026-07-24 00:15:26 +02:00
|
|
|
def test_get_tenant_resolves_by_identifier_not_only_internal_id() -> None:
|
|
|
|
|
"""Found via a real cross-service check (KEY-WP-0005-T02 against a live
|
|
|
|
|
|
|
|
|
|
tenant-engine): external callers like key-cape and flex-auth only ever
|
|
|
|
|
have the tenant's profile identifier (the IAM Profile `tenant` claim
|
|
|
|
|
value), never its internal tenant_id, which is caller-chosen at
|
|
|
|
|
creation and otherwise opaque.
|
|
|
|
|
"""
|
|
|
|
|
store, tenant = _store_with_tenant()
|
|
|
|
|
|
|
|
|
|
by_internal_id = store.get_tenant(tenant.tenant_id)
|
|
|
|
|
by_identifier = store.get_tenant(tenant.identifier)
|
|
|
|
|
|
|
|
|
|
assert by_internal_id == by_identifier == tenant
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_roles_resolves_by_identifier() -> None:
|
|
|
|
|
store, tenant = _store_with_tenant()
|
|
|
|
|
store.grant_role(
|
|
|
|
|
create_role_grant(
|
|
|
|
|
tenant=tenant,
|
|
|
|
|
grant_id="g-1",
|
|
|
|
|
role=CapabilityRole.CUS,
|
|
|
|
|
grant_reason="manual_grant",
|
|
|
|
|
plan_id=None,
|
|
|
|
|
granted_by="ops",
|
|
|
|
|
correlation_id="corr-1",
|
|
|
|
|
granted_at=datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert store.active_roles(tenant.identifier) == frozenset({CapabilityRole.CUS})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_revoke_role_resolves_by_identifier() -> None:
|
|
|
|
|
store, tenant = _store_with_tenant()
|
|
|
|
|
store.grant_role(
|
|
|
|
|
create_role_grant(
|
|
|
|
|
tenant=tenant,
|
|
|
|
|
grant_id="g-1",
|
|
|
|
|
role=CapabilityRole.CUS,
|
|
|
|
|
grant_reason="manual_grant",
|
|
|
|
|
plan_id=None,
|
|
|
|
|
granted_by="ops",
|
|
|
|
|
correlation_id="corr-1",
|
|
|
|
|
granted_at=datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
revoked = store.revoke_role(tenant_id=tenant.identifier, grant_id="g-1", at=datetime.now(UTC))
|
|
|
|
|
|
|
|
|
|
assert revoked.revoked_at is not None
|
|
|
|
|
assert store.active_roles(tenant.tenant_id) == frozenset()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_tenant_rejects_duplicate_identifier_with_different_internal_id() -> None:
|
|
|
|
|
store, tenant = _store_with_tenant()
|
|
|
|
|
other = Tenant.create(tenant_id="a-different-internal-id", identifier=tenant.identifier)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(TenantAlreadyExistsError):
|
|
|
|
|
store.create_tenant(other)
|
|
|
|
|
|
|
|
|
|
|
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>
2026-07-23 22:01:23 +02:00
|
|
|
def test_every_mutation_emits_an_event() -> None:
|
|
|
|
|
store, tenant = _store_with_tenant()
|
|
|
|
|
grant = create_role_grant(
|
|
|
|
|
tenant=tenant,
|
|
|
|
|
grant_id="g-1",
|
|
|
|
|
role=CapabilityRole.CUS,
|
|
|
|
|
grant_reason="manual_grant",
|
|
|
|
|
plan_id=None,
|
|
|
|
|
granted_by="ops",
|
|
|
|
|
correlation_id="corr-1",
|
|
|
|
|
granted_at=datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
store.grant_role(grant)
|
|
|
|
|
store.revoke_role(tenant_id=tenant.tenant_id, grant_id="g-1", at=datetime.now(UTC))
|
2026-08-29 13:02:51 +02:00
|
|
|
store.assign_plan(
|
|
|
|
|
PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC))
|
|
|
|
|
)
|
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>
2026-07-23 22:01:23 +02:00
|
|
|
|
2026-08-29 13:02:51 +02:00
|
|
|
event_types = [event.event_type for event in store.events_for(tenant.tenant_id)]
|
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>
2026-07-23 22:01:23 +02:00
|
|
|
assert event_types == ["tenant_created", "role_granted", "role_revoked", "plan_assigned"]
|