Add an explicit tenant lifecycle (active/retired), allow-listed mutable
metadata, record versioning, and lifecycle timestamps to the tenant authority.
- domain: TenantLifecycle, with_metadata/retire/reactivate, immutability and
transition invariants. Identifier stays immutable -- it is the IAM Profile
`tenant` claim key-cape mints into tokens.
- store: mutate_tenant() commits idempotency replay, version CAS, mutation,
and audit event together; durable receipts survive restart. Retired tenants
refuse new grants and plan changes but keep their history.
- sqlite: forward-only idempotent migration; existing rows default to active
at version 1. Reads now take the write lock -- the concurrent-writer test
caught unguarded reads on the shared connection observing mid-transaction
state as a spurious tenant_not_found.
- api: GET/PATCH /tenants/{id}, POST retire|reactivate. Idempotency-Key and
If-Match required, distinct flex-auth actions per operation, stable error
schema, redacted 503s.
- docs/tenant-lifecycle-api.md: consumer contract for user-engine.
Implemented against SQLite, not PostgreSQL as the workplan assumed --
TEN-WP-0004 shipped SQLite on a PVC as the production store.
124 tests pass (was 66); no breaking change to existing endpoints.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
303 lines
9.7 KiB
Python
303 lines
9.7 KiB
Python
"""TEN-WP-0005-T03/T04: HTTP contract for the lifecycle surface."""
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from tenant_engine.app import create_app
|
|
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
|
|
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
|
|
|
|
HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'}
|
|
BODY = {"actor": "portal", "reason": "operator request", "correlation_id": "corr-1"}
|
|
|
|
|
|
class _AllowAllAuthorizer(WriteAuthorizer):
|
|
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
|
return None
|
|
|
|
|
|
class _ScopedAuthorizer(WriteAuthorizer):
|
|
"""Allows only the listed actions -- stands in for a flex-auth policy that
|
|
grants an operator metadata edits but not retirement."""
|
|
|
|
def __init__(self, *allowed: str) -> None:
|
|
self._allowed = set(allowed)
|
|
|
|
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
|
if action not in self._allowed:
|
|
raise WriteAuthorizationDeniedError(action, "not permitted")
|
|
|
|
|
|
class _BrokenStore(InMemoryTenantStore):
|
|
def mutate_tenant(self, **kwargs):
|
|
raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused")
|
|
|
|
def get_tenant(self, tenant_id: str):
|
|
raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused")
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> TestClient:
|
|
app = create_app(store=InMemoryTenantStore(), authorizer=_AllowAllAuthorizer())
|
|
test_client = TestClient(app)
|
|
test_client.post(
|
|
"/tenants",
|
|
json={
|
|
"tenant_id": "t-1",
|
|
"identifier": "tenant:friendly:binky",
|
|
"actor": "ops",
|
|
"display_name": "Binky",
|
|
},
|
|
)
|
|
return test_client
|
|
|
|
|
|
def _patch(client, *, headers=None, metadata=None, **overrides):
|
|
return client.patch(
|
|
"/tenants/t-1",
|
|
headers={**HEADERS, **(headers or {})},
|
|
json={
|
|
**BODY,
|
|
"metadata": {"display_name": "Binky Ltd"} if metadata is None else metadata,
|
|
**overrides,
|
|
},
|
|
)
|
|
|
|
|
|
# -- read ---------------------------------------------------------------
|
|
|
|
|
|
def test_get_tenant_returns_record_and_etag(client) -> None:
|
|
response = client.get("/tenants/t-1")
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["ETag"] == '"1"'
|
|
body = response.json()
|
|
assert body["identifier"] == "tenant:friendly:binky"
|
|
assert body["lifecycle"] == "active"
|
|
assert body["version"] == 1
|
|
|
|
|
|
def test_get_tenant_resolves_by_identifier(client) -> None:
|
|
response = client.get("/tenants/tenant:friendly:binky")
|
|
assert response.status_code == 200
|
|
assert response.json()["tenant_id"] == "t-1"
|
|
|
|
|
|
def test_get_unknown_tenant_is_404(client) -> None:
|
|
assert client.get("/tenants/nope").status_code == 404
|
|
|
|
|
|
# -- update -------------------------------------------------------------
|
|
|
|
|
|
def test_update_succeeds_and_advances_the_etag(client) -> None:
|
|
response = _patch(client)
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["ETag"] == '"2"'
|
|
assert response.headers["Idempotent-Replay"] == "false"
|
|
assert response.json()["display_name"] == "Binky Ltd"
|
|
assert client.get("/tenants/t-1").json()["display_name"] == "Binky Ltd"
|
|
|
|
|
|
def test_update_rejects_unknown_field(client) -> None:
|
|
response = _patch(client, metadata={"nickname": "binks"})
|
|
assert response.status_code == 422 # schema-level allow-list
|
|
|
|
|
|
def test_update_rejects_identifier_mutation(client) -> None:
|
|
response = _patch(client, metadata={"identifier": "tenant:large:other"})
|
|
|
|
assert response.status_code == 422
|
|
assert client.get("/tenants/t-1").json()["identifier"] == "tenant:friendly:binky"
|
|
|
|
|
|
def test_update_rejects_empty_metadata(client) -> None:
|
|
response = _patch(client, metadata={})
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["error_code"] == "invalid_update"
|
|
|
|
|
|
def test_update_with_stale_version_is_409(client) -> None:
|
|
_patch(client)
|
|
response = _patch(
|
|
client, headers={"Idempotency-Key": "idem-2"}, metadata={"display_name": "Third"}
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
assert response.json()["error_code"] == "version_conflict"
|
|
assert response.json()["correlation_id"] == "corr-1"
|
|
|
|
|
|
def test_duplicate_idempotency_key_replays(client) -> None:
|
|
first = _patch(client)
|
|
replay = _patch(client)
|
|
|
|
assert replay.status_code == 200
|
|
assert replay.headers["Idempotent-Replay"] == "true"
|
|
assert replay.json() == first.json()
|
|
assert client.get("/tenants/t-1").json()["version"] == 2
|
|
|
|
|
|
def test_idempotency_key_reused_for_a_different_request_is_409(client) -> None:
|
|
_patch(client)
|
|
response = _patch(client, metadata={"display_name": "Different"})
|
|
|
|
assert response.status_code == 409
|
|
assert response.json()["error_code"] == "idempotency_key_conflict"
|
|
|
|
|
|
def test_missing_idempotency_key_is_rejected(client) -> None:
|
|
response = client.patch(
|
|
"/tenants/t-1",
|
|
headers={"If-Match": '"1"'},
|
|
json={**BODY, "metadata": {"display_name": "X"}},
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["error_code"] == "idempotency_key_required"
|
|
|
|
|
|
def test_missing_if_match_is_rejected(client) -> None:
|
|
response = client.patch(
|
|
"/tenants/t-1",
|
|
headers={"Idempotency-Key": "idem-1"},
|
|
json={**BODY, "metadata": {"display_name": "X"}},
|
|
)
|
|
assert response.status_code == 428
|
|
assert response.json()["error_code"] == "if_match_required"
|
|
|
|
|
|
def test_wildcard_if_match_is_rejected(client) -> None:
|
|
response = _patch(client, headers={"If-Match": "*"})
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["error_code"] == "invalid_if_match"
|
|
|
|
|
|
def test_weak_etag_form_is_accepted(client) -> None:
|
|
assert _patch(client, headers={"If-Match": 'W/"1"'}).status_code == 200
|
|
|
|
|
|
# -- retire / reactivate -------------------------------------------------
|
|
|
|
|
|
def _retire(client, *, key="idem-retire", version='"1"'):
|
|
return client.post(
|
|
"/tenants/t-1/retire",
|
|
headers={"Idempotency-Key": key, "If-Match": version},
|
|
json=BODY,
|
|
)
|
|
|
|
|
|
def test_retire_then_reactivate(client) -> None:
|
|
retired = _retire(client)
|
|
assert retired.status_code == 200
|
|
assert retired.json()["lifecycle"] == "retired"
|
|
assert retired.json()["retired_at"] is not None
|
|
|
|
reactivated = client.post(
|
|
"/tenants/t-1/reactivate",
|
|
headers={"Idempotency-Key": "idem-react", "If-Match": '"2"'},
|
|
json=BODY,
|
|
)
|
|
assert reactivated.status_code == 200
|
|
assert reactivated.json()["lifecycle"] == "active"
|
|
assert reactivated.json()["version"] == 3
|
|
|
|
|
|
def test_double_retirement_is_409(client) -> None:
|
|
_retire(client)
|
|
response = _retire(client, key="idem-retire-2", version='"2"')
|
|
|
|
assert response.status_code == 409
|
|
assert response.json()["error_code"] == "invalid_lifecycle_transition"
|
|
|
|
|
|
def test_retirement_replay_is_idempotent(client) -> None:
|
|
first = _retire(client)
|
|
replay = _retire(client)
|
|
|
|
assert replay.status_code == 200
|
|
assert replay.headers["Idempotent-Replay"] == "true"
|
|
assert replay.json() == first.json()
|
|
|
|
|
|
def test_update_after_retirement_is_denied(client) -> None:
|
|
_retire(client)
|
|
response = _patch(client, headers={"If-Match": '"2"', "Idempotency-Key": "idem-x"})
|
|
|
|
assert response.status_code == 409
|
|
assert response.json()["error_code"] == "invalid_lifecycle_transition"
|
|
|
|
|
|
def test_role_and_plan_mutations_denied_while_retired(client) -> None:
|
|
_retire(client)
|
|
|
|
granted = client.post(
|
|
"/tenants/t-1/roles/grant",
|
|
json={
|
|
"grant_id": "g-1",
|
|
"role": "CUS",
|
|
"grant_reason": "manual_grant",
|
|
"granted_by": "ops",
|
|
"correlation_id": "corr-1",
|
|
"actor": "ops",
|
|
},
|
|
)
|
|
plan = client.post("/tenants/t-1/plan", json={"plan_id": "plan-x", "actor": "ops"})
|
|
|
|
assert granted.status_code == 409
|
|
assert plan.status_code == 409
|
|
|
|
|
|
def test_lifecycle_mutation_on_unknown_tenant_is_404(client) -> None:
|
|
response = client.post(
|
|
"/tenants/nope/retire", headers={"Idempotency-Key": "k", "If-Match": '"1"'}, json=BODY
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
|
|
# -- authorization and redaction -----------------------------------------
|
|
|
|
|
|
def test_lifecycle_mutations_are_denied_by_default() -> None:
|
|
client = TestClient(create_app(store=InMemoryTenantStore()))
|
|
response = client.patch(
|
|
"/tenants/t-1", headers=HEADERS, json={**BODY, "metadata": {"display_name": "X"}}
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["error_code"] == "write_denied"
|
|
|
|
|
|
def test_update_permission_does_not_imply_retire_permission() -> None:
|
|
app = create_app(
|
|
store=InMemoryTenantStore(),
|
|
authorizer=_ScopedAuthorizer("tenant.create", "tenant.update"),
|
|
)
|
|
client = TestClient(app)
|
|
client.post(
|
|
"/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}
|
|
)
|
|
|
|
assert _patch(client).status_code == 200
|
|
denied = _retire(client, version='"2"')
|
|
assert denied.status_code == 403
|
|
assert denied.json()["action"] == "tenant.retire"
|
|
|
|
|
|
def test_store_outage_is_a_redacted_503() -> None:
|
|
client = TestClient(create_app(store=_BrokenStore(), authorizer=_AllowAllAuthorizer()))
|
|
|
|
read = client.get("/tenants/t-1")
|
|
write = client.patch(
|
|
"/tenants/t-1", headers=HEADERS, json={**BODY, "metadata": {"display_name": "X"}}
|
|
)
|
|
|
|
for response in (read, write):
|
|
assert response.status_code == 503
|
|
assert response.json()["error_code"] == "tenant_authority_unavailable"
|
|
# No database path, driver text, or policy detail may reach a consumer.
|
|
assert "tenant.db" not in response.text
|
|
assert "refused" not in response.text
|