SSH policy and approval

What the gateway lets an operator do on a target host once it knows who they are: the shape of a command rule and what it is matched against, what the policy cannot read, what is inside each certificate the gateway signs, and the gates a connect passes. Who the operator is and which groups gate what is on identities, groups and locks.

Command policy

The shape of a rule

policy.yaml holds a list of rules. Each one is:

Key Meaning
name The rule's identifier. It appears in the denial message and in the audit entry
pattern A regular expression. It is matched against the whole line and against every command inside it — see What the rules are matched against
category A maintained pattern under a name (script_execution, interactive_pty) instead of a raw regex. Mutually exclusive with pattern
action deny or require_approval. Absent means deny. Anything else is a load error
reason The explanation shown to the operator and written to the audit log
timeout_seconds How long an approval request waits. Defaults to 300 for a require_approval rule
min_level The rule is inactive on hosts below this lockdown level
roles The rule only applies to these roles. Empty means every role

There are two actions, not three. There is no allow, and none is needed: a command that matches no rule runs. So every command falls into one of three classes — denied by a matching rule, sent to a second person by a matching rule, or allowed by matching nothing.

Against one piece of text, evaluation is first-match over three blocks in a fixed order: the built-in deny rules, then the rules from policy.yaml, then the built-in approval rules. That order is what makes the union behave. The denials come first, so no file can weaken them; the approval defaults come last, so a narrower rule of your own can match ahead of one and override it.

The built-in deny rules are part of every policy, including the policy of a gateway with no policy.yaml at all:

Rule What it blocks
block_interactive_root_shell sudo -i, sudo -s, and sudo <shell> with no -c, compared by the target program's base name
block_sudo_shell_wrapper sudo <shell> -c …, same base-name comparison — a shell wrapper hides the real command from the policy engine
block_destructive_root_rm rm -rf /
block_passwd_changes passwd, with or without sudo — password changes go through the directory
block_sudo_not_allowlisted Present only when policy.yaml sets sudo_allowed_commands: any sudo target whose base name is not on that list

The two sudo rules match by parsing sudo's own flags and comparing the target program's base name, not by matching text after the word sudo — so sudo /bin/bash and sudo bash are the same fact to them. The allow-list is opt-in, but once opted in it cannot be overridden from the same file: it sits inside the built-in deny block.

The built-in approval rules both apply from lockdown level 4 upward: approve_script_execution_on_prod (running a script bypasses per-command policy) and approve_interactive_pty_on_prod (an interactive terminal program).

At a glance: what runs, what waits, what is refused

Every line an operator types ends in one of three outcomes: it runs, it waits for a second person, or it is refused. This table is the whole decision in one place — the sections after it are the detail.

What the line contains Outcome Why Where you change it
A command no rule matches Runs There is no allow action: a command nothing objects to is allowed Add a rule to policy.yaml (deny or require_approval, optionally narrowed by roles and min_level)
A command one of your rules matches That rule's action Your rule, your decision policy.yaml
A built-in deny: root shell through sudo, a sudo shell wrapper, rm -rf /, passwd Refused The floor every policy keeps — no file can weaken it Not configurable. sudo_allowed_commands can only add to it
A built-in approval: script execution, an interactive terminal program, on hardened hosts Waits for approval Running a script or a full-screen program hides the commands inside it from the policy Your own narrower rule ahead of it (another timeout, other roles)
Several commands in one line (;, &&, &, an or-list, a pipe, ( ), { }, a loop or conditional body) Every command is judged; the strictest outcome wins A second command must not ride on the first one's verdict Not configurable — this is how matching works
A command behind a prefix (sudo, env, timeout, …) or written with quotes and escapes (up\time) Judged as the command that actually runs A wrapper or an escape must not hide the program from the rules Not configurable
Something decided only at run time: $(...), backticks, eval, bash -c, a here-document, a function definition, a command name from a variable Refused by default No parse can say beforehand what will run dynamic_construct_action in policy.yaml: deny (default), require_approval, or allow (a lab setting)
A line bash cannot parse Refused Nobody can say what it means Not configurable (block_unparseable_line)
exit together with other commands in one line Refused Inside a line it would not close your session, which is not what it looks like Not configurable (block_exit_in_compound_line) — put exit on its own line
What is typed inside an allowed vi, less or python Not judged, recorded The gateway sees the line that opened the program, not what happens inside it On the target host: the account's rights and its sudo configuration

