Skip to content

Commit 21d7927

Browse files
feat(auth): enforce domain and account bans on sign-in and workflow executions
1 parent 62c48bf commit 21d7927

10 files changed

Lines changed: 377 additions & 66 deletions

File tree

apps/sim/hooks/use-inline-rename.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ interface UseInlineRenameProps {
99
* `mutateAsync(...)`) — NOT a fire-and-forget `mutate(...)` — so `isSaving`
1010
* spans the in-flight request and a rejection can revive the edit session.
1111
*/
12-
onSave: (id: string, newName: string) => void | Promise<unknown>
12+
onSave: (id: string, newName: string) => unknown
1313
}
1414

1515
/**

apps/sim/lib/auth/access-control.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
3333
},
3434
}))
3535

36-
import { getAccessControlConfig } from '@/lib/auth/access-control'
36+
import { getAccessControlConfig, isEmailInDenylist } from '@/lib/auth/access-control'
3737

3838
const empty: AccessControlConfig = {
3939
blockedSignupDomains: [],
@@ -104,3 +104,45 @@ describe('getAccessControlConfig', () => {
104104
})
105105
})
106106
})
107+
108+
describe('isEmailInDenylist', () => {
109+
it('returns false when denylist is null, empty, or email is missing', () => {
110+
expect(isEmailInDenylist('a@example.com', null)).toBe(false)
111+
expect(isEmailInDenylist('a@example.com', [])).toBe(false)
112+
expect(isEmailInDenylist(null, ['example.com'])).toBe(false)
113+
expect(isEmailInDenylist(undefined, ['example.com'])).toBe(false)
114+
expect(isEmailInDenylist('', ['example.com'])).toBe(false)
115+
})
116+
117+
it('returns false when email has no @', () => {
118+
expect(isEmailInDenylist('not-an-email', ['example.com'])).toBe(false)
119+
})
120+
121+
it('matches exact domain', () => {
122+
expect(isEmailInDenylist('user@dpdns.org', ['dpdns.org'])).toBe(true)
123+
expect(isEmailInDenylist('user@DPDNS.ORG', ['dpdns.org'])).toBe(true)
124+
})
125+
126+
it('matches arbitrary-depth subdomains of a listed parent zone', () => {
127+
expect(isEmailInDenylist('user@xx.lucky04.dpdns.org', ['dpdns.org'])).toBe(true)
128+
expect(isEmailInDenylist('user@a.b.c.qzz.io', ['qzz.io'])).toBe(true)
129+
})
130+
131+
it('does not match look-alike domains', () => {
132+
expect(isEmailInDenylist('user@xdpdns.org', ['dpdns.org'])).toBe(false)
133+
expect(isEmailInDenylist('user@notdpdns.org', ['dpdns.org'])).toBe(false)
134+
})
135+
136+
it('does not match disallowed domains', () => {
137+
expect(isEmailInDenylist('user@gmail.com', ['dpdns.org', 'qzz.io'])).toBe(false)
138+
expect(isEmailInDenylist('user@example.com', ['dpdns.org'])).toBe(false)
139+
})
140+
141+
it('handles multiple denylist entries', () => {
142+
const denylist = ['dpdns.org', 'qzz.io', 'cc.cd']
143+
expect(isEmailInDenylist('user@foo.dpdns.org', denylist)).toBe(true)
144+
expect(isEmailInDenylist('user@bar.qzz.io', denylist)).toBe(true)
145+
expect(isEmailInDenylist('user@baz.cc.cd', denylist)).toBe(true)
146+
expect(isEmailInDenylist('user@example.com', denylist)).toBe(false)
147+
})
148+
})

apps/sim/lib/auth/access-control.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ export interface AccessControlConfig {
2121
blockedEmailMxHosts: string[]
2222
}
2323

24+
/**
25+
* True when the email's domain matches a denylist entry exactly or is a
26+
* subdomain of one.
27+
*/
28+
export function isEmailInDenylist(
29+
email: string | undefined | null,
30+
denylist: readonly string[] | null
31+
): boolean {
32+
if (!denylist || denylist.length === 0 || !email) return false
33+
const domain = email.split('@')[1]?.toLowerCase()
34+
if (!domain) return false
35+
return denylist.some((entry) => domain === entry || domain.endsWith(`.${entry}`))
36+
}
37+
2438
function normalizeList(values: unknown): string[] {
2539
if (!Array.isArray(values)) return []
2640
return Array.from(new Set(values.map((v) => String(v).trim().toLowerCase()).filter(Boolean)))

apps/sim/lib/auth/auth.test.ts

Lines changed: 0 additions & 47 deletions
This file was deleted.

apps/sim/lib/auth/auth.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
renderPasswordResetEmail,
3131
renderWelcomeEmail,
3232
} from '@/components/emails'
33-
import { getAccessControlConfig } from '@/lib/auth/access-control'
33+
import { getAccessControlConfig, isEmailInDenylist } from '@/lib/auth/access-control'
3434
import { sendPlanWelcomeEmail } from '@/lib/billing'
3535
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
3636
import {
@@ -138,16 +138,6 @@ function getMicrosoftUserInfoFromIdToken(tokens: { accessToken?: string }, provi
138138
}
139139
}
140140

