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 |
| — | 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.
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 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.
- No permissive fall-through on a gate you have declared required. With a
_requiredswitch on, a missing directory or a missing group name is a refusal that names what is missing, not an open door. - No shipped copy that counts as the record. The audit log is a local
append-only file that a SIEM or log shipper reads. An opt-in syslog copy
(
audit.yaml:syslog) can forward every entry, but a successful send never satisfies the audit guarantee — the file on disk is the record, and the audit log schema is the contract to parse against.