Skip to content
rw3iss Auth

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 byapp_code in the request bodyclient_id, whose row may name an app_code
Is the app resolved?Yes, before the user lookupYes, at token time only
Is the user looked up within the app’s pools?YesNo
Is user_apps membership required?Yes — or auto-grantNo — not checked at all
Auto-provisioning (linked apps, default org role)YesNo
Result of “user has no membership”403 user is not authorized for this appA 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:

PathWhen
Auto-grantapps.auto_grant_on_signup = true and the person registers or logs in for the first time
ExplicitPOST /admin/users/{userId}/apps/{appId}
JIT migrationA 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:

FieldEffect
default_organization_idNew users auto-join this org
default_role_codeThe 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_codesExtra 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:

SiteWhat 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 assertionSame — sets cg_app_code.
tokenByClientCredentialsRead 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.

GoalMechanismWorks over /auth/loginWorks over OIDC
These users are a different population — same email may be two peopleregistration_namespace + read_namespaces (user pools)✅ enforced❌ not applied
These users are the same population, but only some may enter this appauto_grant_on_signup: false + explicit user_apps grants✅ enforced (403)❌ not checked
Everyone may enter, and we just want the recordauto_grant_on_signup: true✅❌ no row is created
Different permissions inside one appservice_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 applicationSet 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_clients restricts which people may authorise it; the only person-level control is consent, which the user grants or withholds themselves.
  • trusted skips 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_audiences on m2m_clients is described as “mostly defensive” in its own migration — downstream services validate aud locally, and empty means the server default.
  • Self-service clients cannot be scoped to an app at all (app_code is hardcoded NULL). That is deliberate, but it does mean a self-registered application’s tokens carry no app scope and therefore the base permission set.