Users, apps and tenancy
“Which users belong to which application?” has two different answers in this server, depending on how the person signs in. Getting them confused is the most expensive mistake available on this page, so the short version first:
POST /auth/login (first-party) | GET /oauth/authorize → POST /oauth/token (OIDC) | |
|---|---|---|
| Identified by | app_code in the request body | client_id, whose row may name an app_code |
| Is the app resolved? | Yes, before the user lookup | Yes, at token time only |
| Is the user looked up within the app’s pools? | Yes | No |
Is user_apps membership required? | Yes — or auto-grant | No — not checked at all |
| Auto-provisioning (linked apps, default org role) | Yes | No |
| Result of “user has no membership” | 403 user is not authorized for this app | A working token |
The four moving parts
1. apps — the first-party application registry
One row per consumer, identified by a stable code. Not the same
thing as an OAuth client; see
Application types.
CREATE TABLE apps ( id UUID PRIMARY KEY, code VARCHAR(100) NOT NULL UNIQUE, name VARCHAR(200) NOT NULL, description TEXT, allowed_redirect_urls TEXT[] NOT NULL DEFAULT '{}', service_codes TEXT[] NOT NULL DEFAULT '{}', auto_grant_on_signup BOOLEAN NOT NULL DEFAULT false, status VARCHAR(20) NOT NULL DEFAULT 'active', metadata JSONB NOT NULL DEFAULT '{}', …);Later migrations add frontend_url (015), registration_namespace +
read_namespaces (017/018), webhooks (019), and
default_role_code + linked_app_codes (020).
Full operator guide: App registration.
2. user_apps — the membership itself
CREATE TABLE user_apps ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), granted_by UUID REFERENCES users(id) ON DELETE SET NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', -- active | revoked PRIMARY KEY (user_id, app_id));This is the answer to “does this person belong to this application”.
granted_by is NULL for a self/auto grant, so an audit can tell a
deliberate admin decision from an automatic one.
Three ways a row appears:
| Path | When |
|---|---|
| Auto-grant | apps.auto_grant_on_signup = true and the person registers or logs in for the first time |
| Explicit | POST /admin/users/{userId}/apps/{appId} |
| JIT migration | A legacy user is migrated in through the app |
All three converge on the same helper — ensureAppEntitlements — so
every path delivers an identical entitlement set. It is idempotent and
best-effort: failures are logged rather than propagated, because
provisioning must never block an otherwise-valid login.
Revoking (DELETE /admin/users/{userId}/apps/{appId}) sets status
to revoked and bumps the user’s token version, so an outstanding access
token for that app stops validating on the next request. Sessions and
refresh tokens carry an app_id (migration 007) precisely so revoking app
A leaves the person’s app B session alone.
3. Namespaces / user pools — the segregation boundary
The strongest separation available. Email uniqueness is per pool
(UNIQUE (namespace, email)), so the same address can be a genuinely
distinct person in two pools.
users.namespace— the home pool. Default'default'.apps.registration_namespace— the WRITE pool: where users registered through this app are created.apps.read_namespaces— the READ pools: which pools this app authenticates against. Login matches the default pool plus this set, so a person already in a sibling app’s pool is reused, not duplicated.user_namespaces— tag rows, so a pool-scoped app can address exactly its cohort.
Login resolves the app before looking up the user, precisely so the
lookup can be scoped: userRepo.GetByEmailInNamespaces(ctx, email, readNamespaces). A person whose home pool is not in the app’s read set
does not exist to that app — the strongest form of “this user is not
yours” the server offers.
Full model, admin endpoints and worked examples: User pools / namespaces.
4. Organizations and roles — authorisation within an app
Orthogonal to membership. An app may declare:
| Field | Effect |
|---|---|
default_organization_id | New users auto-join this org |
default_role_code | The org role for that membership. Must be org-scoped — the platform roles system_admin / super_admin / base_user are refused and fall back to org_member |
linked_app_codes | Extra apps to grant alongside this one. Unknown codes are skipped with a warning, never fatal |
default_role_code is validated at config time (POST/PATCH /admin/apps rejects a role that is set without an org, or that is not
org-scoped), so a typo surfaces immediately instead of silently degrading
at login.
A client may override role_code and linked_app_codes per request
in the login/register body. role_code is re-validated server-side as an
org-scoped role, so a client can never escalate to a platform role.
What app_code actually does
It appears in two unrelated places, and they do different amounts of work.
On an apps row — it is the identity
apps.code is what the first-party flow resolves. Everything above hangs
off it.
On an oauth_clients row — it is a label
oauth_clients.app_code is a nullable TEXT column with an index
(oauth_clients_app_idx). Every place it is read:
| Site | What it does |
|---|---|
Token() → IssueTokensForUser(userID, appCode) | If the app exists and is active: namespace tags are reconciled, then IsUserAuthorized must pass or the app must set auto_grant_on_signup — otherwise the exchange fails with access_denied (403). On success the token carries the app’s app_id / app_code claims and the permission union for its services. If the app does not exist the lookup fails silently and the token is issued without app claims. |
Token() → NewIDToken(AppCode: …) | Sets the cg_app_code claim, ungated by any scope. |
| FedCM assertion | Same — sets cg_app_code. |
tokenByClientCredentials | Read and then explicitly discarded (_ = appCode). A service token is scoped by client + scopes, not by app. |
That is the complete list. It is not consulted to decide whether the
person may sign in, and it does not scope the user lookup to the app’s
pools. An app_code naming an app the user has no membership in produces
a perfectly good token.
It is admin-only to set, for a reason worth keeping: app_code decides
which app and permission set the minted token is scoped to, so a
self-service registrant could otherwise scope their own client’s tokens to
somebody else’s application. It is absent from the self-service DTO
entirely — printing a field a caller cannot change reads as an invitation
to try.
How an operator actually segregates users
Given the above, here is what works today, strongest first.
| Goal | Mechanism | Works over /auth/login | Works over OIDC |
|---|---|---|---|
| These users are a different population — same email may be two people | registration_namespace + read_namespaces (user pools) | ✅ enforced | ❌ not applied |
| These users are the same population, but only some may enter this app | auto_grant_on_signup: false + explicit user_apps grants | ✅ enforced (403) | ❌ not checked |
| Everyone may enter, and we just want the record | auto_grant_on_signup: true | ✅ | ❌ no row is created |
| Different permissions inside one app | service_codes + roles; default_organization_id + default_role_code | ✅ | Partial — base roles + the app’s permission union are attached; org context is not |
| An org’s members map onto an application | Set the app’s default_organization_id; members are provisioned with default_role_code on first contact | ✅ | ❌ not provisioned |
Claims: what actually lands in a token
Worth stating, because discovery advertises more than the OIDC path emits.
First-party access token (/auth/login, /auth/refresh, and the
access_token from /oauth/token) — HS256:
app_id, app_code, uid, email, names, org_id/org_slug,
roles, permissions, tv, plus the standard claims. See
App registration §4.
OIDC id_token — RS256. cg_app_code is set when the client has one.
Where this model is thin
Stated plainly so nobody has to re-derive it from the code:
- There is no per-client user allow-list. Nothing on
oauth_clientsrestricts which people may authorise it; the only person-level control is consent, which the user grants or withholds themselves. trustedskips consent, so a trusted client has no person-level control at all. That is why it is admin-only and never available through self-service.allowed_audiencesonm2m_clientsis described as “mostly defensive” in its own migration — downstream services validateaudlocally, and empty means the server default.- Self-service clients cannot be scoped to an app at all (
app_codeis hardcodedNULL). That is deliberate, but it does mean a self-registered application’s tokens carry no app scope and therefore the base permission set.
Read next
- App registration — the canonical first-party onboarding guide.
- User pools / namespaces — the full pool model and its admin API.
- Application types — the two client registries.
- Client properties →
app_code