Skip to main content
Version: v5.7.0

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.

Username precedence. Wherever a bare username resolves to one account — run-as and trigger firing-time resolution, notification subscriber lookup, agent owner lookup, write attribution of runs and nodes, ghost-row hiding and the collision defences — a federated identity outranks a same-named local user at every resolver: any provider connection identity (ldap:<code>, oidc:<code>, github:<code>, …) or a legacy directory row ranks ahead of the local row, and only among rows of the same standing does the older one win. A local account that shares a federated identity's name is therefore never what a username means by itself; name the local row explicitly (?provider=local, or the user id) where a surface offers it.

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. Authority never follows the shared name: transport grants with a user subject and approval-policy approver lines store and match the principal id — a write that names sam while two accounts hold it is refused until spelled <provider>:<username> (local:sam, ldap:corp:sam; opschain admin users list shows the value), and a run's creator is carried by id from creation, so the federated sam never inherits the local sam's grant or approver seat. Records written before that rule keep matching by username through a dual-read window that records warn:identity:username_match per hit until opschain admin identity convert-username-subjects rewrites them. 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:

  1. 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: ldap identity provider connection (code ldap) — enabled iff the old chain had ldap enabled, require_tls carried from enable_ssl (a plaintext legacy configuration seeds with the explicit lab opt-out). The stored ldap.* 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.
  2. 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 — and auth_provider rewrites to ldap:<code>. Every re-key is audited (api:auth:identity_rekeyed).
  3. Eager re-key (the admin sweep). IdentityProviderService. SweepIdentityProvider resolves 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>; strength none at establishment, totp after a platform-factor step-up, idp_asserted after an IdP re-authentication — see "MFA factors and step-up" below), which are minted into the access token's amr/acr claims and stamped on the api:auth:login / api:auth:session_refreshed audit events from day one.
  • The access token is signed on a dedicated stored signing key — never token.secret_key — and carries a validated aud claim (api, the vocabulary shared with step tokens). Default TTL 15 minutes (a stored token.access_expiry overrides 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 expiry token.refresh_expiry (7d default), absolute bound token.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|revoke under 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), the User-Agent it 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 (ListSessions takes a user reference — id or username — and RevokeSession any session id; a denied caller reads not-found, so other users' session state stays unknowable). From the CLI: opschain admin sessions list [user] and opschain 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_refresh cookie (scoped to the AuthService procedures, token.enable_cookies permitting) with an X-Requested-With CSRF 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 with X-Forwarded-Proto: https; only plain-HTTP development setups omit it.
  • The quiesce split holds verb-for-verb: Refresh passes a quiesced estate, Login refuses.

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 or activating a further factor once one is active, and removing an active factor, each require a live elevation — activation is the step that plants the factor and replaces the recovery codes, so a factor enrolled earlier by a stolen bearer cannot be activated later without the elevation either; the first enrolment and its activation are the bootstrap and stay 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: BeginIDPStepUp, called with the session's own access token, seals that session id — the only session a step-up can ever elevate; nothing in a link chooses it — with the round trip's nonce, PKCE verifier and a same-origin return_to, binds the trip to the calling browser through the login-binding cookie, and answers the provider's authorize_url (prompt=login, max_age=0) for the browser to open; 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. The sign-in leg GET /auth/login/{code} refuses a step_up parameter outright (auth_error=step_up_unavailable, audited warn:auth:step_up_failed): an unauthenticated link could otherwise nominate which live session a victim's sign-in elevated. 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, a 10-minute expiry and the hash of a browser binding — any replica of any cluster answers the callback identically; nothing is process-local. The binding is a random secret the login leg sets in the browser as the opschain_login_binding-<id> cookie (HttpOnly, SameSite=Lax because the callback is a cross-site top-level GET from the provider, Secure on TLS, confined to /auth/callback/, living as long as the state): the callback finishes the round trip only for the browser that started it, expiring the cookie as it does, and a callback URL captured from one browser and opened in another is refused (auth_error=invalid_state, audited warn:auth:login_failed) — nobody can be signed into an account they did not sign in to. The return_to a sign-in carries is parsed, never passed through: only a same-origin absolute path (query and fragment kept) survives; anything scheme-, host-, backslash- or control-character-shaped lands on /. 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_name grants the named OpsChain group as an effective group, so existing policy assignments keyed by that plain name apply to the provider group's members.
  • role grants a role. admin is mappable today and loads as a Superuser-policy assignment on the namespaced group — the same grant lane as the LDAP admin-group shape; security_administrator and auditor become 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 no hd (hosted-domain) claim — Google Workspace accounts pass, consumer Gmail is structurally excluded. Userinfo responses are never consulted for this check.
  • deny_public_email_domains refuses auto-provisioning when the verified primary email's domain is on the deny list — public_domain_denylist when 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', then retry. While confined, exactly four calls are served — on each API lane, the password change and the identity probe: POST /api/users/me/password and GET /api/me (v1), AuthService.ChangePassword (what opschain passwd rides) and AuthService.Introspect (v2). Every other call — the admin plane and api-key minting included — answers that refusal until a password is set, and the rotation lifts the confinement for the same session at once (no fresh login). 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:

KeyEvaluation defaultHardened default
security.require_bootstrap_password ⚓falsetrue — first boot requires OPSCHAIN_BOOTSTRAP_ADMIN_PASSWORD, admin rotates on first login
security.local_user_modefullbreak_glass_only — creating non-break-glass local users is refused
security.password_min_length014
security.bcrypt_cost1012 (configurable 4–18)
security.password_history05 (break-glass accounts)
security.password_max_age_days0 (off)0 (off)
security.break_glass_containmentfalsetrue
security.max_break_glass_accounts0 (unlimited)2
security.collision_defencefalsetrue
security.directory_reconciliationfalsetrue
security.directory_reconciliation_hours2424
security.allow_local_git_remotes ⚓falsefalse — anchored: true is a named boot error
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.

security.allow_local_git_remotes is the one key in the block that governs content rather than identity: whether a git remote may name a repository on the server's own disk. Off (both defaults), a project editor registering a filesystem path or a file:// URL as a remote is refused by name on the url field — Url must be an http(s)://, ssh://, git:// or user@host:path remote — before the server probes it, and the server's git is fenced to the http, https, ssh and git transports, so a local URL already stored (one registered before the admission existed, or under the flag on a server that later withdrew it) is never cloned or fetched: its periodic fetch fails by name (transport 'file' not allowed), recorded on the remote and as error:git_remote:fetch, and the remote can still be renamed, archived or deleted. Keep it off unless every project editor on the server may read every repository the server's user can; the hardened posture refuses it. 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_networks is refused (break-glass login refused: the source address is outside the auth.break_glass_networks allowlist, event warn: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.
  • No floating template revisions — a floating template revision cannot be owned by a break-glass account: its background refresh runs as the version's creator, and break-glass identities are interactive-only, so creating or switching a version to floating as a break-glass account is refused by name (A floating revision is re-resolved in the background as its creator, and break-glass accounts are interactive-only: pin the revision, or create this version as an everyday user; event warn:template_versions:floating_refused, the principal id only). A floating version such an account already owns carries the same remedy as its float_refresh_error.

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. Admission is one atomic step: an attempt inside either window is refused before any credential is evaluated, and an admitted attempt counts against both ladders and engages the next window immediately — so of any number of concurrent attempts after a window lapses, exactly one is evaluated. A throttled attempt is refused with too many failed login attempts — try again shortly and the engagement emits warn:auth:throttled. A successful sign-in clears the account's own username ladder and refunds the one count its admission took from the source ladder (successful sign-ins never consume the address budget); the per-source backoff otherwise decays on its own and is never reset by a different account's login from the same address — the address budget holds whatever accounts an attacker tries, and a window already engaged stands until it lapses. Throttling is never a hard lockout — state decays on its own (in memory on a single-node server; the shared login_backoff table on PostgreSQL, so every replica consults one estate-wide budget); a replica restart resets the in-memory 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:

EndpointCLI
POST /api/admin/usersopschain 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_passwordcli/admin-users-reset-password
GET /api/admin/groupscli/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​

LeverAuthority
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_urlDB-authoritative stored settings — opschain docs settings
security.*, auth.break_glass_networksDeploy-authoritative config.yaml — opschain docs config
Directory connections (kind: ldap), their TLS posture, bases, attributes and bind credentialThe IdentityProviderService admin verbs (the retired ldap.* triplet is consumed once by the upgrade's boot seed)
OPSCHAIN_BOOTSTRAP_ADMIN_PASSWORDEnvironment (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 resetsThe admin API verbs above

Usage telemetry​

Every federated sign-in is counted on its identity provider's usage row (opschain docs provider-usage): logins (a sign-in landed; a provider or directory that could not answer is an error on it), token_exchanges (an authorization code exchanged at the provider's token endpoint), refusals (the provider, the directory or the provisioning policy refused it) and jwks_refreshes (a signing-key set fetched; a failed fetch is an error). Platform-level, no project; never a username. opschain admin providers usage --kind identity --code <code> reads them; the connection's record opens its operational dashboard.

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.

Principal-id matching: warn:identity:username_match (a grant subject, approver entry or run creator still stored as a username admitted a principal — the record and the remedy, never the username) and audit:identity:username_subjects_conversion (one sweep run's counts).

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 throttled warn:auth:provider_unavailable event, and the outage shows under identity.ldap_providers.<code> in GET /api/admin/status. Reconciliation skips and warns, never mass-disables.
  • A lost token.secret_key fails token issue/verify with the 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.
  • opschain docs config — the security.* and auth.break_glass_networks keys.
  • 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.