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>
189 lines
6.8 KiB
Markdown
189 lines
6.8 KiB
Markdown
# Tenant lifecycle API (TEN-WP-0005)
|
|
|
|
Consumer contract for tenant metadata update and reversible retirement.
|
|
Primary consumer: `user-engine` (USER-WP-0021, platform operator UI/API).
|
|
|
|
`tenant-engine` remains the sole authority for tenant existence and
|
|
lifecycle. Consumers call this API; they do not keep their own tenant table
|
|
and do not implement their own retirement semantics.
|
|
|
|
**There is no hard-delete endpoint, by design.** Retirement is reversible and
|
|
preserves the tenant record, its grant history, and its plan history so audit
|
|
correlation and recovery stay intact.
|
|
|
|
---
|
|
|
|
## Lifecycle
|
|
|
|
```
|
|
┌──────────────── retire ────────────────┐
|
|
│ ▼
|
|
[ active ] [ retired ]
|
|
▲ │
|
|
└────────────── reactivate ──────────────┘
|
|
```
|
|
|
|
| State | New role grants | Plan changes | Metadata updates | Role reads |
|
|
|---|---|---|---|---|
|
|
| `active` | allowed | allowed | allowed | allowed |
|
|
| `retired` | **409** | **409** | **409** | allowed (unchanged) |
|
|
|
|
Role *revocation* stays available while retired: it only reduces privilege,
|
|
and blocking it would be a fail-open behaviour.
|
|
|
|
Reactivation restores the tenant's ability to receive new grants and plan
|
|
changes. It deliberately does **not** resurrect revoked grants or invent plan
|
|
state — those stay exactly as retirement left them.
|
|
|
|
---
|
|
|
|
## Immutability
|
|
|
|
`tenant_id`, `identifier`, and `grouping` are immutable. The identifier is the
|
|
IAM Profile `tenant` claim value that `key-cape` mints into tokens and
|
|
`flex-auth` authorizes against; mutating it would silently invalidate every
|
|
issued token that references it.
|
|
|
|
Mutable metadata is exactly: `display_name`, `contact_email`. Unknown fields
|
|
are rejected by the request schema (`extra: forbid`), so the allow-list is
|
|
visible in the OpenAPI document rather than discovered from a 400.
|
|
|
|
---
|
|
|
|
## Endpoints
|
|
|
|
### `GET /tenants/{tenant_id}`
|
|
|
|
Authoritative record read. `tenant_id` accepts either the internal id or the
|
|
profile identifier (e.g. `tenant:friendly:binky`) — external callers only ever
|
|
hold the identifier.
|
|
|
|
Returns the record and an `ETag` header carrying the record version. **Read
|
|
first, echo the ETag back as `If-Match`** on any mutation.
|
|
|
|
```json
|
|
{
|
|
"tenant_id": "t-1",
|
|
"identifier": "tenant:friendly:binky",
|
|
"grouping": "friendly",
|
|
"display_name": "Binky",
|
|
"contact_email": null,
|
|
"lifecycle": "active",
|
|
"version": 1,
|
|
"created_at": "2026-08-10T12:00:00+00:00",
|
|
"updated_at": "2026-08-10T12:00:00+00:00",
|
|
"retired_at": null,
|
|
"reactivated_at": null
|
|
}
|
|
```
|
|
|
|
### `PATCH /tenants/{tenant_id}`
|
|
|
|
```http
|
|
PATCH /tenants/t-1
|
|
Idempotency-Key: 4f1c…
|
|
If-Match: "1"
|
|
|
|
{"metadata": {"display_name": "Binky Ltd"},
|
|
"actor": "user-engine-portal", "reason": "operator rename", "correlation_id": "corr-1"}
|
|
```
|
|
|
|
### `POST /tenants/{tenant_id}/retire` · `POST /tenants/{tenant_id}/reactivate`
|
|
|
|
Same headers; body is `{"actor", "reason", "correlation_id"}`.
|
|
|
|
All three mutations return the full record (as above) plus `ETag` and
|
|
`Idempotent-Replay: true|false`.
|
|
|
|
---
|
|
|
|
## Concurrency and idempotency
|
|
|
|
Every mutation **requires** both headers:
|
|
|
|
- **`If-Match`** — the record version as an ETag (`"1"` or `W/"1"`). Enforced
|
|
as an atomic compare-and-swap. `*` is rejected: it would mean "whatever
|
|
version is current", which is the unconditional write these endpoints exist
|
|
to prevent.
|
|
- **`Idempotency-Key`** — caller-generated, unique per logical mutation.
|
|
|
|
Replay semantics:
|
|
|
|
- **Same key, same request** → the original result is replayed verbatim with
|
|
`Idempotent-Replay: true`. The mutation is **not** applied twice, and the
|
|
version does not advance. Receipts are durable, so a replay works across a
|
|
`tenant-engine` restart.
|
|
- **Same key, different request** → `409 idempotency_key_conflict`.
|
|
- A mutation that *failed* leaves no receipt, so its key is reusable.
|
|
|
|
A replayed key short-circuits **before** the version check — a genuine retry
|
|
necessarily carries a now-stale `If-Match`. This is why retries do not need to
|
|
re-read the record first.
|
|
|
|
---
|
|
|
|
## Errors
|
|
|
|
Stable schema on every lifecycle endpoint:
|
|
|
|
```json
|
|
{"error_code": "version_conflict", "detail": "record version is 2, not 1", "correlation_id": "corr-1"}
|
|
```
|
|
|
|
| Status | `error_code` | Cause |
|
|
|---|---|---|
|
|
| 400 | `idempotency_key_required` | `Idempotency-Key` header missing |
|
|
| 400 | `invalid_if_match` | `If-Match` is `*` or not a version ETag |
|
|
| 400 | `invalid_update` | empty change set, or a change that is a no-op |
|
|
| 403 | `write_denied` | flex-auth denied the action |
|
|
| 404 | `tenant_not_found` | unknown tenant |
|
|
| 409 | `version_conflict` | stale `If-Match` — re-read and retry |
|
|
| 409 | `idempotency_key_conflict` | key reused for a different request |
|
|
| 409 | `invalid_lifecycle_transition` | double retirement, reactivating an active tenant, updating a retired tenant |
|
|
| 422 | *(schema)* | unknown or immutable field in `metadata` |
|
|
| 428 | `if_match_required` | `If-Match` header missing |
|
|
| 503 | `tenant_authority_unavailable` | store or authority unavailable |
|
|
|
|
A no-op update is rejected rather than silently accepted, so a caller never
|
|
reads a version bump as evidence that a value actually changed.
|
|
|
|
`503` responses are redacted: they never carry a database path, driver text,
|
|
or policy detail.
|
|
|
|
---
|
|
|
|
## Authorization
|
|
|
|
Mutations are gated by `flex-auth`; `tenant-engine` never self-authorizes.
|
|
Three **distinct** actions, so policy can grant a metadata edit without
|
|
thereby granting a retirement:
|
|
|
|
| Action | Resource type |
|
|
|---|---|
|
|
| `tenant.update` | `tenant` |
|
|
| `tenant.retire` | `tenant` |
|
|
| `tenant.reactivate` | `tenant` |
|
|
|
|
These must be added to the flex-auth policy package alongside the existing
|
|
`tenant.create`, `tenant.role.grant`, `tenant.role.revoke`, and
|
|
`tenant.plan.assign` actions. Until they exist, checks resolve to deny —
|
|
correct fail-closed behaviour, not a defect.
|
|
|
|
---
|
|
|
|
## Compatibility
|
|
|
|
No breaking change to existing clients. `POST /tenants` now returns a superset
|
|
of its previous body (`tenant_id`, `identifier`, `grouping` unchanged) and
|
|
optionally accepts `display_name` / `contact_email`. Role read, grant, revoke,
|
|
and plan endpoints are unchanged except that grant and plan-assign now return
|
|
`409 tenant_retired` against a retired tenant.
|
|
|
|
Existing databases are migrated forward-only: lifecycle columns are added and
|
|
existing tenants default to `active` at version 1, with identifiers, grants,
|
|
and plan assignments untouched.
|
|
|
|
> **Note on the backend.** TEN-WP-0005 was drafted against PostgreSQL, but
|
|
> TEN-WP-0004 shipped SQLite on a PVC as the production store. The migration
|
|
> and conformance suite target the store that actually runs; the `TenantStore`
|
|
> Protocol keeps the seam for a future backend change.
|