Applications
Create a product on this engine — its realm, its issuer, and the organizations inside it.
An application is a product built on this engine: DeepDesk, DeepDiligence, your own. It maps one-to-one onto an identity-provider realm, and that realm names the issuer every token from it carries.
One engine serves many applications. A person can hold an account in two of them under the same email address, as two unrelated identities — which is the whole point, and the reason the realm sits at this level rather than at the organization's. See Identity model.
Creating one
Console → Applications → New application.
Two fields:
- Name — what a human calls it (
DeepDesk). - Realm — lowercase letters, digits and hyphens (
deepdesk). It becomes part of every issuer URL this application ever mints and cannot be changed afterwards.
Or over the API:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/admin/applications" \
-d '{"realm":"deepdesk","name":"DeepDesk"}'{ "id": "…", "realm": "deepdesk", "name": "DeepDesk", "status": "active" }What that call actually does
The realm is created first, then the row. The order matters: a row naming a realm that does not exist is an application with an issuer nothing answers on, and the failure would surface later as a customer who cannot sign in. The reverse — a realm with no row — is invisible and gets adopted by the next attempt.
In the identity provider it converges:
- the realm, with organizations enabled and sign-in by email allowed;
- the
org-adminandorg-memberrealm roles, so the application is usable before its first customer exists; - the public
deeplinq-cliclient, with the device grant enabled, sodl auth loginworks against it.
Everything is idempotent: re-running adopts what is already there.
Creating an application creates a realm at your identity provider. A failed run can leave a realm behind with no row — harmless and adopted on retry, but worth knowing before you go looking for it.
An application that brings its own identity provider
Everything above describes an application this engine administers: it creates the realm, the roles and the client. An application can instead bring its own authorization server, in which case the engine creates nothing and only verifies the tokens it is shown.
Console → Applications → New application → Bring your own, or the same
route with management_mode:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/admin/applications" \
-d '{
"name": "Acme SaaS",
"management_mode": "external",
"issuer": "https://auth.acme.example",
"audience": "https://api.deeplinq.example",
"signing_algorithms": ["RS256"],
"claim_mapping": {
"version": 1,
"organization": {"claim": "organization",
"accepted_shapes": ["string"]},
"subject": {"claim": "sub"}
}
}'One route, not two: which mode applies is a property of the application, not a different kind of thing. No realm — asking for one is refused rather than ignored, because the engine administers no identity provider here and a realm name would name something that will never exist.
Claim values are names as the provider emits them, not paths. A namespaced
claim is written literally (https://auth.acme.example/org).
Backend identities
An external application's backend authenticates with a credential its own authorization server issued. The engine mints nothing and stores no secret — it stores the subject those tokens carry, so it can recognise them.
The application's page → Backend identities, or:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/admin/applications/$APPLICATION_ID/credentials" \
-d '{"subject":"<their service account sub>","client_id":"acme-backend"}'The subject must match the sub in their service-account token exactly, case
included. client_id is inventory only and never authorises anything; it must
be unique within the application, and two applications may use the same one —
which is what a product deployed twice on one engine, as separate staging and
production applications, actually needs.
This is the same route rotation uses, because both bring a credential into
existence; the application's mode decides which is legal. …/credentials/rotate
mints for a managed application and is refused for an external one, and this
call is refused for a managed one.
Registering the first backend identity also enables onboarding for that application. Registering a second — which is how the integrator rotates — never changes that setting again, so suspending onboarding during an incident is not undone by a routine rotation.
Two identities may be enabled at once so a rotation can overlap; a third is refused until one is retired.
The integrator's half of this — token claims, the signup call, and what stays theirs — is Bring your own identity provider.
Listing them
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" "$BASE_URL/v1/admin/applications"The console shows the same, with the issuer each realm produces — the value an integrator actually needs:
NAME REALM ISSUER STATUS
Deeplinq deeplinq …/realms/deeplinq active
DeepDesk deepdesk …/realms/deepdesk activeDeleted applications are hidden by default. ?status= widens the listing:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
"$BASE_URL/v1/admin/applications?status=deleted" # the recycle bin
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
"$BASE_URL/v1/admin/applications?status=all" # everythingDeleting and restoring
Deleting an application is reversible and erases nothing (ADR-0078). It
flips a status column — active → deleted — and the realm at the
identity provider is not touched. What changes is that the derived tenant
lane stops resolving that realm's issuer, so every token minted in it fails
lane selection.
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-X DELETE "$BASE_URL/v1/admin/applications/$APPLICATION_ID"{ "id": "…", "realm": "deepdesk", "name": "DeepDesk", "status": "deleted" }Undo it with POST /v1/admin/applications/$APPLICATION_ID/restore, which
answers {"status":"active"} and puts the issuer back in service.
Two refusals, both 409, both deliberate:
| Reason | When |
|---|---|
organizations_remain | Any organization still belongs to the application — the count is in the message. A deleted-but-unpurged organization still counts: it still holds data in that realm. |
default_application | Unconditionally on the seeded deeplinq realm, which every organization created before applications existed reads as its fallback. |
Creating an organization inside a deleted application is refused too, which is what closes the race: without it, a second request could populate a realm between the emptiness check and the deletion.
Purging
Deleting removes access; purging removes the realm. A purge is
irreversible (ADR-0078 Phase 2) — it drops the identity-provider realm and
every account in it, after re-asserting zero organisations remain. It only
runs on an application that is already deleted, and never on the default
application, regardless of status:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-X POST "$BASE_URL/v1/admin/applications/$APPLICATION_ID/purge"{ "id": "…", "realm": "deepdesk", "name": "DeepDesk", "status": "purging" }An active application is refused 409 not_deleted; the seeded deeplinq
realm is refused 409 default_application unconditionally, before its
status is even read. Calling purge again on an already-purging
application is idempotent — it re-drives a stalled workflow rather than
starting over — but only for as long as the application row exists, which
for an application is the first step of the workflow, not the last. See
Watching a purge.
To delete and purge in one call, pass confirm equal to the application's
exact current realm — not its display name, which two applications can
share:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-X DELETE "$BASE_URL/v1/admin/applications/$APPLICATION_ID?purge=true&confirm=deepdesk"{ "id": "…", "realm": "deepdesk", "name": "DeepDesk", "status": "purging" }A confirm that does not match exactly answers 400 confirmation_mismatch,
and the same organizations_remain/default_application refusals above
apply here too — delete --purge deletes before it purges, in the same call.
Watching a purge
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
"$BASE_URL/v1/admin/applications/$APPLICATION_ID/purge"{
"application_id": "…", "status": "purging", "generation": 1,
"stuck": "postgres",
"checkpoints": [
{
"store": "postgres", "status": "failed", "objects": 0,
"last_error": "organizations_remain: 1 organisation(s) appeared since the delete — the realm is untouched",
"updated_at": "2026-08-11T09:14:02Z"
}
]
}The application row is erased FIRST — deliberately inverted from an organization's purge — because deleting it inside a transaction that re-asserts zero organisations is what closes the creation race; only once that succeeds does the realm step have a proven-empty realm to drop.
Past the row deletion there is no purge route at all. Both
GET /v1/admin/applications/{id}/purge and POST …/{id}/purge resolve the
application row before anything else, so the moment step 1 lands they answer
404 — the same 404 an id that never existed answers. There is no status
to read and nothing to re-drive.
That window is the whole of what these two routes can do for an application:
they work while the row deletion is still refused, and they stop working the
instant it succeeds. The example above is that state — an organisation
appeared between the delete and the purge, so postgres reports failed and
the row is still there to ask about.
If the realm deletion fails after the row is gone, the engine writes
application.purge.failed naming identity_realm, retries the job up to five
times (≈6 minutes of backoff), and then discards it. Nothing in the API can
finish the job from there: the realm, with every user and session in that
product, is still standing and the only remedy is deleting it by hand at the
identity provider (the Keycloak admin console or its realm API). Watch for
application.purge.failed in the audit trail — it is the only signal you get.
application.purge.completed fires only once both stores report done, and
it carries the full receipt set, which is where you read a successful purge's
per-store evidence — not from GET .../purge, which by then 404s. An
application that has never been purged answers generation 0, an empty
checkpoint list, and "stuck": "postgres": nothing has reported because
nothing has run.
A deployment with no path to the identity provider refuses the purge outright,
before deleting anything: identity_realm records not_configured,
application.purge.failed names it, and the row — with both routes still
working — is left intact.
Purge is a mechanism, not a retention policy. Retention stays unbounded until an operator explicitly purges — no sweeper, no window, no configured clock. Only a platform-admin can call it.
The default application
Every deployment has one, seeded by the initial migration: realm deeplinq,
with a pinned id. It exists because orgs.application_id is NOT NULL and
defaults to it — without the row, the first organization anyone created would
violate a foreign key.
An organization created without naming an application lands there.
It is deliberately not the platform realm. That one holds operators.
Putting customers' people in the realm that administers the engine would make
one realm both the tenancy boundary and the administrative boundary, which are
different questions.
Moving an organization between applications
There is no such action, and that is not an oversight.
An organization lives inside its application's realm: its people, its machine credential and its identity binding all point at that realm's issuer. Moving it means re-provisioning into the new realm — new machine credentials, new secrets to distribute, and every person re-created or federated afresh. It is a migration, not a setting, and it is deliberately not one click.
orgs.application_id is a NOT NULL foreign key with ON DELETE RESTRICT,
and the API states the same rule as organizations_remain (see Deleting and
restoring): deleting an application that still holds
organizations is refused rather than cascading, because cascading would orphan
tenants from the realm that issues their tokens.
What this looks like at the identity provider
master Keycloak's own administration — never product users
platform this deployment's operators
deeplinq the default application
├── organization acme a customer
└── organization globex a customer
deepdesk another application
└── organization acme a DIFFERENT customer, possibly the same companyThe acme in deeplinq and the acme in deepdesk are unrelated objects in
unrelated namespaces. So are their members, even at the same email address.