Identities, groups and locks

Who the gateway thinks you are and which groups open which doors — the part of the access model SSH and Web Access share. What each capability then lets a user do is on its own page: SSH policy and approval for commands on a target host, and Web Access policy for requests to a web console.

Identities

An operator is a directory entry. With ldap.yaml present, the gateway binds to the directory as its own read-only service account (bind_dn, bind_password) and searches under base_dn for (<user_attr>=<username>). It reads exactly four attributes off the entry it finds:

What ISG calls it ldap.yaml key Default attribute Used for
Username user_attr uid The login name, and the --as principal when it names a person
Role role_attr employeeType Role-scoped policy rules (rules[].roles)
Display name display_name_attr cn The admin view; falls back to the username when empty
E-mail — mail Delivering a multi-factor code

Group membership is read from the group entries, not from the user. A second search runs under group_base_dn for (member=<the user entry's DN>), and the cn of every group that comes back is one of the user's groups. A directory that expresses membership only on the user side (memberOf and nothing else) will therefore resolve a user with no groups — and every group-based gate below will treat that user as a non-member.

The connection to the directory is encrypted, or the gateway does not start. Two secrets cross it — the service account's own bind_password and, at every admin-view login, the operator's password — so the transport is a fail-closed decision made at startup, not a preference:

ldap.yaml Transport Result
an ldaps:// url (port 636 by default) TLS from the first byte Normal — the recommended mode
an ldap:// url (port 389) with start_tls: true StartTLS, before every bind and search Normal
an ldap:// url with neither key — isg serve refuses to start
an ldap:// url with allow_plaintext: true None Starts; isg system check WARNs

The recommendation has a directory-side half the gateway cannot enforce: a directory that still answers on its unencrypted port leaves that transport available to anything else on the network, whatever the gateway's own ldap.yaml says. The reference deployment therefore publishes 636 to the fleet and keeps 389 reachable only from inside the directory host, for components that live there and have no TLS path of their own yet.

StartTLS is mandatory once asked for: a server that cannot do it, or a handshake that fails, is a connection error — there is no fall back to an unencrypted connection, and such a failure lands in the directory_error class, never in the wrong-password counters. tls_ca_file names the PEM that issued the directory's certificate; left empty, the host's system trust store decides. The expected name is the url's host, so a url holding an IP address needs a certificate with an IP SAN for that address — a DNS-only SAN fails the name check. The floor is TLS 1.2, verification is never skipped, and there is no key that could skip it. Contradictions are refused rather than silently resolved: ldaps:// together with start_tls, allow_plaintext together with either, or a tls_ca_file on a connection that carries no TLS. isg system check states the transport in its ldap.yaml row and measures the real handshake — including the certificate's expiry — in its LDAP bind row.

A password is verified by a bind as that user, never by reading a hash. The DN is assembled as <user_attr>=<username>,<base_dn>, so a directory whose people entries do not sit directly under base_dn needs base_dn set to the container they do sit under. An empty username or an empty password is rejected before any network call, because some directories accept an empty password as an anonymous bind and would answer "yes". Two failure kinds are kept apart on purpose: a wrong password is a wrong password, and a directory that cannot be reached is a directory_error — only the first counts toward the lockout counters in mfa.yaml.

An operator may also arrive through the identity provider. With sso.yaml present, the admin view's login page offers the organisation's identity provider — OIDC by default, or SAML 2.0 with protocol: saml, which serves the web login only (the command line then stays password plus the ISG second factor). What the provider returns is a username: the value of username_claim for OIDC (preferred_username by default), the value of the saml_username_attribute attribute for SAML. That value is a lookup key, not an identity: the directory has to resolve it exactly as it resolves a typed username, and a name the directory does not know is refused with not_in_directory. Role, groups and every gate below come from the directory, never from a claim or an assertion attribute. The second factor is mfa: local by default — the same challenge as after a password — or mfa: idp, where the identity provider's own statement satisfies it: the id_token's amr or acr claim for OIDC, an AuthnContextClassRef on the saml_idp_mfa_authn_context allow-list for SAML. An OIDC token that asserts neither falls back to the local challenge (local_fallback). No setting admits a session without a second factor. Over OIDC, logging out works in both directions: with rp_logout: on a logout from the admin view also ends the identity provider's session (the browser is handed to the provider's end_session_endpoint), and a logout at the provider — or the provider signing the user out — ends the admin view's session through OpenID Connect Back-Channel Logout. Neither touches a running SSH session; with SAML, logging out is local to the gateway.

Without a directory, users.json is the identity source. It carries a role, an e-mail address and a group list per user, and it is enough to run the gateway. What it cannot do is make a group claim verifiable: a request whose groups came from that file is marked as not-from-directory, and any access rule that requires a group is refused rather than approximated — the reason recorded is group_unverifiable. An LDAP gate that lets everyone through on the fallback file is not a gate.

Where identity comes from: four entry points resolving one username, the directory branch and the users.json branch, and what the role, the groups and the e-mail address decide

Read it left to right. The left column is everything a front has to go on before the directory is asked anything, and each of them narrows to the same thing — a username. The two branches in the middle are the only two answers that exist, and the right-hand column is why the difference matters: a group list is worth exactly what its source is worth. One asymmetry is worth the look it takes to find it, at the bottom right. The caller's own identity may fall back to users.json; the --as subject's never does.

