Skip to content

Commit 56d974f

Browse files
feat(auth): support banning specific emails via appconfig blockedEmails list
1 parent b41c0fe commit 56d974f

6 files changed

Lines changed: 98 additions & 23 deletions

File tree

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const { mockFetch, envRef, flagRef } = vi.hoisted(() => ({
1010
APPCONFIG_APPLICATION: 'sim-staging' as string | undefined,
1111
APPCONFIG_ENVIRONMENT: 'staging' as string | undefined,
1212
BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined,
13+
BLOCKED_EMAILS: undefined as string | undefined,
1314
ALLOWED_LOGIN_EMAILS: undefined as string | undefined,
1415
ALLOWED_LOGIN_DOMAINS: undefined as string | undefined,
1516
BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined,
@@ -33,10 +34,15 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
3334
},
3435
}))
3536

36-
import { getAccessControlConfig, isEmailInDenylist } from '@/lib/auth/access-control'
37+
import {
38+
getAccessControlConfig,
39+
isEmailBlockedByAccessControl,
40+
isEmailInDenylist,
41+
} from '@/lib/auth/access-control'
3742

3843
const empty: AccessControlConfig = {
3944
blockedSignupDomains: [],
45+
blockedEmails: [],
4046
allowedLoginEmails: [],
4147
allowedLoginDomains: [],
4248
blockedEmailMxHosts: [],
@@ -48,6 +54,7 @@ describe('getAccessControlConfig', () => {
4854
flagRef.isAppConfigEnabled = false
4955
Object.assign(envRef, {
5056
BLOCKED_SIGNUP_DOMAINS: undefined,
57+
BLOCKED_EMAILS: undefined,
5158
ALLOWED_LOGIN_EMAILS: undefined,
5259
ALLOWED_LOGIN_DOMAINS: undefined,
5360
BLOCKED_EMAIL_MX_HOSTS: undefined,
@@ -62,9 +69,11 @@ describe('getAccessControlConfig', () => {
6269

6370
it('parses, trims, lowercases, and dedupes csv env vars', async () => {
6471
envRef.BLOCKED_SIGNUP_DOMAINS = 'Gmail.com, yahoo.com ,gmail.com,'
72+
envRef.BLOCKED_EMAILS = 'Spam@Evil.com, spam@evil.com'
6573
envRef.ALLOWED_LOGIN_DOMAINS = 'Sim.ai'
6674
const result = await getAccessControlConfig()
6775
expect(result.blockedSignupDomains).toEqual(['gmail.com', 'yahoo.com'])
76+
expect(result.blockedEmails).toEqual(['spam@evil.com'])
6877
expect(result.allowedLoginDomains).toEqual(['sim.ai'])
6978
expect(mockFetch).not.toHaveBeenCalled()
7079
})
@@ -146,3 +155,29 @@ describe('isEmailInDenylist', () => {
146155
expect(isEmailInDenylist('user@example.com', denylist)).toBe(false)
147156
})
148157
})
158+
159+
describe('isEmailBlockedByAccessControl', () => {
160+
const config: AccessControlConfig = {
161+
...empty,
162+
blockedSignupDomains: ['bad.com'],
163+
blockedEmails: ['spam@evil.com'],
164+
}
165+
166+
it('matches individually blocked emails case-insensitively', () => {
167+
expect(isEmailBlockedByAccessControl('spam@evil.com', config)).toBe(true)
168+
expect(isEmailBlockedByAccessControl(' Spam@Evil.com ', config)).toBe(true)
169+
expect(isEmailBlockedByAccessControl('other@evil.com', config)).toBe(false)
170+
})
171+
172+
it('matches blocked domains and subdomains', () => {
173+
expect(isEmailBlockedByAccessControl('a@bad.com', config)).toBe(true)
174+
expect(isEmailBlockedByAccessControl('a@mail.bad.com', config)).toBe(true)
175+
expect(isEmailBlockedByAccessControl('a@good.com', config)).toBe(false)
176+
})
177+
178+
it('returns false for missing emails and empty config', () => {
179+
expect(isEmailBlockedByAccessControl(null, config)).toBe(false)
180+
expect(isEmailBlockedByAccessControl(undefined, config)).toBe(false)
181+
expect(isEmailBlockedByAccessControl('a@bad.com', empty)).toBe(false)
182+
})
183+
})

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const ACCESS_CONTROL_PROFILE = 'access-control'
1616
*/
1717
export interface AccessControlConfig {
1818
blockedSignupDomains: string[]
19+
blockedEmails: string[]
1920
allowedLoginEmails: string[]
2021
allowedLoginDomains: string[]
2122
blockedEmailMxHosts: string[]
@@ -35,6 +36,21 @@ export function isEmailInDenylist(
3536
return denylist.some((entry) => domain === entry || domain.endsWith(`.${entry}`))
3637
}
3738

39+
/**
40+
* True when the email is individually banned (`blockedEmails`) or its domain
41+
* is in the blocked-domains list. The single predicate for "this email must
42+
* not sign up, sign in, or execute anything".
43+
*/
44+
export function isEmailBlockedByAccessControl(
45+
email: string | undefined | null,
46+
config: AccessControlConfig
47+
): boolean {
48+
if (!email) return false
49+
const normalized = email.trim().toLowerCase()
50+
if (config.blockedEmails.includes(normalized)) return true
51+
return isEmailInDenylist(normalized, config.blockedSignupDomains)
52+
}
53+
3854
function normalizeList(values: unknown): string[] {
3955
if (!Array.isArray(values)) return []
4056
return Array.from(new Set(values.map((v) => String(v).trim().toLowerCase()).filter(Boolean)))
@@ -51,6 +67,7 @@ function parseCsv(value: string | undefined): string[] {
5167
function fromEnv(): AccessControlConfig {
5268
return {
5369
blockedSignupDomains: parseCsv(env.BLOCKED_SIGNUP_DOMAINS),
70+
blockedEmails: parseCsv(env.BLOCKED_EMAILS),
5471
allowedLoginEmails: parseCsv(env.ALLOWED_LOGIN_EMAILS),
5572
allowedLoginDomains: parseCsv(env.ALLOWED_LOGIN_DOMAINS),
5673
blockedEmailMxHosts: parseCsv(env.BLOCKED_EMAIL_MX_HOSTS),
@@ -61,6 +78,7 @@ function parseConfig(json: unknown): AccessControlConfig {
6178
const obj = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
6279
return {
6380
blockedSignupDomains: normalizeList(obj.blockedSignupDomains),
81+
blockedEmails: normalizeList(obj.blockedEmails),
6482
allowedLoginEmails: normalizeList(obj.allowedLoginEmails),
6583
allowedLoginDomains: normalizeList(obj.allowedLoginDomains),
6684
blockedEmailMxHosts: normalizeList(obj.blockedEmailMxHosts),

apps/sim/lib/auth/auth.ts

Lines changed: 14 additions & 11 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, isEmailInDenylist } from '@/lib/auth/access-control'
33+
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
3434
import { sendPlanWelcomeEmail } from '@/lib/billing'
3535
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
3636
import {
@@ -224,8 +224,8 @@ export const auth = betterAuth({
224224
create: {
225225
before: async (user) => {
226226
const accessControl = await getAccessControlConfig()
227-
if (isEmailInDenylist(user.email, accessControl.blockedSignupDomains)) {
228-
throw new Error('Sign-ups from this email domain are not allowed.')
227+
if (isEmailBlockedByAccessControl(user.email, accessControl)) {
228+
throw new Error('Sign-ups from this email are not allowed.')
229229
}
230230
return { data: user }
231231
},
@@ -582,18 +582,21 @@ export const auth = betterAuth({
582582
session: {
583583
create: {
584584
before: async (session) => {
585-
// Blocked-domain accounts must not establish sessions, regardless of
585+
// Blocked emails/domains must not establish sessions, regardless of
586586
// provider (email/password, OAuth, SSO). Deliberately outside the
587587
// try below — a thrown APIError must propagate, not be swallowed.
588588
const accessControl = await getAccessControlConfig()
589-
if (accessControl.blockedSignupDomains.length > 0) {
589+
if (
590+
accessControl.blockedSignupDomains.length > 0 ||
591+
accessControl.blockedEmails.length > 0
592+
) {
590593
const [sessionUser] = await db
591594
.select({ email: schema.user.email })
592595
.from(schema.user)
593596
.where(eq(schema.user.id, session.userId))
594597
.limit(1)
595-
if (isEmailInDenylist(sessionUser?.email, accessControl.blockedSignupDomains)) {
596-
logger.warn('Blocking session creation for blocked-domain account', {
598+
if (isEmailBlockedByAccessControl(sessionUser?.email, accessControl)) {
599+
logger.warn('Blocking session creation for blocked account', {
597600
userId: session.userId,
598601
})
599602
throw new APIError('FORBIDDEN', {
@@ -836,12 +839,12 @@ export const auth = betterAuth({
836839
}
837840
}
838841

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)) {
842+
// Blocked emails/domains gate both signup and sign-in. OAuth/SSO sign-ins
843+
// have no email in the body here; the session.create.before hook covers them.
844+
if (isEmailBlockedByAccessControl(requestEmail, accessControl)) {
842845
throw new APIError('FORBIDDEN', {
843846
message: isSignUp
844-
? 'Sign-ups from this email domain are not allowed.'
847+
? 'Sign-ups from this email are not allowed.'
845848
: 'Access restricted. Please contact your administrator.',
846849
})
847850
}

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
55

66
const { mockWhere, envRef } = vi.hoisted(() => ({
77
mockWhere: vi.fn(),
8-
envRef: { BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined },
8+
envRef: {
9+
BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined,
10+
BLOCKED_EMAILS: undefined as string | undefined,
11+
},
912
}))
1013

1114
vi.mock('@sim/db', () => ({
@@ -46,6 +49,7 @@ describe('isEmailBlocked', () => {
4649
beforeEach(() => {
4750
vi.clearAllMocks()
4851
envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com'
52+
envRef.BLOCKED_EMAILS = 'spam@evil.com'
4953
mockWhere.mockResolvedValue([])
5054
})
5155

@@ -55,6 +59,11 @@ describe('isEmailBlocked', () => {
5559
expect(mockWhere).not.toHaveBeenCalled()
5660
})
5761

62+
it('returns true for individually blocked emails without querying users', async () => {
63+
expect(await isEmailBlocked('spam@evil.com')).toBe(true)
64+
expect(mockWhere).not.toHaveBeenCalled()
65+
})
66+
5867
it('returns true when the email belongs to an actively banned account', async () => {
5968
mockWhere.mockResolvedValue([{ banned: true, banExpires: null }])
6069
expect(await isEmailBlocked('a@good.com')).toBe(true)
@@ -71,6 +80,7 @@ describe('getActivelyBannedUserIds', () => {
7180
beforeEach(() => {
7281
vi.clearAllMocks()
7382
envRef.BLOCKED_SIGNUP_DOMAINS = undefined
83+
envRef.BLOCKED_EMAILS = undefined
7484
mockWhere.mockResolvedValue([])
7585
})
7686

@@ -95,6 +105,15 @@ describe('getActivelyBannedUserIds', () => {
95105
expect(await getActivelyBannedUserIds(['u1'])).toEqual([])
96106
})
97107

108+
it('returns ids whose email is individually blocked', async () => {
109+
envRef.BLOCKED_EMAILS = 'spam@evil.com'
110+
mockWhere.mockResolvedValue([
111+
{ id: 'u1', email: 'spam@evil.com', banned: false, banExpires: null },
112+
{ id: 'u2', email: 'ok@evil.com', banned: false, banExpires: null },
113+
])
114+
expect(await getActivelyBannedUserIds(['u1', 'u2'])).toEqual(['u1'])
115+
})
116+
98117
it('returns ids whose email domain is in the blocked-domains list, including subdomains', async () => {
99118
envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com'
100119
mockWhere.mockResolvedValue([

apps/sim/lib/auth/ban.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { db, user } from '@sim/db'
22
import { inArray, sql } from 'drizzle-orm'
3-
import { getAccessControlConfig, isEmailInDenylist } from '@/lib/auth/access-control'
3+
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
44

55
/**
66
* True when a ban is currently in effect. Mirrors better-auth admin-plugin
@@ -13,14 +13,15 @@ export function isBanActive(row: { banned: boolean | null; banExpires: Date | nu
1313
}
1414

1515
/**
16-
* True when a raw email (e.g. an inbound sender) is blocked: its domain is in
17-
* the appconfig blocked-domains list, or it belongs to an account with an
18-
* active ban. Covers senders that don't resolve to a known user id.
16+
* True when a raw email (e.g. an inbound sender) is blocked: it is in the
17+
* appconfig blocked-emails list, its domain is in the blocked-domains list,
18+
* or it belongs to an account with an active ban. Covers senders that don't
19+
* resolve to a known user id.
1920
*/
2021
export async function isEmailBlocked(email: string | null | undefined): Promise<boolean> {
2122
if (!email) return false
2223
const accessControl = await getAccessControlConfig()
23-
if (isEmailInDenylist(email, accessControl.blockedSignupDomains)) return true
24+
if (isEmailBlockedByAccessControl(email, accessControl)) return true
2425
const rows = await db
2526
.select({ banned: user.banned, banExpires: user.banExpires })
2627
.from(user)
@@ -30,8 +31,8 @@ export async function isEmailBlocked(email: string | null | undefined): Promise<
3031

3132
/**
3233
* Returns the subset of the given user ids that are currently blocked: an
33-
* active account ban, or an email domain in the appconfig blocked-domains
34-
* list. One user query plus the cached access-control fetch. Throws on db
34+
* active account ban, or an email/domain in the appconfig blocked lists.
35+
* One user query plus the cached access-control fetch. Throws on db
3536
* failure — callers must fail closed.
3637
*/
3738
export async function getActivelyBannedUserIds(userIds: string[]): Promise<string[]> {
@@ -47,8 +48,6 @@ export async function getActivelyBannedUserIds(userIds: string[]): Promise<strin
4748
])
4849

4950
return rows
50-
.filter(
51-
(row) => isBanActive(row) || isEmailInDenylist(row.email, accessControl.blockedSignupDomains)
52-
)
51+
.filter((row) => isBanActive(row) || isEmailBlockedByAccessControl(row.email, accessControl))
5352
.map((row) => row.id)
5453
}

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const env = createEnv({
2727
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
2828
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
2929
BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com")
30+
BLOCKED_EMAILS: z.string().optional(), // Comma-separated list of specific email addresses banned from the platform (signup, sign-in, executions)
3031
SIGNUP_MX_VALIDATION_ENABLED: z.boolean().optional(), // Opt-in: validate the email's MX backend at signup (blocks no-MX domains and denylisted shared spam backends). Off by default; enable on hosted/abuse-targeted deployments.
3132
BLOCKED_EMAIL_MX_HOSTS: z.string().optional(), // Comma-separated MX-host substrings blocked from signing up; matched against the domain's resolved MX backend to catch throwaway domains that share a mail backend. No defaults — operators supply their own list. Only used when SIGNUP_MX_VALIDATION_ENABLED is set.
3233
TRUSTED_ORIGINS: z.string().optional(), // Comma-separated additional origins to trust for auth (e.g., "https://app.example.com,https://www.example.com"). Merged into Better Auth trustedOrigins.

0 commit comments

Comments
 (0)