Step 4 — Users, Seats and Roles
Claim your email domains, assign a plan and seat limit, and control what a provisioned Salesforce user can reach — so SSO arrivals land in the right organization with the right entitlements.
On this page
TL;DR: Claim your email domains in services/access-control/plans.yaml so
SSO arrivals join an organization, consume a seat, and inherit that plan's
entitlements. Skipping this leaves seat limits entirely unenforced.
The Most Important Warning on This Page
A seat check runs only for users whose email domain is claimed by an organization. An arrival on an unclaimed domain lands unattached: they get an account, but no organization, therefore no plan grants, and — critically — no seat check at all. The seat-limit machinery is real and correct, but it simply does not engage for them.
Two organizations ship claimed, both on the enterprise plan:
| Slug | Name | Domains |
|---|---|---|
astound-digital |
Astound Digital | astounddigital.com, astoundcommerce.com |
systemprompt |
systemprompt.io | systemprompt.io |
Every other domain is unclaimed. If you do nothing else on this page, claim your own.
How Just-in-Time Provisioning Works
When a verified, allow-listed Salesforce identity arrives, the platform resolves it in a fixed order and stops at the first match:
- Existing mapping — the issuer and external subject already point at a user. This is a returning login. The last-seen timestamp is bumped.
- Email link — an active local account already owns this email address. The federated identity is attached to it rather than minting a duplicate. This is the account merge, and it is why the verified-email gate in step 1 is not optional: linking an unverified address would let a hostile identity provider claim an existing account.
- Create — no mapping, no local account. A new user is provisioned.
On the create path, the seat check runs before the user row is inserted, so
a full plan rejects the login rather than creating an orphaned account that
cannot reach anything. New users are created active, email-verified, with the
user role, and — when their domain is claimed — a membership row in that
organization. All in one transaction.
The seat limit is enforced at both doors that can mint a seat: an admin creating a user, and SSO just-in-time provisioning. A limit checked at only one door is not a limit, and SSO is the door your users actually arrive through.
1. Claim Your Domains
Edit services/access-control/plans.yaml:
organizations:
- slug: yourcompany
name: Your Company
plan: enterprise
email_domains:
- yourcompany.com
- yourcompany.co.uk
slug is immutable — it is the value every access rule is written against, so
renaming it later orphans those rules. email_domains is what binds an SSO
arrival to this organization.
An arrival whose domain matches nothing is not an error. It simply lands unattached: no organization, no plan grants, no seat consumed. That is a reasonable default for a stray login and a dangerous one for your whole user base — which is precisely the trap above.
Note the two independent domain lists. allowed_email_domains in
salesforce.yaml decides who may sign in at all. email_domains here
decides which organization they join. A domain in the first but not the
second produces exactly the unattached-user problem. Keep them in sync.
2. Choose a Plan
Three plans ship, and a plan is simply a named bundle of grants plus limits:
| Plan | Seats | Monthly cap | Grants |
|---|---|---|---|
house |
unlimited | none | enterprise-demo + astound-admin |
standard |
25 | $2,500 | enterprise-demo |
enterprise |
500 | $50,000 | enterprise-demo + astound-admin |
enterprise-demo is the marketplace carrying the Salesforce plugins, agents,
and the salesforce MCP server. astound-admin is the admin control plane.
Per-customer negotiated seat counts use seat_limit_override on the
organization rather than editing the shared plan:
- slug: yourcompany
name: Your Company
plan: standard
seat_limit_override: 40
email_domains: [yourcompany.com]
This file is the bootstrap source of truth. On startup the publish pipeline upserts each plan and organization, then projects every plan's grants into access-control rules keyed to each organization on that plan. Dashboard edits write to the database only — there is no write-back to this file. To make a change permanent across deployments, edit the file in the repository and redeploy.
3. Understand How Access Resolves
Rules are evaluated across four subject dimensions in precedence order:
organization (300) → role (200) → department (100) → user (0)
Deny overrides allow, and the narrowest scope wins. The practical consequence is worth internalising: a customer admin can narrow what their plan granted, and can never widen it. Entitlement is a ceiling set by the plan; local administration operates underneath it.
Model tiering rides the same machinery through gateway-route grants. A route the organization has no allow rule for is not dispatchable by its members — "Standard does not get the top model tier" is enforced at the gateway rather than hidden in a UI.
4. Review What a Default User Gets
services/access-control/roles.yaml sets the role-level rules. As shipped, a
plain user role gets:
- The
enterprise-demomarketplace, included by default. - Every Salesforce specialist agent — pipeline, account, contact, lead, case, activity, and the general Salesforce agent.
- The
salesforceMCP server.
Skills, plugins, and servers inside enterprise-demo need no rule of their own:
they inherit the marketplace grant through the parent cascade. Add an
entity-level rule only to deny a specific member — a deny overrides the
inherited allow.
The admin control plane is separately gated. The systemprompt MCP server and
the astound-admin plugin are admin-only, so a normal user never sees them.
Declaring any rule for an entity opts it out of the marketplace cascade, which
is how those stay restricted.
To promote someone:
systemprompt admin users role promote <email> admin
The role is baked into the JWT at issue time, so the user must sign out and back in before the change takes effect.
5. Decide Your Provisioning Posture
auto_provision in salesforce.yaml is the choice between two operating models:
auto_provision: true |
auto_provision: false |
|
|---|---|---|
| First login | Creates the account | Rejected with ?sso=not_provisioned |
| Admin work per user | None | Pre-create every account |
| Control point | Domain allow-list, seat limit, and Salesforce pre-authorization | Explicit per-user approval |
The frictionless path is not the loose path, provided step 4 is done properly.
Three independent gates already constrain it: the email domain must be
allow-listed, a seat must be free, and the user must be pre-authorized on the
Salesforce app. Leaving auto_provision: true with claimed domains and a real
seat limit is the recommended posture for most enterprises. Set it to false
when you need a named approval for each individual.
Offboarding
Suspending a user frees their seat while preserving every audit row attached to them — a seat counts only active members. Do not delete users to reclaim seats; deletion destroys the governance trail that makes the integration defensible.
Suspending an entire organization revokes every grant at once without touching a single rule row: a non-active organization reports no organization for its members, so its rules simply stop matching.
Verify
# Confirm plans and organizations were projected on startup
systemprompt infra logs view --level error --since 10m
Then run the real test. Sign in as a Salesforce user whose domain you just claimed, and confirm three things:
- The login succeeds.
- The user appears as a member of the expected organization with the expected seat count consumed.
- The Salesforce tools are visible to them in the bridge.
To confirm the seat limit actually engages, temporarily set
seat_limit_override to the current seat count and attempt one more login. It
should be rejected with ?sso=seat_limit. If it succeeds, the user's domain is
not claimed — go back to section 1.
Troubleshooting
| Symptom | Cause |
|---|---|
| Users sign in but see no Salesforce tools | They landed unattached — their domain is not in any organization's email_domains |
| Seat limits never trigger | Same cause. No organization means no seat check |
?sso=not_provisioned |
auto_provision: false and no account exists |
?sso=seat_limit |
The organization is full. Suspend an inactive user or raise seat_limit_override |
| A promoted admin still sees no admin tools | The role is minted into the JWT at issue time — sign out and back in |
| Dashboard changes vanish after a redeploy | Expected. The dashboard writes only to the database; permanent changes go in the YAML |