Skip to content

Commit c564441

Browse files
committed
fix(sso): hold the domain proof under a row lock while granting trust
Two fixes from review. The trust grant folded the ownership test into the UPDATE's WHERE clause, but under READ COMMITTED the EXISTS subquery is evaluated against the statement's original snapshot. A delete committing while the UPDATE waited on the provider row could therefore still see the removed sso_domain row and grant trust after ownership was gone. The grant now selects the proof FOR SHARE inside a transaction before writing, so the delete blocks until it commits, and if the delete committed first the select finds nothing and no trust is written. Editing a SAML provider also broke on configs written by the previous commit: hydration used `config.idpMetadata?.metadata || config.idpMetadata`, and `{ metadata: '' }` is falsy at the property but truthy as an object, so an object landed in a string field and failed validation on save. It now narrows on the type and handles both the object and legacy bare-string shapes.
1 parent e9949e9 commit c564441

3 files changed

Lines changed: 48 additions & 38 deletions

File tree

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

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -115,16 +115,14 @@ describe('POST /api/auth/sso/register', () => {
115115
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' })
116116
mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test'))
117117
mockRegisterSSOProvider.mockResolvedValue({ id: 'row-1', providerId: 'acme-oidc' })
118-
// The conditional trust UPDATE returns its row by default, i.e. the verified
119-
// domain still existed at write time. Refusal tests override with [].
120-
dbChainMockFns.returning.mockResolvedValue([{ id: 'granted' }])
121118
mockUpdateSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' })
122119
// Default: the org has already verified the domain, so the ownership gate
123-
// passes and each test exercises the logic beyond it. The gate is read twice
124-
// for a successful org-scoped registration (fail-fast entry + authoritative
125-
// re-check before the write); ownership at write time is re-tested inside the
126-
// trust UPDATE itself, not by a third read. Gate-specific tests reset the
127-
// queue to assert the unverified paths.
120+
// passes and each test exercises the logic beyond it. A successful org-scoped
121+
// registration reads it three times: the fail-fast entry gate, the
122+
// authoritative re-check before the write, and the locking read inside the
123+
// trust transaction. Gate-specific tests reset the queue to assert the
124+
// unverified paths.
125+
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
128126
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
129127
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
130128
})
@@ -184,7 +182,7 @@ describe('POST /api/auth/sso/register', () => {
184182
queueMembers([{ organizationId: 'org1', role: 'owner' }])
185183
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified
186184
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check: verified
187-
dbChainMockFns.returning.mockResolvedValue([]) // trust UPDATE matched nothing: revoked
185+
queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
188186
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
189187
const json = await res.json()
190188
expect(res.status).toBe(403)
@@ -285,9 +283,13 @@ describe('POST /api/auth/sso/register', () => {
285283
*/
286284
it('revokes domain trust when verification is removed during an update', async () => {
287285
queueMembers([{ organizationId: 'org1', role: 'owner' }])
286+
resetDbChainMock()
287+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
288+
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate
289+
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check
290+
queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
288291
queueProviders([])
289292
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }]) // provider already owned → update path
290-
dbChainMockFns.returning.mockResolvedValue([]) // trust UPDATE matched nothing
291293

292294
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
293295
expect(res.status).toBe(403)
@@ -304,7 +306,7 @@ describe('POST /api/auth/sso/register', () => {
304306
queueMembers([{ organizationId: 'org1', role: 'owner' }])
305307
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
306308
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
307-
dbChainMockFns.returning.mockResolvedValue([]) // trust UPDATE matched nothing
309+
queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
308310
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
309311
expect(res.status).toBe(403)
310312
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) // it was created…

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

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { db, member, ssoDomain, ssoProvider } from '@sim/db'
22
import { createLogger } from '@sim/logger'
33
import { getErrorMessage } from '@sim/utils/errors'
44
import { normalizeSSODomain } from '@sim/utils/sso-domain'
5-
import { and, eq, exists, isNull, sql } from 'drizzle-orm'
5+
import { and, eq, isNull, sql } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { ssoRegistrationContract } from '@/lib/api/contracts/auth'
88
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
@@ -647,11 +647,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
647647
}
648648

649649
/**
650-
* Grants domain trust with the ownership test folded into the UPDATE's WHERE
651-
* clause, so the write matches nothing once the proof is gone and reports that
652-
* as `false`. Paired with the domain-delete route clearing this flag in the
653-
* same transaction that removes the proof, the provider cannot end up trusted
654-
* without current ownership in either commit order.
650+
* Grants domain trust only while the proof is held under a row lock.
651+
*
652+
* Folding the ownership test into the UPDATE's WHERE clause is not sufficient:
653+
* under READ COMMITTED the EXISTS subquery is evaluated against the statement's
654+
* original snapshot, so a delete committing while the UPDATE waits on the
655+
* provider row can still leave the subquery seeing the removed sso_domain row —
656+
* granting trust after ownership is gone. Taking `FOR SHARE` on that row inside
657+
* a transaction makes the two operations order properly: the delete's removal of
658+
* sso_domain blocks until this commits, and if it committed first the SELECT
659+
* finds nothing and no trust is written.
655660
*
656661
* Org-less (personal) SSO is a self-host-only path — Sim's UI always registers
657662
* org-scoped. It has no verified domain behind it, so it is trusted only when
@@ -663,28 +668,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
663668
await setProviderDomainVerified(!isHosted)
664669
return true
665670
}
666-
const granted = await db
667-
.update(ssoProvider)
668-
.set({ domainVerified: true })
669-
.where(
670-
and(
671-
ownerClause,
672-
exists(
673-
db
674-
.select({ one: sql`1` })
675-
.from(ssoDomain)
676-
.where(
677-
and(
678-
eq(ssoDomain.organizationId, orgId),
679-
eq(ssoDomain.domain, domain),
680-
eq(ssoDomain.status, 'verified')
681-
)
682-
)
671+
return db.transaction(async (tx) => {
672+
const [proof] = await tx
673+
.select({ id: ssoDomain.id })
674+
.from(ssoDomain)
675+
.where(
676+
and(
677+
eq(ssoDomain.organizationId, orgId),
678+
eq(ssoDomain.domain, domain),
679+
eq(ssoDomain.status, 'verified')
683680
)
684681
)
685-
)
686-
.returning({ id: ssoProvider.id })
687-
return granted.length > 0
682+
.limit(1)
683+
.for('share')
684+
if (!proof) return false
685+
686+
await tx.update(ssoProvider).set({ domainVerified: true }).where(ownerClause)
687+
return true
688+
})
688689
}
689690

690691
if (existingOwnedProvider) {

apps/sim/ee/sso/components/sso-settings.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,14 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
387387
callbackUrl = config.callbackUrl || ''
388388
audience = config.audience || ''
389389
wantAssertionsSigned = config.wantAssertionsSigned ?? true
390-
idpMetadata = config.idpMetadata?.metadata || config.idpMetadata || ''
390+
// Two stored shapes: `{ metadata }` from the route, and a bare string from
391+
// older rows. Narrow on the type rather than truthiness — `{ metadata: '' }`
392+
// is falsy at `.metadata` but truthy as an object, which would put an object
393+
// into this string field and fail validation on the next save.
394+
idpMetadata =
395+
typeof config.idpMetadata === 'string'
396+
? config.idpMetadata
397+
: (config.idpMetadata?.metadata ?? '')
391398
mapping = config.mapping ?? {}
392399
identifierFormat = config.identifierFormat || ''
393400
}

0 commit comments

Comments
 (0)