Authentication
The one way into the platform: Astound single sign-on (ADFS over SAML 2.0, with AD groups deciding roles). Covers operator recovery from the CLI, sessions, JWTs, and route protection.
On this page
TL;DR: There is exactly one way into the platform in a browser, and it stores no password. Everyone signs in with Astound SSO — the corporate AD FS farm, over SAML 2.0 — and their Active Directory groups decide what they can do. There is no second login form, no self-registration, and no in-browser invite redemption; operator recovery happens from the CLI.
The directory is the authority. A Systemprompt-* group in Active Directory
is the entitlement; the platform reads it at every sign-in and never stores a
credential of its own for SSO users. The desktop bridge is not an exception:
linking a device sends you through the same browser sign-in, and the link code
it returns is only minted for a session SSO already created.
Being the authority means the directory can also take things away. A sign-in refused because the AD group is gone does not merely fail — it revokes the sessions and bridge credentials that sign-in's owner already holds. See Deprovisioning.
Path 1 — Astound Users: Astound SSO
This is how everyone who is not a platform operator signs in.
The user clicks Sign in with Astound SSO on /admin/login (or picks
this application on the AD FS portal). The platform sends a SAML 2.0
AuthnRequest to AD FS, so the corporate login policies — Windows
authentication, forms, MFA — apply unchanged, and AD FS posts a signed
assertion back to /admin/auth/adfs/acs.
On that callback, the platform:
- Verifies the assertion: XML signature against the IdP certificate pinned
in the committed federation metadata, audience, destination, validity
window, replay, and — for a sign-in we started — the request it answers
and the
RelayStatethis browser's request minted. - Gates the claims: the email must be on an allow-listed Astound domain, and the assertion must carry at least one AD group. An assertion with no group claim at all gets no session — deny by default. A group the platform does not map is not a refusal; it simply mints no role.
- Maps the groups to roles (
services/web/config/adfs.yaml,group_roles). This rewrites the directory half of the account's role set, so leaving an AD group revokes the role at the next sign-in. Roles an admin granted by hand are a separate half and survive. A sign-in carrying no mapped group signs in as a plainuser. - Resolves the identity — returning login, merge onto an existing account by email, or just-in-time provisioning.
- Places the person in the groups and projects those AD groups map to
(
services/web/config/groups.yaml), replacing the directory-sourced memberships from their last sign-in and leaving dashboard-granted ones alone. An AD group that maps to nothing leaves them in the derivedunassignedgroup. - Mints a session JWT and sets it as the
access_tokencookie.
Failures return to /admin/login?sso=<reason>:
| Reason | Meaning |
|---|---|
unavailable |
No usable adfs.yaml (or unreadable IdP metadata) on this instance |
denied |
AD FS refused or the user cancelled |
no_subject |
The assertion carries no NameID — the relying-party trust needs a Name ID claim |
invalid_assertion |
Signature, audience, destination, validity, or replay check failed |
no_email |
The assertion carried no email attribute and no email-format NameID |
forbidden |
The email domain is not allow-listed |
no_group |
The assertion carried no AD group at all |
not_provisioned |
No account exists and auto-provisioning is off |
error |
Anything else — the server log has the detail |
Groups as entitlements
Two things read the AD groups:
- Roles —
group_rolesinadfs.yamlmaps a group toplatform_admin,admin,project_manager,developer,knowledge_workerand/oruser(group_role_patternsadds glob matches such asSystemprompt-ProjectManagers-*). The directory half of the role set is re-projected on every sign-in. What each role opens is on Users, Roles and Access → Roles. - Membership —
services/web/config/groups.yamlmaps a group onto a platform group (who someone is, and what workspace they reach) and a project (what work their usage is attributed to). Both are database rows an admin can also edit from the dashboard.
Groups and projects are the two narrowest shared bands of the access-control
precedence ladder: person 0 → project 140 → group 150 → linked account 170 →
role 200. The narrowest band that names a person decides, and an entity with
rules is closed to everyone they do not name. Every grant — marketplaces
included — is declared in services/access-control/rules.yaml; see
Access Control.
A directory change is observed at the next sign-in, and until then an
existing session keeps what it was given, bounded by the session TTL
(security.jwt_access_token_expiration; 30 days on the production profile).
The exceptions are the ones that revoke: losing the group that grants admin
and a refused sign-in both tear down the person's credentials, and disabling
the account from the console is enforced on every JWT-bearing request within
about 30 seconds. The full table is in
How a directory change propagates.
There is no SCIM endpoint — AD FS does not push one, so nothing observes a
change between sign-ins.
Linking a desktop bridge is not a second login: the device-link approval runs inside an SSO session and mints a device credential from it.
Operator Recovery — CLI Only
SSO is the only browser door. That makes the directory a dependency: if the AD FS farm is unreachable, nobody signs in through the web UI. Recovery is therefore deliberately out-of-band and does not run through the login page at all.
Operators are created out-of-band and there is no way to self-provision one.
The self-registration endpoint and the public invite-redemption page have both
been removed — not disabled behind a flag, removed — so no browser request can
create an account. Accounts are provisioned by operators or by SSO when a signed assertion passes the configured domain and group-claim gates and auto_provision is enabled.
# Create the account and grant the admin role
systemprompt admin users create --name "Jane" --email jane@astounddigital.com
systemprompt admin users role promote jane@astounddigital.com admin
For access when SSO itself is down, mint a CLI admin session directly against
the database rather than through the web UI. This, systemprompt admin bridge issue-code, and a personal access token minted from an existing admin session
are the platform's break-glass credentials: they are retained deliberately
so an AD FS outage is not a lock-out, they are operator tools rather than a
second user-facing login, and every one of them needs either database access or
an SSO session that already exists.
systemprompt admin session switch production # or the profile you are recovering
systemprompt admin session login
The WebAuthn credential tables and the CLI's
admin users webauthn generate-setup-token command still exist for
out-of-band credential enrolment, but no page in this installation presents a
passkey challenge — nothing in the browser consumes one. Treat passkeys as
dormant infrastructure, not as a live sign-in path.
Roles are not carried in the session token. They are read from the user record on every request through a 30-second cache, so promoting or demoting an operator takes effect within half a minute — no sign-out, no waiting for a token to refresh. The OAuth scope minted into the JWT is fixed at issue time, so a change that widens the scope itself still needs a fresh sign-in.
An operator who also signs in through SSO keeps their CLI-granted roles unless the directory maps a role for them — an SSO login that maps no role is not a demotion.
Configuring SSO
services/web/config/adfs.yaml carries the relying party: our entity id, the
ACS URL, the path of the committed IdP federation metadata (which pins the
signing certificate — rotation is a re-fetch and a reviewable diff), the
allow-listed domains, the claim URIs, and the group→role map. There is no
client secret.
Set Up Astound SSO (AD FS) is the full procedure: what your IT team registers in AD FS, the three claim issuance rules, every config key, how groups become roles and per-entity entitlements, certificate rotation, and pointing an instance at a different farm.
The deployed trust and group mapping are configured in
services/web/config/adfs.yaml.
Deprovisioning
Removing someone from their Systemprompt-* group is the whole deprovision
step. There is nothing else to click.
Blocking the next sign-in would not be enough on its own: a cookie session and a
linked bridge both outlive the assertion that created them, so a directory
change that only guarded the front door would leave a working desktop app behind
it. So when a sign-in is refused for a missing group or an off-list domain, and
when a returning sign-in's groups no longer map to admin, the platform revokes
that user's:
- browser sessions (
user_sessions), - bridge PATs (
user_api_keys) — the desktop app must be linked again, - enrolled device certificates,
- any unredeemed bridge device-link code.
Rows are marked revoked, never deleted: what someone held and when it was taken away is part of the audit trail.
One caveat worth knowing when you are timing a removal: a gateway token already minted stays valid for its hour. Revocation stops the next one, so treat "the bridge is dead" as true within the hour, not within the second. For an immediate cut, close the account.
Session Management
| Property | Value |
|---|---|
| Cookie name | access_token |
| Token format | JWT |
| Expiry | security.jwt_access_token_expiration in the profile, also the cookie's Max-Age; there is no default — the profile must set it. Production: 2,592,000 seconds (30 days) |
| Cookie flags | path=/, HttpOnly, SameSite=Lax, Secure on HTTPS |
| Required scopes | user or admin |
That expiry is the outer bound on a session, not the only thing that ends one. Every request also confirms the session row is still active and the user record is still active (the latter through a 30-second cache), so revocation and account disablement cut a session short well before the token expires. A bridge token minted from a PAT is separate and lives one hour.
Every admin request passes through two middleware layers. User context
middleware extracts and validates the JWT, then loads the user's roles and
project into a UserContext. Auth check middleware rejects protected
routes without a valid user ID, returning HTTP 401.
UserContext carries user_id, username, email, roles, group_ids,
project_ids, the caller's marketplace participation, and the derived flags
is_admin, is_console, is_platform_admin and is_developer.
To sign out, clear the access_token cookie.
Public vs. Protected Routes
| Route | Access |
|---|---|
/admin/login |
Public |
/admin/auth/adfs/* |
Public — the SSO start and callback |
/admin/profile, /admin/history, /admin/connect, /admin/connectors |
Any valid session, including a plain user |
/admin/analysis/conversations, /admin/analysis/versions, /admin/sync and the session, request, trace and context detail pages |
A valid session with admin / platform_admin, or marketplace participation — scoped to the participant's marketplaces |
/admin/* (everything else) |
A valid session and a console role (admin or platform_admin) |
Admin JSON writes (/api/public/admin/* mutations) |
admin or platform_admin |
Adding or removing AD-group mappings on groups and projects (POST/DELETE /api/public/admin/{groups,projects}/{id}/ad-mappings) |
platform_admin only |
/bridge-auth/* |
Requires a valid session — the desktop link runs through SSO |
/admin/login and the two SSO routes are the entire public surface. There is
no public registration, credential-enrolment, or invite-redemption route; if you
are adding one, you are adding a second way in.
Anonymous requests to a protected route are redirected to
/admin/login?redirect=…. A signed-in user without a console role is not
shown a 403 for console pages — they are redirected to /admin/profile, unless
the page is one their marketplace participation opens. JSON admin API routes
return HTTP 403 instead. The role model, including participation, is on
Users, Roles and Access → Roles.
System-originated actions
Every action recorded by the platform — including scheduled jobs, hooks, and MCP-server invocations — traces to a real users row. There is no separate "system user" or synthesized principal. The platform refuses to attribute work to an invented identity.
How ownership is declared
Each scheduled job in services/scheduler/config.yaml carries an explicit owner: field naming an existing admin user:
- name: publish_pipeline
extension: web
owner: admin
schedule: "0 */15 * * * *"
enabled: true
At startup the scheduler resolves owner: to a users.id. If the named user does not exist or is inactive, startup fails loudly — the platform refuses to run with unowned jobs. To change ownership, edit the YAML and restart.
How attribution flows
The resolved owner becomes JobContext.actor for every execute() call. Job implementations consume it through ctx.actor() and pass it to any audit-row write. Governance audit rows carry three fields that together give full forensic clarity:
| Column | Meaning |
|---|---|
user_id |
The accountable principal — a real users.id. |
actor_kind |
The surface that ran the action: user, job, mcp. |
actor_id |
A label for that surface (job name, MCP server name, etc.). |
A direct human action shows as (user_id = alice, actor_kind = 'user', actor_id = 'alice'). A scheduled job owned by Alice shows as (user_id = alice, actor_kind = 'job', actor_id = 'publish_pipeline'). Same accountability column, different surface, queryable separately:
SELECT actor_kind, user_id, COUNT(*)
FROM governance_decisions
GROUP BY actor_kind, user_id;
Why no separate "system" user
A dedicated "system" identity would be either a synthesized principal (impersonation) or a backdoor account with no real human accountability. Neither passes the "every action traces to a real user" bar. The designated owner is a normal admin who legitimately authorized the platform's existence by installing it — same accountability model as a unix crontab. Compromising the designated owner is exactly as bad as compromising that admin's credentials directly; there is no additional power and no amplification path.
Service accounts and machine credentials
A workflow that writes back to an external system (opening a GitHub pull request, updating a Salesforce record, commenting in Jira) acts as a service account, never as a shared global key. A service account is a users row with account_kind = 'service'. Core refuses every interactive login for it: session login, passkey, bridge code and OAuth authorization code. Each workflow definition names the account it runs as.
Administrators manage them at People & access → Service accounts (/admin/identity/service-accounts):
- Create the account with a name and an email. It starts with the plain
userrole. - Attach a machine credential per provider, keyed by the MCP server id (
github,salesforce-<org>,atlassian):
| Kind | Non-secret identifiers | Secret | What the broker returns |
|---|---|---|---|
github_app |
App ID, installation ID | App private key (PEM) | An installation token: core signs a 10-minute RS256 App JWT and exchanges it at /app/installations/{id}/access_tokens. |
salesforce_client_credentials |
Instance URL, consumer key (client ID) | Client secret | An access token from the org's client_credentials grant. |
atlassian_api_token |
Token owner email | API token | Basic base64(email:token). |
The secret is sealed with the platform master key in an envelope bound to the account and provider. It is never rendered or returned, and attaching again rotates it. Only a service account may hold a machine credential, and an account that still holds one cannot be deleted.
When core's MCP client asks the credential broker (/api/public/connectors/{provider}/token) for a service account's bearer, the broker requires the x-systemprompt-caller purpose core forwards from the run. It issues a token only for workflow:<definition_id>:write or farm:<id>. Any other purpose, or none, is refused with 403 and logged under the connector_broker target with the account, provider and caller. The broker does not mint anything itself. It opens the sealed secret and hands it to core's machine grant (connector_grant::MachineGrant), the same code that serves core-managed connectors declared with grant: app_installation or client_credentials, so there is one App JWT signer, one token exchange and one token cache. Core caches a minted token until two minutes before it expires; the cache key includes the credential's rotation time, so a rotated credential never serves the previous token. People keep the per-user OAuth path unchanged.
GitHub's hosted MCP server is declared in services/mcp/github.yaml for this purpose. No plugin enables it. services/access-control/rules.yaml grants it, and the Atlassian and Salesforce servers, to the service-accounts group that every new service account joins; among people only administrators reach GitHub.