tenant-engine/src/tenant_engine/app.py

190 lines
7.2 KiB
Python
Raw Normal View History

from __future__ import annotations
from datetime import UTC, datetime
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from tenant_engine import __version__
TEN-WP-0003: FlexAuthWriteAuthorizer -- gate writes through flex-auth flex_auth.py: CheckRequest + FlexAuthCheckClient against flex-auth's real POST /v1/check contract (schemas/check_request.schema.json, decision_envelope.schema.json, read directly from the flex-auth repo, not guessed). Fail-closed by construction: only effect=="allow" authorizes; every other effect, non-200, malformed body, or transport failure resolves to deny, nothing raises past is_allowed(). authz.FlexAuthWriteAuthorizer implements the existing WriteAuthorizer Protocol. Action -> resource-type mapping coordinated with FLEX-WP-0008's planned vocabulary (both repos reference the same table). DefaultDenyWriteAuthorizer stays the fallback when no flex-auth URL is configured. config.py: Settings.from_env(), mirroring qonto-assistant's pattern. docs/flex-auth-integration.md documents the contract, fail-closed rule, and current real state (denies everything until FLEX-WP-0008 lands). 60 tests passing. Verified live twice over real HTTP between separate processes (not just MockTransport): a deny-returning flex-auth double produces 403 from POST /tenants, an allow-returning one produces 201. Also registered (not implemented) the two workplans this depends on for a complete picture: flex-auth/FLEX-WP-0008 (protected-system registration -- what makes allow reachable) and key-cape/KEY-WP-0005 (discovered key-cape emits none of iam-profile_v0.3.md's core claims yet, not just missing tenant_roles -- a bigger, security-sensitive gap flagged rather than quietly worked around). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:54:44 +02:00
from tenant_engine.authz import (
DefaultDenyWriteAuthorizer,
FlexAuthWriteAuthorizer,
WriteAuthorizationDeniedError,
WriteAuthorizer,
)
from tenant_engine.config import Settings
from tenant_engine.domain import (
CapabilityRole,
GrantReason,
InvalidGrantError,
InvalidTenantIdentifierError,
PlanAssignment,
Tenant,
create_role_grant,
)
TEN-WP-0003: FlexAuthWriteAuthorizer -- gate writes through flex-auth flex_auth.py: CheckRequest + FlexAuthCheckClient against flex-auth's real POST /v1/check contract (schemas/check_request.schema.json, decision_envelope.schema.json, read directly from the flex-auth repo, not guessed). Fail-closed by construction: only effect=="allow" authorizes; every other effect, non-200, malformed body, or transport failure resolves to deny, nothing raises past is_allowed(). authz.FlexAuthWriteAuthorizer implements the existing WriteAuthorizer Protocol. Action -> resource-type mapping coordinated with FLEX-WP-0008's planned vocabulary (both repos reference the same table). DefaultDenyWriteAuthorizer stays the fallback when no flex-auth URL is configured. config.py: Settings.from_env(), mirroring qonto-assistant's pattern. docs/flex-auth-integration.md documents the contract, fail-closed rule, and current real state (denies everything until FLEX-WP-0008 lands). 60 tests passing. Verified live twice over real HTTP between separate processes (not just MockTransport): a deny-returning flex-auth double produces 403 from POST /tenants, an allow-returning one produces 201. Also registered (not implemented) the two workplans this depends on for a complete picture: flex-auth/FLEX-WP-0008 (protected-system registration -- what makes allow reachable) and key-cape/KEY-WP-0005 (discovered key-cape emits none of iam-profile_v0.3.md's core claims yet, not just missing tenant_roles -- a bigger, security-sensitive gap flagged rather than quietly worked around). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:54:44 +02:00
from tenant_engine.flex_auth import FlexAuthCheckClient
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
class RevokeRoleRequest(BaseModel):
grant_id: str
actor: str
class AssignPlanRequest(BaseModel):
plan_id: str
actor: str
def create_app(
*,
store: TenantStore | None = None,
authorizer: WriteAuthorizer | None = None,
TEN-WP-0003: FlexAuthWriteAuthorizer -- gate writes through flex-auth flex_auth.py: CheckRequest + FlexAuthCheckClient against flex-auth's real POST /v1/check contract (schemas/check_request.schema.json, decision_envelope.schema.json, read directly from the flex-auth repo, not guessed). Fail-closed by construction: only effect=="allow" authorizes; every other effect, non-200, malformed body, or transport failure resolves to deny, nothing raises past is_allowed(). authz.FlexAuthWriteAuthorizer implements the existing WriteAuthorizer Protocol. Action -> resource-type mapping coordinated with FLEX-WP-0008's planned vocabulary (both repos reference the same table). DefaultDenyWriteAuthorizer stays the fallback when no flex-auth URL is configured. config.py: Settings.from_env(), mirroring qonto-assistant's pattern. docs/flex-auth-integration.md documents the contract, fail-closed rule, and current real state (denies everything until FLEX-WP-0008 lands). 60 tests passing. Verified live twice over real HTTP between separate processes (not just MockTransport): a deny-returning flex-auth double produces 403 from POST /tenants, an allow-returning one produces 201. Also registered (not implemented) the two workplans this depends on for a complete picture: flex-auth/FLEX-WP-0008 (protected-system registration -- what makes allow reachable) and key-cape/KEY-WP-0005 (discovered key-cape emits none of iam-profile_v0.3.md's core claims yet, not just missing tenant_roles -- a bigger, security-sensitive gap flagged rather than quietly worked around). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:54:44 +02:00
settings: Settings | None = None,
) -> FastAPI:
store = store or InMemoryTenantStore()
TEN-WP-0003: FlexAuthWriteAuthorizer -- gate writes through flex-auth flex_auth.py: CheckRequest + FlexAuthCheckClient against flex-auth's real POST /v1/check contract (schemas/check_request.schema.json, decision_envelope.schema.json, read directly from the flex-auth repo, not guessed). Fail-closed by construction: only effect=="allow" authorizes; every other effect, non-200, malformed body, or transport failure resolves to deny, nothing raises past is_allowed(). authz.FlexAuthWriteAuthorizer implements the existing WriteAuthorizer Protocol. Action -> resource-type mapping coordinated with FLEX-WP-0008's planned vocabulary (both repos reference the same table). DefaultDenyWriteAuthorizer stays the fallback when no flex-auth URL is configured. config.py: Settings.from_env(), mirroring qonto-assistant's pattern. docs/flex-auth-integration.md documents the contract, fail-closed rule, and current real state (denies everything until FLEX-WP-0008 lands). 60 tests passing. Verified live twice over real HTTP between separate processes (not just MockTransport): a deny-returning flex-auth double produces 403 from POST /tenants, an allow-returning one produces 201. Also registered (not implemented) the two workplans this depends on for a complete picture: flex-auth/FLEX-WP-0008 (protected-system registration -- what makes allow reachable) and key-cape/KEY-WP-0005 (discovered key-cape emits none of iam-profile_v0.3.md's core claims yet, not just missing tenant_roles -- a bigger, security-sensitive gap flagged rather than quietly worked around). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:54:44 +02:00
settings = settings or Settings.from_env()
authorizer = authorizer or _build_authorizer(settings)
app = FastAPI(title="tenant-engine", version=__version__)
app.state.store = store
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},
)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "service": "tenant-engine", "version": __version__}
# -- 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}
return app
TEN-WP-0003: FlexAuthWriteAuthorizer -- gate writes through flex-auth flex_auth.py: CheckRequest + FlexAuthCheckClient against flex-auth's real POST /v1/check contract (schemas/check_request.schema.json, decision_envelope.schema.json, read directly from the flex-auth repo, not guessed). Fail-closed by construction: only effect=="allow" authorizes; every other effect, non-200, malformed body, or transport failure resolves to deny, nothing raises past is_allowed(). authz.FlexAuthWriteAuthorizer implements the existing WriteAuthorizer Protocol. Action -> resource-type mapping coordinated with FLEX-WP-0008's planned vocabulary (both repos reference the same table). DefaultDenyWriteAuthorizer stays the fallback when no flex-auth URL is configured. config.py: Settings.from_env(), mirroring qonto-assistant's pattern. docs/flex-auth-integration.md documents the contract, fail-closed rule, and current real state (denies everything until FLEX-WP-0008 lands). 60 tests passing. Verified live twice over real HTTP between separate processes (not just MockTransport): a deny-returning flex-auth double produces 403 from POST /tenants, an allow-returning one produces 201. Also registered (not implemented) the two workplans this depends on for a complete picture: flex-auth/FLEX-WP-0008 (protected-system registration -- what makes allow reachable) and key-cape/KEY-WP-0005 (discovered key-cape emits none of iam-profile_v0.3.md's core claims yet, not just missing tenant_roles -- a bigger, security-sensitive gap flagged rather than quietly worked around). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:54:44 +02:00
def _build_authorizer(settings: Settings) -> WriteAuthorizer:
if settings.flex_auth_base_url is None:
return DefaultDenyWriteAuthorizer()
client = FlexAuthCheckClient(
base_url=settings.flex_auth_base_url,
timeout_seconds=settings.flex_auth_timeout_seconds,
)
return FlexAuthWriteAuthorizer(client=client)
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)}