Identity and authentication
Included from Basic tieridentity:builtinidentity:sso
The identity plane authenticates every API request — tokens first, then password credentials against the local store only; directory and federated identities sign in through their provider connection's own login leg — resolves the caller's effective groups through one shared resolver, and manages the local user lifecycle. Provider identities are read-only: OpsChain never writes to an identity provider — a directory user's row here is a shadow account record only.
The principal
Every authenticated request carries a principal: the users row (id,
username — effectively case-insensitive — and auth_provider, local or
a provider method string such as ldap:<code>/oidc:<code>), the
effective groups, a break_glass marker (which changes
nothing about authorisation), a must_rotate_password confinement marker,
and the token the request arrived with, if any. The machine identity
kinds — service_account rows, the user-managed automation principals
of (opschain docs cli/admin-service-accounts), and
agent rows, the enrolled agents' svc-agent-* principals — have no login lane by construction: they never ride the
credential chain, and the session and api-key mints refuse them by name.
An agent row's only credential is the agent channel's keypair/session
handshake; the row carries a fleet cross-link (agent: {project, code, member_id}) on the user listings, filters under the open-set
provider=agent, is managed only through the fleet verbs (never the user
lifecycle or the service-account verbs), and reads dormant once its
agent is revoked or forgotten. Machine identities exist as governed
actors and run-as targets, taking their authority from policy assignments
and group membership like any identity (and are never approvers).
Password login is LOCAL-only
Credential logins (basic auth, and the v2 AuthService.Login the CLI and
GUI use) verify against the local store aloneremoved
the legacy LDAP chain lane together with the ldap.* settings, so no API
request ever triggers a directory bind. Usernames are
whitespace-stripped and lowercased. Directory and federated identities
sign in through their connection's login leg instead (POST /auth/login/{code} for the ldap kind — see Federated sign-in below);
presenting a directory identity's credentials to the local lane answers a
named 401: username "<name>" belongs to a directory identity (provider <auth_provider>): password login is local-only — sign in through its directory connection's login leg (POST /auth/login/<code>; opschain login --provider <code>), or mint a native api key for automation (AuthService CreateAPIKey). An unknown username keeps the bare
credential failure — the named refusal exists only for rows a provider
actually holds.
The safety property carried over from the chain era, now structural: a
directory user's shadow row carries an empty encrypted_password, so no
state of any directory can ever let a directory identity authenticate
against the local store — the two lanes share no
code path at all.
Collision defence (on under the hardened posture): a local login
whose username is also held by a provider-managed shadow row proceeds —
password login resolves local rows only — but emits
warn:auth:provider_collision; and creating a local user whose username
shadows an existing provider identity is refused outright with a 422.
Same username in both providers
A local account sharing its username with a directory identity (allowed
outside the hardened collision defence) is a supported edge case, not
the recommended break-glass pattern — that is a dedicated local-only
account with its own name (see Break-glass accounts). The owner's ruling
(2026-10-15): "Local admin users and service accounts may only be local
users. True break-glass users have nothing to do with LDAP." Many
same-name local rows in existing estates are ghost artifacts (being
fixed) rather than deliberate mirrors. Where the pair exists it is one
name, two credentials, two lanes: the directory password signs in
through the connection's login leg, the local password through the
local lane (emitting warn:auth:provider_collision under collision
defence). On the admin
surface the pair resolves per verb: the local-only write verbs (update,
reset_password) target the local row — the only row they could ever write —
so the shadow-account 422 fires only when the sole match is a
directory/service row; disable/enable accept ANY provider's row —
local, ldap, federated and service-account alike (the disabled marker is OpsChain-local
shadow state, and a re-admitted directory leaver is re-enabled
deliberately through exactly this verb) — on v2
(UserService.DisableUser/EnableUser/BulkSetUserLifecycle, opschain docs api-v2 "User lifecycle writes"). All the lifecycle write verbs sit
on the step-up verb floor:
disable and enable, single and bulk, refuse a session without a live
elevation — see "The verb floor" below. The v1 route resolves a bare pair
name to the local row with ?provider=<value> selecting a provider's row; the read and group-membership verbs refuse
the ambiguous bare name (422 listing the matching providers) until
?provider= — an open auth_provider match (local, ldap:<code>,
oidc:<code>, …opened the earlier closed set) — selects one.
opschain whoami and GET /api/me carry auth_provider, so a session's identity is
always tellable apart. GET /api/me additionally carries two
session-identity booleans (absent on every other user read):
break_glass — the account's break-glass flag — and directory_shadowed —
true when a local session's username is also held by a directory shadow
row (the collision fact above, answered per session; always false when no
enabled ldap-kind connection exists) — which the GUI renders as the profile provider chip and
the amber break-glass banner.
Policy assignments are per-identity: an assignment binds a
policy to exactly one users row, never to every provider's account of a
name. The per-username expansion was benign while a shared name meant a
deliberate local/LDAP mirror, but federated sign-in made the
colliding username attacker-controlled — a public-provider login equal to
a granted username would have inherited that user's policies at first
sign-in. Now a federated identity whose username collides with a granted
account inherits zero assignments (the collision still warns:
warn:auth:provider_collision). Migration 00058 preserved the grants that
were effective at upgrade time by expanding each user-scoped assignment to
one row per identity whose username matched then — deliberately-shared
same-named pairs kept their authority; identities created later inherit
nothing and are assigned explicitly. Assignment writes accept a user_id
beside username; a username held by accounts of more than one provider
is refused as ambiguous ('<username>' names accounts of more than one provider (<providers>) — assignments are per-identity, so pass user_id to pick one), and assigning an account a policy it already holds answers a
named 422 ('<username>' already holds this policy), never a database
constraint error. Authorisation writes also invalidate the shared decision
snapshot, so a new grant decides on the next request (peer clusters
refresh within the snapshot max age, 5 minutes). Group-conferred authority
is unchanged: provider groups are snapshots on the identity's own row,
and local group membership is per-account, so neither
assignments nor groups cross the provider boundary. A mirror that must
share authority needs its own assignment (or its own membership in a local
group of that name).
Ghost rows are hidden, not gone: a ghost is a artifact row — auth_provider local,
empty credential (login-inert), a same-named directory (ldap) row beside
it, and no authentication token ever minted — identified by that shape
alone (no schema change, no deletion). Ghosts are excluded by default from the
user listings (GET /api/admin/users, GET /api/users,
GET /api/ui_support/users, and the v2 UserService.ListUsers — so GUI
pickers never offer them); passing include_ghosts=true (the v2 read's
include_ghosts field) restores them for pre-cleanup inspection, with the
admin listing marking each restored row ghost: true (the GUI badges it).
Hiding is display/listing only: the rows and every FK that points at them
stay untouched, and a ghost's own policy assignments and run-as authority
are unaffected (assignments are per-identity, so they were always the
ghost row's own). A durable
FK-repointing merge migration is planned for a later release.
Local
bcrypt verification against the users row; a disabled row or an empty hash never authenticates. Blank usernames and passwords are refused.
The retired LDAP chain lane, and the migration
The legacy chain lane and its ldap.* settings (the stored subtree, the
config.yaml ldap: block and the OPSCHAIN_LDAP_* environment overlay)
were removed completely by The migration
runs automatically:
- The boot seed. On the first boot after the upgrade, the retired
triplet is resolved one last time exactly as the old server would have
(stored < file < env, bind password on its own ladder) and written as
one
kind: ldapidentity provider connection (codeldap) — enabled iff the old chain had ldap enabled,require_tlscarried fromenable_ssl(a plaintext legacy configuration seeds with the explicit lab opt-out). The storedldap.*block is stripped in the same pass; the boot notice names what was seeded and tells you to delete the config-file block and environment variables (they are seed input only now). The seed never binds to the directory — the operator's first action is the connection's test verb. - Lazy re-key (the default). Each legacy
auth_provider = 'ldap'row re-keys at its owner's next sign-in through the login leg: the verified identity links to the EXISTING row matched by username among un-linked legacy rows — preserving the user id, and with it every per-identity assignment, local group membership and audit association — andauth_providerrewrites toldap:<code>. Every re-key is audited (api:auth:identity_rekeyed). - Eager re-key (the admin sweep).
IdentityProviderService. SweepIdentityProviderresolves every un-linked legacy row in one service-bind directory read, and reports what it could not resolve. The same verb imports directory users ahead of their first sign-in (the successor of the old mirror refresh's pre-provisioning) and detects leavers — one mechanism, three outcomes.
Failure modes, honestly: a directory unreachable at upgrade is
harmless — the connection seeds, the test verb fails loudly, users re-key
at their next successful sign-in. A user renamed in the directory
between upgrade and first sign-in matches no legacy row and mints a
fresh identity; the old row goes dormant (surfaces it) with its
grants stranded — run the eager sweep at upgrade to close that window.
Deleted entries never re-key and read as dormant. Historical audit
events, sessions and auth_provider values recorded before the cutover
keep their ldap strings; nothing rewrites history.
Groups: one shared resolver
A principal's effective groups are the provider-group snapshots (for
provider identities — namespaced <code>:<group> plus whatever the
mapping entries grant; the ldap kind rides the same
snapshots) ∪ the local memberships (local_group_members rows),
deduplicated. The same resolver serves interactive login, run-as
resolution and firing-time RBAC, so "who you are at login" and "who you
are when automation assumes you" can never diverge. A failed snapshot
half resolves fail-safe — the principal proceeds with shrunken authority
— and the shrinkage is made loud with a warn:auth:group_lookup_failed
event on the run-as path.
Snapshots are captured at interactive sign-in and read as a pure DB read,
so a user — including a superuser — authenticates whether or not the
provider is reachable, and the directory is never on the
authenticated-request path. The retired ldap_directory_entries mirror
and its refresh (POST /api/ui_support/ldap_refresh,
GroupService.RefreshDirectory) are gone: the group
catalog (GroupService.ListGroups) serves each connection's distinct
snapshot names with the connection key as the row's source, and
pre-provisioning directory users ahead of their first sign-in is the
per-connection sweep verb (IdentityProviderService.SweepIdentityProvider),
which imports administrable, credential-less shadow identities keyed by
directory entry id — group memberships, policy assignments and disable
all work before the first login, which then reuses the same row.
Tokens
Bearer credentials are the v2 session lane's (below): the signed
session access token and the opaque native api key. Basic auth is
honoured only while the enable_basic_auth stored setting is on
(basic authentication is disabled otherwise). Any bearer failure —
malformed, bad signature, expired, revoked, no row — is one
indistinguishable refusal.
The v1 token lane is retired: every /api/tokens/* route answers the standard 404, and the
upstream-compatible HS256 JWTs signed with token.secret_key no longer
authenticate — however well signed, whatever their expiry. Every user
re-authenticates once after the upgrade. Surviving v1 api_key rows in
authentication_tokens are non-authenticating; at boot the server emits
one warn:auth:v1_api_keys_retired event naming each (owner,
description, created, last used, expiry) plus a count-only startup
notice, so operators re-mint each key on the native lane
(AuthService.CreateAPIKey). Disabling a user still deletes their v1
rows (hygiene: a disabled account's keys leave the re-mint report) and
revokes every session and native api key; a password reset revokes their
sessions.
A bearer class never minted through the AuthService: the
step-scoped token (ocst_…), which the engine injects into
every dispatched step's environment so action code can call the API as
the run's initiating principal — resolved through the same user
directory as run-as delegation, so a disabled or break-glass identity
refuses. It is valid only while its step attempt's lease is live
(revoked at finalisation, expired within one lease TTL otherwise),
audience-bound to the customer API surface, and attributed in the
request log as token kind step. See opschain docs runner-executors,
"The execution environment".
The v2 session lane (AuthService)
The native successor to the retired /api/tokens/* bootstrap routes: the v2 AuthService
(POST /api/v2/opschain.api.v2.AuthService/<Verb>) authenticates the
credential chain into session rows (auth_sessions). It is the only
lane that mints bearer credentials — the v1 routes were removed at the
GUI cutover, with no deprecation window (a pre-GA
privilege, not the post-GA convention).
- Every session records its authn method and strength
(
password/ldap/oidc:<code>/github:<code>; strengthnoneat establishment,totpafter a platform-factor step-up,idp_assertedafter an IdP re-authentication — see "MFA factors and step-up" below), which are minted into the access token'samr/acrclaims and stamped on theapi:auth:login/api:auth:session_refreshedaudit events from day one. - The access token is signed on a dedicated stored signing key —
never
token.secret_key— and carries a validatedaudclaim (api, the vocabulary shared with step tokens). Default TTL 15 minutes (a storedtoken.access_expiryoverrides it). Verification re-reads the session row, so revocation is immediate, estate-wide. - The refresh token is opaque (
ocrt_…) and hashed at rest, rotated on every use; a replayed predecessor revokes the whole session (warn:auth:refresh_reuse). Idle expirytoken.refresh_expiry(7d default), absolute boundtoken.session_absolute_expiry(30d default). - Native api keys (
ocak_…) are opaque and hashed at rest with an explicit expiry (≤ 1 year), minted/listed/revoked on the AuthService verbs — from the CLI:opschain tokens create|list|revokeunder the stored login (the value prints exactly once, at the mint). - Every session records where it came from: the client address it was
established from (
source_address), theUser-Agentit was established with (first seen, never rewritten) and the client address of its latest refresh (last_client_address) — resolved behind any trusted reverse proxy (server.trusted_proxies,opschain docs config), so a user recognises their own devices in the sessions list and an incident reading ties a session to where it was last used. Every audited event a session causes names it back (actor.session_id,opschain docs events). - Sessions are concurrent by default; users list and revoke their own,
and the admin determination lists and revokes any user's (
ListSessionstakes a user reference — id or username — andRevokeSessionany session id; a denied caller reads not-found, so other users' session state stays unknowable). From the CLI:opschain admin sessions list [user]andopschain admin sessions revoke <session-id>under your stored login (opschain docs cli/admin-sessions). Disabling a user revokes every session and native api key immediately. Logout revokes server-side and clears the session cookie. - Browser callers opt into the HttpOnly Secure SameSite=Strict
opschain_refreshcookie (scoped to the AuthService procedures,token.enable_cookiespermitting) with anX-Requested-WithCSRF check on cookie-sourced refreshes; the CLI lane passes tokens in the body. Secure follows the browser's TLS edge — terminated at the server or declared by a fronting proxy withX-Forwarded-Proto: https; only plain-HTTP development setups omit it. - The quiesce split holds verb-for-verb:
Refreshpasses a quiesced estate,Loginrefuses.
All session state — rows, hashes, the signing key — lives in the shared
database: no replica holds process-local session state, so every cluster
verifies and revokes identically and a failover neither resurrects nor
orphans a session. See opschain docs api-v2, "The session lane".
MFA factors and step-up
The factor registry and the step-up verb floor.
Factors. Each user may enrol second factors on the AuthService:
EnrolTOTPFactor mints a pending RFC 6238 TOTP factor — the base32
secret and the otpauth:// provisioning URI (the enrolment QR payload)
are returned exactly once; the secret is encrypted at rest and no read
surface ever serves it back — and ActivateTOTPFactor proves the
authenticator app holds it (verify-to-activate), minting ten one-time
recovery codes (returned once, hashed at rest, each consumed on
use). ListFactors and RemoveFactor manage the registry. The kind set
is open: WebAuthn/passkeys are the planned fast-follow on this same
registry. Factors are optional per user — but required in
practice for the floor verbs below. Two self-protection rules: enrolling
a second factor and removing an active factor each require a
live elevation, so a stolen bearer can neither plant its own factor nor
strip one; the first enrolment is the bootstrap and stays open. A TOTP
code verifies with ±1 step of clock skew and is accepted at most
once, estate-wide; failed proofs ride the login throttle's backoff.
Elevation. Elevate (a TOTP code from an active factor, or an
unspent recovery code) stamps the session elevated for the
settings-backed window — mfa.elevation_window, minutes, default 15 —
and raises its authn strength to totp. The window is non-sliding: every floor verb inside it passes, nothing renews it;
re-elevate when it lapses. Sessions signed in through an idp-mode OIDC
instance elevate at the IdP instead:
GET /auth/login/{code}?step_up=<session id> re-authorizes with
prompt=login and max_age=0, and the callback validates the fresh
id_token's auth_time, confirms the identity maps to the session's own
user, and elevates that session (strength idp_asserted) — never a new
one. A platform-mode instance (GitHub always: re-running its OAuth
flow proves browser-session possession, not presence, and is never
step-up) elevates with a platform factor like local/LDAP
users. Like Refresh, Elevate and the step-up leg pass a quiesced
estate — the floor verbs that resume it need them.
The verb floor:
attestation writes, break-glass, halt/resume, grant/policy/profile
writes, licence apply and the user-lifecycle writes (disable and enable,
single and bulk — a bulk disable is an estate-lockout primitive, an
enable reverses containment decisions) require a stepped-up session.
Enforced today on every surface that
exists in this stack: platform halt/resume (POST /api/platform/halt,
/resume), licence apply/remove (v2 UploadLicence, v1 PUT /api/licence, DELETE /api/licence/{id}), the authorisation-policy/
assignment/rule writes (v2 AuthorisationPolicyService writes, the v1
authz-config writes) and the user-lifecycle writes (v2
DisableUser/EnableUser/BulkSetUserLifecycle, v1 PATCH /api/admin/users/{username}/disable|enable). The attestation-record and change-control-profile
surfaces do not exist yet; their verbs adopt the same check when they
land. A floor verb without a live elevation refuses with the typed
ERROR_REASON_STEP_UP_REQUIRED detail naming the mechanism (platform
| idp), or ERROR_REASON_MFA_ENROLMENT_REQUIRED when the caller has
no factor to elevate with (the GUI walks them through enrolment first).
Exemptions: break-glass principals (the fire door's
posture stands), the agent lane (an agent's identity
strength is its keypair and trust grants, never a human ceremony) and
sessionless machine credentials (native api keys, the dev static
admin token): the floor binds interactive sessions, which are the only
credentials carrying step-up state. Every elevation and every refusal —
expiry included — is an audit event.
Step-up by policy (the three-layer composition): beyond the floor,
customers attach their own step-up demands to arbitrary API surfaces by
setting require_step_up on an authorisation rule (opschain admin authz rules create --require-step-up, packs:security-governance). Step-up then
composes three layers, monotonically — the immovable verb floor, matched
rules' bits, and per-object flags (approval policies, transport grants);
policy only ever ADDS requirements, never weakens the floor. A matched
rule's bit binds the write-shaped actions (update, delete, execute) it
allows; reads join only when the rule also sets step_up_on_read. On the
v2 API the demand rides the same per-request rule matching that answers
404-on-deny, so any v2 write under a demanding rule's path refuses without
a live elevation — the same typed ERROR_REASON_STEP_UP_REQUIRED (or
ERROR_REASON_MFA_ENROLMENT_REQUIRED) refusal, naming the matching rule
(rule_id, rule_name, rule_path). The floor's exemptions carry over
(break-glass, sessionless machine credentials), superusers keep their
standing whole-fabric rule bypass, and every rule-driven refusal and
elevation pass-through is audited. With the pack lapsed the stored bits
are preserved but not enforced, and the estate is told loudly — a
throttled warn:licence:feature_refused event, once per
process-interval, never per request.
Federated sign-in (OIDC, GitHub and LDAP identity providers)
Federated providers join the chain as peers: a
security administrator registers an identity provider connection per
IdP instance — code (the fixed path segment), kind (oidc, github
or ldap; the set stays open at the schema), display name, issuer (OIDC)
or instance host (GitHub; empty means github.com), client id, client
secret (encrypted at rest, write-only: reads serve client_secret_set,
never the value), an optional username_claim (default
preferred_username, falling back to email) and groups_claim (default
groups) on the OIDC kind, scopes (defaults openid email profile /
read:user user:email read:org), a step_up mode (idp | platform,
enforced by the step-up floor — hard-wired to platform for the github
kind, because GitHub has no step-up signal; see "MFA
factors and step-up"), a display_order (the sign-in surface rank,
edited beside the display name: the login page renders enabled
connections by display_order ascending, code ascending — the
per-connection replacement for the retired auth_strategy/
auth.providers kind-ordering list login-surface amendment),
and enabled. The registry is the v2
IdentityProviderService (CRUD + a test verb that probes discovery
and the JWKS, or the GitHub API /meta read), every procedure behind
the admin determination; connection lifecycle and test calls are audited
(api:identity_providers:*), never carrying a secret. Registering a
GitHub host other than github.com (GitHub Enterprise Server) requires
the Enterprise licence (not GitHub
product tier: GHEC/EMU orgs on github.com stay Basic-reachable).
Identity entitlements. Within the
identity capability the licence split is identity:builtin — the
packaged built-in providers as plain identity: GitHub on github.com,
Google (https://accounts.google.com) and Microsoft Entra ID on your
tenant's issuer, https://login.microsoftonline.com/<tenant id>/v2.0 —
the built-in Entra provider requires your directory (tenant) id
(admin identity providers create … --tenant-id <guid> derives the
issuer), and a tenant-derived issuer in that form is what classifies an
Entra connection as built-in. The multi-tenant common endpoint is
not supported: its discovery document names the literal {tenantid}
template as its issuer, which the strict issuer check refuses, and it
would admit every Microsoft tenant. identity:builtin ships in the Basic
defaults and is baseline-unioned onto every licence — versus
identity:sso — any other OIDC issuer (your own IdP, your own
Google Workspace tenant, Okta, Keycloak) and every group→role mapping
write; Enterprise-weighted. Connection creates (and issuer changes) check the
classification's feature; the refusal names the feature and the effective
tier (opschain docs entitlements). The lapse posture is polite and
fails closed on enforcement only: existing connections, listings and
mapping deletion keep working, new registrations and mapping creates
refuse with the licence message, and local users, the ldap connection
kind (registering and signing in against a directory
survives a lapse exactly like local login; its mapping entries follow
the general mapping gate), break-glass and the step-up floor are base
platform — never licence-gated.
The two browser legs are fixed: GET /auth/login/{code} initiates — endpoint discovery from the issuer's
/.well-known/openid-configuration (never hardcoded endpoints), then a
302 to the IdP with authorization-code + PKCE S256, state and
nonce on every round trip — and GET /auth/callback/{code} finishes:
state validation, the confidential-client code exchange (client secret
AND PKCE verifier), id_token validation (RS256 signature against the
issuer's JWKS, iss, aud, exp, nonce), claims mapping, then a
native session established exactly as after Login and a redirect back
into the GUI with the session in the opschain_refresh cookie
(token.enable_cookies is therefore required). Each registered IdP
client carries exactly one redirect URI:
<server base URL>/auth/callback/<code>. The round-trip state is one
sealed, stateless blob (AES-GCM under a shared-database signing key)
carrying the nonce, the encrypted PKCE verifier, the redirect URI and a
10-minute expiry — any replica of any cluster answers the callback
identically; nothing is process-local. Provider tokens are consumed
inside the callback and discarded: OpsChain holds no
standing provider credentials. GET /auth/providers is the
unauthenticated sign-in button discovery read: enabled codes, kinds,
display names and display orders only, served ordered by
(display_order ascending, code ascending) — the order the login page
renders the sign-in buttons in (the
retired auth_strategy/auth.providers kind-ordering settings no
longer influence the sign-in surface and are pruneable —
opschain docs cli/admin-settings-prune). The same read carries the
optional escape hatch: with the stored login.escape_hatch_label and
login.escape_hatch_url settings both set (opschain admin settings —
the URL must be http(s)), the response gains an escape_hatch object and
the sign-in page renders it as a quiet secondary link under the provider
buttons, opening in a new tab — the no-sign-in path ("Prefer not to sign
in? Book a guided walkthrough"). Unset, nothing renders.
Identities key on (provider instance, subject) — never email or
username (no auto-merge, email is an
attacker-influenceable claim). First federated sign-in creates a
read-only shadow users row (auth_provider oidc:<code>, empty
encrypted_password — the directory invariant extended, so an IdP outage
can never let a federated identity authenticate locally, and password
operations refuse). A username another provider already holds proceeds
with the warn:auth:provider_collision defence event; a username another
subject of the same provider holds is refused. The provider group
claim is snapshotted per user+provider at interactive sign-in only
(federated_group_snapshots, the directory-mirror precedent — the IdP
stays off the authenticated-request path; a groupless sign-in clears a
stale snapshot). Group→role mapping over the snapshot is the next section.
An unreachable IdP is a chain-skip (warn:auth:provider_unavailable),
never a credential failure: local login and break-glass keep working with
every IdP down. The login leg refuses while quiesced, exactly as Login.
The GitHub adapter rides the same two legs
with the OIDC machinery swapped for GitHub's OAuth2 web flow — GitHub is
not an OIDC provider: no discovery document, no id_token. Registration is
a GitHub App (sign-in uses only the App's OAuth client credentials —
no installation is required); endpoints derive from the instance host
(https://<host>/login/oauth/*, API at api.github.com or
<host>/api/v3 for GHES). The callback exchanges the code
(confidential client; the sealed expiring state carries the CSRF
defence) and resolves identity from the user API: the numeric GitHub
user id is the stable subject (logins are renameable), the login is
the username, the verified primary email comes from GET /user/emails
(falling back, unverified, to the profile email where the App may not
read emails). Org/team memberships normalise lowercase as <org> and
<org>/<team> into the same per-instance group snapshot the OIDC lane
writes — membership visibility follows GitHub's rules: an org that has
not installed/authorised the App reads as no membership. Every GitHub
call happens server-side inside the callback and the user token is
discarded with it — OpsChain never holds a GitHub
credential, and OpsChain session TTLs are its own, never GitHub's. The
sign-in records authn_method github:<code> on the session, audit
events and shadow row.
The ldap kind makes a directory a first-class
provider instance on the same connection object — one connection per
directory, N directories are N rows (multi-instance from day
one). The directory-shaped fields: directory_url (ldap:// or
ldaps://host[:port]), require_tls (default on — a plaintext
ldap:// URL is refused at create, update and use; disabling it is an
explicit, documented opt-out for lab directories — the
re-anchor of the old global security.ldap_require_tls onto the
connection), bind_dn plus the service bind password on the write-only
client_secret seam (encrypted at rest, rotated by update), user_base
/ user_attribute (default uid) / users_filter (default
(objectClass=inetOrgPerson)), group_base / group_attribute
(default member) / groups_filter (default
(objectClass=groupOfNames)), mail_attribute (default mail), and
entry_id_attribute: the attribute identities key on —
left empty, the adapter detects entryUUID (RFC 4530), falling back
to objectGUID (Active Directory, served canonically despite the
mixed-endian raw form); dn is the explicit, documented last resort for
directories exposing neither, with the caveat that a directory rename
then strands the identity. Never the username, never mail.
LDAP is the port's one non-redirect kind: there is no IdP to bounce
the browser to, so sign-in is a credentials POST to the login leg —
POST /auth/login/{code} with JSON or form username + password
(plus optional use_cookie to carry the refresh token in the HttpOnly
cookie instead of the body). No callback leg exists for the kind; the
redirect legs answer 404 for it. The adapter resolves the login's DN
through the service bind, binds as that DN with the presented
password — verify-at-login, nothing cached, no password ever stored —
then reads the entry id, mail, display name and group memberships on the
service connection and hands the normalised identity to the same
shadow-row find-or-create as every other kind — with one ldap-kind
extra: a first sign-in with no identity row first tries the lazy
re-key, adopting the un-linked legacy auth_provider='ldap' row of the
same username (user id and grants preserved) before minting a fresh one.
The session records
authn_method ldap:<code>. Directory groups snapshot into
federated_group_snapshots and ride the same group→role mapping as
OIDC/GitHub groups — namespaced <code>:<group>, no parallel group
machinery, the directory off the authenticated-request path. Throttling,
audit events and the quiesce refusal apply at the leg exactly as on
Login; a directory that cannot be consulted answers 503 with
warn:auth:provider_unavailable (the credential was never evaluated —
never a 401), and local login is structurally unaffected. The kind's
step_up is hard-wired to platform (the GitHub
pattern): a directory bind proves the password only, so floor verbs
verify a platform-registered factor. The test verb binds as the
service account, proves a user search, and reports the
detected_entry_id_attribute so an operator can pin the keying
deliberately; per-connection health (last bind success / last
unavailable error, per replica) surfaces in /admin/status under
identity.ldap_providers.<code>. The CLI signs in through the same leg
(opschain login --provider <code> --username <name>) — a credentials
POST needs no browser. The sweep verb
(IdentityProviderService.SweepIdentityProvider, admin) runs the
directory read on demand: eager re-key of legacy rows, import of
directory users ahead of their first sign-in, and leaver detection, in
one pass, reporting counts and anything unresolved.
Google is the reference OIDC provider (issuer
https://accounts.google.com; it emits no groups claim, deliberately
exercising the no-groups path); Microsoft Entra ID rides the same generic
lane as a preset. Registration and end-to-end verification are covered in
opschain docs cli/admin-identity-providers; the provider-side console
procedures (GitHub App, Google Cloud client, Entra ID app registration,
directory service account) are the getting-started chapter
opschain docs getting-started/identity-providers-setup.
Group→role mapping (federated providers)
The LDAP mapping shape, generalised: provider groups feed authorisation only through the stored snapshot — a pure DB read, never the IdP — in two lanes.
Namespaced effective groups. Every snapshotted provider group joins
the user's effective groups (the same resolver union that serves login,
run-as and firing-time RBAC) as <provider instance code>:<group> —
okta-prod:platform-admins, never a bare platform-admins — so two
provider instances can never silently alias one another, or an LDAP/local
group. Existing path-RBAC policy assignments keyed by group name work
unchanged against these names: assigning a policy to the group
okta-prod:platform-admins is exactly the LDAP-group grant admins already
know, no new rule grammar.
Mapping entries. Each provider connection carries an admin-managed
list of {provider group → role | OpsChain group} entries
(IdentityProviderService.List/Create/DeleteIdentityProviderGroupMapping;
exactly one target per entry; entries die with their connection):
group_namegrants the named OpsChain group as an effective group, so existing policy assignments keyed by that plain name apply to the provider group's members.rolegrants a role.adminis mappable today and loads as a Superuser-policy assignment on the namespaced group — the same grant lane as the LDAP admin-group shape;security_administratorandauditorbecome mappable when their grant lanes land (never silently inert).
Freshness and revocation. The snapshot refreshes at
interactive sign-in only: an IdP-side group change applies at the user's
next sign-in — there is no background re-sync, and a sign-in that carries
no groups clears the stale snapshot. A mapping write applies to the
next authorisation decision (the compiled authz snapshot is invalidated).
Urgent revocation is user disable (cascades to session revocation) or
deleting the mapping entry. Grant changes are attested: mapping
create/delete is audited (api:identity_provider_group_mappings:*
carrying the grant itself), and at each federated sign-in the mapped
outcomes gained or lost against the previous snapshot land on
api:auth:mapped_grants_changed (granted/revoked roles and groups) — a
group edit at the IdP is a role change OpsChain can attest. Unmapped
group churn emits nothing.
Mapping administration sits behind the admin determination, and mapping
creates additionally require the identity:sso entitlement — except
on an ldap-kind connection, whose
mappings are base platform and never identity-gated, like directory
login itself: under a lapse the entries are retained,
the listing stays open and deletion still works — a licence problem
never blocks grant removal.
Provisioning controls (default group, domain rules, company-email policy, pseudonyms)
Each federated connection carries four optional provisioning controls, edited through the same create/update verbs (and the GUI dialog) as the rest of the connection:
Default group. default_group names the OpsChain group every
identity of the connection that matches no explicit group mapping
resolves into — the catch-all for providers that emit no usable groups
(Google emits no groups claim; GitHub organisation membership is
invisible unless the organisation installed the App). Resolution follows
one ladder per connection: explicit group mapping > email-domain rule >
default group — any mapping match (role or group target alike)
silences both fallbacks. Like mapped groups, the fallback resolves at
group-resolution time from stored state, never the IdP.
Email-domain rules. email_domain_rules maps a verified email
domain to an OpsChain group ahead of the default group — for example
yourcompany.com to a staff group with wider read grants, while every
other domain falls through to the default. Domains match
case-insensitively; each domain appears at most once per connection.
Company-email policy. Two per-connection refusals keep consumer accounts out (each refusal names the posture, never the visitor's address, and is audited as a failed login):
require_hosted_domain(oidc kind only) refuses any sign-in whose verified ID token carries nohd(hosted-domain) claim — Google Workspace accounts pass, consumer Gmail is structurally excluded. Userinfo responses are never consulted for this check.deny_public_email_domainsrefuses auto-provisioning when the verified primary email's domain is on the deny list —public_domain_denylistwhen set, otherwise the built-in list (gmail, googlemail, outlook, hotmail, live, yahoo, proton, protonmail, icloud, aol, gmx, mail.com). The policy binds at first provisioning only: an already provisioned user is never locked out by a list change. It fails closed on an identity whose company email cannot be established — no email at all, or an unverified one on the github kind.
Pseudonymous provisioning. With pseudonymous_provisioning on,
auto-provisioned users receive an opaque Visitor-<n> username —
monotonic, estate-wide and never reused, so no two users can ever share
a pseudonym — while the real name and email persist on the user record
and its federated identity, visible to administrators and exports only.
Attribution surfaces other users can read (audit rows, created-by
fields, approval lists) therefore show the pseudonym. The pseudonym is
minted at first provisioning only: existing users keep their names,
and later sign-ins never re-mint it.
Password policy
Local passwords are bcrypt-hashed at the posture's security.bcrypt_cost.
Policy is length over composition rules (per NIST 800-63B):
security.password_min_length, plus for break-glass accounts a reuse
history (security.password_history: this password was used recently: break-glass accounts refuse password reuse (security.password_history)).
Server-generated passwords (create/reset with none supplied, and the
first-boot admin) are 24 characters over an unambiguous 62-symbol
alphabet (~143 bits) and are returned exactly once.
A principal flagged rotate-on-first-login, or — under
security.password_max_age_days — a local user whose password is older
than the age, is confined to the password-change flow with the 403:
your password must be rotated before this server accepts other requests: change it with 'opschain passwd' (POST /api/users/me/password), then retry. The self-serve change (opschain docs cli/passwd) proves the
current password (the current password is incorrect), is local users
only (this user's password is managed by the identity provider, not by OpsChain), and emits api:users:password_changed.
The security posture
One deploy-authoritative config.yaml setting — security.posture,
evaluation (the default) or hardened — re-defaults the identity
stack. It is never editable through the API or the database, so a
compromised admin credential cannot soften it. Each control is
individually configurable upward in evaluation; one is anchored (⚓)
in hardened and refuses to be configured back down (a boot error, e.g.
security.require_bootstrap_password: false cannot be configured in the hardened posture (an anchored requirement⚓)). The old
security.ldap_require_tls anchor is re-anchored per ldap-kind
connection: every connection carries require_tls
(default on), and the hardened posture additionally refuses disabling it
at create, update and use:
| Key | Evaluation default | Hardened default |
|---|---|---|
security.require_bootstrap_password ⚓ | false | true — first boot requires OPSCHAIN_BOOTSTRAP_ADMIN_PASSWORD, admin rotates on first login |
security.local_user_mode | full | break_glass_only — creating non-break-glass local users is refused |
security.password_min_length | 0 | 14 |
security.bcrypt_cost | 10 | 12 (configurable 4–18) |
security.password_history | 0 | 5 (break-glass accounts) |
security.password_max_age_days | 0 (off) | 0 (off) |
security.break_glass_containment | false | true |
security.max_break_glass_accounts | 0 (unlimited) | 2 |
security.collision_defence | false | true |
security.directory_reconciliation | false | true |
security.directory_reconciliation_hours | 24 | 24 |
auth.break_glass_networks | (any source) | (any source) — CIDR allowlist for break-glass logins |
All are config.yaml keys — see opschain docs config for the catalog.
The boot log names the effective posture and its engaged controls
(settings only, never secret material).
Break-glass accounts
A break-glass account is a local account designated as the fire door:
usable when the directory is down, unchanged in authority. The
recommended shape is a dedicated local-only account with its own
distinct name (e.g. breakglass-admin): no LDAP twin, no collision
semantics, no password-divergence requirement — create it, assign its
policies (or group memberships) directly, done. Assignments are
per-identity, so its authority is exactly what is assigned to it. Containment
controls under the hardened posture:
- Network allowlist — a break-glass login from outside
auth.break_glass_networksis refused (break-glass login refused: the source address is outside the auth.break_glass_networks allowlist, eventwarn:auth:break_glass_refused); with an allowlist configured, an unplaceable source fails closed. - Token containment (
security.break_glass_containment) — access tokens capped at 1 hour, no refresh token issued, and api_key minting refused:break-glass accounts cannot mint api_key tokens under this security posture: a standing credential would outlive the fire door's 1-hour containment. - Bounded count (
security.max_break_glass_accounts) — creating or designating accounts beyond the cap is a 422; the fire door stays small by design. - Every successful break-glass login emits
warn:auth:break_glass, which is never throttled.
First boot
On a server whose users table holds no non-synthetic, non-service-account
rows, boot creates local administrator admin (break-glass, assigned the
system Superuser policy): password from
OPSCHAIN_BOOTSTRAP_ADMIN_PASSWORD (env only, never config.yaml) or
generated and printed to the console exactly once. Concurrent replicas
serialise on an advisory lock; the guard is "no users exist", so it never
fires on an existing install. Under security.require_bootstrap_password
the generate-and-print path is refused as a named boot error and the
created admin must rotate on first login.
Login throttling
Failed credential attempts feed an exponential backoff keyed per username
and per source address (the client address resolved behind any trusted
proxy — behind an unlisted proxy every caller shares the proxy's address and
one backoff; list the proxy under server.trusted_proxies): the fifth
consecutive failure engages a
1-second backoff and each further failure doubles it, capped at 5
minutes. A throttled
attempt is refused with too many failed login attempts — try again shortly and the engagement emits warn:auth:throttled. Throttling is
never a hard lockout — state is in-memory and decays on its own; a
replica restart resets the backoff, never widens access.
Directory reconciliation
With security.directory_reconciliation on, a periodic sweep (cadence
security.directory_reconciliation_hours, default 24; one replica at a
time via the scheduler's cron leadership, the due-check durable in the
events table) re-resolves, per enabled ldap-kind connection and keyed by
directory entry id, every identity the connection
holds, and disables the identities whose entry the directory no longer
returns — through the same disable path as the admin verb, so the leaver's
tokens, sessions and api keys are revoked too. Guard rails: only
identities linked to the swept connection are ever evaluated (local,
service_account and un-linked legacy rows are structurally out of scope),
and an unavailable directory (or an implausible empty user search) skips
and warns (warn:auth:reconciliation_skipped) rather than mass-disabling.
Each sweep emits an api:users:reconciled summary and one
warn:auth:leaver_disabled per disabled identity. The same read serves
the on-demand admin sweep verb (SweepIdentityProvider), which adds
import and eager re-key to the leaver half.
Dormant identities (listings and pickers)
A user whose provider can no longer sign them in is dormant:
legacy un-rekeyed ldap rows (their chain lane is gone;
the re-key revives them as linked ldap:<code> identities), provider
rows whose connection is deleted or disabled, and agent rows whose
agent is revoked or forgotten (the registration is the
"provider" an agent principal signs in through; re-enrolling the same
project/code revives the row). Dormancy is computed per
read — never stored — so re-adding the provider un-dorms its identities
without any row write. The picker-feeding v2 UserService.ListUsers
excludes dormant rows by default; include_dormant restores them
with dormant: true set, which is how the GUI's user tables keep showing
them, marked, while assignment and membership pickers default to live
identities only — ambiguity in pickers is how grants land on the wrong
identity. Local rows are never dormant. The admin listings mark rather
than hide (the earlier audit half): every GET /api/admin/users row
carries dormant: true when dormant (omitted otherwise), and opschain admin users list shows dormant rows with their DORMANT marker by
default — --active-only hides them.
The admin identity surface
Admin-gated endpoints (the admin bearer), managing local rows only —
a directory identity gets the named 422 (…OpsChain never writes to an identity provider — manage this account in the directory (its OpsChain row is a shadow account record only)), a service account likewise:
| Endpoint | CLI |
|---|---|
POST /api/admin/users | opschain docs cli/admin-users-create |
GET /api/admin/users / GET /api/admin/users/{username} | cli/admin-users-list, cli/admin-users-show |
PATCH /api/admin/users/{username} (break_glass designation) | cli/admin-users-update |
PATCH /api/admin/users/{username}/disable / enable (v1 twin; the CLI rides the v2 UserService.DisableUser/EnableUser/BulkSetUserLifecycle verbs — any identity, bulk form, stranded-automations warning) | cli/admin-users-disable, cli/admin-users-enable |
POST /api/admin/users/{username}/reset_password | cli/admin-users-reset-password |
GET /api/admin/groups | cli/admin-groups-list |
POST /api/admin/groups (create empty) / DELETE /api/admin/groups/{groupname} (refused while policy assignments reference it) | cli/admin-groups-create, cli/admin-groups-delete |
POST /api/admin/groups/{groupname}/members / DELETE …/members/{username} | cli/admin-groups-add-member, cli/admin-groups-remove-member |
Responses never include a password hash; a generated password is returned once in the response. The surface requires the PostgreSQL store (404 otherwise).
Disabling warns about stranded automations. A disabled identity refuses firing-time resolution, so a disable response carries an owned_automations summary — the
triggers and scheduled activities the identity EXECUTES: rows naming it
run_as, plus rows it created with no explicit run_as (a row someone
else runs as survives this identity's disable); exact counts, name lists
capped at 20 — and the CLI and GUI surface it as a warning: those firings
refuse until reassigned. opschain admin users reassign-automations <username> repoints everything onto an enabled service account in one
set-based write (v2 UserService.ReassignAutomations); authorship stays
historical, only the executing identity moves. The disable always
proceeds.
Disabling a whole provider's population: prefer disabling the
provider connection (the IdentityProviderService admin verbs
above) — one click, every identity of that connection stops signing in
and reads as dormant, reversibly. The bulk user form (filter by provider
→ select all → disable) is the subset tool: use it when only some of a
provider's identities should be cut, or when the per-user revocation
cascade and audit trail are wanted.
What changes behaviour, and who owns it
| Lever | Authority |
|---|---|
enable_basic_auth, token.secret_key, token.access_expiry, token.refresh_expiry, token.enable_cookies, mfa.elevation_window, login.escape_hatch_label, login.escape_hatch_url | DB-authoritative stored settings — opschain docs settings |
security.*, auth.break_glass_networks | Deploy-authoritative config.yaml — opschain docs config |
Directory connections (kind: ldap), their TLS posture, bases, attributes and bind credential | The IdentityProviderService admin verbs (the retired ldap.* triplet is consumed once by the upgrade's boot seed) |
OPSCHAIN_BOOTSTRAP_ADMIN_PASSWORD | Environment (env-only) |
Provisioning controls (default_group, email_domain_rules, require_hosted_domain, deny_public_email_domains, public_domain_denylist, pseudonymous_provisioning) | The IdentityProviderService admin verbs (opschain admin identity providers create/update) |
| User/group lifecycle, disable/enable, password resets | The admin API verbs above |
Events vocabulary
Authentication: api:auth:login (carries authn_method and
authn_strength from day one — the coupling), api:auth:logout
(self-serve credential revocation: the v2 Logout verb),
warn:auth:login_failed
(never carries the password), warn:auth:throttled,
warn:auth:provider_unavailable
(throttled to once per 10 minutes against the events table; directory
names the unreachable host:port),
warn:auth:break_glass (never throttled), warn:auth:break_glass_refused,
warn:auth:provider_collision, warn:auth:group_lookup_failed.
The v2 session lane adds: api:auth:session_refreshed (rotation, with
method/strength and generation), api:auth:session_revoked (self or
admin revocation, with the reason), warn:auth:refresh_reuse (reuse
detection killed the session), api:auth:api_key_created,
api:auth:api_key_revoked, and the one-shot upgrade report
warn:auth:v1_api_keys_retired (the surviving v1 api keys to re-mint).
MFA and step-up add: api:auth:mfa_enrolment_started,
api:auth:mfa_factor_activated (with the recovery-code count),
api:auth:mfa_factor_removed, api:auth:step_up (every elevation, with
the method — platform_totp, platform_recovery_code or idp:<code> —
and the window expiry), warn:auth:step_up_failed (a proof that did not
verify; feeds the login throttle) and warn:auth:step_up_refused (a
floor verb refused, with the verb and the reason — not_elevated or
elevation_expired: every refusal is audited; a rule-driven refusal
carries rule_id/rule_name/rule_path and the matched action too).
The rule layer adds api:auth:step_up_rule_pass (a matched rule's demand
met by a live elevation, once per rule and action per request). None ever
carries a secret, code or recovery value.
Federated sign-in rides the same vocabulary (api:auth:login with the
oidc:<code> method, warn:auth:login_failed for state/nonce/PKCE/
id_token refusals, warn:auth:provider_unavailable for an unreachable
IdP, warn:auth:provider_collision for a cross-provider username), and
the connection registry adds api:identity_providers:create,
api:identity_providers:update, api:identity_providers:destroy,
api:identity_providers:test, api:identity_providers:sweep — never
carrying a client secret. The migration adds
api:auth:identity_rekeyed (a legacy row linked to its entry-id
identity, lazily or by the sweep).
Lifecycle: api:users:create, api:users:update, api:users:disable,
api:users:enable, api:users:reset_password,
api:users:password_changed, api:users:reconciled,
warn:auth:leaver_disabled, warn:auth:reconciliation_skipped,
api:groups:add_member, api:groups:remove_member.
The v2 GroupService write verbs record api:groups:create,
api:groups:destroy, api:groups:member_added and
api:groups:member_removed (the v1 admin-groups routes keep their
create/delete/add_member/remove_member names above).
Failure modes
- Directory outage: token-authenticated requests and snapshot-resolved
groups are unaffected, and local login is structurally untouched — it
shares no code path with the directory at all.
The login leg answers 503 with
directory authentication is currently unavailable - the credential was not evaluated(the wording), emits the throttledwarn:auth:provider_unavailableevent, and the outage shows underidentity.ldap_providers.<code>inGET /api/admin/status. Reconciliation skips and warns, never mass-disables. - A lost
token.secret_keyfails token issue/verify withthe token secret_key setting is not configured. - A compromised admin credential cannot soften the posture: the
security.*surface is config.yaml-only. - A disabled user is cut off immediately — row checks on every token verification, plus token revocation at disable time.
Related pages
opschain docs config— thesecurity.*andauth.break_glass_networkskeys.opschain docs settings— the stored auth/token settings.opschain docs cli/login,cli/passwd,cli/tokens,cli/admin-users,cli/admin-groups— the user-facing verbs.opschain docs events— where the auth events land.