The second factor is a separate set of branches, decided by mfa.yaml and sso.yaml rather than by the identity source. It belongs in this section because what it settles is still identity: it runs after the directory has answered and before any gate in the next one is evaluated.

The branches of the second factor: the SSO question, the three mfa_mode values, where the authenticator secret lives, what counts as a correct answer, and the two lockout dimensions

The vertical is the order the daemon asks in, and the one branch that leaves it is near the top: a provider may satisfy this factor, it can never remove it. Below that, mfa.yaml decides both what is asked for and where an authenticator secret is kept — this gateway's own disk, or OpenBao. The last row is what happens when the answers keep being wrong, and what an outage of the shared counter store does to that protection.

The gateway's own accounts

Everything below is what the packaged unit and isg system provision actually create; nothing here is inferred from convention.

Account or group Where it comes from What it is for
User isg The packaged isg-ui.service (User=isg) The daemon's own OS identity. The CA private keys are mode 0600 owned by it, so no human shell can read them
Group isg-operators The same unit (Group=isg-operators) The daemon's primary group, which is what the runtime directory and the sockets in it inherit. Operators reach session.sock and issue.sock by being in this group
Group isg-gateway isg system provision (override: ISG_GATEWAY_SOCK_GROUP) Owns the gateway login socket only. Deliberately separate, so the SSH entry account never joins isg-operators and therefore never reaches the issuance or session sockets
Account isg-login isg system provision (override: ISG_GATEWAY_LOGIN_ACCOUNT) The shared account sshd's ForceCommand admits a human as, before ISG's own authentication runs

isg system provision is idempotent and does exactly this much: it creates the group and the account, and adds isg to the gateway group. It never writes /etc/pam.d/ or /etc/ssh/sshd_config.d/ and never restarts a service — packaging/ carries the reference files and an administrator applies them by hand.

On a target host the gateway needs no account of its own. It authenticates as the OS user the operator asked for, using a certificate. What the target needs is set out in Target host preparation.

Roles and groups

Two different attributes drive two different things, and mixing them up is the usual source of "why did that rule not fire".

The role is one string per user — the directory's role_attr value, or users.json's role. It gates policy rules: a rule with a roles: list only applies to users whose role is on that list, and a rule with no roles: list applies to everyone.

Group membership gates who may perform an action at all. Each gate is a group name in ldap.yaml, and each has a _required twin that turns a missing group or a missing identity source from a permissive fall-through into a hard configuration error:

Gate Group key Fail-closed switch
Who may decide approval requests approver_group approver_group_required
Who may reset another user's multi-factor enrolment mfa_reset_group mfa_reset_group_required
Who may run CA administration operations (on the issuance socket and in the web UI) ca_admin_group ca_admin_group_required
Who may close another user's session (in the web UI and with isg sessions close) or re-run the system check in the web UI session_admin_group session_admin_group_required
Who may invoke break-glass breakglass_group breakglass_group_required

Break-glass is the one that is closed even without the switch: with no breakglass_group configured it is denied to everyone. There is no reading of "unset" that makes it permissive.

What the web UI shows

The web UI decides on the server what each signed-in user may see, not only what the menu offers. A page outside a user's level answers 403 with a message, and a button for an action the user may not take is not drawn at all. Each user gets the highest level that matches:

Level Who What they see
Admin A member of session_admin_group or ca_admin_group. With neither group set, everyone Everything. Each write button still follows its own gate
Auditor A member of auditor_group. With the key unset, nobody Every page and every session, read-only, with no write buttons
Approver A member of approver_group. With the key unset, everyone Their own sessions and requests, plus every pending request with the decision buttons. Not other users' transcripts
User Everyone else My apps, Profile, and their own sessions with transcript and recording

Anyone may close their own session, in the web UI and with isg sessions close; the SESSION_CLOSED entry names who closed it (closed_by, via). Closing someone else's session takes session_admin_group, and a refusal is audited as SESSION_ADMIN_DENIED (via: webui or via: cli). If the level cannot be worked out — the directory does not answer, or a _required gate has no group — the user gets the User level.

Locking a user or an app

When an account or an application has to be stopped at once, an administrator (session_admin_group) locks it — on the web UI's Locks page or with isg lock add (--user or --app, a mandatory --reason, and --for for a lock that ends by itself). The user name is checked in the identity source: an unknown name is refused, because a typo would lock no one, unless the lock is forced (--force, or the box on the Locks page); when the identity source cannot be asked, the lock goes in and the notice says the name is unchecked. A user lock closes every running SSH and Web Access session of that user on every broker and removes their web UI sign-ins; an app lock closes every session of that application. While the lock lives, the gateway, the web UI and Web Access refuse the user (or the app) before any session starts. isg lock rm lifts it; isg lock list shows the locks, expired ones marked. The audit log records LOCK_CREATED and LOCK_REMOVED, and the closed sessions carry the reason locked. A browser the lock signed out sees a general "your access was suspended" notice on the sign-in page, without the reason. With lock_mail enabled in notify.yaml, the session_admin_group members get an e-mail on every lock and lift (who, what, why, until when), and with mail_locked_user the locked user gets a general one.

What the gateway does not provide

These are measured absences, listed so a deployment is not designed around one of them. The ones that belong to one capability are on its page: SSH and Web Access.