Skip to content

Commit 2e86604

Browse files
committed
feat(sso): add safe member provisioning
1 parent f3fb445 commit 2e86604

25 files changed

Lines changed: 21918 additions & 78 deletions

File tree

apps/docs/content/docs/platform/enterprise/sso.mdx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Go to **Settings → Security → Single sign-on** in your organization settings
4848
| **Provider ID** | A short slug identifying this connection. Letters, numbers, and dashes only. It must be **unique across every Sim organization**, so include something specific to you — `azure-ad-acme`, not `azure-ad`. If the ID is taken, Sim tells you and suggests a free one. |
4949
| **Issuer URL** | The identity provider's issuer URL. Must be HTTPS. |
5050
| **Domain** | Your organization's email domain, e.g. `company.com`. Users with this domain will be routed through SSO at sign-in. |
51+
| **Member provisioning** | **Automatic** adds a user authenticated through this verified SSO connection to the organization as a Member and consumes a billed seat. Team seat counts grow with membership; fixed-seat plans require available capacity. **Invite only** authenticates the user without creating organization membership. Neither mode grants workspace access automatically. |
5152

5253
**OIDC additional fields:**
5354

@@ -267,16 +268,17 @@ Once SSO is configured, users with your domain (`company.com`) can sign in throu
267268
1. User goes to `sim.ai` and clicks **Sign in with SSO**
268269
2. They enter their work email (e.g. `alice@company.com`)
269270
3. Sim redirects them to your identity provider
270-
4. After authenticating, they are returned to Sim and added to your organization automatically
271-
5. They land in the workspace
271+
4. After authenticating, they are returned to Sim
272+
5. If **Member provisioning** is **Automatic**, Sim adds them to the organization as a Member, growing a Team seat count or validating available fixed-seat capacity
273+
6. They land in an accessible workspace, or see a clear no-access state until an admin grants workspace access
272274

273-
Users who sign in via SSO for the first time are automatically provisioned and added to your organization — no manual invite required.
275+
With **Automatic** provisioning, no invitation is required for organization membership. The join follows the organization's seat policy and does not infer a role from IdP claims: every new user starts as a Member. Team subscriptions grow their billed seat count with membership; fixed-seat plans reject the join when capacity is full. With **Invite only**, SSO proves identity but an invitation is still required for organization or workspace access.
274276

275277
<Callout type="warning">
276278
Sign-in must start from Sim. Launching from your identity provider's app portal (Microsoft's **My Apps**, Okta's dashboard tile) sends an unsolicited assertion, which Sim rejects. This is deliberate — accepting them would let anyone replay an assertion into your tenant — but it means an IdP-initiated test fails even when the configuration is correct.
277279
</Callout>
278280

279-
SSO provisioning creates internal organization members. External workspace members are different: they are invited to a specific workspace without joining your organization or consuming one of your seats.
281+
SSO provisioning creates internal organization members but does not grant workspace access. External workspace members are different: they are invited to a specific workspace without joining your organization or consuming one of your seats. Existing invitations and external access take precedence over automatic provisioning so their intended role and workspace grants are preserved.
280282

281283
<Callout type="info">
282284
Password-based login remains available. Forcing all organization members to use SSO exclusively is not yet supported.
@@ -299,7 +301,11 @@ SSO provisioning creates internal organization members. External workspace membe
299301
},
300302
{
301303
question: "What happens when a user signs in with SSO for the first time?",
302-
answer: "Sim creates an account for them automatically and adds them to your organization. No manual invite is needed. They are assigned the member role by default. External workspace members are not provisioned through SSO into your organization; they are invited directly to a workspace and remain outside your org roster."
304+
answer: "Sim creates or links their account. If Member provisioning is Automatic and a seat is available, Sim adds them to your organization as a Member; no manual organization invite is needed. Workspace access is always granted separately. If provisioning is Invite only, or the user already has a pending invitation or external workspace access, Sim preserves that flow instead of creating membership automatically."
305+
},
306+
{
307+
question: "Does disabling someone in the identity provider remove their Sim access?",
308+
answer: "No. Disabling the IdP account blocks future SSO authentication, but Sim does not currently receive SCIM deprovisioning or IdP logout events to remove membership or revoke active Sim sessions. Remove or suspend the user in Sim as part of offboarding."
303309
},
304310
{
305311
question: "Can I still use email/password login after enabling SSO?",

apps/sim/app/api/auth/sso/providers/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
7575
samlConfig: ssoProvider.samlConfig,
7676
userId: ssoProvider.userId,
7777
organizationId: ssoProvider.organizationId,
78+
jitProvisioningEnabled: ssoProvider.jitProvisioningEnabled,
7879
})
7980
.from(ssoProvider)
8081
.where(whereClause)

