Finish TEN-WP-0010-T03/T04: audited grouping mutation
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 1m5s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 1m5s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2e11b6a155
commit
b998ca2332
6 changed files with 422 additions and 3 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue