The audit log schema — the SIEM parser's contract

This document is the contract a SIEM (or any other external log consumer) parses against: the file's location and permissions, the JSON shape of one entry, the versioning rule that governs when that shape is allowed to change, and the rotation behavior an ingesting agent must tolerate. internal/audit/audit.go is the implementation; this file is what an integrator should read instead of that source.

1. The file

Canonical path: $ISG_HOME/audit.log. In the isolated-CA production layout (see docs/provisioning-isolated-ca.md) ISG_HOME is /var/lib/isg, so the file is /var/lib/isg/audit.log.

The file is opened, appended to, and closed on every single write (see §4) — there is no long-lived file handle inside isg holding it open.

2. The fields

Every line is an object with exactly these top-level keys:

Key Type Required Meaning
schema_version string see §3 Contract version this line was written under. Absent on lines written before this field existed — see §3.
timestamp string yes Event time, RFC3339 (e.g. 2026-06-18T10:30:00Z).
broker string yes Which broker instance wrote this line, e.g. R1 — the id from that gateway's broker.yaml, the same mark that appears in the middle field of its session IDs (SESS-2026-R1-000042). Empty string means an unmarked broker, which is the supported single-broker default, not a missing value. Never omitted; absent only on lines written before this field existed — see §2.4.
session_id string yes The ISG session this event belongs to, e.g. SESS-2026-000001. Empty string for events with no session context (some CA/admin/probe events).
event string yes Event type, e.g. SESSION_STARTED, COMMAND_DENIED, CERT_ISSUED. See internal/audit/audit.go's AllEvents for the full, current list — the set of event names is not itself part of this schema's version and grows over time.
details object yes Event-specific key/value data. Always present, {} when an event has none.
prev string yes Hash chain link: the hex SHA-256 of the previous physical line of the file this line was appended to, newline excluded. Empty string means this line was the first of a chain (a genesis). Never omitted; absent only on lines written before the chaining existed — see §2.5.

details is intentionally an open, event-dependent map — a command-denied entry carries command and rule, a cert-issued entry carries different keys entirely. A parser must not assume a fixed key set inside details, and must not fail on keys it doesn't recognize there either. The one documented cross-event convention: events that record who acted use either details.actor or details.user depending on the event (see Query's actor filter in internal/audit/audit.go for the exact split) — a parser matching on "who did this" should check both.

2.1 Acting on someone else's behalf: as_user, and why it is not principal

An operator can open a session for a different directory identity (isg connect <host> --as <user>). The events that know about that distinction carry it in as_user, and they carry it always — including when nobody was impersonated, in which case as_user holds the acting user's own name. Absence is never the signal: a rule looking for impersonation compares as_user against the event's own subject (user, or caller_user where the event has one) rather than testing whether a key is there.

That is a change of contract, made deliberately before any external consumer existed. Until v0.231.0 this axis was written as principal, omitted when empty — so a missing key meant both "no --as was passed" and "no impersonation happened", and nothing in the line told the two apart. Worse, principal was carrying four unrelated meanings across the log at once:

Meaning Events
the --as subject MFA_RECOVERY_USED, MFA_REPLAY_REJECTED, LOGIN_MFA_OK, REQUESTER_ACCESS_DENIED, REQUESTER_ACCESS_WOULD_DENY
the certificate's own principal CERT_ISSUED
the requester's resolved directory name ISSUE_REQUEST_DENIED, ISSUE_REQUEST_WOULD_DENY
the target-side SSH login name BREAK_GLASS_KEYSET_READ

A rule filtering on principal to answer "who acted for whom" therefore pulled in certificate issuance and — inverting its own question — the identity of the requester in the issuance-authorization events.

Since v0.231.0 the first group writes as_user, and the third writes requester_directory_user, which pairs with the requester_user already taken from the peer credential: which OS user asked, and who the directory says that is. Rows two and four keep principal, because there the word is the correct SSH term for what it names — a certificate's subject, and the login name a probe authenticated as. principal in this log never means "on whose behalf".

requester_directory_user keeps the omit-when-empty rule, and there the absence is real information rather than ambiguity: it means the directory lookup never got that far. caller_user is a third, separate axis — which OS user opened the session — and is untouched: it is written only when it differs from the subject, and that remains a deliberate choice recorded at its own events.

2.2 One event records a QUERY, not an execution

Every other event in this log names something that happened: a command ran, a certificate was issued, a session closed. COMMAND_COMPLETION is the exception, and a consumer that reads it as an executed command will draw conclusions the line does not support.

It records a Tab-completion lookup from the brokered prompt. The operator typed a path fragment and pressed Tab; the daemon ran a fixed listing command with that fragment as a single quoted argument and sent the names back to the client. Nothing the operator typed was executed, and the command gate never evaluated it — the gate decides about running a command, and here nothing is run on the operator's behalf.

