Set Up Astound SSO (AD FS)
The two-sided setup for Astound single sign-on: what your IT team registers in AD FS, and how every claim, domain, and Active Directory group maps into platform roles and entitlements. Includes moving an instance from a test farm to production.
On this page
TL;DR: SSO is a SAML 2.0 trust with two halves. Your AD FS team
registers this platform as a relying party and writes three claim issuance
rules. You put the matching values in services/web/config/adfs.yaml and
map each Systemprompt-* Active Directory group to the roles it grants. There
is no client secret anywhere: trust is anchored on the identity provider's
signing certificate, committed to the repository as a federation metadata file.
This page is the setup procedure. For how sign-in works at runtime — the callback, sessions, cookies, and route protection — see Authentication.
What you are building
The platform is the service provider (relying party). AD FS is the
identity provider. A sign-in is standard SAML 2.0 Web Browser SSO: the
platform sends an AuthnRequest, your corporate login policies apply
unchanged — Windows authentication, forms, MFA — and AD FS posts a signed
assertion back to the platform's assertion consumer service.
Three consequences worth stating up front:
- No secret to distribute or rotate. The assertion's XML signature is verified against the certificate in the pinned metadata. Certificate rotation is a file refresh and a redeploy, reviewable as a diff.
- The directory is the authority. Active Directory group membership is the entitlement. The platform re-reads it at every sign-in and stores no credential of its own for SSO users.
- Deny by default. An assertion carrying no group claim gets
no session. An unmapped group can sign in as
user.
Step 1 — Register the relying-party trust in AD FS
Give your AD FS administrator the two values in the middle column, where
<host> is the platform's public hostname (for example
sp-dev.astound.digital). Both must be https.
| AD FS setting | Value | Config key it must equal |
|---|---|---|
| Relying party identifier | https://<host>/saml/metadata |
entity_id (derived) |
| Assertion Consumer Service (POST binding) | https://<host>/admin/auth/adfs/acs |
acs_url (derived) |
| Token signature | Assertion signed, RSA-SHA256 | — |
| Token encryption | Not encrypted | — |
| Name ID format | Email address | — |
<host> is whatever the profile's --external-url names: both SAML URLs are
derived from it at boot, so there is nothing to edit in adfs.yaml and the two
values cannot disagree with each other or with the URL the instance serves
itself under. Each host that signs in needs its own relying-party trust.
Two deployments on one AD FS farm — a staging host and a production host, say —
are two trusts with identical claim rules; an instance whose identifier has no
trust behind it is refused with MSIS7007: The requested relying party trust ... is unspecified or unsupported.
Ask for one value back: the farm's federation metadata URL, normally
https://<farm>/FederationMetadata/2007-06/FederationMetadata.xml. Step 5
pins it.
The ACS URL must match character for character. The farm writes the registered endpoint into the assertion as both
DestinationandRecipient, and the platform rejects an assertion whoseDestinationdisagrees with its ownacs_url. A trust registered for one path while the platform serves another does not fail with a clear message — the browser is redirected, re-POSTs to a route that does not accept POST, and the user sees a bare405.
Also decide whether users may start a sign-in from the AD FS portal — the
application tile on /adfs/ls/idpinitiatedsignon. The platform allows it by
default (allow_idp_initiated: true); such an assertion is verified exactly
like a solicited one, only without the request-id correlation.
Step 2 — Claim issuance rules
The relying-party trust needs three issuance rules. All three are required; the platform rejects a sign-in that is missing any of them.
- Name ID, in email-address format. Without it the assertion carries no
subject and the sign-in fails with
?sso=no_subject. - E-Mail-Address, sourced from the user's
mailattribute. Source it frommail, not from the internal AD namespace — a rule that emitsuser@company.localproduces an address that is not routable and is not on any domain you would sensibly allow-list, which makes the domain gate in Step 3 meaningless. - Group, filtered to the platform's groups only. Emit group membership
under
http://schemas.xmlsoap.org/claims/Group, filtered to names matchingSystemprompt-*. Filtering matters: an unfiltered claim ships a user's entire directory group list to the platform on every sign-in.
Agree the exact group names at this point, and confirm the spelling in a live assertion before going live — a misspelt group is a group that maps to nothing and a user who cannot sign in.
Step 3 — Map the claims into configuration
Everything lives in one file, services/web/config/adfs.yaml. The claim URIs
default to the standard AD FS ones; set them only if your issuance rules name
them differently.
| AD FS claim | Config key | The platform uses it for |
|---|---|---|
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress |
email_attribute |
The login identity and the domain gate |
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name |
name_attribute |
The display name |
http://schemas.xmlsoap.org/claims/Group |
groups_attribute |
Roles, and group and project membership |
The rest of the file:
| Key | Default | What it does |
|---|---|---|
enabled |
false |
Turns SSO on. Off, the routes still answer, with ?sso=unavailable |
entity_id |
Derived from api_external_url |
Optional override of the relying-party identifier from Step 1 |
acs_url |
Derived from api_external_url |
Optional override of the assertion consumer service from Step 1 |
idp_metadata_path |
(required) | The pinned metadata file, relative to services/web/config/ |
allowed_email_domains |
astounddigital.com, astoundcommerce.com |
Only these email domains may sign in |
group_roles |
(empty) | Active Directory group → platform roles. See Step 4 |
group_role_patterns |
(empty) | Group name glob → platform roles, applied on top of group_roles. See Step 4 |
deny_without_group |
true |
No group claim, no session; unmapped groups receive the user role |
auto_provision |
false |
Create an account on a first sign-in |
allow_idp_initiated |
true |
Accept sign-ins started from the AD FS portal |
clock_skew_seconds |
120 |
Tolerance for drift between the farm and this server |
Unknown keys are rejected at load time, so a typo fails loudly rather than silently reverting to a default.
A complete file:
enabled: true
# entity_id and acs_url derive from the profile's api_external_url.
idp_metadata_path: adfs-federation-metadata.xml
allowed_email_domains:
- astounddigital.com
email_attribute: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
name_attribute: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
groups_attribute: "http://schemas.xmlsoap.org/claims/Group"
deny_without_group: true
allow_idp_initiated: true
clock_skew_seconds: 120
group_roles:
Systemprompt-Admins: [platform_admin, admin, user]
Systemprompt-Commerce: [developer, user]
Systemprompt-Core: [developer, user]
Systemprompt-Business: [knowledge_worker, user]
group_role_patterns:
Systemprompt-ProjectManagers-*: [project_manager, user]
auto_provision: true
Step 4 — Groups, roles, and entitlements
This is the part worth getting right. An Active Directory group reaches the platform as a claim and then does four separate jobs.
The group decides whether there is a session at all
deny_without_group: true — the default, and the recommended setting — means
an assertion carrying no group claim at all is refused with
?sso=no_group. The directory group is the authorisation; without one there
is nothing to grant.
A group this file does not name is a different case and is not a refusal: the
person signs in as a plain user, and groups.yaml below decides which
workspace they land in. That way a group IT adds before you map it enrols
people rather than locking them out.
Turn it off only on an evaluation instance where every user on an allow-listed
domain should get in as a plain user.
This setting is also what makes auto_provision safe. Auto-provisioning
creates an account on a first successful sign-in. With deny-on and a group
claim filtered to Systemprompt-*, a first sign-in that carries a mapped
group is one your IT team has already authorised — the AD group is the
invitation. With deny off, auto-provisioning would create an account for
anyone on an allow-listed domain, which is a different and much weaker gate.
With auto-provisioning off, SSO signs in existing accounts only, and an
unknown identity is rejected with ?sso=not_provisioned.
The group decides the platform role
group_roles maps each group to the roles it grants:
group_roles:
Systemprompt-Admins: [admin, user]
Systemprompt-Commerce: [developer, user]
Systemprompt-Core: [developer, user]
Systemprompt-Business: [knowledge_worker, user]
A user's roles are the union of the roles of every mapped group in their assertion, deduplicated. Two rules follow:
- Directory-sourced roles are replaced on every sign-in; manually granted roles survive. Removing someone from
Systemprompt-Adminsin Active Directory revokesadminat their next sign-in — a separately granted manual admin role must also be removed here. - A group the file does not name maps to nothing. It grants no role and it is not recorded, so it cannot be used for entitlements either.
What each role opens — including that project_manager is an entitlement
role, not a console role, and reaches the admin dashboard only through
marketplace participation — is in the one role table on
Users, Roles and Access → Roles.
This page only decides which AD groups grant which role.
One rule for many regional groups
Project managers are organised by region, so naming every group in
group_roles would go stale each time IT adds one. group_role_patterns maps
a group-name glob instead:
group_role_patterns:
Systemprompt-ProjectManagers-*: [project_manager, user]
One * is the whole grammar: the prefix before it and the suffix after it
must both match, and the * can match zero or more characters. Patterns
are applied on top of group_roles, never instead of it, so a user in
both Systemprompt-Admins and Systemprompt-ProjectManagers-UK holds every
role both grants. A group matched only by a pattern still counts as mapped, so
it is recorded against the account like any other.
Roles are read from the user record on every request, through a cache that holds an entry for 30 seconds, so a role changed in the console reaches an existing session within half a minute — no sign-out required. A role changed in the directory waits for the next sign-in; the table under How a directory change propagates sets the two side by side.
The group decides the workspace and the project
services/web/config/groups.yaml maps an AD group onto two separate things:
- a group — who someone is, and which marketplace workspace reaches their desktop;
- a project — what their usage is attributed to, and the split every dashboard, listing and report can be narrowed by.
groups:
- id: europe-devs
name: Europe developers
ad_groups: [Systemprompt-Commerce]
projects:
- id: commerce
name: Commerce
ad_groups: [Systemprompt-Commerce]
Four rules follow:
- Directory memberships are replaced on every sign-in. Leaving an AD group removes the membership it created at the member's next login.
- Dashboard grants survive that. An admin who adds someone to a group by hand creates a separate row the directory never touches, so a temporary placement does not evaporate overnight.
- An unmapped group places nobody. A person whose AD groups map to no
group at all is in the derived
unassignedgroup — a real row whose membership is computed, so a rule written against it binds. - A person may be on several projects. Unlike the old single-project column, project membership is a list, and someone on no project is a legitimate state.
Groups and projects can also be created and filled from the admin console. The AD mappings are the exception: they are platform-admin territory, because a mapping decides who the directory enrols.
The group decides per-entity entitlements
Roles are coarse. To confine one plugin, MCP server, agent, skill,
marketplace, or gateway route to the members of a named group, name the group
under the entity's allow.group in services/access-control/rules.yaml
(allow.project writes at the project band); the full model is in
Access Control:
entities:
- entity: mcp_server/systemprompt
default: closed
why: The admin MCP server, confined to the Europe developers.
allow:
group: [europe-devs]
Marketplaces are declared the same way (entity: marketplace/<id>); there is
no second place where a workspace's audience is written.
Unlike the SP configuration above, this file is not profile-aware: it is read from one fixed path, so every profile shares it. A grant added here applies everywhere the image is deployed.
The file is the declaration; the database is what is enforced. A fresh
install is seeded from the file; after that every boot only compares the two
and /admin/sync reconciles them on request. At the group
and project bands an entity is closed to everyone the rules do not name,
so a user outside every listed group cannot reach it whatever their role
would otherwise allow — unless a role band on the same entity names that
role. Both bands sit above role in the precedence ladder:
person 0 → project 140 → group 150 → linked account 170 → role 200
A project grant is therefore the narrowest shared statement available: where both match the same entity, the project rule decides.
The values here are platform group and project ids, not AD group names.
An id that services/web/config/groups.yaml does not declare fails the
services gate in CI, so the two files cannot drift.
End to end
A user in Systemprompt-Admins signing in:
- AD FS issues the filtered group claim carrying
Systemprompt-Admins. group_rolesmaps it to[platform_admin, admin, user], which replace the directory half of the account's roles. Anything an admin granted by hand stays.groups.yamlplaces them in whichever group and project the name maps to, replacing the memberships their last sign-in wrote.- The user reaches the whole console, plus any entity granted to their group, and is closed out of entities granted only to other groups.
- IT removes them from
Systemprompt-Admins. At their next sign-in the directory roles are re-projected withoutadmin, the platform revokes the sessions and API keys minted while they held it, and the membership that group created is gone.
How a directory change propagates
The directory is only consulted at sign-in, so "the group changed" and "the platform noticed" are different moments. What decides the gap is which of three mechanisms carries the change: the next assertion, the per-request user lookup, or explicit credential revocation.
| Change | Takes effect | Carried by |
|---|---|---|
Added to, or moved between, Systemprompt-* groups |
Next sign-in | The assertion re-projects the directory roles and memberships; nothing is pushed between sign-ins |
| Removed from a mapped group (non-admin role lost) | Next sign-in | Same re-projection; the existing session keeps its roles until then |
Removed from the group that grants admin |
Next sign-in, then immediately | The re-projection drops admin and the platform revokes every session, bridge PAT, device certificate and unredeemed link code the person holds |
| Removed from every group, or moved off an allow-listed domain | Next sign-in attempt, then immediately | The refused sign-in triggers the same revocation, keyed on the email the assertion carried |
admin removed from the console (manual role) |
Immediately | The role change runs the same revocation without waiting for a sign-in |
| Account disabled from the console | Within about 30 seconds | Every request that presents a JWT re-reads the user record through a 30-second cache; a disabled account is refused once its entry expires |
| Nothing changes | Until the session expires | A browser session lives for security.jwt_access_token_expiration seconds — 2,592,000 (30 days) on the production profile; a bridge token minted from a PAT lives one hour |
Two consequences follow. First, a plain role change made in Active Directory is not enforced until the person signs in again, and a session on the production profile can be a month old; if the change cannot wait, disable the account or remove the role from the console, both of which reach the live session. Second, revocation is what makes admin demotion real: a token minted while someone was an admin carries that permission until it expires, so the platform tears the credentials down rather than trusting the expiry.
SCIM provisioning is not available. AD FS does not push standards-based SCIM, so there is no endpoint on this platform for a directory to call and nothing that would observe a change before the next sign-in. Joiner, mover and leaver events reach the platform only through the assertion and the revocation paths above.
Step 5 — Pin the identity provider's signing certificate
The platform never fetches federation metadata at sign-in time. It reads the copy committed beside the config, so the trusted signing certificate is pinned and any change to it is a reviewable diff:
curl -sS https://<farm>/FederationMetadata/2007-06/FederationMetadata.xml \
-o services/web/config/adfs-federation-metadata.xml
Commit the result and redeploy. When the farm rotates its token-signing
certificate, repeat this — a stale pinned certificate rejects every
assertion with ?sso=invalid_assertion. Ask your AD FS team for the current
certificate's expiry and diarise the refresh ahead of it.
If enabled: true and the file named by idp_metadata_path cannot be read,
the platform treats it as a configuration error rather than quietly serving
sign-in as unavailable.
Pointing an instance at a different farm
Two supported ways.
One environment — edit the file. Change the profile's api_external_url for a new public hostname. Change
allowed_email_domains, group_roles, and the metadata path in
services/web/config/adfs.yaml, re-pin the metadata for the new farm, and
redeploy.
Several environments — add a per-profile file. The loader prefers
adfs.<profile>.yaml — the active deployment profile's name — and falls back
to adfs.yaml when it is absent. Each file names its own metadata, so both
farms' certificates sit side by side:
services/web/config/
adfs.yaml # the default every profile falls back to
adfs-federation-metadata.xml
adfs.production.yaml # used only by the `production` profile
adfs-federation-metadata.production.xml
Add the override; never rename the default.
The name must match the deployment profile exactly. The loader builds the
filename from the active profile's name, so profile production reads
adfs.production.yaml and nothing else. A misspelt or misplaced override is
not an error: it is simply absent, and the instance silently falls back to
adfs.yaml — pointing at whichever farm the default names, which on a fresh
checkout is somebody else's. The farm then rejects the assertion and the
symptom is ?sso=invalid_assertion, which reads like a certificate or ACS
problem and sends you to the wrong place entirely.
So confirm the file actually loaded rather than assuming it. The startup log names it:
systemprompt infra logs view --since 5m | grep "Loaded ADFS SSO config"
Check this first, before anything else in the triage table below. It is one line, and it rules out the failure that looks like every other failure.
Verifying a live sign-in
- Use Sign in in the site header, or open
/admin/loginand use Sign in with Astound SSO — both start the same flow at/admin/auth/adfs/start. Repeat from the AD FS portal tile if you allow IdP-initiated sign-in. - Confirm the roles the directory granted:
systemprompt admin users list. - Check for errors:
systemprompt infra logs view --level error --since 10m.
Every failure returns to /admin/login?sso=<reason>. The browser is told only
the reason; the detail is in the server log.
| Reason | What to fix |
|---|---|
unavailable |
SSO is off, or the pinned metadata is unreadable |
denied |
AD FS refused, or the user cancelled |
no_subject |
No Name ID claim — issuance rule 1 in Step 2 |
invalid_assertion |
Signature, audience, Destination, validity, or replay — check the pinned certificate and the ACS URL |
no_email |
No email claim and no email-format Name ID — issuance rules 1 and 2 |
forbidden |
The email's domain is not in allowed_email_domains |
no_group |
The assertion carried no group at all — check the claim filter |
not_provisioned |
No account, and auto_provision is off |
error |
Anything else; the server log has the detail |