Concepts

Identity model

Application → Organization → Member — what a realm is, what a token carries, and who decides what a person may do.

Three levels, and every one of them is a real object at the identity provider. Read this page before anything else about authentication: nearly every question about "why did that token get refused" is answered by which level it named.

LevelWhat it isAt the identity providerHow the engine reads it
ApplicationA product built on this engine — DeepDesk, DeepDiligencea realmfrom the token's iss, which is <base>/realms/<realm>
OrganizationA customer inside that productan organization in the realmfrom the organization claim
MemberA person, in one or more organizationsa realm userfrom sub, resolved to a principal

The one sentence to remember: the identity provider proves who signed in and which organization they chose; the engine decides what they may do.

Everything else follows from that split:

  • iss selects the application, and the signature proves the realm signed it;
  • the organization claim selects the tenant, and the provider only emits it for a real membership;
  • authority inside that tenant is the engine's answer, held in its own record — never read from the token.

The identity provider is Keycloak, adopted on 2026-08-09 for one reason — a realm is a separate user namespace — and that is why the realm sits at the application level.

What a token actually carries

A tenant access token from this deployment looks like this. Every field below is load-bearing:

{
  "iss": "https://identity.example.com/realms/deeplinq",
  "aud": ["deeplinq", "account"],
  "sub": "ea4fc71f-c2c6-4d1c-b22b-cf6a8e62adae",
  "organization": ["acme"],
  "exp": 1754750000
}
  • iss names the application. There is nothing to look up and nothing to trust: the realm is in the URL, and the signature proves the realm signed it.
  • aud carries the realm name, put there by a protocol mapper the engine installs. Keycloak's default audience is account, which names nothing this deployment owns and would make every realm's tokens interchangeable at the audience check.
  • organization names the organization. Keycloak emits it only for one the holder is genuinely a member of — asking for one you do not belong to yields a null claim, never the requested name.
  • sub names the member.
  • exp is the expiry the engine enforces itself, offline. A token is refused after it without asking the provider anything.

A token carries no roles you can rely on. What a person may do comes from the engine, not from the token — see Who decides what a person may do. A groups claim may be present; tenant authorization ignores it.

Selecting the organization at sign-in

The organization claim is empty unless the sign-in asks for it. A person in two organizations who requests plain scope=openid gets a token naming neither, and the engine refuses it rather than guessing:

scope=openid                     → organization: null   → refused
scope=openid organization:acme   → organization: ["acme"]
scope=openid organization:*      → organization: ["acme","globex"]  → refused: ambiguous

Ask for one organization per token. organization:* returns every membership, and a token naming more than one resolves to none — picking the first would place someone in a tenant at random, which is worse than refusing.

The engine publishes the scopes a client must request, so no client has to know this dialect:

curl -s "$BASE_URL/.well-known/oauth-protected-resource"
# → { "authorization_servers": ["…/realms/deeplinq"],
#     "scopes_supported": ["openid","profile","offline_access","organization:*"], … }

Why a realm per application

The obvious alternative — one instance whose organizations all share one user namespace — makes a single fact impossible: the same person, at the same email address, as two unrelated accounts in two products. A realm does not share its namespace, which is why the realm is the application boundary here.

jane@gmail.com signs in to DeepDesk    ─┐
                                        ├─ one namespace → one account → one identity
jane@gmail.com signs in to DeepDiligence ┘

A Keycloak realm is a separate user namespace, so the same address is two independent people:

…/realms/deepdesk       jane@gmail.com  → user A
…/realms/deepdiligence  jane@gmail.com  → user B   (unrelated)

That is the whole reason for the migration, and it is why the application — not the organization — is the realm.

Ory was evaluated alongside Keycloak and eliminated on this exact point: Kratos has one identity pool per deployment, which reproduces the same wall.

The engine performs no membership check

iss and organization are both signed statements the identity provider only makes when they are true. Keycloak will not put an organization in a token for someone who is not a member of it — measured, not assumed.

So the engine reads them and trusts them. Re-deciding membership locally would add a second opinion that can disagree with the provider, and the provider is the one holding the memberships.

What the engine does check:

  • the signature, against the realm's JWKS, offline — no call on the request path;
  • iss, matched exactly against the application it resolved;
  • aud, which must carry this realm;
  • that the organization is bound to a Deeplinq organization. An unbound one is a 401; there is no auto-provisioning.

Who decides what a person may do

The engine. Roles live in principal_roles, keyed by (principal, organization).

This is not a stylistic choice. It is what a Keycloak role can and cannot say.

Keycloak signs two independent facts. It never asserts the pair. A token can say "this person is in Globex" and "this person has the role org-admin", but it has no way to say "this person is an administrator of Globex" — because a realm role belongs to the realm, not to an organization inside it.

Follow what that allows, one step at a time:

  1. Mallory belongs to two organizations: Acme and Globex.

  2. She is given the realm role org-admin — once, for Acme. Being realm-wide, it now follows her into every token she is issued in that realm.

  3. She signs in selecting Globex. Only the organization claim changes:

    scope=…organization:acme    →  organization=["acme"]    role claim: org-admin
    scope=…organization:globex  →  organization=["globex"]  role claim: org-admin
  4. An engine that trusted both claims would combine organization = Globex with an unrelated realm-wide org-admin and manufacture a permission nobody granted: administrator of Globex.

