Skip to content

Commit 089141c

Browse files
committed
fix(sso): harden provisioning edge cases
1 parent 228fb39 commit 089141c

6 files changed

Lines changed: 110 additions & 25 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ Once SSO is configured, users with your domain (`company.com`) can sign in throu
272272
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
273273
6. They land in an accessible workspace, or see a clear no-access state until an admin grants workspace access
274274

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.
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 newly provisioned 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 does not create new membership or workspace access; new access must be granted separately, while existing organization membership and workspace access remain available.
276276

277277
<Callout type="warning">
278278
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.

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,35 @@ describe('POST /api/auth/sso/register', () => {
324324
})
325325
})
326326

327+
it('reverts the config and provisioning mode when the trust write fails', async () => {
328+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
329+
queueProviders([])
330+
queueTableRows(schemaMock.ssoProvider, [
331+
{
332+
id: 'p1',
333+
issuer: 'https://old-issuer.example.com',
334+
domain: 'acme.com',
335+
oidcConfig: '{"stored":"oidc"}',
336+
samlConfig: null,
337+
jitProvisioningEnabled: true,
338+
},
339+
])
340+
dbChainMockFns.returning.mockRejectedValueOnce(new Error('trust write failed'))
341+
342+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1', jitProvisioningEnabled: false }))
343+
344+
expect(res.status).toBe(500)
345+
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
346+
expect(dbChainMockFns.set).toHaveBeenCalledWith({
347+
issuer: 'https://old-issuer.example.com',
348+
domain: 'acme.com',
349+
oidcConfig: '{"stored":"oidc"}',
350+
samlConfig: null,
351+
domainVerified: false,
352+
jitProvisioningEnabled: true,
353+
})
354+
})
355+
327356
it('does not mark domain-verified when the registration is rolled back', async () => {
328357
queueMembers([{ organizationId: 'org1', role: 'owner' }])
329358
resetDbChainMock()

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

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
675675
}
676676

677677
if (existingOwnedProvider) {
678+
const revertProviderUpdate = async (): Promise<void> => {
679+
await db
680+
.update(ssoProvider)
681+
.set({
682+
issuer: existingOwnedProvider.issuer,
683+
domain: existingOwnedProvider.domain,
684+
oidcConfig: existingOwnedProvider.oidcConfig,
685+
samlConfig: existingOwnedProvider.samlConfig,
686+
domainVerified: false,
687+
jitProvisioningEnabled: existingOwnedProvider.jitProvisioningEnabled,
688+
})
689+
.where(eq(ssoProvider.id, existingOwnedProvider.id))
690+
}
691+
678692
await auth.api.updateSSOProvider({
679693
body: {
680694
providerId,
@@ -686,21 +700,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
686700
headers,
687701
})
688702

703+
let domainTrustGranted: boolean
704+
try {
705+
domainTrustGranted = await grantProviderDomainTrust()
706+
} catch (error) {
707+
try {
708+
await revertProviderUpdate()
709+
} catch (rollbackError) {
710+
logger.error('Failed to revert SSO provider after domain trust write failed', {
711+
domain,
712+
orgId,
713+
providerId,
714+
userId: session.user.id,
715+
error,
716+
rollbackError,
717+
})
718+
}
719+
throw error
720+
}
721+
689722
// Restore the pre-update config and clear the flag together. Clearing alone
690723
// is not enough: re-verifying the domain now regrants trust automatically,
691724
// which would activate the very config this request reported as rejected.
692-
if (!(await grantProviderDomainTrust())) {
693-
await db
694-
.update(ssoProvider)
695-
.set({
696-
issuer: existingOwnedProvider.issuer,
697-
domain: existingOwnedProvider.domain,
698-
oidcConfig: existingOwnedProvider.oidcConfig,
699-
samlConfig: existingOwnedProvider.samlConfig,
700-
domainVerified: false,
701-
jitProvisioningEnabled: existingOwnedProvider.jitProvisioningEnabled,
702-
})
703-
.where(eq(ssoProvider.id, existingOwnedProvider.id))
725+
if (!domainTrustGranted) {
726+
await revertProviderUpdate()
704727
logger.warn('Reverted SSO update: domain verification was removed mid-write', {
705728
domain,
706729
orgId,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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. 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."
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 SSO 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.
@@ -123,7 +123,7 @@ Integrate with your existing identity provider through SAML 2.0 or OIDC. We supp
123123

124124
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-
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.
126+
Administrators choose how first-time users enter the organization. For a user without a pending invitation or existing external workspace access, **Automatic** provisioning adds them 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 access to be granted separately. Existing membership and workspace access remain available. 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/lib/auth/sso/application/admit-sso-user.test.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { auditMock, dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
4+
import {
5+
auditMock,
6+
dbChainMock,
7+
dbChainMockFns,
8+
flattenMockConditions,
9+
queueTableRows,
10+
resetDbChainMock,
11+
schemaMock,
12+
} from '@sim/testing'
513
import { beforeEach, describe, expect, it, vi } from 'vitest'
614

715
const {
@@ -164,7 +172,10 @@ describe('SSO JIT admission', () => {
164172
kind: 'provisioning-disabled',
165173
organizationId: 'org-1',
166174
})
167-
expect(mockAcquireOrganizationUserMutationLocks).not.toHaveBeenCalled()
175+
expect(mockAcquireOrganizationUserMutationLocks).toHaveBeenCalledWith(dbChainMock.db, {
176+
userId: 'user-1',
177+
organizationIds: ['org-1'],
178+
})
168179
expect(mockEnsureUserInOrganizationTx).not.toHaveBeenCalled()
169180
})
170181

@@ -177,7 +188,10 @@ describe('SSO JIT admission', () => {
177188
organizationId: 'org-1',
178189
memberId: 'member-existing',
179190
})
180-
expect(mockAcquireOrganizationUserMutationLocks).not.toHaveBeenCalled()
191+
expect(mockAcquireOrganizationUserMutationLocks).toHaveBeenCalledWith(dbChainMock.db, {
192+
userId: 'user-1',
193+
organizationIds: ['org-1'],
194+
})
181195
expect(mockEnsureUserInOrganizationTx).not.toHaveBeenCalled()
182196
})
183197

@@ -213,6 +227,20 @@ describe('SSO JIT admission', () => {
213227
expect(mockEnsureUserInOrganizationTx).not.toHaveBeenCalled()
214228
})
215229

230+
it('only preserves external access from active workspaces', async () => {
231+
queueIdentity()
232+
233+
await expect(execute()).resolves.toEqual({
234+
kind: 'provisioned',
235+
organizationId: 'org-1',
236+
memberId: 'member-1',
237+
})
238+
const conditions = dbChainMockFns.where.mock.calls.flatMap(([condition]) =>
239+
flattenMockConditions(condition)
240+
)
241+
expect(conditions).toContainEqual({ type: 'isNull', column: schemaMock.workspace.archivedAt })
242+
})
243+
216244
it('rejects a user who belongs to another organization', async () => {
217245
queueIdentity()
218246
queueTableRows(schemaMock.member, [{ id: 'member-other', organizationId: 'org-other' }])

apps/sim/lib/auth/sso/application/admit-sso-user.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import { createLogger } from '@sim/logger'
1414
import { normalizeSSODomain } from '@sim/utils/sso-domain'
1515
import { normalizeEmail } from '@sim/utils/string'
16-
import { and, desc, eq, gt, inArray, sql } from 'drizzle-orm'
16+
import { and, desc, eq, gt, inArray, isNull, sql } from 'drizzle-orm'
1717
import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy'
1818
import { ssoJitAdmissionOperation } from '@/lib/auth/sso/application/operations'
1919
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
@@ -124,6 +124,12 @@ async function runAdmissionTransaction(
124124
result: { kind: 'organization-not-bound', organizationId: null },
125125
}
126126
}
127+
128+
await acquireOrganizationUserMutationLocks(tx, {
129+
userId,
130+
organizationIds: [provider.organizationId],
131+
})
132+
127133
if (!provider.jitProvisioningEnabled) {
128134
const [sameOrganization] = await tx
129135
.select({ id: member.id })
@@ -149,11 +155,6 @@ async function runAdmissionTransaction(
149155
}
150156
}
151157

152-
await acquireOrganizationUserMutationLocks(tx, {
153-
userId,
154-
organizationIds: [provider.organizationId],
155-
})
156-
157158
const memberships = await tx
158159
.select({ id: member.id, organizationId: member.organizationId })
159160
.from(member)
@@ -195,7 +196,11 @@ async function runAdmissionTransaction(
195196
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspace.id))
196197
)
197198
.where(
198-
and(eq(permissions.userId, userId), eq(workspace.organizationId, provider.organizationId))
199+
and(
200+
eq(permissions.userId, userId),
201+
eq(workspace.organizationId, provider.organizationId),
202+
isNull(workspace.archivedAt)
203+
)
199204
)
200205
.limit(1),
201206
])

0 commit comments

Comments
 (0)