Skip to main content

DELETE /admin/tenants/{tenant}

Requires manage-tenants, the capability every other route over the tenant collection takes, so only a system administrator reaches it. Deletes the tenant and every row it holds, in one statement: every table holding a tenant's rows is reached by a cascade from tenants, directly or through a subject or a client, and an integration test counts every table carrying a tenant_id, read from the catalogue, before and after. confirm must be the tenant's own name — a slip of the path deletes nothing — refused with 400 naming it otherwise. The system tenant is refused with 409: every cross-tenant administrator authenticates against it. So is a caller who does not hold every admin capability some subject of the tenant holds, with 403: deleting the tenant takes all of it at once (ADR 0040's target ceiling, over every subject). The tenant's own trail goes with it, so tenant.delete is recorded in the system tenant's, in the same transaction (withTenantThen, @odudu/db), and so are its refusals. No last-administrator guard is needed beside the refusal of system: a system administrator's authority lives in system, which no deletion reaches.

No relying party is left signed in without having been sent a Back-Channel Logout Token. So an enabled tenant is refused with 409, about:blank#tenant-enabled. Disabling it first — PATCH /admin/tenants/{tenant} or PATCH /settings with {"enabled": false} — commits the disable on its own, then ends every live session it holds, with no ceiling, 500 to a transaction, queueing a Logout Token for each client that registered a back-channel URI, the way DELETE /sessions does; each batch writes a session.end_all row with via: "tenant_disabled". The answer comes once none is left. Should a batch fail, the disable stands and the answer is 500, about:blank#sessions-not-ended, saying how many sessions are still live; sending {"enabled": false} again ends the rest. So a tenant that still has a live session is refused with 409, about:blank#sessions-live, naming how many. The batches run in the request rather than in a scheduled pass: a session ends in a few statements, so even a large tenant is done in the request, the operator learns there whether it finished, and nothing new has to be enabled, scheduled or watched for it. A tenant whose tokens are still to be sent is refused with 409, about:blank#logout-deliveries-pending, naming how many; one that was delivered, or that spent its last attempt, is not waited for, since it will never be offered again.

A disabled tenant keeps answering its discovery document and /certs, and nothing else: the Logout Tokens its disable queued are signed with its keys, and a relying party validates each against them (Back-Channel Logout 1.0 §2.6). Deleting the tenant removes them.

One window no deletion can close. A resource server that verifies access tokens itself, against a cached copy of the tenant's JWKS, accepts one issued before the tenant was disabled until it expires — at most 3600 seconds, the cap on access_token_ttl_seconds. Disabling ends the sessions and refresh tokens behind it; it cannot recall a token already handed out.

The deletion is a single transaction, and its duration grows with the tenant's audit trail, the largest table it cascades through: there is no bound, batch or timeout, so a tenant holding tens of millions of audit rows holds its locks until the last is gone. A chunked, resumable delete is P11's (the umbrella spec's §11, P11 row). A tenant-scoped write that raced it and began second fails its foreign-key check once the deletion commits, and answers 500; nothing is orphaned, since the row it needed is gone and it writes nothing.

ada seeded in system, ODUDU_ALLOW_PRIVATE_CLIENT_URLS=true and the server's own logout sender off, so the passes below are the ones the transcript runs: a tenant doomed created through POST /admin/tenants, given a client through POST /clients, its id 01a140bf-2b2b-7e8f-a63a-c6c8b0f964d9. count-rows.sql counts that id's rows in every table with a tenant_id column, as the database's owner:

SELECT table_name,
(xpath('/row/n/text()', query_to_xml(format(
'select count(*) as n from %I where tenant_id = %L',
table_name, '01a140bf-2b2b-7e8f-a63a-c6c8b0f964d9'), false, true, '')))[1]::text::int AS n
FROM information_schema.columns
WHERE table_schema = 'public' AND column_name = 'tenant_id'
ORDER BY n DESC, table_name;

ada was seeded in it with odudu seed user and signed in once through doomed-app, a confidential client with a back-channel logout URI of https://postgres:9/backchannel, where nothing listens. Before, three refusals — no confirm, a wrong one, and system — then the refusal of an enabled tenant; its disabling, which ended ada's session in one batch and queued one token; the batch's row; the disabled tenant's discovery document and key set, still served, beside a /token it refuses; a session written straight into the table after the disable, and the refusal it causes; the disable sent again, which ends it; the refusal while the token is pending; five passes of the sender a minute apart, each a failure, the fifth its last attempt; the deletion, with $A minted again first because the loop outlasts its five minutes; a read of the tenant, its discovery document and key set, now gone; after; and system's trail:

A="Authorization: Bearer $ADMIN_TOKEN"
T=http://localhost:3187/admin/tenants
O=http://localhost:3187/tenants/doomed
pg() { docker compose exec -T postgres sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f -'; }
pg < count-rows.sql
curl -sS -X DELETE -H "$A" "$T/doomed"; echo
curl -sS -X DELETE -H "$A" "$T/doomed?confirm=Doomed"; echo
curl -sS -X DELETE -H "$A" "$T/system?confirm=system"; echo
curl -sS -X DELETE -H "$A" "$T/doomed?confirm=doomed"; echo
curl -sS -X PATCH -H "$A" -H 'content-type: application/json' -d '{"enabled":false}' "$T/doomed"; echo
curl -sS -H "$A" "$T/doomed/audit?action=session.end_all" \
| python3 -c 'import json,sys;[print(json.dumps({k:i[k] for k in ("action","actor_name","detail")})) for i in json.load(sys.stdin)["items"]]'
for p in .well-known/openid-configuration protocol/openid-connect/certs protocol/openid-connect/token; do
printf '%-40s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' "$O/$p")"
done
curl -sS "$O/protocol/openid-connect/certs" | python3 -c 'import json,sys;print([k["kid"] for k in json.load(sys.stdin)["keys"]])'
echo "INSERT INTO sessions (id, tenant_id, subject_id, expires_at, last_active_at, secret_hash)
SELECT gen_random_uuid(), tenant_id, subject_id, now() + interval '1 hour', now(), md5('late')
FROM users WHERE tenant_id = '01a140bf-2b2b-7e8f-a63a-c6c8b0f964d9' AND username = 'ada';" | pg
curl -sS -X DELETE -H "$A" "$T/doomed?confirm=doomed"; echo
curl -sS -o /dev/null -w '%{http_code}\n' -X PATCH -H "$A" -H 'content-type: application/json' \
-d '{"enabled":false}' "$T/doomed"
curl -sS -X DELETE -H "$A" "$T/doomed?confirm=doomed"; echo
for pass in 1 2 3 4 5; do
docker compose exec -T odudu node dist/main.js send-logouts 2>/dev/null
[ "$pass" = 5 ] || sleep 61
done
curl -sS -D - -X DELETE -H "$A" "$T/doomed?confirm=doomed"
curl -sS -H "$A" "$T/doomed"; echo
for p in .well-known/openid-configuration protocol/openid-connect/certs; do
printf '%-40s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' "$O/$p")"
done
pg < count-rows.sql
curl -sS -H "$A" "$T/system/audit?action=tenant.delete&resource_type=tenant&resource_id=01a140bf-2b2b-7e8f-a63a-c6c8b0f964d9" \
| python3 -c 'import json,sys;[print(json.dumps({k:i[k] for k in ("action","outcome","actor_name","resource_id","detail")})) for i in json.load(sys.stdin)["items"]]'
curl -sS -H "$A" "$T/system/audit?action=tenant.delete&outcome=refused&limit=1" \
| python3 -c 'import json,sys;[print(json.dumps({k:i[k] for k in ("action","outcome","actor_name","resource_id","detail")})) for i in json.load(sys.stdin)["items"]]'
table_name | n
-------------------------------+----
client_scope_assignments | 16
client_scopes | 8
role_composites | 8
roles | 8
audit_events | 6
authentication_executions | 4
client_oidc_config | 2
clients | 2
subjects | 2
authentication_sessions | 1
authorization_codes | 1
sessions | 1
signing_keys | 1
token_grants | 1
user_credentials | 1
users | 1
action_tokens | 0
backchannel_logout_deliveries | 0
client_assertion_jti | 0
client_origins | 0
client_registration_tokens | 0
client_scope_mappers | 0
client_scope_roles | 0
consent_scopes | 0
consents | 0
console_logins | 0
console_sessions | 0
email_outbox | 0
group_roles | 0
groups | 0
login_failures | 0
refresh_tokens | 0
subject_groups | 0
subject_roles | 0
tenant_smtp | 0
user_required_actions | 0
(36 rows)

{"type":"about:blank","title":"Error","status":400,"detail":"querystring must have required property 'confirm'","errors":[{"path":"confirm","message":"must have required property 'confirm'"}],"instance":"01a140bf-be87-77d4-877c-d365c631feba"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"confirm: must be the tenant’s own name, doomed","errors":[{"path":"confirm","message":"must be the tenant’s own name, doomed"}],"instance":"01a140bf-be92-7f53-9a9e-c3b3c55d3640"}
{"type":"about:blank","title":"Conflict","status":409,"detail":"the system tenant is where every cross-tenant administrator authenticates, and is never deleted","instance":"01a140bf-beab-7098-8d9e-d4fe29242035"}
{"type":"about:blank#tenant-enabled","title":"Conflict","status":409,"detail":"doomed is enabled: disable it first, which ends its sessions and tells their relying parties","instance":"01a140bf-bec2-7b3e-9c95-9051417c88ff"}
{"id":"01a140bf-2b2b-7e8f-a63a-c6c8b0f964d9","name":"doomed","display_name":null,"enabled":false,"created_at":"2026-10-15T18:07:12.683Z"}
{"action": "session.end_all", "actor_name": null, "detail": {"via": "tenant_disabled", "ended": 1, "remaining": 0}}
.well-known/openid-configuration 200
protocol/openid-connect/certs 200
protocol/openid-connect/token 404
['01a140bf-2b76-7cb8-a9e8-842a01ba082c']
INSERT 0 1
{"type":"about:blank#sessions-live","title":"Conflict","status":409,"detail":"sessions still live: 1; disable doomed again to end them and tell their relying parties","instance":"01a140bf-bfaf-719a-942f-8ee3a32b79f2"}
200
{"type":"about:blank#logout-deliveries-pending","title":"Conflict","status":409,"detail":"Back-Channel Logout Tokens still to be sent: 1; deleting the tenant would discard them","instance":"01a140bf-bfea-7fca-98f3-077111bac882"}
{"ran":true,"delivered":0,"failed":1}
{"ran":true,"delivered":0,"failed":1}
{"ran":true,"delivered":0,"failed":1}
{"ran":true,"delivered":0,"failed":1}
{"ran":true,"delivered":0,"failed":1}
HTTP/1.1 204 No Content
x-request-id: 01a140c3-83f3-7be8-87b8-8f16b0b7f002
cache-control: no-store
Date: Thu, 15 Oct 2026 18:11:57 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"type":"about:blank","title":"Unauthorized","status":401,"instance":"01a140c3-8418-758b-9e00-e7deb1aa2f25"}
.well-known/openid-configuration 404
protocol/openid-connect/certs 404
table_name | n
-------------------------------+---
action_tokens | 0
audit_events | 0
authentication_executions | 0
authentication_sessions | 0
authorization_codes | 0
backchannel_logout_deliveries | 0
client_assertion_jti | 0
client_oidc_config | 0
client_origins | 0
client_registration_tokens | 0
client_scope_assignments | 0
client_scope_mappers | 0
client_scope_roles | 0
client_scopes | 0
clients | 0
consent_scopes | 0
consents | 0
console_logins | 0
console_sessions | 0
email_outbox | 0
group_roles | 0
groups | 0
login_failures | 0
refresh_tokens | 0
role_composites | 0
roles | 0
sessions | 0
signing_keys | 0
subject_groups | 0
subject_roles | 0
subjects | 0
tenant_smtp | 0
token_grants | 0
user_credentials | 0
user_required_actions | 0
users | 0
(36 rows)

{"action": "tenant.delete", "outcome": "allowed", "actor_name": "ada", "resource_id": "01a140bf-2b2b-7e8f-a63a-c6c8b0f964d9", "detail": {"name": "doomed"}}
{"action": "tenant.delete", "outcome": "refused", "actor_name": "ada", "resource_id": "0199aa00-0000-7000-8000-000000000001", "detail": {"name": "system", "reason": "system_tenant_guarded"}}

The read of doomed afterwards answers 401 rather than 404: the router resolves the tenant a path names before anything else, and an unknown one is unauthenticated there, as it is for every route.