Skip to content

Commit e0c19ea

Browse files
feat(auth): dynamic signup/login ban lists via AWS AppConfig
- Move blocked-domain/allowlist/MX gating from env vars into AWS AppConfig (queried at runtime via the AppConfig Data SDK with a 30s in-process cache); env vars remain a fallback for self-hosted/OSS. - Add a new bannedEmails denylist that blocks a specific address at both sign-in and sign-up. - Generic, profile-agnostic AppConfig client so future config (feature flags) reuses the same plumbing; AppConfig client shares the same credential resolution as the S3 client. - Defense in depth: authenticateApiKeyFromHeader now rejects keys belonging to banned users.
1 parent 24a6086 commit e0c19ea

14 files changed

Lines changed: 530 additions & 99 deletions

File tree

apps/sim/lib/api-key/service.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,18 @@ describe('authenticateApiKeyFromHeader', () => {
108108
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
109109
})
110110

111+
it('returns invalid when the key belongs to a banned user', async () => {
112+
const record = personalKeyRecord({ userBanned: true })
113+
dbChainMockFns.where.mockResolvedValueOnce([record])
114+
115+
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
116+
userId: 'user-1',
117+
})
118+
119+
expect(result).toEqual({ success: false, error: 'Invalid API key' })
120+
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
121+
})
122+
111123
it('returns invalid when the hash lookup finds no row', async () => {
112124
dbChainMockFns.where.mockResolvedValueOnce([])
113125

apps/sim/lib/api-key/service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from '@sim/db'
2-
import { apiKey as apiKeyTable } from '@sim/db/schema'
2+
import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq } from 'drizzle-orm'
55
import { hashApiKey } from '@/lib/api-key/crypto'
@@ -47,6 +47,7 @@ interface HashCandidate {
4747
workspaceId: string | null
4848
type: string
4949
expiresAt: Date | null
50+
userBanned: boolean | null
5051
}
5152

5253
/**
@@ -82,15 +83,20 @@ export async function authenticateApiKeyFromHeader(
8283
workspaceId: apiKeyTable.workspaceId,
8384
type: apiKeyTable.type,
8485
expiresAt: apiKeyTable.expiresAt,
86+
userBanned: userTable.banned,
8587
})
8688
.from(apiKeyTable)
89+
.leftJoin(userTable, eq(apiKeyTable.userId, userTable.id))
8790
.where(eq(apiKeyTable.keyHash, keyHash))
8891

8992
if (rows.length === 0) return INVALID
9093

9194
const record = rows[0]
9295
const keyType = record.type as 'personal' | 'workspace'
9396

97+
// Defense in depth: banning deletes a user's keys, but reject any survivor too.
98+
if (record.userBanned) return INVALID
99+
94100
if (options.userId && record.userId !== options.userId) return INVALID
95101
if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) return INVALID
96102
if (record.expiresAt && record.expiresAt < new Date()) return INVALID
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import type { AccessControlConfig } from '@/lib/auth/access-control'
6+
7+
const { mockFetch, envRef } = vi.hoisted(() => ({
8+
mockFetch: vi.fn(),
9+
envRef: {
10+
APPCONFIG_APPLICATION: undefined as string | undefined,
11+
APPCONFIG_ENVIRONMENT: undefined as string | undefined,
12+
BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined,
13+
ALLOWED_LOGIN_EMAILS: undefined as string | undefined,
14+
ALLOWED_LOGIN_DOMAINS: undefined as string | undefined,
15+
BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined,
16+
},
17+
}))
18+
19+
vi.mock('@/lib/core/config/appconfig', () => ({
20+
fetchAppConfigProfile: mockFetch,
21+
}))
22+
23+
vi.mock('@/lib/core/config/env', () => ({
24+
get env() {
25+
return envRef
26+
},
27+
}))
28+
29+
import { getAccessControlConfig } from '@/lib/auth/access-control'
30+
31+
const empty: AccessControlConfig = {
32+
blockedSignupDomains: [],
33+
allowedLoginEmails: [],
34+
allowedLoginDomains: [],
35+
blockedEmailMxHosts: [],
36+
bannedEmails: [],
37+
}
38+
39+
describe('getAccessControlConfig', () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks()
42+
Object.assign(envRef, {
43+
APPCONFIG_APPLICATION: undefined,
44+
APPCONFIG_ENVIRONMENT: undefined,
45+
BLOCKED_SIGNUP_DOMAINS: undefined,
46+
ALLOWED_LOGIN_EMAILS: undefined,
47+
ALLOWED_LOGIN_DOMAINS: undefined,
48+
BLOCKED_EMAIL_MX_HOSTS: undefined,
49+
})
50+
})
51+
52+
describe('env fallback (AppConfig not configured)', () => {
53+
it('returns empty lists when nothing is set', async () => {
54+
expect(await getAccessControlConfig()).toEqual(empty)
55+
expect(mockFetch).not.toHaveBeenCalled()
56+
})
57+
58+
it('parses, trims, lowercases, and dedupes csv env vars', async () => {
59+
envRef.BLOCKED_SIGNUP_DOMAINS = 'Gmail.com, yahoo.com ,gmail.com,'
60+
envRef.ALLOWED_LOGIN_DOMAINS = 'Sim.ai'
61+
const result = await getAccessControlConfig()
62+
expect(result.blockedSignupDomains).toEqual(['gmail.com', 'yahoo.com'])
63+
expect(result.allowedLoginDomains).toEqual(['sim.ai'])
64+
expect(result.bannedEmails).toEqual([])
65+
expect(mockFetch).not.toHaveBeenCalled()
66+
})
67+
})
68+
69+
describe('AppConfig source', () => {
70+
beforeEach(() => {
71+
envRef.APPCONFIG_APPLICATION = 'sim-staging'
72+
envRef.APPCONFIG_ENVIRONMENT = 'staging'
73+
})
74+
75+
it('reads the access-control profile and normalizes the payload', async () => {
76+
mockFetch.mockImplementation((_ids, parse) =>
77+
Promise.resolve(
78+
parse({
79+
blockedSignupDomains: ['X.com'],
80+
allowedLoginDomains: ['sim.ai'],
81+
bannedEmails: ['A@B.com', 'a@b.com', ' '],
82+
blockedEmailMxHosts: 'not-an-array',
83+
})
84+
)
85+
)
86+
87+
const result = await getAccessControlConfig()
88+
expect(result.blockedSignupDomains).toEqual(['x.com'])
89+
expect(result.allowedLoginDomains).toEqual(['sim.ai'])
90+
expect(result.bannedEmails).toEqual(['a@b.com'])
91+
expect(result.blockedEmailMxHosts).toEqual([])
92+
expect(mockFetch).toHaveBeenCalledWith(
93+
{ application: 'sim-staging', environment: 'staging', profile: 'access-control' },
94+
expect.any(Function)
95+
)
96+
})
97+
98+
it('falls back to env vars when the fetch yields null', async () => {
99+
envRef.BLOCKED_SIGNUP_DOMAINS = 'spam.example'
100+
mockFetch.mockResolvedValue(null)
101+
const result = await getAccessControlConfig()
102+
expect(result.blockedSignupDomains).toEqual(['spam.example'])
103+
})
104+
})
105+
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { fetchAppConfigProfile } from '@/lib/core/config/appconfig'
2+
import { env } from '@/lib/core/config/env'
3+
4+
/**
5+
* Name of the AppConfig configuration profile holding the signup/login gating
6+
* lists. This is a cross-repo contract: it must match the `CfnConfigurationProfile`
7+
* name created by the infra stack.
8+
*/
9+
const ACCESS_CONTROL_PROFILE = 'access-control'
10+
11+
/**
12+
* Normalized signup/login gating lists. All entries are trimmed, lowercased, and
13+
* de-duplicated. Domains are bare hostnames; emails are full addresses; MX hosts
14+
* are substrings matched against resolved MX exchanges.
15+
*/
16+
export interface AccessControlConfig {
17+
blockedSignupDomains: string[]
18+
allowedLoginEmails: string[]
19+
allowedLoginDomains: string[]
20+
blockedEmailMxHosts: string[]
21+
bannedEmails: string[]
22+
}
23+
24+
function normalizeList(values: unknown): string[] {
25+
if (!Array.isArray(values)) return []
26+
return Array.from(new Set(values.map((v) => String(v).trim().toLowerCase()).filter(Boolean)))
27+
}
28+
29+
function parseCsv(value: string | undefined): string[] {
30+
return normalizeList(value?.split(','))
31+
}
32+
33+
/**
34+
* Fallback source for self-hosted/OSS/local deployments that have no AppConfig.
35+
* Reads the same env vars the app used before AppConfig. There is no env
36+
* equivalent for `bannedEmails` — that list is AppConfig-only.
37+
*/
38+
function fromEnv(): AccessControlConfig {
39+
return {
40+
blockedSignupDomains: parseCsv(env.BLOCKED_SIGNUP_DOMAINS),
41+
allowedLoginEmails: parseCsv(env.ALLOWED_LOGIN_EMAILS),
42+
allowedLoginDomains: parseCsv(env.ALLOWED_LOGIN_DOMAINS),
43+
blockedEmailMxHosts: parseCsv(env.BLOCKED_EMAIL_MX_HOSTS),
44+
bannedEmails: [],
45+
}
46+
}
47+
48+
function parseConfig(json: unknown): AccessControlConfig {
49+
const obj = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
50+
return {
51+
blockedSignupDomains: normalizeList(obj.blockedSignupDomains),
52+
allowedLoginEmails: normalizeList(obj.allowedLoginEmails),
53+
allowedLoginDomains: normalizeList(obj.allowedLoginDomains),
54+
blockedEmailMxHosts: normalizeList(obj.blockedEmailMxHosts),
55+
bannedEmails: normalizeList(obj.bannedEmails),
56+
}
57+
}
58+
59+
/**
60+
* AppConfig is the source of truth only when both identifiers are present
61+
* (injected by the infra stack). Mirrors the `hasS3Config` presence-check
62+
* pattern — the AppConfig client is never constructed otherwise.
63+
*/
64+
function isAppConfigEnabled(): boolean {
65+
return Boolean(env.APPCONFIG_APPLICATION && env.APPCONFIG_ENVIRONMENT)
66+
}
67+
68+
/**
69+
* Resolve the current signup/login gating lists. Reads from AWS AppConfig when
70+
* configured (cached, ~30s TTL, never blocks after the first fetch), otherwise
71+
* falls back to env vars so self-hosted/OSS deployments work with no AWS.
72+
*/
73+
export async function getAccessControlConfig(): Promise<AccessControlConfig> {
74+
if (!isAppConfigEnabled()) return fromEnv()
75+
76+
const value = await fetchAppConfigProfile(
77+
{
78+
application: env.APPCONFIG_APPLICATION as string,
79+
environment: env.APPCONFIG_ENVIRONMENT as string,
80+
profile: ACCESS_CONTROL_PROFILE,
81+
},
82+
parseConfig
83+
)
84+
85+
return value ?? fromEnv()
86+
}

apps/sim/lib/auth/auth.ts

Lines changed: 42 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
renderPasswordResetEmail,
3131
renderWelcomeEmail,
3232
} from '@/components/emails'
33+
import { getAccessControlConfig } from '@/lib/auth/access-control'
3334
import { sendPlanWelcomeEmail } from '@/lib/billing'
3435
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
3536
import {
@@ -137,16 +138,6 @@ function getMicrosoftUserInfoFromIdToken(tokens: { accessToken?: string }, provi
137138
}
138139
}
139140

