tenant-engine/docs/tenant-lifecycle-api.md
tegwick 749461b97b
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
Implement PostgreSQL production store path
Add the PostgreSQL backend, migration and stopped-write transfer tools, lease-aware deployment manifests, tenancy declarations, and shared conformance coverage. Persist grouping mutations in durable stores and separate process liveness from database readiness.
2026-08-19 14:43:08 +02:00

242 lines
8.9 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.
Retention and erasure semantics are normative in
`docs/data-retention-policy.md`. In particular, retirement does not erase
contact data, and the 30-day PostgreSQL backup window is a copy horizon rather
than a primary-record expiry rule.
---
## Immutability
`tenant_id` and `identifier` 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.
**`grouping` is mutable as of TEN-WP-0010**, but not through `PATCH` — see
below. This document previously said it was immutable; that is no longer true.
---
## Reclassification (`grouping`)
### `POST /tenants/{tenant_id}/grouping`
```http
POST /tenants/t-1/grouping
Idempotency-Key: 4f1c…
If-Match: "1"
{"grouping": "large", "actor": "ops",
"reason": "grew past the band", "correlation_id": "corr-1"}
```
Returns the full tenant record, `ETag`, and `Idempotent-Replay`, like the other
mutations.
**Why this is not a `PATCH` field.** `display_name` and `contact_email` are
cosmetic. `grouping` resolves spend ceilings (see
`tenant-guardrail-policy.md`), so a reclassification moves money. It gets its
own route, its own flex-auth action `tenant.grouping.set`, and its own audit
event `tenant_grouping_changed`, so policy can permit a rename without
permitting a reclassification and the audit trail shows which one happened.
**The identifier's grouping segment is historical.** A tenant created as
`tenant:small:acme` and reclassified to `large` keeps the identifier
`tenant:small:acme` and reports `"grouping": "large"`. The two are *expected*
to diverge.
> **Do not parse grouping out of the identifier.** The segment records the
> grouping at onboarding and is not authoritative afterwards. Read the
> `grouping` field from this API. (ADR-0013 amendment proposed under
> TEN-WP-0010; the identifier itself remains immutable either way.)
| Rule | Behaviour |
|---|---|
| unknown grouping | `400 invalid_grouping` |
| no-op | `400 invalid_update` |
| reserved tenant (`tenant:platform`, `tenant:coulomb`) | `400` — ungrouped by design; they resolve guardrails through the reserved profile |
| retired tenant | `409 invalid_lifecycle_transition` — reactivate first |
---
## 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` |
| `tenant.grouping.set` | `tenant` |
These sit alongside the existing `tenant.create`, `tenant.role.grant`,
`tenant.role.revoke`, and `tenant.plan.assign` actions in package
`tenant-engine.write-api.mutate` v1. Production flex-auth serves the
seven-action revision from image
`forgejo.coulomb.social/coulomb/flex-auth@sha256:9320df394a642eff24da8af4a0ee8886a7bb78b0f14d8ee1deeb30ea8eeeaba7`
(commit `e9911eb`). Unknown action strings still deny.
---
## 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.