Skip to content

Identity: who users are and who administers

For operators

How a deployment decides who may sign in, who is an administrator, and how to get back in when nobody can; the provider's own registration is Identity: the OIDC provider.

People sign in to the console, and to the chat when it is deployed, through OpenID Connect only. There is no password sign-in in the gateway itself. Use the bundled Authelia that deploy/ (and the deploy kit) can run beside the gateway, or bring your own issuer. This page is everything the gateway decides after the provider has answered.

One provider, set in the environment

One provider at a time, named by the OIDC_* variables of the deployment's .env. The gateway takes its settings from there at every start and rewrites its provider row to match, so the console shows the provider read-only. What the console edits is the rest of identity: the identity policy (who may join, which claim names their groups, what an IdP group means here) and the users themselves (the Users page).

If you need several sources of users, federate them in your own IdP (Keycloak, Authentik and the like, deployed separately) and point the stack at it. If you need multi-factor authentication, that is also the IdP's job: the bundled Authelia is password-only, so switch the stack to an external IdP that does MFA instead.

Variable Gateway setting Meaning
OIDC_ISSUER GATEWAY_OIDC__ISSUER the issuer URL, exactly as the discovery document's issuer states it
OIDC_INTERNAL_BASE_URL GATEWAY_OIDC__INTERNAL_BASE_URL where the gateway reaches the issuer from inside the network (bundled Authelia: http://authelia:9091/authelia); empty means the issuer URL itself
OIDC_KIND GATEWAY_OIDC__KIND authelia for the bundled one, generic otherwise (entra, okta, authentik, google are recognised kinds)
OIDC_CONSOLE_CLIENT_ID, _SECRET GATEWAY_OIDC__CLIENT_ID, _CLIENT_SECRET the console's client
OIDC_CHAT_CLIENT_ID, _SECRET GATEWAY_OIDC__CHAT_CLIENT_ID, _CHAT_CLIENT_SECRET the chat's client, when the chat is deployed; the id also tells the gateway which accepted client may announce chat sign-ins. The gateway uses the secret only to introspect the chat's tokens at an IdP whose access tokens are opaque (GitLab)
OIDC_MACHINE_CLIENT_ID GATEWAY_OIDC__MACHINE_CLIENT_ID galopin's public client, opencode-enrollment by default: the device flow for agent machines, and the client the code_agents gate keys on (Chat-only people)
OIDC_AUDIENCE GATEWAY_OIDC__ACCESS_TOKEN_AUDIENCE the audience /v1 requires on OIDC access tokens; default pystino-api
OIDC_GROUPS_CLAIM GATEWAY_OIDC__GROUPS_CLAIM which claim carries group membership (below)
OIDC_GROUP_SYNC GATEWAY_OIDC__GROUP_SYNC how often the groups claim applies: every_login, first_login or never
OIDC_GROUP_IMPORT GATEWAY_OIDC__GROUP_IMPORT what a claimed group name no group carries yet does: manual (default) lists it in the console to import, auto creates it (Groups)
OIDC_GROUP_ALLOWLIST GATEWAY_OIDC__GROUP_ALLOWLIST advanced, optional: only these group names are considered at all; comma-separated or a JSON list, empty for every name
OIDC_JOIN_MODE GATEWAY_OIDC__JOIN_MODE who may join: open (default), approval, invite or group (Who may join); the console's Identity page can set it instead, and a decision there wins
OIDC_REQUIRED_GROUPS GATEWAY_OIDC__REQUIRED_GROUPS join_mode: group only: the local group names that confer access, comma-separated
OIDC_GROUP_FALLBACK GATEWAY_OIDC__GROUP_FALLBACK join_mode: group only: what a stranger without a required group gets, refuse (default) or approval
ACCESS_CODE_AGENTS GATEWAY_ACCESS__CODE_AGENTS the default code_agents flag new groups are created with: on (default) or off; the console's Settings page decides once it saves, and the old everyone/groups spellings still parse for one release (Chat-only people)
(none; set by the Cerea kit) GATEWAY_CHAT__ENABLED true on a full chat + Pystino deployment, default false. The code_agents and local_connectors capabilities below exist only when it is true: otherwise they are hidden in the console, refused by the admin API, not reported on /v1/me, and the machine-client gate does not apply (deployment)
ACCESS_LOCAL_CONNECTORS GATEWAY_ACCESS__LOCAL_CONNECTORS the default local_connectors flag new groups are created with: on (default) or off; the console's Settings page decides once it saves (Chat-only people)
ACCESS_CONSOLE GATEWAY_ACCESS__CONSOLE the default console flag new groups are created with: on (default) or off; the console's Settings page decides once it saves (Console access). GATEWAY_CHAT__URL (optional; the Cerea kit sets <PUBLIC_ORIGIN>/chat) is the chat's address: a member without console access is redirected to its /settings/usage
ACCESS_BYOK GATEWAY_ACCESS__BYOK the default byok flag new groups are created with: on or off (default off); the console's Settings page decides once it saves (Bring your own key). ACCESS_BYOK_PRIVATE_NETWORKS (GATEWAY_ACCESS__BYOK_PRIVATE_NETWORKS, default false) lifts the network guard on members' own providers' URLs
ACCESS_API_KEYS GATEWAY_ACCESS__API_KEYS the default api_keys flag new groups are created with: on (default) or off; the console's Settings page decides once it saves, and the old everyone/groups spellings still parse for one release (Chat-only people)
OIDC_DEFAULT_GROUP GATEWAY_OIDC__DEFAULT_GROUP the group everyone joins once, users by default; empty turns it off (the default group)
OIDC_LOGOUT_URL GATEWAY_OIDC__LOGOUT_URL for an issuer without end_session_endpoint: where the browser is sent to end the IdP session; {redirect} is where to land
OIDC_SCOPES_JSON GATEWAY_OIDC__SCOPES the scopes the console asks for, as a JSON list; ["openid","profile","email","groups"] unless the issuer does not offer one (the groups scope). The deploy kit writes it from the issuer's discovery; deploy/compose.yaml here sets the default literally
OIDC_ADMIN_EMAIL GATEWAY_OIDC__ADMIN_EMAILS comma-separated addresses that confer admin (below)
OIDC_ADMIN_CLAIM, OIDC_ADMIN_CLAIM_VALUE GATEWAY_OIDC__ADMIN_CLAIM, GATEWAY_OIDC__ADMIN_CLAIM_VALUES a claim path and the comma-separated values that confer admin (below)
OIDC_LINK_BY_EMAIL GATEWAY_OIDC__LINK_BY_EMAIL a first sign-in attaches to the existing account with the same verified email; off by default (below)
OIDC_ACCEPTED_CLIENTS GATEWAY_OIDC__ACCEPTED_CLIENTS the clients a bearer token's azp may name; empty derives the list from the console, chat and machine client ids
PYSTINO_BOOTSTRAP_ADMIN_EMAIL GATEWAY_BOOTSTRAP_ADMIN_EMAIL the bundled Authelia's first administrator; honoured once per deployment

On top of the audience check, a bearer token whose azp (or client_id) is present must name one of the accepted clients — the console, the chat and the machine client. A token minted for any other purpose by any other client is refused.

What the gateway checks at startup

The compose deployments run the gateway in production mode, where these refuse to start:

  • an external IdP with neither an admin email list nor a claim pair:
an external identity provider needs an admin rule: set OIDC_ADMIN_EMAIL,
or OIDC_ADMIN_CLAIM with OIDC_ADMIN_CLAIM_VALUE, in .env
  • half a claim pair (OIDC_ADMIN_CLAIM without OIDC_ADMIN_CLAIM_VALUE, or the reverse);
  • an audience set with the accepted-clients list empty;
  • OIDC_JOIN_MODE=group with no OIDC_REQUIRED_GROUPS: a group gate with no groups refuses everyone who is not an administrator, including the account that would fix it;
  • a removed variable still set — GATEWAY_OIDC__LINK_LOCAL_BY_EMAIL=true, GATEWAY_LOCAL_AUTH__ENABLED=true, GATEWAY_IDP__ENABLED=true, GATEWAY_OIDC__ADMIN_GROUPS, or any GATEWAY_LOCAL_AUTH__PASSWORD_RESET__* — with an error naming what replaced it.

Warnings, logged at every start, never fatal:

  • OIDC_LINK_BY_EMAIL on: a first sign-in with a verified email will attach to the existing non-admin account with that address; turn it off when the transition is done.
  • PYSTINO_BOOTSTRAP_ADMIN_EMAIL set while OIDC_KIND is not authelia: the bootstrap is the bundled IdP's only, so the variable is ignored there.
  • an audience set without GATEWAY_OIDC__CHAT_CLIENT_ID: every chat sign-in is refused until the chat's client id is set.
  • an allowlist that accepts clients besides the console's and the chat's while OIDC_MACHINE_CLIENT_ID names none of them: likely a misnamed machine client, and the code_agents restriction will not reach machine tokens until that variable names a client the list accepts (Chat-only people).

The provider row follows the environment

At every start the gateway rewrites the provider row the environment names: issuer, client, scopes, groups claim, group sync, admin rule and logout URL are overwritten from .env, in one transaction under a database advisory lock. When the issuer changed, the previous row is renamed previous-<date>-<id> and disabled — never deleted — and a fresh row takes the name default, so the callback path /auth/callback/default never changes. Identities are stored by issuer and subject, so switching back re-enables the earlier row with everyone's identities intact.

Removing a previous provider nobody used

A disabled previous row stays on the Settings screen so users who signed in there can be linked back. When nobody ever did (a provider you tried and abandoned), Remove on the row clears it; the row's directory entries and sync history go with it, and the removal is audited (idp.remove). The console offers the button only where the gateway says the row is removable, and the API (DELETE /api/admin/identity-providers/<id>) answers 409 for anything else:

  • an enabled row, which is the environment's and can never be removed here;
  • a row with users: any account keyed on its issuer, or any linked identity at it, even a disabled one, so they can still be linked back;
  • a row whose directory logins are bound to accounts.

Removing a row loses nothing you cannot get back from .env: pointing the deployment at that issuer again creates the row afresh.

Every other row is disabled, and the start logs one line per disabled row with its user count. The console keeps editing what the environment does not own: the identity policy and its group mappings, and directory sync on the active row (an Authelia users-file pull, and SCIM 2.0 pushes at /scim/v2/<provider>; a first sync is a dry run until an administrator confirms it).

Who may join

One answer governs what a stranger's first sign-in does, for any identity provider. The console's Identity page sets it as one radio choice, or the environment states it (OIDC_JOIN_MODE; the console's decision wins, within the ten seconds a saved policy takes to reach every worker):

join_mode A stranger — an identity not yet known here — who signs in… Existing users
open gets an active account at once unaffected
approval gets an account created disabled, shown on the Users page under Awaiting approval unaffected
invite is refused unless pre-invited (below) or created by the directory unaffected
group gets an active account only if one of their claimed group names maps to one of OIDC_REQUIRED_GROUPS; otherwise they are refused, or held for approval (OIDC_GROUP_FALLBACK) gated on every sign-in and every /v1 call: they must hold a required group

Cross-cutting, the same in every mode:

  • Pre-invited users always get in. An invitation (Users → Invite) creates a pending account with no login; the person's first sign-in claims it by verified email, keeping the groups the administrator pre-assigned, and the claim consumes the invitation — the account stops reading as Invited. An invitation expires after 14 days — re-inviting (the invite-text button) re-issues it.
  • Administrators are never refused by the gate. is_admin accounts and anyone matching an env admin rule get in whatever the mode says, so a misconfigured group mode cannot lock every administrator out. Break-glass is unchanged.
  • A group name from a token reaches a local group only through a mapping. The one rule, everywhere: model access, billing, the default-group logic and the join gate all resolve claim names through the console's group mappings and nothing else. An unmapped name resolves to nothing, even when a local group carries the same name — IdP group namespaces are user-writable, so a same-name claim is not a vouch. Unmapped names are what the Groups page lists, to be mapped by hand. Memberships the console granted, and everything a directory (SCIM or pull) pushed, count as before. The residual risk: a mapping trusts an IdP name. If the real IdP group is deleted and someone else creates a group of the same name there, the mapping trusts the new one — remove the mapping when an IdP group is deleted.
  • The gate never disables an account. Losing the last required group refuses access dynamically; regaining it restores access. Taking an account away stays the directory sync's job.
  • The refusal texts name no group, and Cerea shows them as they arrive: a stranger in invite or group mode is told to ask an administrator, and an approval-queue account is told its request is waiting, which reads differently from plain "disabled".

On /v1 — the chat's API calls and galopin — the group gate answers too: an authenticated caller outside the required groups is refused with 403 outside_access_groups, never the 401 that would make Cerea revoke a machine for a loss that regaining the group reverses. Cerea closes the machine link (4404) without revoking the device, and it reconnects by itself once the person is back in a required group. API keys meet the same gate; administrators are exempt there as everywhere.

One group-mapping surface. The Groups page's Groups from your identity provider panel lists every unmapped IdP name with one decision per row: Map (the IdP name then grants the group it is mapped to), or Ignore. The Map dialog asks what the name means here: a new group with the same name, a new group under another name, or an existing group. Current mappings are listed with their Unmap. OIDC_GROUP_ALLOWLIST stays env-only: it narrows which claimed names are considered at all and vouches for nothing by itself. OIDC_GROUP_IMPORT keeps its two modes (below).

Where the invite link points: the gateway builds it from its own host — the chat at /chat when one is deployed, else the console at /console. A satellite whose chat runs on a different host than its gateway will hand invitees the gateway's address; sign in there once and the invitation is claimed regardless. (Known limit; re-issue the text and point users at the right host by hand for now.)

Chat-only people

A deployment may want some users to use the chat and nothing else — no coding agents, no API keys, no console — while others keep everything. The capabilities (ADR 0096, amended) answer that, and each is only a per-group flag: a person holds one when they are an administrator, or hold an active membership (any source — a directory grant counts the same as a hand grant) in an active group with the flag on. There is no "everyone | groups" mode that gates access any more.

Capability What it gates
code_agents the /code panel and the machine links that serve it (galopin's enrolment and the agent session; the plain-API-key opencode script is an API-key use, not this)
api_keys minting API keys, and using one already minted
local_connectors a connectors-only machine: galopin serving the person's own local MCP servers to the chat. It signs in with the same machine client as an agent machine but gets no model access
byok adding their own providers and models (their own API key) and using them privately (Bring your own key). Unlike the others it needs no chat, and new groups default to off (ACCESS_BYOK). It needs console (below)
console signing in to the gateway's web console (keys, usage, limits, requests). New groups default to on (ACCESS_CONSOLE)

Console access

console lets a deployment give some users the chat and nothing else. A user whose groups all lack it can still sign in (nothing about the identity changes), but opening any console route (including right after signing in) answers an HTTP redirect (307) to the chat's usage page, <GATEWAY_CHAT__URL>/settings/usage. Only a person with a valid session who is neither an administrator nor holds the capability is redirected; visitors without a session still reach the console's sign-in, and /console/login is never redirected. Where the deployment does not say where its chat is (GATEWAY_CHAT__URL empty, a standalone Pystino) the console loads and shows a plain page saying the account is managed through the chat. The cookie-backed management API (/api/me/*, apart from GET /api/me itself, which tells the console what to show) answers 403 console_not_allowed. Their /v1 access is untouched: the chat reads quotas from GET /v1/pystino/usage, so a chat-only user sees theirs in the chat's Settings → Usage and never needs the console.

  • Administrators always have the console: they administer through it, so no group flag can lock one out. The Groups screen says so beside the checkbox.
  • Existing groups are on (migration 0078), and so are new ones unless the deployment says otherwise (ACCESS_CONSOLE=off, or Settings): nothing changes until an administrator turns it off, per group or with the bulk action.
  • byok needs console, because bring-your-own-key is managed in the console. A user's effective byok is off without it (as reported on /v1/me and /api/me, and at request time: their my/... models are kept and work again when console access returns). The Groups screen warns when a group has byok on and console off.
  • GET /v1/me and GET /api/me report capabilities.console; a client that does not see the field treats it as allowed. GATEWAY_CHAT__URL is where the chat is (the Cerea kit sets it to <PUBLIC_ORIGIN>/chat); without it there is no redirect and no link.

local_connectors is managed exactly like code_agents: a per-group flag, a new-group default (ACCESS_LOCAL_CONNECTORS, on by default), the same bulk levers on Settings and a checkbox on Groups. Migration 0071 switched it on for every existing group: the feature is new, "new groups default on" is the rule, and no behaviour in force is widened (a person gains only the right to run MCP servers on their own computer). Administrators who want it off turn it off per group or for all groups.

A person in no group holds neither capability. That is the edge case the flag model allows; in practice the default group covers everyone — OIDC_DEFAULT_GROUP (users) is granted once at first sign-in and its flags start on — so a fresh person holds both until an administrator says otherwise, and only an administrator's removal of that membership (or of the group) can leave someone with neither.

The deployment setting is the default flag new groups are created with, nothing more. ACCESS_CODE_AGENTS and ACCESS_API_KEYS (GATEWAY_ACCESS__CODE_AGENTS, GATEWAY_ACCESS__API_KEYS) keep their names and take on (the default) or off; the old everyone and groups spellings still parse for one release, each with a deprecation warning. Every group created from now on gets its flags from that default — a console New group, the Map dialog's new group, OIDC_GROUP_IMPORT=auto's auto-created groups, and the groups a directory (SCIM or pull) creates. The console's Settings page shows and saves the default with the same row-over-env fold as before (a decision there wins, within the ten seconds a saved policy takes to reach every worker), and migration 0060 recorded the folded defaults in an appended carry-forward row (reason migration 0060: capabilities became per-group), so an upgraded deployment's new groups start exactly as usable as its existing ones were.

Settings also holds the bulk levers, one pair per capability: Turn on for all groups and Turn off for all groups. Each first shows a confirm with the preview — how many active, non-admin users gain or lose the capability — and then applies to every group in one transaction, audited once as group.capability_bulk with the capability, the value and the count. On Groups, each group's two capability checkboxes are always live and show the stored flag; a flip takes effect at the next decision, because the bearer path re-reads memberships on every request.

The effective capability for a person is reported on GET /v1/me (and on GET /api/me for the console's own key page), so Cerea can tell a chat-only person why /code is not there for them. The Users page badges a person holding neither as Chat only.

Cross-cutting, the same as the join gate:

  • Administrators are exempt from both capabilities, so a wrong flag cannot lock the users who fix it out of the surfaces that fix it.
  • A lost capability is refused dynamically, never disabled and never deleted. A machine-client token (matched on azp/client_id against OIDC_MACHINE_CLIENT_ID, opencode-enrollment by default) from a person without code_agents is refused on every /v1 route — /v1/me included, whose body still carries capabilities so the client can tell why — with 403 agents_not_allowed, unless the person holds local_connectors: then the machine client is admitted to /v1/me only (so Cerea can read which of code_agents and local_connectors the person has and decide per machine mode), and every other /v1 route stays refused, so a connectors-only machine never reaches a model. A person with neither is refused as above; Cerea gates /code on that report and closes the link (4404) without revoking the device, and it reconnects by itself once a group grants the flag. (Chat tokens are unaffected: the chat keeps working for a chat-only person.) A key whose owner lost api_keys is refused with 403 api_keys_not_allowed, at the mint and at key resolution alike; the key is refused, not deleted, so regaining the capability restores the same key, no re-mint needed. The install script explains that refusal instead of telling the person to mint a fresh key they are not allowed to.
  • Switching code_agents (or local_connectors) off is refused while the machine client cannot match it. A machine token is refused before the gate runs, so a restriction the machine client never matches would fail silently open for machines while still applying to the panel. A per-group toggle to off, and the bulk turn-off alike, answer 400 naming the setting to fix when OIDC_MACHINE_CLIENT_ID names a client that is not in a non-empty OIDC_ACCEPTED_CLIENTS. Granting is always fine, and api_keys never trips this. For flags that predate that refusal — or an environment changed under them — the policy resolver logs an ERROR once per distinct state while any active group has code_agents off and the machine client is not accepted, and the startup warning above flags the likely misnamed machine client (the GitLab-uid shape). The 0.7.x startup refusal for an env-baseline mismatch is gone: there is no mode left to read.
  • The /v1 path stays query-free and write-free. The effective capability is computed from memberships authentication already loaded, and neither refusal writes anything — an audit write on a path a machine link re-checks every minute would be a write per minute per machine.

Groups

How the provider's answer about groups is applied is OIDC_GROUP_SYNC: every_login, first_login (once, administered in the console afterwards) or never. IdP group names mean whatever the console's group mappings say they mean: the mappings live in the provisioning policy, not on the provider. Bundled users carry only the users group in their users file; everything else is console groups. Which claim carries the groups, and in which token it appears, depends on the provider: see the per-provider table. A string claim is one group; it is never split on separators.

Mapping the provider's groups

A directory reports every group a person is in, and most of them mean nothing here: with GitLab, one person's first sign-in used to create 67 groups. OIDC_GROUP_IMPORT decides what a claimed name that nothing maps does at a sign-in:

  • manual (the default): nothing is created. The name is recorded per provider, and the console's Groups page lists it under Groups from your identity provider, searchable, with how many users carry it and when it was last seen. One decision per row: Map — the dialog asks whether the name means a new group with the same name, a new group under another name, or an existing group — and the mapping is a new row on the identity policy's history. The users recorded as carrying the name get the group straight away when the provider's group_sync is every_login; otherwise at their next sign-in, and the console says which. Unmapping goes at each person's next sign-in. Ignore stops listing it (Show dismissed lists those, to map or restore).
  • auto: each new claimed name is mapped to a local group of the same name — the group is created (source oidc) and the self-mapping appended, so the name keeps resolving after the token that named it is gone. A name an existing group carries maps onto it. A name the group gate requires is never auto-mapped: the console maps those, because auto-minting the mapping would let a token vouch for itself.

A name equal to an existing local group is listed like any other unmapped name — under the rule it resolves to nothing until it is mapped, which is the difference from before this rule. An upgrade restates what claims were feeding as self-mappings (migration 0058), so the same token gives the same memberships after the upgrade: only new names stop becoming groups. The default for an upgraded deployment is manual, unless it had set the older GATEWAY_OIDC__AUTO_CREATE_GROUPS, which still applies (true is auto, false is manual; set together with GROUP_IMPORT, the two must agree). Names are recorded only at a sign-in (and by a directory sync run), never on a plain /v1 request; a name already recorded for a person does not count as a change on that path, so the request stays write-free. The bundled Authelia is not affected: its provider never applies the groups claim.

The default group

Everyone joins one group once, at their first sign-in, with any identity provider: OIDC_DEFAULT_GROUP, users unless set. A person who was here before the setting existed joins at their next sign-in. It exists so that a new person can use the chat before anything of theirs is imported: a request needs a group to bill, and this is it. It grants no model by itself: restrict models by group, or keep them public; a public model is what a person with only users can use.

  • The membership is the console's (manual), so no directory's sync removes it, and it is granted once: an administrator who removes someone from it is not overruled at their next sign-in. A membership the person already had in that group (from a directory claim of the same name) is kept and becomes the console's.
  • It is not a choice anyone made, so it never makes a person "multi-group": someone in users and one other group bills that other group by default. With several other groups and no default chosen, they bill users until they choose, rather than being refused.
  • With the bundled Authelia it is the same users group its accounts always had: no second group, and nobody's membership changes.
  • An empty value turns it off.

What the default billing group decides — admission, the model list, ledger attribution and group quotas — and how a request may bill another of the person's groups with x-bill-to, is on Billing groups.

Administrators

Administrators come from two environment rules, re-checked at every sign-in:

  • OIDC_ADMIN_EMAIL grants admin when three things hold: the IdP asserted email_verified as the literal boolean true (the string "true" does not count), the normalised address is ASCII, and it is in the list.
  • OIDC_ADMIN_CLAIM plus OIDC_ADMIN_CLAIM_VALUE grants admin when the claim carries one of the values, compared both as the IdP spells them and after the group mappings.

Either rule grants; a person is revoked only when neither matches.

Rules only revoke what rules granted. Every administrator carries a provenance: env when a rule granted it, manual when an administrator's decision in the console, pystino admin grant, the bootstrap or break-glass did. A rule match never converts a manual administrator into a rule one, and a rule mismatch never revokes a manual one. Removing an address from OIDC_ADMIN_EMAIL takes admin away from the users that rule granted at their next sign-in, and at the next gateway start a sweep drops the email-rule grants whose address is no longer on the list. The sweep never touches the claim rule's grants: with no fresh claims at startup there is nothing to re-check them against, so they lapse at that person's next sign-in.

Rules never make an administrator of someone who has not signed in yet: the admin flag moves through the rules, an administrator's decision in the console on someone who has signed in, the once-per-deployment bootstrap (PYSTINO_BOOTSTRAP_ADMIN_EMAIL, the bundled Authelia's first administrator), or break-glass.

The last-admin guard

The last active administrator is never revoked, by any path: the console's edit and delete, pystino admin revoke, a rule at sign-in, the startup sweep, or a directory sync. "Active" means signed in, an administrator and enabled; the placeholder account an invitation creates before someone's first sign-in does not count, so a deployment cannot lock itself out by counting a person who has never arrived.

The check is serialized. Under PostgreSQL every guard takes a transaction-scoped advisory lock (pg_advisory_xact_lock) before it asks whether another active administrator exists, so two guards running at once (two administrators demoting each other, or a console demotion racing a rule-driven revocation at a sign-in) queue up instead of both seeing "another admin exists" and both committing. The rule never depends on READ COMMITTED interleaving. A refused revocation from a sign-in is audited as admin.refused_last.

With OIDC_LINK_BY_EMAIL=true (off by default; a warning prints at every start while it is on), a first sign-in from an unknown identity attaches to the one existing account whose stored address matches, when all of these hold:

  • the sign-in's email_verified is the literal boolean true;
  • the address normalises to ASCII;
  • exactly one account holds that address, and its address was itself verified or set by an administrator;
  • the account is active, is not an administrator, is not still a placeholder awaiting its first sign-in, and has no identity at this issuer yet.

Links happen only at the two sign-in doors — the console callback and the chat's announce — never on a plain /v1 call. Every link is audited; every refusal is logged with a numbered reason. An administrator who switches providers moves through a merge or break-glass instead, never a link.

Merging and deleting accounts

Merge into… (a person's edit panel on the Users page) moves one account's identities, memberships, model access, keys, spend history and rules onto another, in one transaction. An identity both accounts hold at one issuer is dropped, and named in the preview. The confirmation asks you to type the source's address, and optionally a reason (recorded in the audit log when given). The merge is irreversible: the undo is the backup taken before it.

Delete on the Users page erases the person everywhere, chat included: the gateway's rows, the bundled login, and the chat's records (conversations, files, projects, assistants, knowledge bases, memories, skills, connectors, settings, sessions and machine links). The preview names what is removed and what is shared with other users, whose confirmation tick acknowledges it. Spend history stays in the ledger, anonymised. If the chat cannot be reached, the erasure is recorded as pending and retried with a backoff (from 30 seconds up to an hour) until it confirms; pystino erasure list and pystino erasure retry <id> show and force it (below).

The audit trail

Every identity action lands in identity_events, an append-only table the database itself protects: triggers refuse UPDATE and DELETE. It records user, admin, password, link, merge, break-glass, provider re-seed and removal, and bootstrap actions, with the actor, the target, and the reason when one was given. It is kept forever; archive it with SQL when it grows.

Read it at GET /api/admin/identity-events?user_id=…&action=…&before=…, newest first; user_id matches either side of an action. The Users page shows the same trail per person (Activity).

Break-glass

When nobody can administer, or the identity provider itself is gone. Two things have to be true before it can work, and they explain the two steps:

  • the gateway must be pointed at the bundled Authelia, because break-glass creates or re-enables a bundled login and prints its password. If the deployment is on an external provider that is now unreachable, that means switching .env to the bundled one first; and
  • you need shell access to the host, which already implies the database.

With the deploy kit, one command does both:

./configure --break-glass --admin-email ops@example.org --reason "IdP outage"

It rewrites .env to the bundled Authelia (your external settings survive in the .env.bak-<timestamp> it makes, and nowhere else), starts PostgreSQL and runs the bootstrap, runs pystino break-glass in a one-off gateway container, brings the stack up, and prints the sign-in URL, the login and a one-time password, once. Each person with an agent machine has to re-enroll it afterwards, because its tokens name the old issuer; tell them before you switch. In a Pystino-only deployment, edit deploy/.env to the bundled Authelia yourself, then run the command that step wraps:

docker compose run --rm --no-deps gateway pystino break-glass \
  --email you@example.org --reason "…"

--reason is optional; it is recorded in the audit log when given. The command finds or creates the account with that email, creates or re-enables their bundled login, grants admin (as manual, so no rule can take it back) and prints a one-time password once, to stdout, never to logs. Users, memberships, data and chat records are untouched. When the email names several accounts it lists them and takes --user-id.

To go back to your external provider afterwards, switch .env back with the values from the backup file; the accounts and identities that provider held resolve again.

If you suspect the deployment itself was compromised, rotate GATEWAY_SESSION_SECRET (openssl rand -hex 32, then restart): it signs the console's sessions, so rotating it signs everyone out, including anyone holding a stolen cookie. It is deliberately separate from GATEWAY_SECRET_KEY, which encrypts provider credentials and must not be rotated casually.

For someone who can already sign in, docker compose exec gateway pystino admin grant you@example.org grants admin without touching anything else.

The command line

Run inside the gateway container — docker compose exec gateway pystino …, or docker compose run --rm --no-deps gateway pystino … when the gateway is not up:

Command What it does
pystino break-glass --email <address> [--reason "<why>"] [--login <name>] recover admin access when nobody can administer: find or create the account with that email, create or re-enable their bundled login, grant admin, and print a one-time password — once, to stdout, never to logs. Users, memberships, data and chat records are untouched. When the email names several accounts, it lists them and takes --user-id.
pystino admin grant\|revoke <email> [--issuer <url>] the ordinary grant or revoke for someone who can already sign in (the account must exist, and an email held at several issuers needs --issuer); a revoke that would leave no active administrator is refused
pystino idp check [--discovery-only\|--device\|--token <jwt>] [--issuer <url>] [--internal-base-url <url>] [--client-id <id>] probe the configured issuer: discovery and JWKS, and — with --device, which prints a URL to sign in at, or --token, which validates a pasted access token — the sub, the email and email_verified with its JSON type, the groups claim, the audience and client, and whether each admin rule would grant; exits non-zero when discovery fails, the audience is wrong, or no rule would grant
pystino erasure list the pending chat erasures: id, user, attempts, next attempt, last error
pystino erasure retry <id> force one pending erasure's next attempt now, ignoring its backoff
pystino email export-env the mail configuration in force, as SMTP_* KEY=VALUE lines, password included — for moving console-era mail settings into the environment
pystino quota health each quota rule's counter against the ledger, as JSON; read-only (Quota health and reconcile)