Administration

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:

  1. the realm, with organizations enabled and sign-in by email allowed;
  2. the org-admin and org-member realm roles, so the application is usable before its first customer exists;
  3. the public deeplinq-cli client, with the device grant enabled, so dl auth login works 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         active

Deleted 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"       # everything

Deleting and restoring

Deleting an application is reversible and erases nothing (ADR-0078). It flips a status column — activedeleted — 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:

ReasonWhen
organizations_remainAny 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_applicationUnconditionally 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 company

The acme in deeplinq and the acme in deepdesk are unrelated objects in unrelated namespaces. So are their members, even at the same email address.

On this page