{"event":"COMMAND_COMPLETION","session_id":"SESS-2026-000251",
 "details":{"prefix":"/etc/ssh/ssh_h","matches":6,"truncated":false,
            "cwd":"/mnt/cdrom","as_user":"peter"}}

prefix is the point of the event: it is the only field that says what was probed. matches is a count and never the list — the names would add nothing a reader needs, while writing a directory's whole contents into the log on every Tab. truncated says the listing hit its cap, so the count is a floor rather than a total.

2.3 Why it is a separate event, and what to alert on

The lookup could have been folded into COMMAND_ALLOWED. It was not, deliberately: three Tab presses while typing one command would write three times as many rows as the command itself, and a log that used to say "this operator ran these seven commands" would dilute into noise.

The split also gives a consumer something better than noise. This is the one place in ISG where a directory tree can be examined without running a command, so volume is the signal: a burst of completions — say a hundred in a minute, or a walk down successive path components — looks nothing like a person finishing a filename, and looks exactly like someone mapping a filesystem. Alert on the rate, not on any single row; a single completion is an operator pressing Tab.

Two limits worth knowing before writing that rule. The lookup is gated at the session level — it can only happen inside an open, authorized session on a host that operator was already allowed to reach — so it widens no host boundary. And a completion leaves no trace on the target beyond that host's own sshd/auditd records of the listing command; this log is where the intent is recorded.

2.4 broker: an absent key and an empty value are different facts

A parser sees three cases on this key, and only two of them are about the same thing:

What the line looks like What it means
no broker key at all The line was written before the field existed. It says nothing about which broker wrote it.
"broker":"" An unmarked broker wrote it — no broker.yaml, or an empty id in it. That is the supported default for a single-gateway deployment.
"broker":"R1" Broker R1 wrote it.

The first case exists forever, because the log is append-only (§5) and old lines are never rewritten to add the key. The second is why the field carries no "omit when empty" rule: on a line this code writes, the empty string is a statement — this broker has no mark — and dropping the key would merge that statement with the silence of the pre-field lines.

The distinction is only visible in the raw JSON, on the key's presence. A consumer that unmarshals into a struct with a plain string field gets "" for both and cannot tell them apart afterwards; if a rule needs to, it must check for the key itself.

One thing the field does not claim: it names the broker that WROTE the line, which for a daemon-forwarded event (the client measures, the daemon records) is the daemon — not whatever process first observed the event. That is deliberate; a client-supplied mark would be a claim rather than a fact.

2.5 prev: the hash chain, and what it does and does not prove

Every line written since this landed carries the hex SHA-256 of the line physically above it in the same file — the line's bytes without the trailing newline, which is exactly what a line-oriented reader hands back. Nothing is canonicalized anywhere: isg serializes the record once, and that one byte sequence is what goes into audit.log, what is hashed, and what is re-rendered into the syslog export's MSG (§7). There is no second representation for a verifier to disagree with.

The same three-case reading broker has (§2.4) applies here, for the same reason and with the same "the key's presence decides" caveat:

What the line looks like What it means
no prev key at all Written before the chaining existed. Not verifiable, and not a fault.
"prev":"" A genesis: this line was the first of a chain, with nothing above it.
"prev":"<64 hex>" The hash of the line above it — across a rotation boundary too.

The chain flows across rotation (§4): the first line written into a fresh audit.log links to the last line of the piece that was just renamed away, so the pieces and the live file are one chain, walked oldest-first. It also flows across a daemon restart, which re-reads the tail on startup.

What it proves. An edit or a deletion anywhere in the trail breaks the chain from that point on. isg system audit-chain verify walks the pieces and the live file in one pass and names the first line that no longer follows from the one above it.

What it does not. The file is on the broker's own disk, so whoever can rewrite it can also recompute every hash after their edit and hand back a chain that verifies perfectly. The chain is worth something because its head leaves the machine: isg serve writes an AUDIT_CHAIN_HEAD event at startup and every hour, carrying the current head in details.head, and that event goes out on the ordinary fan-out — into the SIEM, over the syslog export. A rewritten log has to reproduce every head exported before the edit, over bytes that are no longer there.

So the head event is the one an ingesting agent should keep and be able to look up. Two properties make it self-checking: details.head equals that same line's own prev (the head is read immediately before the entry is appended), and a missing hourly entry is not by itself evidence of tampering — a daemon that was down writes none.

Per-record authentication — a MAC proving the line came from this ISG rather than from anyone with write access — is a separate, not-yet-built step. This one detects modification of a sequence; it does not authenticate an individual record.

2.6 The SSO login events, and the one field to alert on

