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.
| Level | What it is | At the identity provider | How the engine reads it |
|---|---|---|---|
| Application | A product built on this engine — DeepDesk, DeepDiligence | a realm | from the token's iss, which is <base>/realms/<realm> |
| Organization | A customer inside that product | an organization in the realm | from the organization claim |
| Member | A person, in one or more organizations | a realm user | from 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:
issselects the application, and the signature proves the realm signed it;- the
organizationclaim 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
}issnames 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.audcarries the realm name, put there by a protocol mapper the engine installs. Keycloak's default audience isaccount, which names nothing this deployment owns and would make every realm's tokens interchangeable at the audience check.organizationnames 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.subnames the member.expis 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: ambiguousAsk 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:
-
Mallory belongs to two organizations: Acme and Globex.
-
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. -
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 -
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:
- The identity provider needs no role administration. It authenticates; it does not authorize.
- 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 tooThat 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:
- they exist in the application's realm — created there, invited, or federated from your customer's own identity provider;
- they are a member of the organization;
- they sign in selecting it (
scope=… organization:acme); - 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, auditThe 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 systemNever 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 usersAn 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
| Fact | Home | Notes |
|---|---|---|
| Which applications exist | applications table | one row per realm; realm is unique |
| Which application an organization belongs to | orgs.application_id | NOT NULL, defaults to the deeplinq application |
| Which provider organization is ours | the organization's deeplinq_org_id attribute | the reverse-adoption stamp — a name is an index, this is the proof |
| Which provider organization a tenant maps to | org_identity_bindings | keyed by the alias, which is what tokens carry |
| What a person may do | principal_roles | (principal_id, org_id, role), composite FK to principals |
| A machine credential | org_machine_credentials | subject = the service-account user id |