Skip to content

Commit a0bd392

Browse files
committed
fix(sso): make domain-trust grants atomic and propagate revocation
Greptile flagged that the ownership check and the domainVerified write were separate statements, so a domain deleted between them still ended with trust granted. Two changes close it from both sides. The grant now folds the ownership test into the UPDATE's WHERE clause, so Postgres evaluates both in one statement and the write matches nothing once the proof is gone. Removing a verified domain now clears domainVerified for providers on that domain, in the same transaction as the delete. This was a standing gap, not just a race: deleting a domain previously left linking trust set indefinitely. Together the provider cannot end up trusted without current ownership in either commit order — if the grant lands first the delete clears it, and if the delete lands first the grant no-ops.
1 parent e4b9fcc commit a0bd392

3 files changed

Lines changed: 103 additions & 19 deletions

File tree

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

Lines changed: 51 additions & 11 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, isNull, sql } from 'drizzle-orm'
5+
import { and, eq, exists, 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'
@@ -656,6 +656,46 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
656656
await db.update(ssoProvider).set({ domainVerified: verified }).where(ownerClause)
657657
}
658658

659+
/**
660+
* Grants domain trust with the ownership test folded into the UPDATE's WHERE
661+
* clause, so Postgres evaluates both in one statement and the write simply
662+
* matches nothing once the proof is gone. That removes the window a separate
663+
* read-then-write leaves open.
664+
*
665+
* Together with the domain-delete route — which clears this flag in the same
666+
* transaction that removes the proof — the provider cannot end up trusted
667+
* without current ownership in either commit order: if this write lands first
668+
* the delete clears it, and if the delete lands first this write no-ops.
669+
* Org-less (personal) SSO is not domain-gated by Sim, so it grants
670+
* unconditionally as it always has.
671+
*/
672+
const grantProviderDomainTrust = async () => {
673+
if (!orgId) {
674+
await setProviderDomainVerified(true)
675+
return
676+
}
677+
await db
678+
.update(ssoProvider)
679+
.set({ domainVerified: true })
680+
.where(
681+
and(
682+
ownerClause,
683+
exists(
684+
db
685+
.select({ one: sql`1` })
686+
.from(ssoDomain)
687+
.where(
688+
and(
689+
eq(ssoDomain.organizationId, orgId),
690+
eq(ssoDomain.domain, domain),
691+
eq(ssoDomain.status, 'verified')
692+
)
693+
)
694+
)
695+
)
696+
)
697+
}
698+
659699
if (existingOwnedProvider) {
660700
await auth.api.updateSSOProvider({
661701
body: {
@@ -668,11 +708,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
668708
headers,
669709
})
670710

671-
// Compensating re-check, mirroring the create path below: the verified
672-
// sso_domain row can be deleted while updateSSOProvider is in flight.
673-
// Granting trust here would re-authorize same-email account linking for a
674-
// domain the org no longer proves it owns, so clear the flag instead —
675-
// that both denies linking and blocks sign-in until it is re-verified.
711+
// The verified sso_domain row can be deleted while updateSSOProvider is in
712+
// flight. There is no newly-created row to roll back here, so on failure
713+
// clear the flag: `updateSSOProvider` only resets it when the domain
714+
// changes, so a same-domain edit would otherwise leave stale trust standing.
676715
if (orgId && !(await isOrgDomainVerified())) {
677716
await setProviderDomainVerified(false)
678717
logger.warn('Revoked SSO domain trust: verification was removed mid-update', {
@@ -684,7 +723,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
684723
return domainNotVerifiedResponse()
685724
}
686725

687-
await setProviderDomainVerified(true)
726+
await grantProviderDomainTrust()
688727
logger.info('SSO provider updated successfully', { providerId, providerType, domain })
689728
return NextResponse.json({
690729
success: true,
@@ -699,9 +738,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
699738
headers,
700739
})
701740

702-
// Close the residual TOCTOU between the re-check above and Better Auth
703-
// persisting the provider: the verified sso_domain row could be removed in
704-
// that window. registerSSOProvider is create-only (it throws if the
741+
// Grant trust in the same statement that re-tests ownership, closing the
742+
// window between Better Auth persisting the provider and this write. A failure
743+
// means the verified sso_domain row was removed in that window, so roll the
744+
// provider back. registerSSOProvider is create-only (it throws if the
705745
// providerId already exists), so a successful call always created a brand-new
706746
// row — we roll it back by its primary-key `id` (not the logical providerId,
707747
// which a concurrent delete+recreate could point at a different row). Personal
@@ -734,7 +774,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
734774
return domainNotVerifiedResponse()
735775
}
736776

737-
await setProviderDomainVerified(true)
777+
await grantProviderDomainTrust()
738778

739779
logger.info('SSO provider registered successfully', {
740780
providerId,

apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,25 @@ describe('remove org domain route', () => {
8383
expect.objectContaining({ action: 'organization.domain.removed' })
8484
)
8585
})
86+
87+
/**
88+
* `domainVerified` on a provider is what authorizes auto-linking an SSO sign-in
89+
* to an existing same-email account. Removing the proof has to withdraw that
90+
* trust in the same transaction, or the authorization outlives the ownership.
91+
*/
92+
it('revokes SSO domain trust for providers on the removed domain', async () => {
93+
queueTableRows(member, [{ role: 'owner' }])
94+
dbChainMockFns.returning.mockResolvedValueOnce([{ domain: 'acme.com' }])
95+
const res = await DELETE(createMockRequest('DELETE'), routeContext)
96+
expect(res.status).toBe(200)
97+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: false })
98+
})
99+
100+
it('does not revoke trust when no domain was removed', async () => {
101+
queueTableRows(member, [{ role: 'owner' }])
102+
dbChainMockFns.returning.mockResolvedValueOnce([]) // delete matched nothing
103+
const res = await DELETE(createMockRequest('DELETE'), routeContext)
104+
expect(res.status).toBe(404)
105+
expect(dbChainMockFns.set).not.toHaveBeenCalled()
106+
})
86107
})

apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
3-
import { member, ssoDomain } from '@sim/db/schema'
3+
import { member, ssoDomain, ssoProvider } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
6-
import { and, eq } from 'drizzle-orm'
6+
import { and, eq, sql } from 'drizzle-orm'
77
import { type NextRequest, NextResponse } from 'next/server'
88
import { removeOrganizationDomainContract } from '@/lib/api/contracts/organization'
99
import { parseRequest } from '@/lib/api/server'
@@ -18,8 +18,10 @@ const logger = createLogger('OrgDomainDeleteAPI')
1818
* DELETE /api/organizations/[id]/domains/[domainId]
1919
* Removes a claimed/verified domain. Requires owner/admin role. Removing a
2020
* verified domain drops the ownership proof, so SSO can no longer be configured
21-
* for it until it is re-verified. It does not retroactively un-register an
22-
* already-configured SSO provider — that flows through the SSO provider itself.
21+
* for it until it is re-verified, and any provider already on that domain loses
22+
* its `domainVerified` trust in the same transaction. The provider itself is not
23+
* un-registered — that flows through the SSO provider — but it can no longer
24+
* auto-link sign-ins to existing accounts.
2325
*/
2426
export const DELETE = withRouteHandler(
2527
async (request: NextRequest, context: { params: Promise<{ id: string; domainId: string }> }) => {
@@ -59,10 +61,31 @@ export const DELETE = withRouteHandler(
5961
)
6062
}
6163

62-
const [removed] = await db
63-
.delete(ssoDomain)
64-
.where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId)))
65-
.returning({ domain: ssoDomain.domain })
64+
// Removing the proof must also withdraw the trust it granted. `domainVerified`
65+
// on a provider is what authorizes auto-linking an SSO sign-in to an existing
66+
// same-email account, so leaving it set would keep that authorization alive
67+
// indefinitely after ownership was revoked. Both writes share a transaction so
68+
// a domain can never be gone while its provider still claims to be verified.
69+
const removed = await db.transaction(async (tx) => {
70+
const [deleted] = await tx
71+
.delete(ssoDomain)
72+
.where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId)))
73+
.returning({ domain: ssoDomain.domain })
74+
75+
if (!deleted) return null
76+
77+
await tx
78+
.update(ssoProvider)
79+
.set({ domainVerified: false })
80+
.where(
81+
and(
82+
eq(ssoProvider.organizationId, organizationId),
83+
sql`lower(${ssoProvider.domain}) = ${deleted.domain}`
84+
)
85+
)
86+
87+
return deleted
88+
})
6689

6790
if (!removed) {
6891
return NextResponse.json({ error: 'Domain not found' }, { status: 404 })

0 commit comments

Comments
 (0)