Skip to content

Commit 33353cd

Browse files
committed
chore(search): sync staging and regenerate embedding repair
2 parents bbd39e1 + 4d5e4aa commit 33353cd

26 files changed

Lines changed: 29512 additions & 2668 deletions

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
88
import { FAQ } from '@/components/ui/faq'
99
import { Image } from '@/components/ui/image'
1010

11-
Single Sign-On lets your team sign in to Sim through your company's identity provider instead of managing separate passwords. Sim supports both OIDC and SAML 2.0.
11+
Single Sign-On lets your team sign in to Sim through your company's identity provider instead of managing separate passwords. Sim supports both OIDC and SAML 2.0, and an organization can use more than one identity provider at a time, one per verified domain.
1212

1313
---
1414

@@ -36,7 +36,9 @@ Go to **Settings → Organization → Single sign-on**. The page has three tabs:
3636
| **Domains** | DNS verification shared by SSO and SCIM |
3737
| **Provisioning** | SCIM connection, tokens, rules, group mappings, and activity |
3838

39-
Use **Domains** to verify ownership, then return to **Sign-in** to configure your provider. Switching tabs preserves an unsaved sign-in draft while you stay on this page; use **Save** or **Update** to commit it. The selected tab is included in the URL, so it can be bookmarked or shared. On self-hosted deployments, Provisioning appears when SCIM is enabled.
39+
Use **Domains** to verify ownership, then return to **Sign-in** to configure your provider. Switching tabs preserves an unsaved sign-in draft while you stay on this page; use **Save** or **Update** to commit it. The selected tab and provider are included in the URL, so they can be bookmarked or shared. On self-hosted deployments, Provisioning appears when SCIM is enabled.
40+
41+
An organization can run several identity providers at once, each serving a different verified domain: Okta for `eng.acme.com` and Microsoft Entra ID for `acme.com`, for example. **Sign-in** lists them; select **Add identity provider** for another, or a row to view, edit, or delete one. Sim routes each sign-in by the email domain, so a domain routes to exactly one provider.
4042

4143
### 2. Choose a protocol
4244

@@ -99,7 +101,7 @@ Click **Save**. To test, sign out and use the **Sign in with SSO** button on the
99101

100102
## Editing and advanced configuration
101103

102-
For a saved connection, open **Sign-in** and select **Edit**. The Provider ID remains fixed. A saved OIDC client secret appears as a mask with a suffix when available; **Replace** lets you enter a new secret, and **Keep saved** cancels that replacement. Select **Update** to save the provider, or **Discard** to abandon changes.
104+
For a saved connection, open **Sign-in**, select the provider, and select **Edit**. The Provider ID remains fixed. **Delete** removes that sign-in path only: accounts and memberships it admitted stay, and people at its domain sign in another way until a provider serves the domain again. A saved OIDC client secret appears as a mask with a suffix when available; **Replace** lets you enter a new secret, and **Keep saved** cancels that replacement. Select **Update** to save the provider, or **Discard** to abandon changes.
103105

104106
**Advanced options** contains OIDC scopes and optional authorization, token, and JWKS endpoint overrides. For SAML, it contains Audience, Callback URL override, signed-assertion requirements, NameID format, and optional IdP metadata XML. **Attribute mapping** lets either protocol override the email, name, and stable user-ID claim names. Leave a mapping blank to use the protocol default.
105107

@@ -285,7 +287,7 @@ Once SSO is configured, users with your domain (`company.com`) can sign in throu
285287