140-
const blockedSignupDomains = env.BLOCKED_SIGNUP_DOMAINS
141-
? Array.from(
142-
new Set(
143-
env.BLOCKED_SIGNUP_DOMAINS.split(',')
144-
.map((d) => d.trim().toLowerCase())
145-
.filter(Boolean)
146-
)
147-
)
148-
: null
149-
150141
export function isEmailInDenylist(
151142
email: string | undefined | null,
152143
denylist: readonly string[] | null
@@ -157,10 +148,6 @@ export function isEmailInDenylist(
157148
return denylist.some((entry) => domain === entry || domain.endsWith(`.${entry}`))
158149
}
159150

160-
function isSignupEmailBlocked(email: string | undefined | null): boolean {
161-
return isEmailInDenylist(email, blockedSignupDomains)
162-
}
163-
164151
const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) =>
165152
logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value })
166153
)
@@ -246,7 +233,12 @@ export const auth = betterAuth({
246233
user: {
247234
create: {
248235
before: async (user) => {
249-
if (isSignupEmailBlocked(user.email)) {
236+
const accessControl = await getAccessControlConfig()
237+
const email = user.email?.toLowerCase()
238+
if (email && accessControl.bannedEmails.includes(email)) {
239+
throw new Error('Sign-ups from this email domain are not allowed.')
240+
}
241+
if (isEmailInDenylist(user.email, accessControl.blockedSignupDomains)) {
250242
throw new Error('Sign-ups from this email domain are not allowed.')
251243
}
252244
return { data: user }
@@ -813,51 +805,55 @@ export const auth = betterAuth({
813805
})
814806
}
815807

816-
if (
817-
(ctx.path.startsWith('/sign-in') || ctx.path.startsWith('/sign-up')) &&
818-
(env.ALLOWED_LOGIN_EMAILS || env.ALLOWED_LOGIN_DOMAINS)
819-
) {
820-
const requestEmail = ctx.body?.email?.toLowerCase()
821-
822-
if (requestEmail) {
823-
let isAllowed = false
808+
const isSignIn = ctx.path.startsWith('/sign-in')
809+
const isSignUp = ctx.path.startsWith('/sign-up')
824810

825-
if (env.ALLOWED_LOGIN_EMAILS) {
826-
const allowedEmails = env.ALLOWED_LOGIN_EMAILS.split(',').map((email) =>
827-
email.trim().toLowerCase()
828-
)
829-
isAllowed = allowedEmails.includes(requestEmail)
830-
}
811+
if (isSignIn || isSignUp) {
812+
const accessControl = await getAccessControlConfig()
813+
const requestEmail = ctx.body?.email?.toLowerCase()
831814

832-
if (!isAllowed && env.ALLOWED_LOGIN_DOMAINS) {
833-
const allowedDomains = env.ALLOWED_LOGIN_DOMAINS.split(',').map((domain) =>
834-
domain.trim().toLowerCase()
835-
)
836-
const emailDomain = requestEmail.split('@')[1]
837-
isAllowed = emailDomain && allowedDomains.includes(emailDomain)
838-
}
815+
if (requestEmail && accessControl.bannedEmails.includes(requestEmail)) {
816+
throw new APIError('FORBIDDEN', {
817+
message: 'Access restricted. Please contact your administrator.',
818+
})
819+
}
839820

821+
const hasAllowlist =
822+
accessControl.allowedLoginEmails.length > 0 ||
823+
accessControl.allowedLoginDomains.length > 0
824+
if (hasAllowlist && requestEmail) {
825+
const emailDomain = requestEmail.split('@')[1]
826+
const isAllowed =
827+
accessControl.allowedLoginEmails.includes(requestEmail) ||
828+
(!!emailDomain && accessControl.allowedLoginDomains.includes(emailDomain))
840829
if (!isAllowed) {
841830
throw new APIError('FORBIDDEN', {
842831
message: 'Access restricted. Please contact your administrator.',
843832
})
844833
}
845834
}
846-
}
847-
848-
if (ctx.path.startsWith('/sign-up') && isSignupEmailBlocked(ctx.body?.email)) {
849-
throw new APIError('FORBIDDEN', {
850-
message: 'Sign-ups from this email domain are not allowed.',
851-
})
852-
}
853835

854-
if (isSignupMxValidationEnabled && ctx.path.startsWith('/sign-up/email') && ctx.body?.email) {
855-
const mxCheck = await validateSignupEmailMx(ctx.body.email)
856-
if (!mxCheck.allowed) {
836+
if (isSignUp && isEmailInDenylist(ctx.body?.email, accessControl.blockedSignupDomains)) {
857837
throw new APIError('FORBIDDEN', {
858838
message: 'Sign-ups from this email domain are not allowed.',
859839
})
860840
}
841+
842+
if (
843+
isSignupMxValidationEnabled &&
844+
ctx.path.startsWith('/sign-up/email') &&
845+
ctx.body?.email
846+
) {
847+
const mxCheck = await validateSignupEmailMx(
848+
ctx.body.email,
849+
accessControl.blockedEmailMxHosts
850+
)
851+
if (!mxCheck.allowed) {
852+
throw new APIError('FORBIDDEN', {
853+
message: 'Sign-ups from this email domain are not allowed.',
854+
})
855+
}
856+
}
861857
}
862858

863859
if (ctx.path === '/sign-up/email' && ctx.body?.email) {

0 commit comments

Comments
 (0)