apps/sim/app/api/auth/sso/register/route.test.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,10 @@ describe('POST /api/auth/sso/register', () => {
263263
queueMembers([{ organizationId: 'org1', role: 'owner' }])
264264
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
265265
expect(res.status).toBe(200)
266-
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
266+
expect(dbChainMockFns.set).toHaveBeenCalledWith({
267+
domainVerified: true,
268+
jitProvisioningEnabled: true,
269+
})
267270
})
268271

269272
/** updateSSOProvider resets domainVerified to false whenever the domain changes. */
@@ -274,7 +277,10 @@ describe('POST /api/auth/sso/register', () => {
274277
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
275278
expect(res.status).toBe(200)
276279
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
277-
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
280+
expect(dbChainMockFns.set).toHaveBeenCalledWith({
281+
domainVerified: true,
282+
jitProvisioningEnabled: true,
283+
})
278284
})
279285

280286
/**
@@ -299,6 +305,7 @@ describe('POST /api/auth/sso/register', () => {
299305
domain: 'acme.com',
300306
oidcConfig: '{"stored":"oidc"}',
301307
samlConfig: null,
308+
jitProvisioningEnabled: false,
302309
},
303310
]) // provider already owned → update path
304311

@@ -313,6 +320,7 @@ describe('POST /api/auth/sso/register', () => {
313320
oidcConfig: '{"stored":"oidc"}',
314321
samlConfig: null,
315322
domainVerified: false,
323+
jitProvisioningEnabled: false,
316324
})
317325
})
318326

@@ -339,14 +347,33 @@ describe('POST /api/auth/sso/register', () => {
339347
setEnvFlags({ isSsoEnabled: true, isHosted: true })
340348
const res = await POST(request(OIDC_BODY))
341349
expect(res.status).toBe(200)
342-
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: false })
350+
expect(dbChainMockFns.set).toHaveBeenCalledWith({
351+
domainVerified: false,
352+
jitProvisioningEnabled: true,
353+
})
343354
})
344355

345356
it('grants domain trust to a personal provider when self-hosted', async () => {
346357
setEnvFlags({ isSsoEnabled: true, isHosted: false })
347358
const res = await POST(request(OIDC_BODY))
348359
expect(res.status).toBe(200)
349-
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
360+
expect(dbChainMockFns.set).toHaveBeenCalledWith({
361+
domainVerified: true,
362+
jitProvisioningEnabled: true,
363+
})
364+
})
365+
366+
it('persists invite-only provisioning without changing Better Auth provider config', async () => {
367+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
368+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1', jitProvisioningEnabled: false }))
369+
expect(res.status).toBe(200)
370+
expect(dbChainMockFns.set).toHaveBeenCalledWith({
371+
domainVerified: true,
372+
jitProvisioningEnabled: false,
373+
})
374+
expect(mockRegisterSSOProvider.mock.calls[0][0].body).not.toHaveProperty(
375+
'jitProvisioningEnabled'
376+
)
350377
})
351378

352379
/**

apps/sim/app/api/auth/sso/register/route.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
108108
if (!parsed.success) return parsed.response
109109

110110
const body = parsed.data.body
111-
const { providerId, issuer, providerType, mapping, orgId } = body
111+
const { providerId, issuer, providerType, mapping, orgId, jitProvisioningEnabled } = body
112112

113113
if (orgId) {
114114
const [membership] = await db
@@ -624,6 +624,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
624624
domain: ssoProvider.domain,
625625
oidcConfig: ssoProvider.oidcConfig,
626626
samlConfig: ssoProvider.samlConfig,
627+
jitProvisioningEnabled: ssoProvider.jitProvisioningEnabled,
627628
})
628629
.from(ssoProvider)
629630
.where(ownerClause)
@@ -643,7 +644,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
643644
*/
644645
const grantProviderDomainTrust = async (): Promise<boolean> => {
645646
if (!orgId) {
646-
await db.update(ssoProvider).set({ domainVerified: !isHosted }).where(ownerClause)
647+
await db
648+
.update(ssoProvider)
649+
.set({ domainVerified: !isHosted, jitProvisioningEnabled })
650+
.where(ownerClause)
647651
return true
648652
}
649653
return db.transaction(async (tx) => {
@@ -663,7 +667,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
663667

664668
const granted = await tx
665669
.update(ssoProvider)
666-
.set({ domainVerified: true })
670+
.set({ domainVerified: true, jitProvisioningEnabled })
667671
.where(ownerClause)
668672
.returning({ id: ssoProvider.id })
669673
return granted.length > 0
@@ -694,6 +698,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
694698
oidcConfig: existingOwnedProvider.oidcConfig,
695699
samlConfig: existingOwnedProvider.samlConfig,
696700
domainVerified: false,
701+
jitProvisioningEnabled: existingOwnedProvider.jitProvisioningEnabled,
697702
})
698703
.where(eq(ssoProvider.id, existingOwnedProvider.id))
699704
logger.warn('Reverted SSO update: domain verification was removed mid-write', {

apps/sim/content/blog/enterprise/index.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ slug: enterprise
33
title: 'Sim for Enterprise'
44
description: 'Access control, BYOK, self-hosted deployments, on-prem Copilot, SSO & SAML, whitelabeling, Admin API, and flexible data retention—enterprise features for teams with strict security and compliance requirements.'
55
date: 2026-02-11
6-
updated: 2026-08-27
6+
updated: 2026-08-31
77
authors:
88
- vik
99
readingTime: 10
@@ -35,7 +35,7 @@ faq:
3535
- q: "Can Copilot be used without sending workflow data to an external AI service?"
3636
a: "Yes. Copilot can run entirely within a self-hosted deployment using your own LLM keys, so prompts containing context from your workflows, execution logs, and workspace configuration route directly to your chosen provider and never leave your network."
3737
- q: "What identity providers does Sim support for SSO, and what happens when an employee is deprovisioned?"
38-
a: "Sim integrates with Okta, Azure AD (Entra ID), Google Workspace, OneLogin, Auth0, JumpCloud, Ping Identity, ADFS, and any SAML 2.0 or OIDC compliant identity provider. Session management ties to your IdP, so logging out there terminates Sim sessions, and account deprovisioning immediately revokes access."
38+
a: "Sim integrates with Okta, Azure AD (Entra ID), Google Workspace, OneLogin, Auth0, JumpCloud, Ping Identity, ADFS, and any SAML 2.0 or OIDC compliant identity provider. IdP deprovisioning blocks future authentication but does not currently remove Sim membership or revoke active Sim sessions, so offboarding must also remove or suspend access in Sim."
3939
---
4040

4141
We've been working with security teams at larger organizations to bring Sim into environments with strict compliance and data handling requirements. This post covers the enterprise capabilities we've built: granular access control, bring-your-own-keys, self-hosted deployments, on-prem Copilot, SSO & SAML, whitelabeling, compliance, and programmatic management via the Admin API.
@@ -121,9 +121,9 @@ This is particularly relevant for organizations where the context Copilot needs
121121

122122
Integrate with your existing identity provider through SAML 2.0 or OIDC. We support Okta, Azure AD (Entra ID), Google Workspace, OneLogin, Auth0, JumpCloud, Ping Identity, ADFS, and any compliant identity provider.
123123

124-
Once enabled, users authenticate through your IdP instead of Sim credentials. Your MFA policies apply automatically. Session management ties to your IdPlogout there terminates Sim sessions. Account deprovisioning immediately revokes access.
124+
Once enabled, users authenticate through your IdP, so its MFA and sign-in policies apply to the authentication event. Sim sessions have their own lifecycle: IdP logout or deprovisioning does not currently revoke an active Sim session or remove organization membership, so those are explicit Sim admin steps during offboarding.
125125

126-
New users are provisioned on first SSO login based on IdP attributes. No invitation emails, no password setup, no manual account creation required.
126+
Administrators choose how first-time users enter the organization. **Automatic** provisioning adds a user authenticated through the verified SSO connection as a Member and consumes a billed seat; Team seat counts grow with membership, while fixed-seat plans require available capacity. **Invite only** authenticates the user but requires an invitation for organization or workspace access. Automatic provisioning never promotes IdP claims into Sim roles and never grants workspace access implicitly.
127127

128128
This centralizes your authentication and audit trail. Your security team's policies apply to Sim access through the same system that tracks everything else.
129129

apps/sim/ee/sso/components/sso-form.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ vi.mock('@/lib/auth/auth-client', () => ({
3838
}))
3939

4040
vi.mock('@/app/(auth)/components', () => ({
41+
AuthFormMessage: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
4142
AuthSubmitButton: ({
4243
children,
4344
disabled = false,
@@ -149,6 +150,20 @@ describe('SSOForm sign-in errors', () => {
149150
container.remove()
150151
})
151152

153+
it('shows an actionable seat message after a successful IdP login cannot provision access', async () => {
154+
renderInteractive('error=sso_no_seats')
155+
156+
await act(async () => {})
157+
158+
expect(container).toHaveTextContent('Your organization has no available seat capacity.')
159+
expect(container).toHaveTextContent('Ask an administrator to increase capacity')
160+
expect(container.querySelector('[role="alert"]')).toHaveTextContent(
161+
'Your organization has no available seat capacity.'
162+
)
163+
expect(container.querySelector('#email')).not.toHaveAttribute('aria-invalid')
164+
expect(container.querySelector('#email')).not.toHaveAttribute('aria-describedby')
165+
})
166+
152167
it('shows a generic retryable error when Better Auth resolves with a 404', async () => {
153168
mockSsoSignIn.mockResolvedValue({
154169
data: null,

0 commit comments

Comments
 (0)