286288
1. User goes to `sim.ai` and clicks **Sign in with SSO**
287289
2. They enter their work email (e.g. `alice@company.com`)
288-
3. Sim redirects them to your identity provider
290+
3. Sim looks up the provider that serves `company.com` and redirects them to it
289291
4. After authenticating, they are returned to Sim
290292
5. If **First sign-in** is **Automatic**, Sim adds them to the organization as a Member, growing a Team seat count or validating available fixed-seat capacity
291293
6. They land in an accessible workspace, or see a clear no-access state until an admin grants workspace access
@@ -311,7 +313,11 @@ SSO provisioning creates internal organization members but does not grant worksp
311313
},
312314
{
313315
question: "What is the Domain field used for?",
314-
answer: "The domain (e.g. company.com) is how Sim routes users to the right identity provider. When a user enters their email on the SSO sign-in page, Sim matches their email domain to a registered SSO provider and redirects them there."
316+
answer: "The domain (e.g. company.com) is how Sim routes users to the right identity provider. When a user enters their email on the SSO sign-in page, Sim matches their email domain to the provider that serves it and redirects them there. Each verified domain routes to one provider, and an organization can serve different domains with different providers."
317+
},
318+
{
319+
question: "Can we use more than one identity provider?",
320+
answer: "Yes. Add one provider per verified domain: for example Okta for eng.acme.com and Microsoft Entra ID for acme.com. Sign-in routes by email domain, so a single domain cannot be split across two providers. SCIM provisioning stays organization-wide and works alongside any number of providers."
315321
},
316322
{
317323
question: "Do I need to provide OIDC endpoints manually?",
@@ -343,7 +349,7 @@ SSO provisioning creates internal organization members but does not grant worksp
343349
},
344350
{
345351
question: "How do I update or replace an existing SSO configuration?",
346-
answer: "Open Settings → Organization → Single sign-on → Sign-in and select Edit. Change the fields and select Update. The Provider ID cannot be changed; replacing it requires deleting the provider and creating a new one."
352+
answer: "Open Settings → Organization → Single sign-on → Sign-in, select the provider, and select Edit. Change the fields and select Update. The Provider ID cannot be changed; replacing it means deleting the provider and adding a new one."
347353
}
348354
]} />
349355

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import {
5+
createMockRequest,
6+
dbChainMock,
7+
dbChainMockFns,
8+
queueTableRows,
9+
resetDbChainMock,
10+
schemaMock,
11+
} from '@sim/testing'
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() }))
15+
16+
vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
17+
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
18+
19+
import { DELETE } from '@/app/api/auth/sso/providers/[providerId]/route'
20+
21+
const context = { params: Promise.resolve({ providerId: 'acme-okta' }) }
22+
const request = () => createMockRequest('DELETE')
23+
24+
describe('DELETE /api/auth/sso/providers/[providerId]', () => {
25+
beforeEach(() => {
26+
vi.clearAllMocks()
27+
resetDbChainMock()
28+
mockGetSession.mockResolvedValue({ user: { id: 'u1' } })
29+
dbChainMockFns.returning.mockResolvedValue([{ id: 'row-1' }])
30+
})
31+
32+
it('requires a session', async () => {
33+
mockGetSession.mockResolvedValue(null)
34+
const res = await DELETE(request(), context)
35+
expect(res.status).toBe(401)
36+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
37+
})
38+
39+
it('answers 404 for an unknown provider', async () => {
40+
queueTableRows(schemaMock.ssoProvider, [])
41+
const res = await DELETE(request(), context)
42+
expect(res.status).toBe(404)
43+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
44+
})
45+
46+
it("refuses an organization provider to a member who is not the organization's admin", async () => {
47+
queueTableRows(schemaMock.ssoProvider, [
48+
{ id: 'row-1', organizationId: 'org1', userId: 'u-other', domain: 'acme.com' },
49+
])
50+
queueTableRows(schemaMock.member, [{ role: 'member' }])
51+
const res = await DELETE(request(), context)
52+
expect(res.status).toBe(403)
53+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
54+
})
55+
56+
it('lets an organization admin delete a provider another admin created', async () => {
57+
queueTableRows(schemaMock.ssoProvider, [
58+
{ id: 'row-1', organizationId: 'org1', userId: 'u-other', domain: 'acme.com' },
59+
])
60+
queueTableRows(schemaMock.member, [{ role: 'admin' }])
61+
const res = await DELETE(request(), context)
62+
expect(res.status).toBe(200)
63+
await expect(res.json()).resolves.toEqual({ success: true, providerId: 'acme-okta' })
64+
expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.ssoProvider)
65+
})
66+
67+
it('lets only the creator delete a personal provider', async () => {
68+
queueTableRows(schemaMock.ssoProvider, [
69+
{ id: 'row-1', organizationId: null, userId: 'u-other', domain: 'acme.com' },
70+
])
71+
const refused = await DELETE(request(), context)
72+
expect(refused.status).toBe(403)
73+
74+
resetDbChainMock()
75+
dbChainMockFns.returning.mockResolvedValue([{ id: 'row-1' }])
76+
queueTableRows(schemaMock.ssoProvider, [
77+
{ id: 'row-1', organizationId: null, userId: 'u1', domain: 'acme.com' },
78+
])
79+
const allowed = await DELETE(request(), context)
80+
expect(allowed.status).toBe(200)
81+
})
82+
83+
it.each([129, 256])('deletes a provider with a %i-character ID', async (length) => {
84+
const providerId = 'a'.repeat(length)
85+
queueTableRows(schemaMock.ssoProvider, [
86+
{ id: 'row-1', organizationId: 'org1', userId: 'u1', domain: 'acme.com' },
87+
])
88+
queueTableRows(schemaMock.member, [{ role: 'owner' }])
89+
90+
const res = await DELETE(request(), { params: Promise.resolve({ providerId }) })
91+
92+
expect(res.status).toBe(200)
93+
await expect(res.json()).resolves.toEqual({ success: true, providerId })
94+
expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.ssoProvider)
95+
})
96+
97+
it('answers 404 when the row vanished between the check and the delete', async () => {
98+
queueTableRows(schemaMock.ssoProvider, [
99+
{ id: 'row-1', organizationId: 'org1', userId: 'u1', domain: 'acme.com' },
100+
])
101+
queueTableRows(schemaMock.member, [{ role: 'owner' }])
102+
dbChainMockFns.returning.mockResolvedValue([])
103+
const res = await DELETE(request(), context)
104+
expect(res.status).toBe(404)
105+
})
106+
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { db, member, ssoProvider } from '@sim/db'
2+
import { createLogger } from '@sim/logger'
3+
import { and, eq, isNull } from 'drizzle-orm'
4+
import { type NextRequest, NextResponse } from 'next/server'
5+
import { deleteSsoProviderContract } from '@/lib/api/contracts/auth'
6+
import { parseRequest } from '@/lib/api/server'
7+
import { getSession } from '@/lib/auth'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
10+
const logger = createLogger('SSOProviderRoute')
11+
12+
/**
13+
* Removes one identity provider.
14+
*
15+
* Sim owns this rather than exposing the SSO plugin's `delete-provider`, which
16+
* `/api/auth/[...all]` blocks by design: the plugin gates only on the row's
17+
* creator, while an organization's providers belong to the organization and
18+
* are removed by its owners and admins. Accounts and memberships the provider
19+
* admitted are untouched; only the sign-in path goes.
20+
*/
21+
export const DELETE = withRouteHandler(
22+
async (request: NextRequest, context: { params: Promise<{ providerId: string }> }) => {
23+
const session = await getSession()
24+
if (!session?.user?.id) {
25+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
26+
}
27+
28+
const parsed = await parseRequest(deleteSsoProviderContract, request, context)
29+
if (!parsed.success) return parsed.response
30+
const { providerId } = parsed.data.params
31+
32+
const [provider] = await db
33+
.select({
34+
id: ssoProvider.id,
35+
organizationId: ssoProvider.organizationId,
36+
userId: ssoProvider.userId,
37+
domain: ssoProvider.domain,
38+
})
39+
.from(ssoProvider)
40+
.where(eq(ssoProvider.providerId, providerId))
41+
.limit(1)
42+
if (!provider) return NextResponse.json({ error: 'Provider not found' }, { status: 404 })
43+
44+
if (provider.organizationId) {
45+
const [membership] = await db
46+
.select({ role: member.role })
47+
.from(member)
48+
.where(
49+
and(
50+
eq(member.userId, session.user.id),
51+
eq(member.organizationId, provider.organizationId)
52+
)
53+
)
54+
.limit(1)
55+
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
56+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
57+
}
58+
} else if (provider.userId !== session.user.id) {
59+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
60+
}
61+
62+
/**
63+
* Deleted by primary key under the same ownership the check established,
64+
* so a concurrent re-registration of the providerId cannot be the row
65+
* removed.
66+
*/
67+
const ownerClause = provider.organizationId
68+
? eq(ssoProvider.organizationId, provider.organizationId)
69+
: and(eq(ssoProvider.userId, session.user.id), isNull(ssoProvider.organizationId))
70+
const removed = await db
71+
.delete(ssoProvider)
72+
.where(and(eq(ssoProvider.id, provider.id), ownerClause))
73+
.returning({ id: ssoProvider.id })
74+
if (removed.length === 0) {
75+
return NextResponse.json({ error: 'Provider not found' }, { status: 404 })
76+
}
77+
78+
logger.info('Deleted SSO provider', {
79+
providerId,
80+
organizationId: provider.organizationId,
81+
domain: provider.domain,
82+
userId: session.user.id,
83+
})
84+
return NextResponse.json({ success: true, providerId })
85+
}
86+
)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { db, member, ssoProvider } from '@sim/db'
22
import { createLogger } from '@sim/logger'
3-
import { and, eq } from 'drizzle-orm'
3+
import { and, asc, eq } from 'drizzle-orm'
44
import { type NextRequest, NextResponse } from 'next/server'
55
import { listSsoProvidersContract } from '@/lib/api/contracts/auth'
66
import { parseRequest } from '@/lib/api/server'
@@ -79,6 +79,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
7979
})
8080
.from(ssoProvider)
8181
.where(whereClause)
82+
.orderBy(asc(ssoProvider.providerId))
8283

