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>
This commit is contained in:
parent
0b37f792a1
commit
5d57c7d488
13 changed files with 648 additions and 4 deletions
|
|
@ -7,7 +7,13 @@ from fastapi.responses import JSONResponse
|
|||
from pydantic import BaseModel
|
||||
|
||||
from tenant_engine import __version__
|
||||
from tenant_engine.authz import DefaultDenyWriteAuthorizer, WriteAuthorizationDeniedError, WriteAuthorizer
|
||||
from tenant_engine.authz import (
|
||||
DefaultDenyWriteAuthorizer,
|
||||
FlexAuthWriteAuthorizer,
|
||||
WriteAuthorizationDeniedError,
|
||||
WriteAuthorizer,
|
||||
)
|
||||
from tenant_engine.config import Settings
|
||||
from tenant_engine.domain import (
|
||||
CapabilityRole,
|
||||
GrantReason,
|
||||
|
|
@ -17,6 +23,7 @@ from tenant_engine.domain import (
|
|||
Tenant,
|
||||
create_role_grant,
|
||||
)
|
||||
from tenant_engine.flex_auth import FlexAuthCheckClient
|
||||
from tenant_engine.store import (
|
||||
GrantNotFoundError,
|
||||
InMemoryTenantStore,
|
||||
|
|
@ -57,9 +64,11 @@ def create_app(
|
|||
*,
|
||||
store: TenantStore | None = None,
|
||||
authorizer: WriteAuthorizer | None = None,
|
||||
settings: Settings | None = None,
|
||||
) -> FastAPI:
|
||||
store = store or InMemoryTenantStore()
|
||||
authorizer = authorizer or DefaultDenyWriteAuthorizer()
|
||||
settings = settings or Settings.from_env()
|
||||
authorizer = authorizer or _build_authorizer(settings)
|
||||
|
||||
app = FastAPI(title="tenant-engine", version=__version__)
|
||||
app.state.store = store
|
||||
|
|
@ -158,6 +167,16 @@ def create_app(
|
|||
return app
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,18 @@ from __future__ import annotations
|
|||
|
||||
from typing import Protocol
|
||||
|
||||
from tenant_engine.flex_auth import CheckRequest, FlexAuthCheckClient, new_request_id
|
||||
|
||||
# TEN-WP-0003-T01/T02: action names here must match FLEX-WP-0008-T01's
|
||||
# resource/action vocabulary exactly -- the two repos coordinate on these
|
||||
# strings, neither invents its own.
|
||||
_RESOURCE_TYPES: dict[str, str] = {
|
||||
"tenant.create": "tenant",
|
||||
"tenant.role.grant": "role-grant",
|
||||
"tenant.role.revoke": "role-grant",
|
||||
"tenant.plan.assign": "plan-assignment",
|
||||
}
|
||||
|
||||
|
||||
class WriteAuthorizationDeniedError(Exception):
|
||||
def __init__(self, action: str, reason: str = "denied") -> None:
|
||||
|
|
@ -31,3 +43,29 @@ class DefaultDenyWriteAuthorizer:
|
|||
raise WriteAuthorizationDeniedError(
|
||||
action, "no flex-auth integration configured (default-deny stub)"
|
||||
)
|
||||
|
||||
|
||||
class FlexAuthWriteAuthorizer:
|
||||
"""Gates writes through flex-auth's POST /v1/check (FLEX-WP-0008).
|
||||
|
||||
Until FLEX-WP-0008's policy package exists for tenant-engine, every
|
||||
check resolves to deny -- that's the correct fail-closed behavior, not
|
||||
a bug in this client (see flex_auth.FlexAuthCheckClient).
|
||||
"""
|
||||
|
||||
def __init__(self, *, client: FlexAuthCheckClient) -> None:
|
||||
self._client = client
|
||||
|
||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
||||
resource_type = _RESOURCE_TYPES.get(action, "tenant")
|
||||
request = CheckRequest(
|
||||
request_id=new_request_id(),
|
||||
tenant=tenant_id,
|
||||
subject_id=actor,
|
||||
subject_type="service",
|
||||
action=action,
|
||||
resource_id=tenant_id,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
if not self._client.is_allowed(request):
|
||||
raise WriteAuthorizationDeniedError(action, "denied by flex-auth policy check")
|
||||
|
|
|
|||
21
src/tenant_engine/config.py
Normal file
21
src/tenant_engine/config.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Settings:
|
||||
flex_auth_base_url: str | None
|
||||
flex_auth_timeout_seconds: float
|
||||
host: str
|
||||
port: int
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
return cls(
|
||||
flex_auth_base_url=os.getenv("TENANT_ENGINE_FLEX_AUTH_URL") or None,
|
||||
flex_auth_timeout_seconds=float(os.getenv("TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS", "3")),
|
||||
host=os.getenv("TENANT_ENGINE_HOST", "127.0.0.1"),
|
||||
port=int(os.getenv("TENANT_ENGINE_PORT", "8090")),
|
||||
)
|
||||
98
src/tenant_engine/flex_auth.py
Normal file
98
src/tenant_engine/flex_auth.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
# flex-auth's DecisionEnvelope schema (schemas/decision_envelope.schema.json)
|
||||
# allows five effects; only "allow" authorizes anything.
|
||||
ALLOW_EFFECT = "allow"
|
||||
|
||||
|
||||
class CheckRequest:
|
||||
"""Mirrors flex-auth/schemas/check_request.schema.json's shape."""
|
||||
|
||||
__slots__ = ("id", "tenant", "subject", "action", "resource", "context")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
request_id: str,
|
||||
tenant: str,
|
||||
subject_id: str,
|
||||
subject_type: str,
|
||||
action: str,
|
||||
resource_id: str,
|
||||
resource_type: str,
|
||||
resource_system: str = "tenant-engine",
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.id = request_id
|
||||
self.tenant = tenant
|
||||
self.subject = {"id": subject_id, "type": subject_type}
|
||||
self.action = action
|
||||
self.resource = {"id": resource_id, "type": resource_type, "system": resource_system}
|
||||
self.context = context or {}
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"tenant": self.tenant,
|
||||
"subject": self.subject,
|
||||
"action": self.action,
|
||||
"resource": self.resource,
|
||||
"context": self.context,
|
||||
}
|
||||
|
||||
|
||||
class FlexAuthCheckClient:
|
||||
"""Client for flex-auth's POST /v1/check.
|
||||
|
||||
Fail-closed by construction: every non-"allow" effect, every non-2xx
|
||||
response, every malformed body, and every transport failure (timeout,
|
||||
connection error) resolves to `False` from `is_allowed()`. Nothing
|
||||
raises past this boundary -- callers (the WriteAuthorizer seam) get a
|
||||
plain deny, not an exception to handle inconsistently.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
timeout_seconds: float = 3.0,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._client = httpx.Client(
|
||||
base_url=self.base_url,
|
||||
timeout=httpx.Timeout(timeout_seconds),
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
def is_allowed(self, request: CheckRequest) -> bool:
|
||||
try:
|
||||
response = self._client.post("/v1/check", json=request.to_json())
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
if response.status_code != 200:
|
||||
return False
|
||||
|
||||
try:
|
||||
envelope = response.json()
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if not isinstance(envelope, dict):
|
||||
return False
|
||||
|
||||
return envelope.get("effect") == ALLOW_EFFECT
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
|
||||
def new_request_id() -> str:
|
||||
return f"check:{uuid4()}"
|
||||
|
|
@ -3,10 +3,12 @@ from __future__ import annotations
|
|||
import uvicorn
|
||||
|
||||
from tenant_engine.app import create_app
|
||||
from tenant_engine.config import Settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
uvicorn.run(create_app(), host="127.0.0.1", port=8090)
|
||||
settings = Settings.from_env()
|
||||
uvicorn.run(create_app(settings=settings), host=settings.host, port=settings.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue