Skip to main content

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.

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 Destination and Recipient, and the platform rejects an assertion whose Destination disagrees with its own acs_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 bare 405.

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.

  1. Name ID, in email-address format. Without it the assertion carries no subject and the sign-in fails with ?sso=no_subject.
  2. E-Mail-Address, sourced from the user's mail attribute. Source it from mail, not from the internal AD namespace — a rule that emits user@company.local produces 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.
  3. Group, filtered to the platform's groups only. Emit group membership under http://schemas.xmlsoap.org/claims/Group, filtered to names matching Systemprompt-*. 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-Admins in Active Directory revokes admin at 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 unassigned group — 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:

  1. AD FS issues the filtered group claim carrying Systemprompt-Admins.
  2. group_roles maps it to [platform_admin, admin, user], which replace the directory half of the account's roles. Anything an admin granted by hand stays.
  3. groups.yaml places them in whichever group and project the name maps to, replacing the memberships their last sign-in wrote.
  4. The user reaches the whole console, plus any entity granted to their group, and is closed out of entities granted only to other groups.
  5. IT removes them from Systemprompt-Admins. At their next sign-in the directory roles are re-projected without admin, 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

  1. Use Sign in in the site header, or open /admin/login and 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.
  2. Confirm the roles the directory granted: systemprompt admin users list.
  3. 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