One word changed in her request. That is the escalation.

The role arrives in a claim named groups — Keycloak realm roles republished as a flat list, because the console needs one top-level claim to read. "Realm role" and what you see in groups are the same thing here.

So tenant authorization reads the engine's own record and ignores the claim. A role the token asserts and the engine did not grant earns nothing.

Two consequences worth knowing:

  1. The identity provider needs no role administration. It authenticates; it does not authorize.
  2. Federation becomes possible. A customer bringing their own Entra ID or Okta cannot reasonably be asked to model your product's roles inside their corporate directory, per customer. Their provider proves who signed in; the engine decides what they may do.

Platform authority (platform-admin, platform-billing) is never a tenant role: a database CHECK refuses to store one, and the read path strips it as well.

How an ordinary person gets in

Nobody grants org-member. Belonging to the organization is what makes you one, and the claim already proves belonging:

the provider says   organization = ["acme"]        →  org-member in Acme
the engine says     principal_roles: org-admin     →  and an administrator too

That is the same split one line down: membership is a fact the provider asserts about this organization, so it can be trusted; elevation is realm-wide in the provider's vocabulary, so it cannot.

So the path for a normal user is short:

  1. they exist in the application's realm — created there, invited, or federated from your customer's own identity provider;
  2. they are a member of the organization;
  3. they sign in selecting it (scope=… organization:acme);
  4. on first sight the engine enrols them as a principal and they hold org-member.

No operator action is required for step 4, and none is required at all unless you want to make them an administrator. The operator levers that do exist — re-binding an identity, and stopping someone immediately — are in Members.

Subjects and principals

The provider's sub names someone in its namespace. The engine resolves it to a principal — a UUID it assigns and owns — and everything belonging to a person keys on that: owned rows, dataset and project grants, team membership, spend limits, ledger rows, audit rows.

sub (identity provider)  →  principal (engine)  →  rows, grants, limits, audit

The split is what lets a user deleted and re-created at the provider keep their history: an operator re-binds the new sub to the existing principal in one write, instead of losing the rows or rewriting every one of them.

Send opaque, pairwise subject identifiers

Requirement for consuming applications. The sub your realm asserts — and any identifier you broker in from your own identity provider — must be opaque and pairwise: meaningless outside the pairing of your application and this engine, and different for the same person at a different relying party. OpenID Connect Core §8 (pairwise subject identifier types) is the reference shape.

sub: "8f14e45fceea167a5a36dedd4bea2543"   ✅ opaque, pairwise
sub: "jane.doe@acme.com"                  ❌ an email
sub: "acme-crm-00417"                     ❌ a join key into another system

Never an email address, a name, a phone number, or a customer reference that means something in another system. The same rule applies to any identifier you put in a resource_id, a dataset name, or a conversation title.

This is data minimisation, not erasure. An opaque identifier narrows what a breach, a log line, or a support transcript discloses, and it stops the engine's copies from becoming join keys into your other systems. It does not discharge your obligation to erase a person, and it does not put the data outside GDPR in the engine's hands — the engine attributes spend limits, usage and row ownership to that identifier on purpose, and the realm user's email and name live at the identity provider this deployment operates. What is deletable, what is retained, and why, is the engine's data-erasure position (docs/decisions/specs/2026-08-13-data-erasure-position.md).

Machine credentials

An organization's backend authenticates as a service-account client in its application's realm — one per organization, named dl-<org-uuid>-<slot>.

A machine token carries the same organization claim as a person's, pinned by a mapper on the client, so the engine has one tenant-resolution path rather than two. It carries no end user, so user-owned endpoints refuse it with a 400 naming the requirement.

The engine registers a machine credential under the service-account user's id, not the client's. A client_credentials token's sub is the service account user — a different UUID from the client's — and registering the wrong one produces a credential that authenticates perfectly and is refused as unregistered.

Platform operators

An operator managing the deployment itself is on neither tenant lane. They sign in against the platform realm, which holds operators and nothing else.

Authority follows the realm that granted the role, never the role's name: a tenant naming a role platform-admin inside its own realm confers nothing, because it was not granted in the console's realm.

platform realm    → operators                 → platform-admin
deeplinq realm    → customers of the product  → org-admin, org-member (engine-held)
master realm      → Keycloak's own admins     → never product users

An operator token names no organization — reach one explicitly:

Authorization: Bearer <operator access token>
X-Deeplinq-Admin-Org: <org-uuid>

A tenant token presenting that header is refused.

Where each fact is stored

FactHomeNotes
Which applications existapplications tableone row per realm; realm is unique
Which application an organization belongs toorgs.application_idNOT NULL, defaults to the deeplinq application
Which provider organization is oursthe organization's deeplinq_org_id attributethe reverse-adoption stamp — a name is an index, this is the proof
Which provider organization a tenant maps toorg_identity_bindingskeyed by the alias, which is what tokens carry
What a person may doprincipal_roles(principal_id, org_id, role), composite FK to principals
A machine credentialorg_machine_credentialssubject = the service-account user id

On this page