Configuring applications
An application gets behind the ISG login by an entry in apps.yaml on every broker, or — with a dynamic apps
domain — by an administrator on the web UI. This page is both routes, the three ways an application learns who the
user is, and what the customer's side has to provide. The full key list is in the
configuration reference; apps.example.yaml in the source tree carries every key with a
comment.
A missing apps.yaml means Web Access is off, and the daemon starts as before. An apps.yaml that is there but
does not load stops the daemon from starting, and the error names the file and the field; isg system check runs
the same loader (the apps.yaml row). Web Access needs the web UI's password login against an identity source:
without one the daemon does not start with this file.
With the shipped Ansible
The switch is isg_cfg_apps in the environment's variables: one entry per application, with the fields of
apps.example.yaml. Left empty, Web Access is off and no apps.yaml is written. Every entry needs an id of the
form APP-2026-000001, unique and never handed to another application: approvals, sessions, locks, tickets and
the audit log (app_id) all refer to the application by it, while the name stays the technical name (the JWT
audience and the OpenBao path). Set, a run does this:
playbooks/config-src.ymlwritesapps.yaml, the listener's certificatetls/apps.crtwith its keytls/apps.key, and the assertion key pair (apps/assertion.key,apps/assertion.pub,apps/assertion.jwks.json). Withisg_config_src_pki_enabledthe role issues the certificate under the environment's root, for every app host; without it, the customer's own pair has to be there first, or the run stops before writing anything.playbooks/site.ymlships the files to the brokers, which listen onisg_apps_port(8444 by default; not 8443, which a tester's machine usually forwards to the identity provider already).- The monitoring host. When an
identity: jwtapplication's upstream is that host, Grafana gets[auth.jwt]withjwk_set_file: the JWKS file from the config source, so Grafana depends on neither the brokers nor name resolution. The upstream ports open to the brokers throughisg_monitoring_firewall_app_sources(the mail catcher's throughisg_directory_firewall_mail_ui_app_sources); an emptyisg_monitoring_firewall_admin_sourcescloses the direct route, so the console is reached through ISG only.
A change to an application is a configuration change: config-src.yml writes the new apps.yaml, and the
rolling upgrade carries it to the brokers one at a time. Replacing the binary alone does not.
The listener and its certificate
| Key | What it is |
|---|---|
listen |
The TLS listener's address, for every application |
tls_cert_file, tls_key_file |
The listener's chain and key (key mode 0600 or 0400); the leaf has to cover every host |
webui_url |
The web UI's address: where the proxy sends the browser to sign in, and back to on sign-out |
idle_timeout_seconds, max_duration_seconds |
The application session's idle limit (1800 s) and absolute limit (28800 s), independent of the web UI's |
reserved_usernames |
Names that never reach an application (admin, root, administrator by default) |
credential_kv_prefix |
Where the shared accounts live in OpenBao (isg/apps by default) |
dynamic_apps_domain |
The domain of the applications added on the web UI; empty means none |
What ISG does not do for you: the DNS. Every app host name has to resolve to the brokers' shared address, and has
to be on the same site as the web UI — the web UI's session cookie is SameSite=Strict. The client trusts the
apps' certificate the same way as the web UI's.
One application
- id: APP-2026-000002
name: prometheus
display_name: Prometheus
host: prometheus.apps.example.internal
upstream: http://monitor1.example.internal:9090
identity: none
allow_groups: [sysadmins]
nameis[a-z0-9-]{1,32}and does not change: it is the JWT audience's default and the OpenBao path.display_nameis what people read.hostis a DNS name (not an IP), unique across the applications.upstreamishttp(s)://host[:port]without a path; for anhttpsupstreamupstream_ca_filenames its CA.allow_groupsis mandatory and not empty: there is no application open to every signed-in user.logout_paths(for example/logout) are the paths where the application's own sign-out button lands; a request there signs the user out of the application session.level(0–5) is the application's lockdown level: at thejira.yamlticket_min_levelthreshold a Web Access session opens a ticket, as an SSH session does.- Approval, self-service access, request recording and the request rules are on the Web Access policy page.
What the application learns about the user
identity: none
ISG gates and audits; the application learns nothing. For a console with no user notion of its own (Prometheus, a mail catcher), or one that is fine with a single shared view.
identity: jwt
Every request carries a fresh assertion in assertion_header (X-ISG-Assertion by default), signed with ES256
(assertion_key_file, an ECDSA P-256 key): sub, email, name, groups, aud (audience, by default the
name), iss (assertion_issuer, isg by default), and the role from role_map (the first matching group
wins), else default_role. It is valid for assertion_ttl_seconds (60 by default, 10–300). The public key is on
every application host at /.well-known/isg/jwks.json. The application maps sub to its local account of the same
name — that is why the reserved names exist: measured on Grafana, sub: admin became the built-in super-admin.
identity: credential
ISG puts a shared account's secret into the request; the user never sees it, and their own name stays in the ISG
audit and transcript (account). credentials is a list: the first entry whose groups meet the user's groups is
used, and with none the answer is 403 — there is no silent default account. The secret is read from OpenBao at
<kv_mount>/data/<prefix>/<app>/<name>, where <prefix> is credential_kv_prefix, so vault.yaml is mandatory
next to such an application. When it cannot be read, the request does not go up (503).
kind |
What goes up | OpenBao fields |
|---|---|---|
basic |
Authorization: Basic |
username, password |
header |
the header named on the entry (e.g. X-Api-Key) |
value |
form |
the upstream session cookies of an account ISG signed in with itself | username, password, optional totp_secret |
The browser's own Authorization header and the named headers are removed from every request, and the upstream's
WWW-Authenticate response header too, so the browser never opens its own password dialog.
A console that signs in with a form (kind: form)
The account's form recipe is the console's own sign-in, step by step: steps (a GET only extracts tokens; at
least one POST; encoding: form or json; field values are literals or {{username}}, {{password}},
{{totp}}, {{token.<name>}}), success (all of status, cookie, redirect_prefix that are given must hold on
the last step's answer) and logged_out (when an answer means the upstream session has ended: status, or a 3xx
whose Location starts with redirect_prefix).
credentials:
- name: admin
groups: [sysadmins]
kind: form
form:
steps:
- method: POST
path: /login
encoding: json
fields: {user: '{{username}}', password: '{{password}}'}
success: {status: [200], cookie: grafana_session}
logged_out:
status: [401]
redirect_prefix: /login
unless_body_contains: [session.token.rotate]
- The sign-in happens once per application session and account, on the first request; parallel requests wait for
the same sign-in.
APP_UPSTREAM_LOGINis audited (withrelogin: truewhen it is a renewal). - When the upstream session ends, ISG signs in again. A
GETorHEADis repeated once, invisibly; any other method gets a 409 page that asks the user to send it again, because a modifying request is never repeated behind the user's back. - A sign-in that fails gives a 502 page;
APP_UPSTREAM_LOGIN_FAILEDnames why (status,success_cookie,success_redirect,extract,timeout,transport,secret), never a value. unless_body_containsnarrowslogged_out: an answer whose first 8 KiB contains one of the strings is the application talking to its own front end, not a sign-out, and goes to the browser untouched. Grafana's token rotation is the case it exists for (see preparing a web console).browser_cookieslists cookies that pass in both directions and are not kept by the broker (a CSRF cookie the page's script reads, for example); anisg_name is refused.- On the way up, the browser's
OriginandRefererare rewritten to the upstream's own address when they are the application's public address — a console that checks the origin of a cookie-authenticated request then accepts it. A foreign origin goes up unchanged, so the console's own CSRF protection stays. - Not supported: a captcha, and a token the console's script passes on by itself (such as a BMC that keeps its session token in the page's storage).
Applications added on the web UI
With dynamic_apps_domain set in apps.yaml, an administrator adds, changes and deletes applications on the web
UI's Manage apps page (/apps/manage) next to the ones in the file; every broker serves the change within about
5 s, with no restart. Each such application's host is one label under that domain, so two things are a one-time
setup on the customer's side: the listener certificate needs a wildcard SAN for the domain (star-dot-domain;
checked when apps.yaml loads, a missing one refuses the start), and a wildcard DNS record for the domain has to
point at the brokers' shared address. The applications in the file stay as they are and cannot be edited on the web
UI. With Ansible the domain is isg_cfg_apps_dynamic_domain; when the role issues the application certificate, it
adds the wildcard SAN itself. ISG gives each such application its id from the record store's sequence; deleting one
and adding it again under the same name is a new application, so none of the old one's approvals carry over. A
dynamic application cannot switch on record_body.