All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
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.
242 lines
8.9 KiB
Markdown
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.
|