Three events describe a login that went through an external OIDC identity provider. They sit beside the LOGIN_MFA_* events rather than replacing them, because they record a different party's decision.

surface says which front door, and it is on all three events, from both fronts, always — never omitted, so a rule never has to infer it from which other keys happen to be present. Two values: webui (the browser's authorization-code callback) and gateway (the CLI's RFC 8628 device flow, ssh isg-login@gateway).

The two fronts carry different companion keys, and the differences are deliberate rather than an inconsistency:

Key surface: webui surface: gateway
ip present — the HTTP peer address, observed absent
client_ip_claimed absent present — the unverified string the front-end read out of its own SSH_CONNECTION (see §2.4's sibling reasoning: a claimed value must never be written under the name of a measured one)
login_attempt_id absent present — joins these events to the GATEWAY_AUTH_OK/GATEWAY_AUTH_DENIED and any session events of the same connection
user on LOGIN_SSO_START absent present

protocol says which SSO protocol (B272), and it is on all three events, from both fronts, always: oidc or saml. The gateway front is always oidc — under protocol: saml the CLI login stays password plus the local second factor. On saml, issuer is the identity provider's entityID, sub is the assertion's NameID, and LOGIN_SSO_OK carries authn_context_class_ref and session_index instead of sid/amr.

Event details Written when
LOGIN_SSO_START surface, protocol, issuer, + the per-surface keys above webui: the browser is redirected to the identity provider — no user, nobody has claimed an identity yet and inventing one from a cookie would record a guess. gateway: the IdP has issued a device code — with a user, because the operator typed one before the flow began.
LOGIN_SSO_OK surface, protocol, user, issuer, sub, sid (when the IdP issues one), amr (list), mfa_source; on saml authn_context_class_ref and session_index instead of sid/amr The id_token verified and the username it carries resolved in the directory. On the gateway front the username also had to match what was typed.
LOGIN_SSO_FAILED surface, protocol, reason, issuer, user (once known), claim_user (on user_mismatch only) Any refused callback, ACS post or device flow.

mfa_source on LOGIN_SSO_OK is the field worth a rule. It has three values:

Value What happened
local sso.yaml says mfa: local. The gateway's own second factor runs next, and its LOGIN_MFA_OK follows on the same login.
idp mfa: idp, and the id_token's amr/acr satisfied what sso.yaml requires. No local factor ran; this event is the whole record of the login.
local_fallback mfa: idp was configured and the id_token did not carry the required assertion, so the local factor ran after all.

local_fallback is the one to alert on. It is not a failure — the login is still fully authenticated — but it means the identity provider is not asserting what the deployment believes it asserts, and the operator should find out why before anyone widens the trust.

reason on LOGIN_SSO_FAILED is a closed set. Most values belong to one surface, because they name a step only that surface has:

reason Surface What happened
state webui The callback matches no flow this gateway started — login CSRF, a stale bookmark, or a flow already consumed.
nonce webui The id_token was minted for a different authorization request — replay.
idp_error webui The IdP itself refused and sent ?error=; its code is in the daemon log, not in the response.
exchange webui The token endpoint rejected the code or was unreachable.
verify webui Signature, issuer, audience or expiry failed.
claim_missing webui The configured username_claim is absent or empty in an otherwise valid id_token — a claim-mapping misconfiguration.
no_resolver webui No directory is wired at all — infrastructure, not a rejection of this user.
locked webui The source IP or, once the claim was read, the user is already locked out by the password/MFA fronts' shared counters.
user_locked webui An administrator's incident lock (B477, §2.10) is on the user.
locks_unavailable webui The incident-lock list could not be read; the login is refused rather than let through.
device_auth gateway The device authorization request failed — the IdP was unreachable or answered unusably. Nothing was refused; no LOGIN_SSO_START precedes it, because no code was ever issued.
poll gateway A token-endpoint poll failed for a reason that is neither a verdict nor "still pending". Infrastructure, like device_auth.
idp_denied gateway The IdP answered access_denied — the human said no, or a policy at the IdP refused.
expired gateway The device code expired, or the connection's pre-auth deadline ran out first.
rounds_exhausted gateway Every one of sso.yaml's device_max_rounds rounds came back pending. The code may well still be alive; it is the operator who never finished.
user_mismatch gateway The id_token named a different user than the one typed at the username prompt. Both names are logged: user is what was typed, claim_user is what the IdP vouched for.
not_in_directory both The identity provider vouched for somebody this gateway does not know.

Under protocol: saml the webui front has its own steps (B272). state, not_in_directory, no_resolver and locked mean what they mean above; a state here is an ACS post whose RelayState does not match its flow cookie, or whose flow is unknown, expired or already used. The rest name the check the SAML Response failed:

reason What happened
malformed The post is not a parseable SAML Response.
encrypted_assertion The assertion is encrypted, which this phase does not accept yet.
signature The signature did not verify against the IdP metadata's signing certificates, was missing, covered only the Response and not the assertion itself, or the certificate is not valid now.
status The IdP answered with a non-success status — typically the user cancelled at the IdP.
destination The Response's Destination is missing or is not saml_acs_url.
recipient The bearer confirmation's Recipient is not saml_acs_url.
issuer The Response or the assertion was issued by another entity than the IdP metadata's.
audience The assertion has no AudienceRestriction, or one that does not name saml_sp_entity_id.
time NotBefore/NotOnOrAfter is outside the clock-skew window, or the bearer confirmation has expired.
in_response_to The Response does not answer the AuthnRequest of this flow — or answers none: an IdP-initiated login.
assertion_count Not exactly one assertion.
username_missing The saml_username_attribute attribute (or, with saml_use_name_id, the NameID) is absent, empty or multi-valued.
replay This assertion was already used — on any broker — within its validity.

signature, in_response_to and replay deserve a rule of their own: nothing legitimate produces them.

Three of them deserve their own rules. not_in_directory means a valid token for a non-user, which is what a federation misconfiguration and an IdP compromise both look like from here. verify means a token that did not survive signature/issuer/audience/expiry checking, which nothing legitimate produces. user_mismatch means somebody approved a browser step as an account other than the one they claimed at the terminal — a run of them is either an operator with two accounts or an IdP whose username claim does not map to directory names.

None of these events counts toward a lockout, on either surface. There is no password in either flow, so nothing was guessed; counting a refusal here would let anyone lock a real user out by replaying a malformed callback or by refusing somebody else's device authorization. Both fronts read the lockout counters the password and MFA fronts write — a user locked out there is refused here too — and neither ever adds to them. A rule correlating brute force must therefore not expect LOGIN_LOCKED to follow a run of LOGIN_SSO_FAILED.

All three carry an empty session_id: an ISG session (SESS-…) is a brokered SSH session, and a login is not one — not a browser login, and not the gateway login that goes on to open one (that session's own events carry its id, and login_attempt_id is what joins them to these).

The logout events (B273, B463)

Event details Written when
LOGOUT surface (always webui), user, ip, auth_source (password | sso; unknown for a session record that carries none), idp_logout; on sso also protocol, issuer, sid (when known) A webUI logout removed a live session. No line when the browser had no live session (nobody was logged out) or when the delete failed (the answer is a 503).
LOGOUT_SSO_BACKCHANNEL ip, issuer, sid, sub (each when the logout token carried it), sessions_closed, pending_closed A verified OIDC back-channel logout token from the identity provider closed this gateway's webUI sessions of that IdP session (or, for a sub-only token, of that subject). pending_closed counts unfinished logins (pre-auth and enrollment). Zero closed is valid. Running SSH sessions are never touched.
LOGOUT_SSO_BACKCHANNEL_FAILED ip, issuer, reason A back-channel logout request was refused.

idp_logout on LOGOUT is a closed set: redirect — the browser was sent to the identity provider's end_session_endpoint, so the IdP session ends too (rp_logout: on, OIDC session, a kept id_token, and an IdP that publishes the endpoint) — or none — only the gateway's own session ended (a password or SAML session, rp_logout: off, or no endpoint/id_token).

reason on LOGOUT_SSO_BACKCHANNEL_FAILED is a closed set:

reason What happened
verify Signature, issuer or audience failed.
logout_events The token has no events member naming the back-channel logout event.
logout_nonce The token carries a nonce, which a logout token must not.
logout_no_sid_sub The token names neither sid nor sub.
logout_iat iat is missing, older than five minutes or more than one minute in the future, or exp has passed.
logout_jti The token has no jti.
replay This jti was already used, on any broker.
storage The login-state store failed; the request was answered 503 and the IdP may retry.

verify and replay deserve a rule: nothing legitimate produces them. A request without any logout_token is answered 400 and writes no line.

2.7 Target names: host is canonical, host_input is what was typed (B457)

Every target name that enters the gateway — a session request, a gateway-login target, an issue request on the issuance socket, an mTLS renewal — is brought to one canonical form before any decision: surrounding space and one trailing dot removed, lower case, checked against the RFC 1123 label rule, and, when connect.yaml sets target_domain, a dot-less name qualified with that domain. From then on host always holds the canonical name — on the decision events, on SESSION_STARTED, on every in-session event. Before this, an alias of a host (its FQDN, upper case, its IP) was a different string to every gate, and could resolve to a lower lockdown level than the host itself.

host_input is written next to host when, and only when, the typed name differs from the canonical one. Absent means the operator typed the canonical name. A rule correlating with what a user actually entered reads host_input when present and host otherwise; a rule grouping by target reads host only.

A name that cannot be canonicalized is refused on the spot with reason: invalid_target_name: empty, a character outside the RFC 1123 label rule, or an IP literal while target_domain is set. That line carries host_input and no host — there is no canonical name to write — plus a detail with the parser's message. The event is the one the surface already uses for a refusal:

Surface Event decision / denied_by
session socket (connect, gateway front) HUMAN_ACCESS_EVAL decision: deny
issuance socket issue, mTLS renewal CERT_DENIED denied_by: policy

invalid_target_name is added to the closed set of reason values those two events carry. The issuance-path CERT_ISSUED, CERT_DENIED and HOST_GROUP_* lines written by the shared signing core carry the canonical host only; on that path host_input appears on the socket's own ISSUE_REQUEST_* lines.

The lockdown-level source cmdb-miss (the servers level command, the webUI and JIRA ticket bodies) names a host the CMDB does not know; its level is cmdb.yaml's miss_level.

2.8 HTTP brokering: the application-session events (B277)

When apps.yaml is present, ISG brokers web applications behind the webUI login. A browser reaches an application's own host name, is handed to the webUI for a single-use code, and gets an application session of its own (cookie isg_app) — one per user and application, a child of the webUI session it came from. Four events describe it; an allowed proxied request writes nothing to the audit log.

Event Details reason (closed set)
APP_SESSION_STARTED user, app, app_id, host, parent_auth_source, ip; account (identity: credential); approval_id, approval_until (require_approval) —
APP_SESSION_CLOSED user, app, app_id, reason logout, idle, max, parent_ended, access_revoked, approval_window_ended, admin_close, locked, app_deleted, viewer_gone (B524), target_closed (B538), superseded (B539)
APP_ACCESS_DENIED user (when known), app, app_id, reason, ip; account, holder_user, holder_session_id (rdp_account_busy) reserved_username, not_in_group, handoff_invalid, handoff_unbound, directory_unavailable, no_credential, approval_missing, locked, license (B467, §2.11), step_up_mismatch (B495), rdp_account_busy (B539)
APP_REQUEST_DENIED user, app, app_id, method, path, rule, ip, mode — (rule is the http_rules entry's name, or http_mode:allow)
APP_REQUEST_WOULD_DENY user, app, app_id, method, path, ip, mode —
APP_STEP_UP_REQUIRED user, app, app_id, method, path, rule, ip —
APP_STEP_UP_OK user, app, app_id, factor, valid_until, ip — (factor: totp, recovery, email)
APP_STEP_UP_FAILED user, app, app_id, reason, ip; attempts (invalid_code, too_many_attempts) no_factor, invalid_code, expired, too_many_attempts, locked_out
APP_UPSTREAM_LOGIN user, app, app_id, account, steps, relogin, duration_ms —
APP_UPSTREAM_LOGIN_FAILED user, app, app_id, account, steps, relogin, duration_ms, step, status — (why, closed set: status, success_cookie, success_redirect, extract, timeout, transport, secret)
APP_RDP_UPSTREAM_CONNECTED user, app, app_id, target, rbi_id, session_id, channels — (target is the rdp:// upstream's host:port; B278. channels: the opened rdp_channels, [] when all are off; B278 F2)
APP_RDP_UPSTREAM_FAILED user, app, app_id, target, rbi_id, session_id, channels, error — (error is the dial's text, free form)
APP_RDP_UPSTREAM_CLOSED user, app, app_id, target, rbi_id, session_id, channels, ended_by, first_eof, bytes_to_target, bytes_from_target, duration_seconds — (ended_by: target, isg; first_eof: target, guacd; B538)
APP_RDP_CHANNEL_TRANSFER user, app, app_id, rbi_id, session_id, channel, mimetype, bytes, blocked; filename (printer only) — (channel: clipboard_to_remote, clipboard_from_remote, printer; B278 F2)

The Web Access session record (B277 a2-2). Each application session gets an ordinary session record on every broker that serves it, opened by its first request there, so SESSION_STARTED and SESSION_CLOSED describe it with the usual fields plus protocol: http (absent on an SSH session). target is the app id (B491; before it, the app name) and target_label the app's friendly name at the start, auth is web_access, and commands_allowed / commands_denied count requests. The reason of its SESSION_CLOSED is client_exit (logout, or the application session is gone), idle_timeout, max_duration, external_close (the webUI session ended, or an administrator closed the record — then APP_SESSION_CLOSED says admin_close), access_revoked (the group gate failed mid-session), locked (an incident lock, §2.10 — then APP_SESSION_CLOSED says locked too), app_deleted (an administrator deleted the dynamic app, below — APP_SESSION_CLOSED says app_deleted too) or daemon_shutdown. A failed transcript write is one RECORDING_UNAVAILABLE (session_id, app, app_id, user, reason: transcript_write_failed) per failure spell; with require_recording: true the session's requests are refused (503) until a write succeeds again.

Dynamic applications (B475). With dynamic_apps_domain in apps.yaml, an administrator past the session_admin_group gate (§2.9) adds, changes and deletes applications on the webUI's /apps/manage page; the apps.yaml ones cannot be changed there. Three events, via is webui:

Event Details
APP_CREATED app_id (minted by the record store, B491), name, host, upstream, identity (none or jwt), allow_groups, require_approval, level, by, via
APP_UPDATED APP_CREATED's, with the new values; old and new (each {allow_groups, require_approval, level}); changed (the name of every field that changed, also the ones outside old/new)
APP_DELETED APP_CREATED's, with the deleted values; closed_sessions (session records the deleting broker closed with app_deleted)

2.9 webUI admin writes refused by a group gate (B480)

Two ldap.yaml groups gate the webUI's administrative writes, checked against the logged-in user. A refusal changes nothing and writes one line:

Event Group op Details
CA_ADMIN_DENIED ca_admin_group webui-revoke, webui-host-cert-revoke, webui-join-token-create op, user, via (webui), reason
SESSION_ADMIN_DENIED session_admin_group session-close, system-refresh, lock-add, lock-remove, app-create, app-update, app-delete op, user, via (webui or cli), reason; session_id on session-close (also the line's own session_id)

2.10 Incident locks on a user or an application (B477)

An administrator past the session_admin_group gate can lock a user or a Web Access application (its app id, B491; isg lock add --app also takes the name and resolves it) — on the webUI's /locks page or with isg lock add. A lock made on an app name before B491 still holds. The lock is shared by every broker on the state root; creating it closes what it locks out, and while it lives every entry point refuses.

Event Details
LOCK_CREATED kind (user or app), target (a username lower-cased, or an app id), target_label (an app lock: the app's friendly name), reason (free text, required), by, via (webui or cli), until (RFC 3339, only on a time-limited lock), closed_sessions (session records closed on the creating broker), logged_out_logins (login-state records the lock deleted: webUI sessions, Web Access app sessions and app handoff codes together; B508)
LOCK_REMOVED kind, target, target_label (an app lock that has one), by, via

2.11 The license (B467)

The broker reads an offline, signed license.yaml (re-checked every 30 seconds, no restart needed). Without one it runs in a 30-day evaluation mode whose clock is one record in the shared record store. Only a NEW session is gated: running sessions and break-glass are never refused or cut.

Event Details
LICENSE_LOADED license_id, customer, not_after (RFC 3339), modules (list), named_users (0 = no ceiling), key_id
LICENSE_REJECTED reason (why the file does not verify: unknown key, bad signature, bad JSON, a missing field, issued_at after not_after), file
LICENSE_EVAL_STARTED started_at, ends_at (RFC 3339)
LICENSE_DENIED module (ssh_broker or web_access), user, state, via (ssh or web_access); caller on ssh

3. The versioning rule

schema_version states which version of this contract — the meaning and type of the fields in §2 — the line was written under. Current value: "1".

4. The rotation contract

Rotation is configured in packaging/isg-audit.logrotate and is load-bearing for how an ingesting agent must track the file:

/var/lib/isg/audit.log {
    monthly
    maxsize 50M
    rotate 9999
    compress
    delaycompress
    missingok
    notifempty
    create 0640 isg isg-operators
    su isg isg-operators
}

5. The fail-closed limit — file is, and must remain, the audit-of-record

Writing to audit.log is fail-closed as the default posture: internal/audit's Logger.Log returns an error on a failed write, and the main session/command path (internal/session's session-start, command-allowed/denied/error, and session-close audit calls) propagates that error up rather than continuing as if the write had succeeded. A narrow, explicit exception exists — LogOrWarn, used only by call sites where the state change already happened before the audit write (lazy expiry of stale approvals) or where failing the operation would be worse than continuing without the record (some sshbroker probes) — and even there the failure is never silent: it's logged to the process's own standard logger so the gap is at least visible operationally, per that function's own doc comment in internal/audit/audit.go.

Any future forwarding path this project might add — syslog, HTTP, a message queue — would necessarily be fire-and-forget or otherwise fail-open at the point where it hands off to a remote receiver that might be unreachable, slow, or down. Because of that, no future forwarding path may ever become the sole audit sink. The on-disk file written by internal/audit must remain authoritative; disabling the file write in favor of a forward-only path would silently open an audit gap the moment that path's target is unreachable, with no local record left to reconstruct what was missed. Anyone integrating a SIEM forwarder should tail/ship this file (§4) rather than have isg push to them directly, precisely so the local file stays the fail-closed source of truth regardless of what happens downstream.

Such a forwarding path now exists — the optional syslog export described in §7 — and it was built to this rule rather than around it: it is a copy, it can never rescue a failed file write, and it cannot be made the sole sink. Read §7.4 before treating its stream as complete.

6. A second, different file: the collected break-glass log

Everything above is about audit.log, which isg writes. There is a second file a SIEM will encounter in an ISG deployment, and it obeys none of the rules above:

/var/log/isg-breakglass-remote.log

It is not written by isg. It is a verbatim rsyslog copy of the target hosts' own sshd lines, forwarded to the gateway so that the emergency (break-glass) door leaves a trace on a machine the person using that door does not control. Plain RFC3164 syslog text, not JSONL; no schema_version, no details, none of §2's guarantees. This section exists because the lines in it are easy to misread in a way that inverts their meaning.

6.1 Which line means somebody got in

sshd writes two different lines during a certificate login, and only one of them is an entry:

Line What it means
Accepted certificate ID "isg-bg:…" (serial N) signed by CA … The CA check passed. The presented certificate is valid and signed by a trusted CA. Nobody has logged in at this point.
Accepted publickey for isg-breakglass … ED25519-CERT … ID isg-bg:… Somebody logged in. This is the entry.

Measured (2026-08-15, on a target host): a login attempt whose private key could not sign — the operator key was passphrase-protected and ssh ran with BatchMode=yes — produced

Accepted certificate ID "isg-bg:peter@INC-9001" (serial 9001) signed by CA ED25519 SHA256:…
Postponed publickey for isg-breakglass … [preauth]
Failed publickey for isg-breakglass … ED25519 SHA256:…

No login happened. A rule that alerts on Accepted certificate as "someone used the emergency door" would have reported successful root access for an attempt that never got in.

6.2 Why Accepted certificate is not evidence of anything an attacker cannot fake

The offering phase of SSH public-key authentication does not require the private key: the client may present a certificate and only later be asked to prove possession. A certificate is public data by design — ISG's own break-glass workflow copies certs around with scp, legitimately.

Therefore anyone who obtains a copy of an issued certificate can produce an unlimited number of Accepted certificate lines on any target that trusts the signing CA, without being able to log in at all. Treat the line as "a certificate was presented", never as "a certificate was used".

One further limit, measured: the message itself carries no username, no source IP and no port — only the ID, the serial and the CA fingerprint — so the break-glass account can be recognised in it solely through the isg-bg: identity prefix.

The syslog frame, however, does carry a connection identifier that the message body lacks: the tag field holds the sshd session's pid (sshd-session[7516]), and one sshd session serves exactly one connection and writes every line of it — the presentations, the Postponed publickey, and the final Accepted/Failed — under that same pid. A presentation and the login (or failure) that followed it can therefore be correlated on (host, pid).

An earlier revision of this section stated the opposite, that no shared identifier existed. That was wrong: it described the message body and overlooked the frame around it. The correction matters for anyone writing rules here, so it is recorded rather than quietly edited away.

Two costs come with that key, and a consumer must pick which error it prefers, exactly as ISG did (§6.3): pids are reused, so two connections far enough apart can merge — hiding a "presented, never got in" behind a later success — while adding a date to the key splits a connection that crosses midnight and invents a "never got in" that did not happen.

6.3 What ISG itself does with these lines

internal/breakglasslog has two parsers, and the split is the same one this section draws:

Both parsers also expose the tag's pid, and the drill-trace row groups the lines by (host, pid) before counting anything, so what it reports is connections, not lines:

cert presentations: 14 connections, 10 entered, 4 without a login

Counting the lines would roughly double the truth. A successful certificate login writes the Accepted certificate line twice — once when the certificate is offered, once when the signature is checked — while an attempt that presents a certificate and then cannot sign writes it once. Measured on this project's own collected log: every one of its connections had one of exactly two shapes, 2 presentations + 1 login (10 of them) or 1 presentation + 1 failure (4 of them). A line-based reading of the same file reports "24 presentations, 15 entries" and invites the conclusion that nine attempts failed; four did.

The row keeps the key without a date and therefore undercounts rather than raising a false alarm (§6.2). On a log whose lines carry no pid at all it falls back to one connection per line, which reproduces the old line-based reading on exactly the input where nothing better exists.

The row deliberately never changes its status because of these numbers. The collected log is append-only: one old failed drill would otherwise hold the row at WARN forever, and a warning that is always on is one operators learn to skip. Windowed alerting on this signal belongs in the consuming SIEM, which has the retention and the time windows to do it properly — this section is what such a rule should be written against.

A presentation without a matching entry is not suppressed anywhere, and it should not be: "a valid certificate was presented and nobody got in" is a real signal. It is a failed drill, or a copied certificate being probed against the fleet. Both are worth seeing.

6.4 Counting caveat

All three numbers describe the collected log as it stands. The file rotates, and a target that stops forwarding is indistinguishable from a target nobody touched. Pid reuse merges connections rather than splitting them (§6.2). Every one of those effects pushes the counts down, so treat them as lower bounds — never as a complete history.

7. The syslog export — what a receiving parser gets

Since B208's phase 1b, internal/audit fans every entry out to zero or more sinks past the file, and phase 1b-2 ships the first one: internal/auditsyslog, an opt-in copy of every entry forwarded to a SIEM. It is off unless audit.yaml says otherwise (see audit.example.yaml); with it off, nothing in this section exists — no goroutine, no queue, no connection.

An earlier revision of this document argued that no Sink interface should exist yet, precisely because an interface with no real implementation invites someone to wire up a stub and switch the file writer off (§5). The interface exists now, and that concern is answered in code rather than in prose: a sink has to declare, through a method the compiler demands, whether a successful write means the record reached durable storage. The syslog sink answers no, permanently — see §7.4.

7.1 Transport and framing

7.2 The RFC 5424 envelope

<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG
Field What a parser finds
PRI facility*8 + 5. The severity is notice (5) on every entry, with no per-event map and no way to configure one. An audit entry is not a fault report: its severity is carried by the KIND of event, which is what MSGID and the JSON event field say. Which kinds are critical is a decision for the receiving rule set, which knows the deployment; we do not.
version Always 1.
TIMESTAMP RFC 3339 with microsecond precision, parsed from the entry's own timestamp field rather than read off a second clock — the envelope and the JSON can never name two different instants. The fractional digits are therefore always zero: the record's own precision is one second (§2), written in the envelope's grammar. - (NILVALUE) if the entry carries no readable timestamp.
HOSTNAME The OS hostname of the machine. It is not the broker identifier — see §7.3.
APP-NAME audit.yaml's app_name, default isg.
PROCID The isg serve process's pid. Useful for tying a burst of entries to one daemon lifetime; it changes on every restart and means nothing across them.
MSGID The event name (CERT_ISSUED, …), truncated to RFC 5424's 32 characters where a name is longer. Truncation never loses information: the full name is in the JSON event field of the same message a few bytes later. Route on MSGID if you must route before parsing; filter on event when you want the exact name.
STRUCTURED-DATA Always -. The record's structure is the JSON in MSG; re-encoding the same fields as SD-PARAMs would create a second, subtly different representation for a consumer to disagree with.
MSG The JSON line, byte for byte — the same json.Marshal of the same entry that produced the line in audit.log, minus the trailing newline (framing handles boundaries). Everything §2 and §3 say about the file's lines applies here unchanged.

There is no BOM. RFC 5424 §6.4 says a UTF-8 MSG SHOULD begin with one; this is a deliberate departure from that SHOULD. Our MSG is JSON that collectors will parse, and a leading U+FEFF makes naive JSON parsers fail on an otherwise valid document. The encoding is unambiguous without it.

7.3 Correlate on broker, not on HOSTNAME

They answer different questions, and the difference bites exactly when it matters — a trail merged from several gateways. HOSTNAME names the machine; broker (§2.4) names the ISG instance. Two brokers can share a host, one broker can be moved between hosts, and a containerized gateway's hostname may be an ephemeral id nobody chose. A rule that groups a session's entries, or that asks "which gateway served this", must read the broker field inside MSG.

7.4 What a delivered message does and does not prove

It does not prove the record was stored. There is no application-level acknowledgement in RFC 5424 over RFC 6587 framing: the receiver sends nothing back, and a completed TCP write only means the local kernel accepted the bytes. This is why the sink reports itself not durable, and why a successful syslog send can never make a failed audit.log write look successful — §5's rule, enforced at the fan-out rather than merely documented. A deployment with strict: cert_issued: true still requires the file write to succeed.

Entries can be dropped, and dropping is reported, not silent. The sink never blocks the gateway: an audit copy that could stall a session is worse than an audit copy with a hole in it. Writes go into a bounded queue (queue_size), and when it is full the new entry is refused — never a queued one, so what does arrive stays in chronological order. Every drop and every send failure increments isg_audit_target_failure_total{audit_target="syslog"} and produces a process-log line (rate-limited to one per minute per target, carrying the count of the ones folded into it; the counter itself is never folded).

The file remains the complete record. Everything §5 says still holds: audit.log is the audit-of-record, this export is a copy, and a consumer that needs completeness should reconcile against the file (§4) rather than assume the stream had no gaps.