To see the outcome for a line before anyone runs it: isg policy check.

The web UI's approvals and sessions view: no pending approval, the approval history, and the closed sessions with their target, user, role, start time, the number of allowed and denied commands, transcript, recording and ticket

Screenshot: reference environment (env1), 2026-10-17.

What the rules are matched against

An operator types a LINE, and a line can run more than one command. The gateway parses it the way bash parses it and evaluates every command that line could run, not just its beginning:

Quoting and escaping are resolved first, so re\boot is the command reboot, which is what bash would run.

The strictest outcome wins: deny beats require_approval beats no match. A rule of your own cannot soften a built-in denial by matching something else in the same line — there is no allow action to write it with. When the decision came from one part rather than the whole line, the message says which:

DENIED by policy: block_passwd_changes (password changes must go through the IdP) — part 2 of 2: "passwd -S peter"

Lines the policy cannot read

Some constructs decide what runs only once the line is already running, and no parse can tell beforehand: a command substitution ($(...) or backticks), a process substitution (<(...)), a here-document, a shell function definition, eval, source, ., exec, alias, a shell or interpreter handed its program as a string (bash -c, python3 -c, perl -e), and a command name that only exists after expansion ($X, or a glob in the program's own name).

policy.yaml's dynamic_construct_action decides what happens to such a line. It takes deny (the default, and what an absent key means), require_approval, or allow; any other value is a load error. Under the first two the decision shows up as the rule block_dynamic_shell_construct or approve_dynamic_shell_construct. allow runs the line unevaluated and is a lab setting — it logs a warning when the policy loads.

Two line-level rules are not configurable at all:

Rule When it fires
block_unparseable_line The line is not a shell line bash can parse — an unterminated quote, an unclosed here-document. There is no reading of "run it anyway" for a line nobody can say the meaning of
block_exit_in_compound_line exit, quit or logout appears in a line that has other commands in it. Those words close the session only as a line of their own; anywhere else they are a command sent to the target that does nothing to your session, and the difference is worth refusing rather than silently doing the other thing

To see all of this for a line of your own without running it, isg policy check prints the decomposition, every candidate text, and the rule that decided:

isg policy check -- 'id; passwd -S peter'

Its exit status is the decision: 0 allowed, 1 denied, 2 approval required.

Where the policy stops

The gateway's policy is not a hard boundary, and it is not sold as one. It narrows what reaches the target and records what was attempted. Three things are outside its reach by construction:

The hard boundary lives on the target host: a least-privilege account and a sudo configuration that names what that account may escalate to. The gateway is the gate in front of it and the record of who walked through — the two together, not either alone. See Target host for the account's shape.

What a certificate contains

The gateway signs four kinds of certificate, and the differences are in what each one names and how long it lives.

Human session Service account Renewable identity Host
Principals The one resolved --as OS user The one principal from ca.yaml's service_accounts[].principal — (an X.509 client certificate) Every hostname the gateway dials that host by
Key ID isg-human:<principal> The account name — isg-host:<first principal>
Extensions permit-pty, and nothing else permit-pty — none
Critical options none none — none
Lifetime 10 minutes, centred on issuance ttl_seconds, 1 hour by default renewable_ttl_days, 30 days by default host_cert_ttl, 1 year by default
Key origin Generated in the daemon's memory, never written to disk The requester's key Generated on the service machine The target's existing host key, which the CA only signs

The principal is the answer to --as, after resolution — the OS user the session becomes on the target. Nothing else is in it: one principal, one certificate, one session.

permit-pty and nothing else is a decision, not an omission. No permit-port-forwarding, no permit-agent-forwarding, no permit-X11-forwarding, no permit-user-rc. A forwarded port is exactly the channel the policy engine cannot see, so the credential itself is unable to open one.

Group membership is never written into a certificate. Groups gate whether a certificate is issued; they are not carried by it. Neither is the role, neither is the identity of whoever requested the session when it was requested on someone else's behalf — those live in the audit log, which is where a question about them belongs.

The ten-minute human window is sized for clock drift between the gateway and the target during the handshake, and for nothing else. SSH checks a certificate only while the connection is being established, so a session that connected inside its window keeps working afterwards; the lever for cutting someone off is refusing the next issuance, which is what the access gates below do.

The gates a connect passes

Before any credential exists, a request is settled in a fixed order, and everything non-interactive happens first so that a request which was never going to be allowed does not cost the operator a multi-factor challenge.

human_access in ca.yaml is an allow-list with first-match-wins semantics. Each rule names principals (the --as values it covers; * is allowed here), and then either hosts (literal names — there is deliberately no *) or host_labels (a set of CMDB key/value pairs that must all match). A rule may additionally require a group (require_group) and may name who is allowed to request a session as that principal (requested_by; with the key absent, only the principal themselves may). There is no deny rule: a request that matches nothing is refused with no_matching_rule.

Two properties of this list are worth stating plainly. A host_labels rule cannot match on CMDB labels that are stale or unavailable — it reports labels_stale or labels_unavailable rather than guessing. And whether the section is enforced is its own switch, human_access.enforce: with it off, every decision is still evaluated and audited, and none of them block. Turning on requested_by_enforce without enforce is refused as a contradiction.

A third property is about where the labels come from, and it is why the daemon is strict about it. A host_labels rule is only as real as the CMDB behind it: a resolver with no data answers every host with an empty label set, so every such rule stops matching at once — quietly, with nothing in the output to say the labels went away. So a cmdb.yaml that names a mock_file which is not there refuses the start rather than coming up labelless, and the cmdb.yaml row of isg system check reads red with the same text. Leaving the key out entirely is still allowed — that is the explicit way to run with no labels — and the row then reads amber, naming the consequence.

Where these rules come from on a provisioned gateway. When no ca.yaml is shipped from the controller, the Ansible role generates a minimal one, and it writes two allow rules rather than one wide list. The first is the operators' (isg_gateway_operators): the same people who also get a local account and isg-operators membership on the gateway host itself. The second appears only when isg_gateway_human_access_users names someone, and it covers those named humans over the same hosts. That second group gets no local account on the gateway and no group membership there — their route to a target is the certificate, and the gateway is only its issuer. The names are account names, never *: the role refuses to render a list that contains one, because in principals a * is not a name but everybody. isg_gateway_requested_by_enforce is what turns on requested_by_enforce in that same generated file, and since neither rule carries requested_by, each principal may only ask for a session as themselves.

hostgroup.yaml is the second, independent gate: per-host required groups, selected by the host's labels. It has its own enforce switch and behaves the same way when it is off — measured and audited, not enforced.

Then the multi-factor challenge, whose mode is mfa.yaml's mfa_mode: email (a random code delivered by SMTP, or written to totp_outbox.txt when no relay is configured), totp (RFC 6238, enrolled once per operator with isg mfa enroll), or totp-with-email-fallback. Repeated failures lock the user and the source address out for the periods mfa.yaml sets.

In totp-with-email-fallback — and only there — an operator who signed in with an e-mailed code can register an authenticator app themselves, from the webUI's /profile page. The confirmation asks for two codes, not one: the app's, and a fresh one sent to the factor the account already has. That is because in this mode a TOTP record replaces the e-mailed factor, so a stolen browser session must not be able to enrol an app of its own. There is no re-enrollment on this path — a lost authenticator is an administrator's MFA_RESET, not a self-service step.

Who controls the second factor in email mode. The gateway sends the code to the mail attribute it reads off the directory entry at login, and the first factor is the directory password. Whoever can reset a user's password in the directory and rewrite that user's mail field therefore holds both factors under mfa_mode: email — the second factor is then not independent of the directory. With totp the user enrols the secret once and the gateway keeps it (mfa.yaml store: a file in the gateway's own state, $ISG_HOME/totp, or OpenBao with store: vault), outside the directory's reach. totp-with-email-fallback sits between the two: an operator who has enrolled an authenticator app is in the totp position, one who has not is in the email position — and can enrol an app on the strength of an e-mailed code. Recommendation: where the directory is not administered by the same people as the gateway, choose totp; email fits where you accept the directory as the trust anchor for both factors, and totp-with-email-fallback is the migration path between them.

The gateway login (ssh isg-login@gateway) has one more way in: with sso.yaml setting cli: optional an empty password at the prompt starts the RFC 8628 device flow with the identity provider, and with cli: required there is no password prompt at all. The username typed at the prompt has to match the name the provider returns, and after that it is the same directory gate and the same multi-factor branch as above. isg connect is not on this path — its caller is established from the socket, not from a login.

Only then is a certificate signed — and every command typed afterwards is its own policy decision and its own audit entry.

What the gateway does not provide on SSH

These are measured absences: things the code refuses to do, listed here so a deployment is not designed around one of them.