2026-08-19 14:31:39 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
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
|
|
|
import httpx
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from tenant_engine.flex_auth import CheckRequest, FlexAuthCheckClient, new_request_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _request() -> CheckRequest:
|
|
|
|
|
return CheckRequest(
|
|
|
|
|
request_id=new_request_id(),
|
|
|
|
|
tenant="tenant:friendly:binky",
|
|
|
|
|
subject_id="tenant-engine",
|
|
|
|
|
subject_type="service",
|
|
|
|
|
action="tenant.create",
|
|
|
|
|
resource_id="t-1",
|
|
|
|
|
resource_type="tenant",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _client(handler) -> FlexAuthCheckClient:
|
|
|
|
|
return FlexAuthCheckClient(
|
|
|
|
|
base_url="https://flex-auth.example.test",
|
|
|
|
|
timeout_seconds=1,
|
|
|
|
|
transport=httpx.MockTransport(handler),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_allow_effect_authorizes() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
2026-08-29 13:02:51 +02:00
|
|
|
return httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
|
|
|
|
|
)
|
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
|
|
|
|
|
|
|
|
assert _client(handler).is_allowed(_request()) is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("effect", ["deny", "redact", "audit_only", "not_applicable"])
|
|
|
|
|
def test_non_allow_effects_deny(effect: str) -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
2026-08-29 13:02:51 +02:00
|
|
|
return httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}},
|
|
|
|
|
)
|
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
|
|
|
|
|
|
|
|
assert _client(handler).is_allowed(_request()) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_200_status_denies() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
return httpx.Response(500, json={"error": "internal"})
|
|
|
|
|
|
|
|
|
|
assert _client(handler).is_allowed(_request()) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_malformed_json_body_denies() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
return httpx.Response(200, content=b"not json")
|
|
|
|
|
|
|
|
|
|
assert _client(handler).is_allowed(_request()) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_object_json_body_denies() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
return httpx.Response(200, json=["not", "an", "object"])
|
|
|
|
|
|
|
|
|
|
assert _client(handler).is_allowed(_request()) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_connection_failure_denies() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
raise httpx.ConnectError("connection refused", request=request)
|
|
|
|
|
|
|
|
|
|
assert _client(handler).is_allowed(_request()) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_timeout_denies() -> None:
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
raise httpx.TimeoutException("timed out", request=request)
|
|
|
|
|
|
|
|
|
|
assert _client(handler).is_allowed(_request()) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_request_body_matches_schema_shape() -> None:
|
|
|
|
|
seen: dict[str, object] = {}
|
|
|
|
|
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
seen.update(json.loads(request.content))
|
2026-08-29 13:02:51 +02:00
|
|
|
return httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
|
|
|
|
|
)
|
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
|
|
|
|
|
|
|
|
_client(handler).is_allowed(_request())
|
|
|
|
|
|
|
|
|
|
assert seen["tenant"] == "tenant:friendly:binky"
|
|
|
|
|
assert seen["action"] == "tenant.create"
|
|
|
|
|
assert seen["subject"] == {"id": "tenant-engine", "type": "service"}
|
|
|
|
|
assert seen["resource"] == {"id": "t-1", "type": "tenant", "system": "tenant-engine"}
|
2026-08-19 14:31:39 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rotating_caller_token_is_read_for_each_check(tmp_path: Path) -> None:
|
|
|
|
|
token_file = tmp_path / "token"
|
|
|
|
|
token_file.write_text("token-one\n")
|
|
|
|
|
seen: list[str] = []
|
|
|
|
|
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
seen.append(request.headers["authorization"])
|
|
|
|
|
return httpx.Response(200, json={"id": "d-1", "effect": "allow"})
|
|
|
|
|
|
|
|
|
|
client = FlexAuthCheckClient(
|
|
|
|
|
base_url="https://flex-auth.example.test",
|
|
|
|
|
transport=httpx.MockTransport(handler),
|
|
|
|
|
bearer_token_file=str(token_file),
|
|
|
|
|
)
|
|
|
|
|
assert client.is_allowed(_request()) is True
|
|
|
|
|
token_file.write_text("token-two\n")
|
|
|
|
|
assert client.is_allowed(_request()) is True
|
|
|
|
|
assert seen == ["Bearer token-one", "Bearer token-two"]
|