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 __future__ import annotations
|
|
|
|
|
|
TEN-WP-0002 T04-T07: cache-read, live-lookup (fail-closed), write API, close
- authz.py: WriteAuthorizer Protocol + DefaultDenyWriteAuthorizer. Every
write endpoint calls it before touching the store; denial maps to
403 write_denied via an exception handler.
- app.py: GET /tenants/{id}/roles (cache-read, key-cape) and
GET /tenants/{id}/roles/live (live-lookup, flex-auth) share one handler
that fails closed (503) on StoreUnavailableError -- deliberately made
identical rather than giving cache-read weaker guarantees than the task
strictly required. POST /tenants, /roles/grant, /roles/revoke, /plan --
all four gated by the WriteAuthorizer seam, domain/store errors mapped to
400/404/409 after authorization passes.
- store.py: new StoreUnavailableError for the fail-closed test double.
43 tests passing: default-deny on every write endpoint, an
_AllowAllAuthorizer test double proving the seam actually gates (full
create->grant->read->revoke->read->assign-plan lifecycle over real HTTP),
and a _BrokenStore double proving outage never looks like "zero roles".
Verified live over real HTTP, not just TestClient.
TEN-WP-0002 closed: all 7 tasks done, boundary-contract ownership checked
against the implementation with no drift found. Follow-ups recorded in the
closure note (real flex-auth WriteAuthorizer, key-cape wiring, guardrail
policy design, Binky as first real tenant record, durable persistence).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:24:09 +02:00
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
from pydantic import BaseModel
|
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 tenant_engine import __version__
|
TEN-WP-0002 T04-T07: cache-read, live-lookup (fail-closed), write API, close
- authz.py: WriteAuthorizer Protocol + DefaultDenyWriteAuthorizer. Every
write endpoint calls it before touching the store; denial maps to
403 write_denied via an exception handler.
- app.py: GET /tenants/{id}/roles (cache-read, key-cape) and
GET /tenants/{id}/roles/live (live-lookup, flex-auth) share one handler
that fails closed (503) on StoreUnavailableError -- deliberately made
identical rather than giving cache-read weaker guarantees than the task
strictly required. POST /tenants, /roles/grant, /roles/revoke, /plan --
all four gated by the WriteAuthorizer seam, domain/store errors mapped to
400/404/409 after authorization passes.
- store.py: new StoreUnavailableError for the fail-closed test double.
43 tests passing: default-deny on every write endpoint, an
_AllowAllAuthorizer test double proving the seam actually gates (full
create->grant->read->revoke->read->assign-plan lifecycle over real HTTP),
and a _BrokenStore double proving outage never looks like "zero roles".
Verified live over real HTTP, not just TestClient.
TEN-WP-0002 closed: all 7 tasks done, boundary-contract ownership checked
against the implementation with no drift found. Follow-ups recorded in the
closure note (real flex-auth WriteAuthorizer, key-cape wiring, guardrail
policy design, Binky as first real tenant record, durable persistence).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:24:09 +02:00
|
|
|
from tenant_engine.authz import DefaultDenyWriteAuthorizer, WriteAuthorizationDeniedError, WriteAuthorizer
|
|
|
|
|
from tenant_engine.domain import (
|
|
|
|
|
CapabilityRole,
|
|
|
|
|
GrantReason,
|
|
|
|
|
InvalidGrantError,
|
|
|
|
|
InvalidTenantIdentifierError,
|
|
|
|
|
PlanAssignment,
|
|
|
|
|
Tenant,
|
|
|
|
|
create_role_grant,
|
|
|
|
|
)
|
|
|
|
|
from tenant_engine.store import (
|
|
|
|
|
GrantNotFoundError,
|
|
|
|
|
InMemoryTenantStore,
|
|
|
|
|
StoreUnavailableError,
|
|
|
|
|
TenantAlreadyExistsError,
|
|
|
|
|
TenantNotFoundError,
|
|
|
|
|
TenantStore,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CreateTenantRequest(BaseModel):
|
|
|
|
|
tenant_id: str
|
|
|
|
|
identifier: str
|
|
|
|
|
actor: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GrantRoleRequest(BaseModel):
|
|
|
|
|
grant_id: str
|
|
|
|
|
role: CapabilityRole
|
|
|
|
|
grant_reason: GrantReason
|
|
|
|
|
plan_id: str | None = None
|
|
|
|
|
granted_by: str
|
|
|
|
|
correlation_id: str
|
|
|
|
|
actor: str
|
|
|
|
|
|
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
|
|
|
|
TEN-WP-0002 T04-T07: cache-read, live-lookup (fail-closed), write API, close
- authz.py: WriteAuthorizer Protocol + DefaultDenyWriteAuthorizer. Every
write endpoint calls it before touching the store; denial maps to
403 write_denied via an exception handler.
- app.py: GET /tenants/{id}/roles (cache-read, key-cape) and
GET /tenants/{id}/roles/live (live-lookup, flex-auth) share one handler
that fails closed (503) on StoreUnavailableError -- deliberately made
identical rather than giving cache-read weaker guarantees than the task
strictly required. POST /tenants, /roles/grant, /roles/revoke, /plan --
all four gated by the WriteAuthorizer seam, domain/store errors mapped to
400/404/409 after authorization passes.
- store.py: new StoreUnavailableError for the fail-closed test double.
43 tests passing: default-deny on every write endpoint, an
_AllowAllAuthorizer test double proving the seam actually gates (full
create->grant->read->revoke->read->assign-plan lifecycle over real HTTP),
and a _BrokenStore double proving outage never looks like "zero roles".
Verified live over real HTTP, not just TestClient.
TEN-WP-0002 closed: all 7 tasks done, boundary-contract ownership checked
against the implementation with no drift found. Follow-ups recorded in the
closure note (real flex-auth WriteAuthorizer, key-cape wiring, guardrail
policy design, Binky as first real tenant record, durable persistence).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:24:09 +02:00
|
|
|
class RevokeRoleRequest(BaseModel):
|
|
|
|
|
grant_id: str
|
|
|
|
|
actor: str
|
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
|
|
|
|
TEN-WP-0002 T04-T07: cache-read, live-lookup (fail-closed), write API, close
- authz.py: WriteAuthorizer Protocol + DefaultDenyWriteAuthorizer. Every
write endpoint calls it before touching the store; denial maps to
403 write_denied via an exception handler.
- app.py: GET /tenants/{id}/roles (cache-read, key-cape) and
GET /tenants/{id}/roles/live (live-lookup, flex-auth) share one handler
that fails closed (503) on StoreUnavailableError -- deliberately made
identical rather than giving cache-read weaker guarantees than the task
strictly required. POST /tenants, /roles/grant, /roles/revoke, /plan --
all four gated by the WriteAuthorizer seam, domain/store errors mapped to
400/404/409 after authorization passes.
- store.py: new StoreUnavailableError for the fail-closed test double.
43 tests passing: default-deny on every write endpoint, an
_AllowAllAuthorizer test double proving the seam actually gates (full
create->grant->read->revoke->read->assign-plan lifecycle over real HTTP),
and a _BrokenStore double proving outage never looks like "zero roles".
Verified live over real HTTP, not just TestClient.
TEN-WP-0002 closed: all 7 tasks done, boundary-contract ownership checked
against the implementation with no drift found. Follow-ups recorded in the
closure note (real flex-auth WriteAuthorizer, key-cape wiring, guardrail
policy design, Binky as first real tenant record, durable persistence).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:24:09 +02:00
|
|
|
|
|
|
|
|
class AssignPlanRequest(BaseModel):
|
|
|
|
|
plan_id: str
|
|
|
|
|
actor: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_app(
|
|
|
|
|
*,
|
|
|
|
|
store: TenantStore | None = None,
|
|
|
|
|
authorizer: WriteAuthorizer | None = None,
|
|
|
|
|
) -> FastAPI:
|
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 = store or InMemoryTenantStore()
|
TEN-WP-0002 T04-T07: cache-read, live-lookup (fail-closed), write API, close
- authz.py: WriteAuthorizer Protocol + DefaultDenyWriteAuthorizer. Every
write endpoint calls it before touching the store; denial maps to
403 write_denied via an exception handler.
- app.py: GET /tenants/{id}/roles (cache-read, key-cape) and
GET /tenants/{id}/roles/live (live-lookup, flex-auth) share one handler
that fails closed (503) on StoreUnavailableError -- deliberately made
identical rather than giving cache-read weaker guarantees than the task
strictly required. POST /tenants, /roles/grant, /roles/revoke, /plan --
all four gated by the WriteAuthorizer seam, domain/store errors mapped to
400/404/409 after authorization passes.
- store.py: new StoreUnavailableError for the fail-closed test double.
43 tests passing: default-deny on every write endpoint, an
_AllowAllAuthorizer test double proving the seam actually gates (full
create->grant->read->revoke->read->assign-plan lifecycle over real HTTP),
and a _BrokenStore double proving outage never looks like "zero roles".
Verified live over real HTTP, not just TestClient.
TEN-WP-0002 closed: all 7 tasks done, boundary-contract ownership checked
against the implementation with no drift found. Follow-ups recorded in the
closure note (real flex-auth WriteAuthorizer, key-cape wiring, guardrail
policy design, Binky as first real tenant record, durable persistence).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:24:09 +02:00
|
|
|
authorizer = authorizer or DefaultDenyWriteAuthorizer()
|
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
|
|
|
|
|
|
|
|
app = FastAPI(title="tenant-engine", version=__version__)
|
|
|
|
|
app.state.store = store
|
TEN-WP-0002 T04-T07: cache-read, live-lookup (fail-closed), write API, close
- authz.py: WriteAuthorizer Protocol + DefaultDenyWriteAuthorizer. Every
write endpoint calls it before touching the store; denial maps to
403 write_denied via an exception handler.
- app.py: GET /tenants/{id}/roles (cache-read, key-cape) and
GET /tenants/{id}/roles/live (live-lookup, flex-auth) share one handler
that fails closed (503) on StoreUnavailableError -- deliberately made
identical rather than giving cache-read weaker guarantees than the task
strictly required. POST /tenants, /roles/grant, /roles/revoke, /plan --
all four gated by the WriteAuthorizer seam, domain/store errors mapped to
400/404/409 after authorization passes.
- store.py: new StoreUnavailableError for the fail-closed test double.
43 tests passing: default-deny on every write endpoint, an
_AllowAllAuthorizer test double proving the seam actually gates (full
create->grant->read->revoke->read->assign-plan lifecycle over real HTTP),
and a _BrokenStore double proving outage never looks like "zero roles".
Verified live over real HTTP, not just TestClient.
TEN-WP-0002 closed: all 7 tasks done, boundary-contract ownership checked
against the implementation with no drift found. Follow-ups recorded in the
closure note (real flex-auth WriteAuthorizer, key-cape wiring, guardrail
policy design, Binky as first real tenant record, durable persistence).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:24:09 +02:00
|
|
|
app.state.authorizer = authorizer
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(WriteAuthorizationDeniedError)
|
|
|
|
|
async def handle_denied(_: Request, exc: WriteAuthorizationDeniedError) -> JSONResponse:
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
status_code=403,
|
|
|
|
|
content={"error_code": "write_denied", "action": exc.action, "detail": exc.reason},
|
|
|
|
|
)
|
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
|
|
|
|
|
|
|
|
@app.get("/health")
|
|
|
|
|
async def health() -> dict[str, str]:
|
|
|
|
|
return {"status": "ok", "service": "tenant-engine", "version": __version__}
|
|
|
|
|
|
TEN-WP-0002 T04-T07: cache-read, live-lookup (fail-closed), write API, close
- authz.py: WriteAuthorizer Protocol + DefaultDenyWriteAuthorizer. Every
write endpoint calls it before touching the store; denial maps to
403 write_denied via an exception handler.
- app.py: GET /tenants/{id}/roles (cache-read, key-cape) and
GET /tenants/{id}/roles/live (live-lookup, flex-auth) share one handler
that fails closed (503) on StoreUnavailableError -- deliberately made
identical rather than giving cache-read weaker guarantees than the task
strictly required. POST /tenants, /roles/grant, /roles/revoke, /plan --
all four gated by the WriteAuthorizer seam, domain/store errors mapped to
400/404/409 after authorization passes.
- store.py: new StoreUnavailableError for the fail-closed test double.
43 tests passing: default-deny on every write endpoint, an
_AllowAllAuthorizer test double proving the seam actually gates (full
create->grant->read->revoke->read->assign-plan lifecycle over real HTTP),
and a _BrokenStore double proving outage never looks like "zero roles".
Verified live over real HTTP, not just TestClient.
TEN-WP-0002 closed: all 7 tasks done, boundary-contract ownership checked
against the implementation with no drift found. Follow-ups recorded in the
closure note (real flex-auth WriteAuthorizer, key-cape wiring, guardrail
policy design, Binky as first real tenant record, durable persistence).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:24:09 +02:00
|
|
|
# -- Cache-read API (key-cape, at token issuance) --------------------
|
|
|
|
|
|
|
|
|
|
@app.get("/tenants/{tenant_id}/roles")
|
|
|
|
|
async def cache_read_roles(tenant_id: str) -> dict:
|
|
|
|
|
return _read_roles(store, tenant_id)
|
|
|
|
|
|
|
|
|
|
# -- Live-lookup API (flex-auth, for aal2-class decisions) -----------
|
|
|
|
|
# Same handler as the cache-read path: both fail closed on store
|
|
|
|
|
# unavailability (503, never 200 + []). The distinction between the two
|
|
|
|
|
# routes is operational intent -- key-cape calls this one to source a
|
|
|
|
|
# cached claim at issuance, flex-auth calls it synchronously before
|
|
|
|
|
# authorizing a privileged action -- not payload shape or error handling.
|
|
|
|
|
|
|
|
|
|
@app.get("/tenants/{tenant_id}/roles/live")
|
|
|
|
|
async def live_lookup_roles(tenant_id: str) -> dict:
|
|
|
|
|
return _read_roles(store, tenant_id)
|
|
|
|
|
|
|
|
|
|
# -- Write API (grant/revoke/plan mutation) ---------------------------
|
|
|
|
|
# Every mutation goes through `authorizer.authorize()` first. tenant-engine
|
|
|
|
|
# never self-authorizes; see authz.py.
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
try:
|
|
|
|
|
tenant = Tenant.create(tenant_id=payload.tenant_id, identifier=payload.identifier)
|
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
try:
|
|
|
|
|
tenant = store.get_tenant(tenant_id)
|
|
|
|
|
grant = create_role_grant(
|
|
|
|
|
tenant=tenant,
|
|
|
|
|
grant_id=payload.grant_id,
|
|
|
|
|
role=payload.role,
|
|
|
|
|
grant_reason=payload.grant_reason,
|
|
|
|
|
plan_id=payload.plan_id,
|
|
|
|
|
granted_by=payload.granted_by,
|
|
|
|
|
correlation_id=payload.correlation_id,
|
|
|
|
|
granted_at=datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
store.grant_role(grant)
|
|
|
|
|
except TenantNotFoundError as exc:
|
|
|
|
|
raise HTTPException(status_code=404, detail="tenant_not_found") 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}
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
try:
|
|
|
|
|
revoked = store.revoke_role(tenant_id=tenant_id, grant_id=payload.grant_id, at=datetime.now(UTC))
|
|
|
|
|
except TenantNotFoundError as exc:
|
|
|
|
|
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
|
|
|
|
except GrantNotFoundError as exc:
|
|
|
|
|
raise HTTPException(status_code=404, detail="grant_not_found") from exc
|
|
|
|
|
except InvalidGrantError as exc:
|
|
|
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
|
|
|
return {"grant_id": revoked.grant_id, "tenant_id": tenant_id, "revoked": True}
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
try:
|
|
|
|
|
store.assign_plan(
|
|
|
|
|
PlanAssignment(tenant_id=tenant_id, plan_id=payload.plan_id, assigned_at=datetime.now(UTC))
|
|
|
|
|
)
|
|
|
|
|
except TenantNotFoundError as exc:
|
|
|
|
|
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
|
|
|
|
return {"tenant_id": tenant_id, "plan_id": payload.plan_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
|
|
|
return app
|
TEN-WP-0002 T04-T07: cache-read, live-lookup (fail-closed), write API, close
- authz.py: WriteAuthorizer Protocol + DefaultDenyWriteAuthorizer. Every
write endpoint calls it before touching the store; denial maps to
403 write_denied via an exception handler.
- app.py: GET /tenants/{id}/roles (cache-read, key-cape) and
GET /tenants/{id}/roles/live (live-lookup, flex-auth) share one handler
that fails closed (503) on StoreUnavailableError -- deliberately made
identical rather than giving cache-read weaker guarantees than the task
strictly required. POST /tenants, /roles/grant, /roles/revoke, /plan --
all four gated by the WriteAuthorizer seam, domain/store errors mapped to
400/404/409 after authorization passes.
- store.py: new StoreUnavailableError for the fail-closed test double.
43 tests passing: default-deny on every write endpoint, an
_AllowAllAuthorizer test double proving the seam actually gates (full
create->grant->read->revoke->read->assign-plan lifecycle over real HTTP),
and a _BrokenStore double proving outage never looks like "zero roles".
Verified live over real HTTP, not just TestClient.
TEN-WP-0002 closed: all 7 tasks done, boundary-contract ownership checked
against the implementation with no drift found. Follow-ups recorded in the
closure note (real flex-auth WriteAuthorizer, key-cape wiring, guardrail
policy design, Binky as first real tenant record, durable persistence).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:24:09 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_roles(store: TenantStore, tenant_id: str) -> dict:
|
|
|
|
|
try:
|
|
|
|
|
roles = store.active_roles(tenant_id)
|
|
|
|
|
except TenantNotFoundError as exc:
|
|
|
|
|
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
|
|
|
|
except StoreUnavailableError as exc:
|
|
|
|
|
# Fail closed, never open: unavailability must not look like "zero
|
|
|
|
|
# roles" to a caller on a privileged-decision path.
|
|
|
|
|
raise HTTPException(status_code=503, detail="tenant_roles_unavailable") from exc
|
|
|
|
|
return {"tenant_id": tenant_id, "roles": sorted(role.value for role in roles)}
|