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_CLAIMwithoutOIDC_ADMIN_CLAIM_VALUE, or the reverse); - an audience set with the accepted-clients list empty;
OIDC_JOIN_MODE=groupwith noOIDC_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 anyGATEWAY_LOCAL_AUTH__PASSWORD_RESET__*— with an error naming what replaced it.
Warnings, logged at every start, never fatal:
OIDC_LINK_BY_EMAILon: 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_EMAILset whileOIDC_KINDis notauthelia: 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_IDnames none of them: likely a misnamed machine client, and thecode_agentsrestriction 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_adminaccounts 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
inviteorgroupmode 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. byokneedsconsole, because bring-your-own-key is managed in the console. A user's effectivebyokis off without it (as reported on/v1/meand/api/me, and at request time: theirmy/...models are kept and work again when console access returns). The Groups screen warns when a group hasbyokon andconsoleoff.GET /v1/meandGET /api/mereportcapabilities.console; a client that does not see the field treats it as allowed.GATEWAY_CHAT__URLis 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_idagainstOIDC_MACHINE_CLIENT_ID,opencode-enrollmentby default) from a person withoutcode_agentsis refused on every/v1route —/v1/meincluded, whose body still carriescapabilitiesso the client can tell why — with 403agents_not_allowed, unless the person holdslocal_connectors: then the machine client is admitted to/v1/meonly (so Cerea can read which ofcode_agentsandlocal_connectorsthe person has and decide per machine mode), and every other/v1route stays refused, so a connectors-only machine never reaches a model. A person with neither is refused as above; Cerea gates/codeon 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 lostapi_keysis refused with 403api_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(orlocal_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 whenOIDC_MACHINE_CLIENT_IDnames a client that is not in a non-emptyOIDC_ACCEPTED_CLIENTS. Granting is always fine, andapi_keysnever 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 hascode_agentsoff 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
/v1path 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'sgroup_syncisevery_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 (sourceoidc) 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
usersand one other group bills that other group by default. With several other groups and no default chosen, they billusersuntil they choose, rather than being refused. - With the bundled Authelia it is the same
usersgroup 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_EMAILgrants admin when three things hold: the IdP assertedemail_verifiedas the literal booleantrue(the string"true"does not count), the normalised address is ASCII, and it is in the list.OIDC_ADMIN_CLAIMplusOIDC_ADMIN_CLAIM_VALUEgrants 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.
Link by email¶
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_verifiedis the literal booleantrue; - 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
.envto 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:
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) |