141-
export function isEmailInDenylist(
142-
email: string | undefined | null,
143-
denylist: readonly string[] | null
144-
): boolean {
145-
if (!denylist || denylist.length === 0 || !email) return false
146-
const domain = email.split('@')[1]?.toLowerCase()
147-
if (!domain) return false
148-
return denylist.some((entry) => domain === entry || domain.endsWith(`.${entry}`))
149-
}
150-
151141
const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) =>
152142
logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value })
153143
)
@@ -592,6 +582,26 @@ export const auth = betterAuth({
592582
session: {
593583
create: {
594584
before: async (session) => {
585+
// Blocked-domain accounts must not establish sessions, regardless of
586+
// provider (email/password, OAuth, SSO). Deliberately outside the
587+
// try below — a thrown APIError must propagate, not be swallowed.
588+
const accessControl = await getAccessControlConfig()
589+
if (accessControl.blockedSignupDomains.length > 0) {
590+
const [sessionUser] = await db
591+
.select({ email: schema.user.email })
592+
.from(schema.user)
593+
.where(eq(schema.user.id, session.userId))
594+
.limit(1)
595+
if (isEmailInDenylist(sessionUser?.email, accessControl.blockedSignupDomains)) {
596+
logger.warn('Blocking session creation for blocked-domain account', {
597+
userId: session.userId,
598+
})
599+
throw new APIError('FORBIDDEN', {
600+
message: 'Access restricted. Please contact your administrator.',
601+
})
602+
}
603+
}
604+
595605
try {
596606
// Find the first organization this user is a member of
597607
const members = await db
@@ -826,9 +836,13 @@ export const auth = betterAuth({
826836
}
827837
}
828838

829-
if (isSignUp && isEmailInDenylist(ctx.body?.email, accessControl.blockedSignupDomains)) {
839+
// Blocked domains gate both signup and sign-in. OAuth/SSO sign-ins have
840+
// no email in the body here; the session.create.before hook covers them.
841+
if (isEmailInDenylist(requestEmail, accessControl.blockedSignupDomains)) {
830842
throw new APIError('FORBIDDEN', {
831-
message: 'Sign-ups from this email domain are not allowed.',
843+
message: isSignUp
844+
? 'Sign-ups from this email domain are not allowed.'
845+
: 'Access restricted. Please contact your administrator.',
832846
})
833847
}
834848

apps/sim/lib/auth/ban.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockWhere, envRef } = vi.hoisted(() => ({
7+
mockWhere: vi.fn(),
8+
envRef: { BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined },
9+
}))
10+
11+
vi.mock('@sim/db', () => ({
12+
db: { select: vi.fn(() => ({ from: vi.fn(() => ({ where: mockWhere })) })) },
13+
user: { id: 'id', email: 'email', banned: 'banned', banExpires: 'banExpires' },
14+
}))
15+
vi.mock('drizzle-orm', () => ({ inArray: vi.fn() }))
16+
vi.mock('@/lib/core/config/appconfig', () => ({ fetchAppConfigProfile: vi.fn() }))
17+
vi.mock('@/lib/core/config/env', () => ({
18+
get env() {
19+
return envRef
20+
},
21+
}))
22+
vi.mock('@/lib/core/config/feature-flags', () => ({ isAppConfigEnabled: false }))
23+
24+
import { getActivelyBannedUserIds, isBanActive } from '@/lib/auth/ban'
25+
26+
describe('isBanActive', () => {
27+
it('returns true for a permanent ban', () => {
28+
expect(isBanActive({ banned: true, banExpires: null })).toBe(true)
29+
})
30+
31+
it('returns false for an expired temporary ban', () => {
32+
expect(isBanActive({ banned: true, banExpires: new Date(Date.now() - 1000) })).toBe(false)
33+
})
34+
35+
it('returns true for an unexpired temporary ban', () => {
36+
expect(isBanActive({ banned: true, banExpires: new Date(Date.now() + 60_000) })).toBe(true)
37+
})
38+
39+
it('returns false when not banned', () => {
40+
expect(isBanActive({ banned: false, banExpires: null })).toBe(false)
41+
expect(isBanActive({ banned: null, banExpires: null })).toBe(false)
42+
})
43+
})
44+
45+
describe('getActivelyBannedUserIds', () => {
46+
beforeEach(() => {
47+
vi.clearAllMocks()
48+
envRef.BLOCKED_SIGNUP_DOMAINS = undefined
49+
mockWhere.mockResolvedValue([])
50+
})
51+
52+
it('short-circuits on empty input without querying', async () => {
53+
expect(await getActivelyBannedUserIds([])).toEqual([])
54+
expect(await getActivelyBannedUserIds([''])).toEqual([])
55+
expect(mockWhere).not.toHaveBeenCalled()
56+
})
57+
58+
it('returns ids with an active db ban', async () => {
59+
mockWhere.mockResolvedValue([
60+
{ id: 'u1', email: 'a@ok.com', banned: true, banExpires: null },
61+
{ id: 'u2', email: 'b@ok.com', banned: false, banExpires: null },
62+
])
63+
expect(await getActivelyBannedUserIds(['u1', 'u2'])).toEqual(['u1'])
64+
})
65+
66+
it('treats an expired ban as lifted', async () => {
67+
mockWhere.mockResolvedValue([
68+
{ id: 'u1', email: 'a@ok.com', banned: true, banExpires: new Date(Date.now() - 1000) },
69+
])
70+
expect(await getActivelyBannedUserIds(['u1'])).toEqual([])
71+
})
72+
73+
it('returns ids whose email domain is in the blocked-domains list, including subdomains', async () => {
74+
envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com'
75+
mockWhere.mockResolvedValue([
76+
{ id: 'u1', email: 'a@bad.com', banned: false, banExpires: null },
77+
{ id: 'u2', email: 'b@mail.bad.com', banned: false, banExpires: null },
78+
{ id: 'u3', email: 'c@good.com', banned: false, banExpires: null },
79+
])
80+
expect(await getActivelyBannedUserIds(['u1', 'u2', 'u3'])).toEqual(['u1', 'u2'])
81+
})
82+
83+
it('propagates db failures so callers fail closed', async () => {
84+
mockWhere.mockRejectedValue(new Error('db down'))
85+
await expect(getActivelyBannedUserIds(['u1'])).rejects.toThrow('db down')
86+
})
87+
})

apps/sim/lib/auth/ban.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { db, user } from '@sim/db'
2+
import { inArray } from 'drizzle-orm'
3+
import { getAccessControlConfig, isEmailInDenylist } from '@/lib/auth/access-control'
4+
5+
/**
6+
* True when a ban is currently in effect. Mirrors better-auth admin-plugin
7+
* semantics: a ban whose `banExpires` is in the past is treated as lifted.
8+
*/
9+
export function isBanActive(row: { banned: boolean | null; banExpires: Date | null }): boolean {
10+
if (!row.banned) return false
11+
if (row.banExpires && row.banExpires.getTime() <= Date.now()) return false
12+
return true
13+
}
14+
15+
/**
16+
* Returns the subset of the given user ids that are currently blocked: an
17+
* active account ban, or an email domain in the appconfig blocked-domains
18+
* list. One user query plus the cached access-control fetch. Throws on db
19+
* failure — callers must fail closed.
20+
*/
21+
export async function getActivelyBannedUserIds(userIds: string[]): Promise<string[]> {
22+
const ids = [...new Set(userIds.filter(Boolean))]
23+
if (ids.length === 0) return []
24+
25+
const [accessControl, rows] = await Promise.all([
26+
getAccessControlConfig(),
27+
db
28+
.select({ id: user.id, email: user.email, banned: user.banned, banExpires: user.banExpires })
29+
.from(user)
30+
.where(inArray(user.id, ids)),
31+
])
32+
33+
return rows
34+
.filter(
35+
(row) => isBanActive(row) || isEmailInDenylist(row.email, accessControl.blockedSignupDomains)
36+
)
37+
.map((row) => row.id)
38+
}

0 commit comments

Comments
 (0)