8384
providers = results.map((provider) => {
8485
let oidcConfig = provider.oidcConfig

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,43 @@ describe('POST /api/auth/sso/register', () => {
216216
expect(conflictWhere?.[0]?.values).toContain('acme.com')
217217
})
218218

219+
it('refuses a second provider on a domain the organization already routes', async () => {
220+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
221+
queueProviders([
222+
{ domain: 'acme.com', userId: 'u1', organizationId: 'org1', providerId: 'acme-saml' },
223+
])
224+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
225+
const json = await res.json()
226+
expect(res.status).toBe(409)
227+
expect(json.code).toBe('SSO_DOMAIN_ALREADY_ROUTED')
228+
expect(json.error).toContain('acme-saml')
229+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
230+
})
231+
232+
it('turns a lost race on the domain index into the same 409 as the pre-check', async () => {
233+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
234+
queueProviders([])
235+
mockRegisterSSOProvider.mockRejectedValue(
236+
Object.assign(new Error('duplicate key value violates unique constraint'), {
237+
code: '23505',
238+
constraint_name: 'sso_provider_org_domain_unique',
239+
})
240+
)
241+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
242+
const json = await res.json()
243+
expect(res.status).toBe(409)
244+
expect(json.code).toBe('SSO_DOMAIN_ALREADY_ROUTED')
245+
expect(json.error).toContain('acme.com')
246+
})
247+
248+
it('lets the organization add a provider for a different verified domain', async () => {
249+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
250+
queueProviders([])
251+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1', domain: 'eng.acme.com' }))
252+
expect(res.status).toBe(200)
253+
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
254+
})
255+
219256
it('registers when the domain is unclaimed', async () => {
220257
queueMembers([{ organizationId: 'org1', role: 'owner' }])
221258
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))

0 commit comments

Comments
 (0)