From e0c19ea111854a0c6acc8b29814878552d7bcfdf Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 8 Jun 2026 13:13:10 -0700 Subject: [PATCH 1/4] 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. --- apps/sim/lib/api-key/service.test.ts | 12 ++ apps/sim/lib/api-key/service.ts | 8 +- apps/sim/lib/auth/access-control.test.ts | 105 +++++++++++++ apps/sim/lib/auth/access-control.ts | 86 +++++++++++ apps/sim/lib/auth/auth.ts | 88 ++++++----- apps/sim/lib/core/config/appconfig.test.ts | 80 ++++++++++ apps/sim/lib/core/config/appconfig.ts | 140 ++++++++++++++++++ apps/sim/lib/core/config/aws.ts | 24 +++ apps/sim/lib/core/config/env.ts | 4 + .../messaging/email/validation.server.test.ts | 37 ++--- .../lib/messaging/email/validation.server.ts | 31 ++-- apps/sim/lib/uploads/providers/s3/client.ts | 10 +- apps/sim/package.json | 1 + bun.lock | 3 + 14 files changed, 530 insertions(+), 99 deletions(-) create mode 100644 apps/sim/lib/auth/access-control.test.ts create mode 100644 apps/sim/lib/auth/access-control.ts create mode 100644 apps/sim/lib/core/config/appconfig.test.ts create mode 100644 apps/sim/lib/core/config/appconfig.ts create mode 100644 apps/sim/lib/core/config/aws.ts diff --git a/apps/sim/lib/api-key/service.test.ts b/apps/sim/lib/api-key/service.test.ts index 0fab5400d1e..02fec43ca54 100644 --- a/apps/sim/lib/api-key/service.test.ts +++ b/apps/sim/lib/api-key/service.test.ts @@ -108,6 +108,18 @@ describe('authenticateApiKeyFromHeader', () => { expect(dbChainMockFns.where).toHaveBeenCalledTimes(1) }) + it('returns invalid when the key belongs to a banned user', async () => { + const record = personalKeyRecord({ userBanned: true }) + dbChainMockFns.where.mockResolvedValueOnce([record]) + + const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', { + userId: 'user-1', + }) + + expect(result).toEqual({ success: false, error: 'Invalid API key' }) + expect(dbChainMockFns.where).toHaveBeenCalledTimes(1) + }) + it('returns invalid when the hash lookup finds no row', async () => { dbChainMockFns.where.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/api-key/service.ts b/apps/sim/lib/api-key/service.ts index b00166805d1..aa451498c1d 100644 --- a/apps/sim/lib/api-key/service.ts +++ b/apps/sim/lib/api-key/service.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { apiKey as apiKeyTable } from '@sim/db/schema' +import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { hashApiKey } from '@/lib/api-key/crypto' @@ -47,6 +47,7 @@ interface HashCandidate { workspaceId: string | null type: string expiresAt: Date | null + userBanned: boolean | null } /** @@ -82,8 +83,10 @@ export async function authenticateApiKeyFromHeader( workspaceId: apiKeyTable.workspaceId, type: apiKeyTable.type, expiresAt: apiKeyTable.expiresAt, + userBanned: userTable.banned, }) .from(apiKeyTable) + .leftJoin(userTable, eq(apiKeyTable.userId, userTable.id)) .where(eq(apiKeyTable.keyHash, keyHash)) if (rows.length === 0) return INVALID @@ -91,6 +94,9 @@ export async function authenticateApiKeyFromHeader( const record = rows[0] const keyType = record.type as 'personal' | 'workspace' + // Defense in depth: banning deletes a user's keys, but reject any survivor too. + if (record.userBanned) return INVALID + if (options.userId && record.userId !== options.userId) return INVALID if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) return INVALID if (record.expiresAt && record.expiresAt < new Date()) return INVALID diff --git a/apps/sim/lib/auth/access-control.test.ts b/apps/sim/lib/auth/access-control.test.ts new file mode 100644 index 00000000000..c5a9263a05b --- /dev/null +++ b/apps/sim/lib/auth/access-control.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AccessControlConfig } from '@/lib/auth/access-control' + +const { mockFetch, envRef } = vi.hoisted(() => ({ + mockFetch: vi.fn(), + envRef: { + APPCONFIG_APPLICATION: undefined as string | undefined, + APPCONFIG_ENVIRONMENT: undefined as string | undefined, + BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined, + ALLOWED_LOGIN_EMAILS: undefined as string | undefined, + ALLOWED_LOGIN_DOMAINS: undefined as string | undefined, + BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined, + }, +})) + +vi.mock('@/lib/core/config/appconfig', () => ({ + fetchAppConfigProfile: mockFetch, +})) + +vi.mock('@/lib/core/config/env', () => ({ + get env() { + return envRef + }, +})) + +import { getAccessControlConfig } from '@/lib/auth/access-control' + +const empty: AccessControlConfig = { + blockedSignupDomains: [], + allowedLoginEmails: [], + allowedLoginDomains: [], + blockedEmailMxHosts: [], + bannedEmails: [], +} + +describe('getAccessControlConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.assign(envRef, { + APPCONFIG_APPLICATION: undefined, + APPCONFIG_ENVIRONMENT: undefined, + BLOCKED_SIGNUP_DOMAINS: undefined, + ALLOWED_LOGIN_EMAILS: undefined, + ALLOWED_LOGIN_DOMAINS: undefined, + BLOCKED_EMAIL_MX_HOSTS: undefined, + }) + }) + + describe('env fallback (AppConfig not configured)', () => { + it('returns empty lists when nothing is set', async () => { + expect(await getAccessControlConfig()).toEqual(empty) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('parses, trims, lowercases, and dedupes csv env vars', async () => { + envRef.BLOCKED_SIGNUP_DOMAINS = 'Gmail.com, yahoo.com ,gmail.com,' + envRef.ALLOWED_LOGIN_DOMAINS = 'Sim.ai' + const result = await getAccessControlConfig() + expect(result.blockedSignupDomains).toEqual(['gmail.com', 'yahoo.com']) + expect(result.allowedLoginDomains).toEqual(['sim.ai']) + expect(result.bannedEmails).toEqual([]) + expect(mockFetch).not.toHaveBeenCalled() + }) + }) + + describe('AppConfig source', () => { + beforeEach(() => { + envRef.APPCONFIG_APPLICATION = 'sim-staging' + envRef.APPCONFIG_ENVIRONMENT = 'staging' + }) + + it('reads the access-control profile and normalizes the payload', async () => { + mockFetch.mockImplementation((_ids, parse) => + Promise.resolve( + parse({ + blockedSignupDomains: ['X.com'], + allowedLoginDomains: ['sim.ai'], + bannedEmails: ['A@B.com', 'a@b.com', ' '], + blockedEmailMxHosts: 'not-an-array', + }) + ) + ) + + const result = await getAccessControlConfig() + expect(result.blockedSignupDomains).toEqual(['x.com']) + expect(result.allowedLoginDomains).toEqual(['sim.ai']) + expect(result.bannedEmails).toEqual(['a@b.com']) + expect(result.blockedEmailMxHosts).toEqual([]) + expect(mockFetch).toHaveBeenCalledWith( + { application: 'sim-staging', environment: 'staging', profile: 'access-control' }, + expect.any(Function) + ) + }) + + it('falls back to env vars when the fetch yields null', async () => { + envRef.BLOCKED_SIGNUP_DOMAINS = 'spam.example' + mockFetch.mockResolvedValue(null) + const result = await getAccessControlConfig() + expect(result.blockedSignupDomains).toEqual(['spam.example']) + }) + }) +}) diff --git a/apps/sim/lib/auth/access-control.ts b/apps/sim/lib/auth/access-control.ts new file mode 100644 index 00000000000..cafc17775ba --- /dev/null +++ b/apps/sim/lib/auth/access-control.ts @@ -0,0 +1,86 @@ +import { fetchAppConfigProfile } from '@/lib/core/config/appconfig' +import { env } from '@/lib/core/config/env' + +/** + * Name of the AppConfig configuration profile holding the signup/login gating + * lists. This is a cross-repo contract: it must match the `CfnConfigurationProfile` + * name created by the infra stack. + */ +const ACCESS_CONTROL_PROFILE = 'access-control' + +/** + * Normalized signup/login gating lists. All entries are trimmed, lowercased, and + * de-duplicated. Domains are bare hostnames; emails are full addresses; MX hosts + * are substrings matched against resolved MX exchanges. + */ +export interface AccessControlConfig { + blockedSignupDomains: string[] + allowedLoginEmails: string[] + allowedLoginDomains: string[] + blockedEmailMxHosts: string[] + bannedEmails: string[] +} + +function normalizeList(values: unknown): string[] { + if (!Array.isArray(values)) return [] + return Array.from(new Set(values.map((v) => String(v).trim().toLowerCase()).filter(Boolean))) +} + +function parseCsv(value: string | undefined): string[] { + return normalizeList(value?.split(',')) +} + +/** + * Fallback source for self-hosted/OSS/local deployments that have no AppConfig. + * Reads the same env vars the app used before AppConfig. There is no env + * equivalent for `bannedEmails` — that list is AppConfig-only. + */ +function fromEnv(): AccessControlConfig { + return { + blockedSignupDomains: parseCsv(env.BLOCKED_SIGNUP_DOMAINS), + allowedLoginEmails: parseCsv(env.ALLOWED_LOGIN_EMAILS), + allowedLoginDomains: parseCsv(env.ALLOWED_LOGIN_DOMAINS), + blockedEmailMxHosts: parseCsv(env.BLOCKED_EMAIL_MX_HOSTS), + bannedEmails: [], + } +} + +function parseConfig(json: unknown): AccessControlConfig { + const obj = (json && typeof json === 'object' ? json : {}) as Record + return { + blockedSignupDomains: normalizeList(obj.blockedSignupDomains), + allowedLoginEmails: normalizeList(obj.allowedLoginEmails), + allowedLoginDomains: normalizeList(obj.allowedLoginDomains), + blockedEmailMxHosts: normalizeList(obj.blockedEmailMxHosts), + bannedEmails: normalizeList(obj.bannedEmails), + } +} + +/** + * AppConfig is the source of truth only when both identifiers are present + * (injected by the infra stack). Mirrors the `hasS3Config` presence-check + * pattern — the AppConfig client is never constructed otherwise. + */ +function isAppConfigEnabled(): boolean { + return Boolean(env.APPCONFIG_APPLICATION && env.APPCONFIG_ENVIRONMENT) +} + +/** + * Resolve the current signup/login gating lists. Reads from AWS AppConfig when + * configured (cached, ~30s TTL, never blocks after the first fetch), otherwise + * falls back to env vars so self-hosted/OSS deployments work with no AWS. + */ +export async function getAccessControlConfig(): Promise { + if (!isAppConfigEnabled()) return fromEnv() + + const value = await fetchAppConfigProfile( + { + application: env.APPCONFIG_APPLICATION as string, + environment: env.APPCONFIG_ENVIRONMENT as string, + profile: ACCESS_CONTROL_PROFILE, + }, + parseConfig + ) + + return value ?? fromEnv() +} diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index cd27bbd5e2d..ca0a3cac7d3 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -30,6 +30,7 @@ import { renderPasswordResetEmail, renderWelcomeEmail, } from '@/components/emails' +import { getAccessControlConfig } from '@/lib/auth/access-control' import { sendPlanWelcomeEmail } from '@/lib/billing' import { authorizeSubscriptionReference } from '@/lib/billing/authorization' import { @@ -137,16 +138,6 @@ function getMicrosoftUserInfoFromIdToken(tokens: { accessToken?: string }, provi } } -const blockedSignupDomains = env.BLOCKED_SIGNUP_DOMAINS - ? Array.from( - new Set( - env.BLOCKED_SIGNUP_DOMAINS.split(',') - .map((d) => d.trim().toLowerCase()) - .filter(Boolean) - ) - ) - : null - export function isEmailInDenylist( email: string | undefined | null, denylist: readonly string[] | null @@ -157,10 +148,6 @@ export function isEmailInDenylist( return denylist.some((entry) => domain === entry || domain.endsWith(`.${entry}`)) } -function isSignupEmailBlocked(email: string | undefined | null): boolean { - return isEmailInDenylist(email, blockedSignupDomains) -} - const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) => logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value }) ) @@ -246,7 +233,12 @@ export const auth = betterAuth({ user: { create: { before: async (user) => { - if (isSignupEmailBlocked(user.email)) { + const accessControl = await getAccessControlConfig() + const email = user.email?.toLowerCase() + if (email && accessControl.bannedEmails.includes(email)) { + throw new Error('Sign-ups from this email domain are not allowed.') + } + if (isEmailInDenylist(user.email, accessControl.blockedSignupDomains)) { throw new Error('Sign-ups from this email domain are not allowed.') } return { data: user } @@ -813,51 +805,55 @@ export const auth = betterAuth({ }) } - if ( - (ctx.path.startsWith('/sign-in') || ctx.path.startsWith('/sign-up')) && - (env.ALLOWED_LOGIN_EMAILS || env.ALLOWED_LOGIN_DOMAINS) - ) { - const requestEmail = ctx.body?.email?.toLowerCase() - - if (requestEmail) { - let isAllowed = false + const isSignIn = ctx.path.startsWith('/sign-in') + const isSignUp = ctx.path.startsWith('/sign-up') - if (env.ALLOWED_LOGIN_EMAILS) { - const allowedEmails = env.ALLOWED_LOGIN_EMAILS.split(',').map((email) => - email.trim().toLowerCase() - ) - isAllowed = allowedEmails.includes(requestEmail) - } + if (isSignIn || isSignUp) { + const accessControl = await getAccessControlConfig() + const requestEmail = ctx.body?.email?.toLowerCase() - if (!isAllowed && env.ALLOWED_LOGIN_DOMAINS) { - const allowedDomains = env.ALLOWED_LOGIN_DOMAINS.split(',').map((domain) => - domain.trim().toLowerCase() - ) - const emailDomain = requestEmail.split('@')[1] - isAllowed = emailDomain && allowedDomains.includes(emailDomain) - } + if (requestEmail && accessControl.bannedEmails.includes(requestEmail)) { + throw new APIError('FORBIDDEN', { + message: 'Access restricted. Please contact your administrator.', + }) + } + const hasAllowlist = + accessControl.allowedLoginEmails.length > 0 || + accessControl.allowedLoginDomains.length > 0 + if (hasAllowlist && requestEmail) { + const emailDomain = requestEmail.split('@')[1] + const isAllowed = + accessControl.allowedLoginEmails.includes(requestEmail) || + (!!emailDomain && accessControl.allowedLoginDomains.includes(emailDomain)) if (!isAllowed) { throw new APIError('FORBIDDEN', { message: 'Access restricted. Please contact your administrator.', }) } } - } - - if (ctx.path.startsWith('/sign-up') && isSignupEmailBlocked(ctx.body?.email)) { - throw new APIError('FORBIDDEN', { - message: 'Sign-ups from this email domain are not allowed.', - }) - } - if (isSignupMxValidationEnabled && ctx.path.startsWith('/sign-up/email') && ctx.body?.email) { - const mxCheck = await validateSignupEmailMx(ctx.body.email) - if (!mxCheck.allowed) { + if (isSignUp && isEmailInDenylist(ctx.body?.email, accessControl.blockedSignupDomains)) { throw new APIError('FORBIDDEN', { message: 'Sign-ups from this email domain are not allowed.', }) } + + if ( + isSignupMxValidationEnabled && + ctx.path.startsWith('/sign-up/email') && + ctx.body?.email + ) { + const mxCheck = await validateSignupEmailMx( + ctx.body.email, + accessControl.blockedEmailMxHosts + ) + if (!mxCheck.allowed) { + throw new APIError('FORBIDDEN', { + message: 'Sign-ups from this email domain are not allowed.', + }) + } + } } if (ctx.path === '/sign-up/email' && ctx.body?.email) { diff --git a/apps/sim/lib/core/config/appconfig.test.ts b/apps/sim/lib/core/config/appconfig.test.ts new file mode 100644 index 00000000000..1c795a25307 --- /dev/null +++ b/apps/sim/lib/core/config/appconfig.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSend } = vi.hoisted(() => ({ + mockSend: vi.fn(), +})) + +vi.mock('@aws-sdk/client-appconfigdata', () => ({ + AppConfigDataClient: class { + send = mockSend + }, + StartConfigurationSessionCommand: class { + __type = 'start' + constructor(public input: unknown) {} + }, + GetLatestConfigurationCommand: class { + __type = 'get' + constructor(public input: unknown) {} + }, +})) + +import { fetchAppConfigProfile } from '@/lib/core/config/appconfig' + +const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)) + +let counter = 0 +/** Unique identifiers per test so the module-level cache never bleeds across tests. */ +function uniqueIds() { + counter += 1 + return { application: `app-${counter}`, environment: `env-${counter}`, profile: 'access-control' } +} + +describe('fetchAppConfigProfile', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('starts a session then returns the parsed configuration', async () => { + mockSend.mockImplementation((command: { __type: string }) => { + if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' }) + return Promise.resolve({ + Configuration: encode({ blockedSignupDomains: ['spam.example'] }), + NextPollConfigurationToken: 'tok-2', + }) + }) + + const result = await fetchAppConfigProfile( + uniqueIds(), + (json) => json as Record + ) + expect(result).toEqual({ blockedSignupDomains: ['spam.example'] }) + + const sentTypes = mockSend.mock.calls.map(([c]) => c.__type) + expect(sentTypes).toEqual(['start', 'get']) + }) + + it('returns null when the cold fetch fails (never throws)', async () => { + mockSend.mockRejectedValue(new Error('appconfig down')) + const result = await fetchAppConfigProfile(uniqueIds(), (json) => json) + expect(result).toBeNull() + }) + + it('applies the parse function to the decoded JSON', async () => { + mockSend.mockImplementation((command: { __type: string }) => { + if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' }) + return Promise.resolve({ + Configuration: encode({ count: 2 }), + NextPollConfigurationToken: 'tok-2', + }) + }) + + const result = await fetchAppConfigProfile( + uniqueIds(), + (json) => (json as { count: number }).count * 10 + ) + expect(result).toBe(20) + }) +}) diff --git a/apps/sim/lib/core/config/appconfig.ts b/apps/sim/lib/core/config/appconfig.ts new file mode 100644 index 00000000000..f4ceeada7c5 --- /dev/null +++ b/apps/sim/lib/core/config/appconfig.ts @@ -0,0 +1,140 @@ +import { + AppConfigDataClient, + GetLatestConfigurationCommand, + StartConfigurationSessionCommand, +} from '@aws-sdk/client-appconfigdata' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws' +import { env } from '@/lib/core/config/env' + +const logger = createLogger('AppConfig') + +const DEFAULT_TTL_MS = 30_000 + +export interface AppConfigProfileIdentifiers { + application: string + environment: string + profile: string +} + +interface CacheEntry { + /** Last successfully parsed value, or `null` if no successful fetch yet. */ + value: T | null + /** Token for the next `GetLatestConfiguration` poll, rotated on each call. */ + nextToken: string | undefined + expiresAt: number + refreshing: boolean +} + +const cache = new Map>() + +let client: AppConfigDataClient | null = null + +/** + * Lazily construct the AppConfig data-plane client. Never instantiated unless a + * caller actually fetches a profile, so deployments without AppConfig configured + * never reach for AWS credentials. + */ +function getClient(): AppConfigDataClient { + if (!client) { + client = new AppConfigDataClient({ + region: env.AWS_REGION, + credentials: getAwsCredentialsFromEnv(), + }) + } + return client +} + +function cacheKey(ids: AppConfigProfileIdentifiers): string { + return `${ids.application}/${ids.environment}/${ids.profile}` +} + +/** + * Run one AppConfig poll for `entry`: starts a session if no token is held, then + * calls `GetLatestConfiguration`. An empty payload means "unchanged" and the + * previous value is kept. Any error is logged and the last good value is + * retained. Returns the (possibly unchanged) value. + */ +async function poll( + ids: AppConfigProfileIdentifiers, + parse: (json: unknown) => T, + entry: CacheEntry +): Promise { + try { + const dataClient = getClient() + + if (!entry.nextToken) { + const session = await dataClient.send( + new StartConfigurationSessionCommand({ + ApplicationIdentifier: ids.application, + EnvironmentIdentifier: ids.environment, + ConfigurationProfileIdentifier: ids.profile, + }) + ) + entry.nextToken = session.InitialConfigurationToken + } + + const response = await dataClient.send( + new GetLatestConfigurationCommand({ ConfigurationToken: entry.nextToken }) + ) + entry.nextToken = response.NextPollConfigurationToken ?? entry.nextToken + + if (response.Configuration && response.Configuration.length > 0) { + const text = new TextDecoder().decode(response.Configuration) + entry.value = parse(JSON.parse(text)) + } + + entry.expiresAt = Date.now() + DEFAULT_TTL_MS + return entry.value + } catch (error) { + // Drop the token so the next attempt starts a fresh session (handles expired + // or invalid tokens). Keep the last good value rather than failing the caller. + entry.nextToken = undefined + entry.expiresAt = Date.now() + DEFAULT_TTL_MS + logger.error('AppConfig fetch failed; serving last known value', { + profile: cacheKey(ids), + error: getErrorMessage(error), + }) + return entry.value + } +} + +/** + * Fetch and cache a single AppConfig configuration profile as JSON. + * + * Profile-agnostic: pass the `application`/`environment` (from env) and a + * `profile` constant owned by the calling feature. Uses an in-process TTL cache + * with stale-while-revalidate — a warm cache returns immediately and refreshes + * in the background once the TTL lapses, so no request blocks on the AppConfig + * round trip after the first (cold) fetch. Returns `null` only when the very + * first fetch fails before any value is cached. + */ +export async function fetchAppConfigProfile( + ids: AppConfigProfileIdentifiers, + parse: (json: unknown) => T +): Promise { + const key = cacheKey(ids) + const entry = (cache.get(key) as CacheEntry | undefined) ?? { + value: null, + nextToken: undefined, + expiresAt: 0, + refreshing: false, + } + cache.set(key, entry) + + // Cold: no value yet — fetch synchronously so the caller gets real data. + if (entry.value === null) { + return poll(ids, parse, entry) + } + + // Warm but stale: serve cached value, refresh in the background. + if (Date.now() >= entry.expiresAt && !entry.refreshing) { + entry.refreshing = true + void poll(ids, parse, entry).finally(() => { + entry.refreshing = false + }) + } + + return entry.value +} diff --git a/apps/sim/lib/core/config/aws.ts b/apps/sim/lib/core/config/aws.ts new file mode 100644 index 00000000000..13f5570a2e6 --- /dev/null +++ b/apps/sim/lib/core/config/aws.ts @@ -0,0 +1,24 @@ +import { env } from '@/lib/core/config/env' + +interface AwsCredentials { + accessKeyId: string + secretAccessKey: string +} + +/** + * Explicit AWS credentials from the environment, or `undefined` to defer to the + * default AWS provider chain (the ECS task role in our deployments). + * + * Shared by every AWS SDK client (S3, AppConfig, …) so credential resolution is + * identical everywhere: explicit keys when both `AWS_ACCESS_KEY_ID` and + * `AWS_SECRET_ACCESS_KEY` are set (self-hosted, trigger.dev workers), otherwise + * the instance/task role. + */ +export function getAwsCredentialsFromEnv(): AwsCredentials | undefined { + return env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY + ? { + accessKeyId: env.AWS_ACCESS_KEY_ID, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY, + } + : undefined +} diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 35e5d9d24cf..f83c534644c 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -222,6 +222,10 @@ export const env = createEnv({ S3_ENDPOINT: z.string().optional(), // Custom endpoint for S3-compatible storage (Cloudflare R2, MinIO, Backblaze B2). Leave unset for AWS S3 S3_FORCE_PATH_STYLE: z.string().optional(), // Force path-style addressing (MinIO/Ceph RGW). Defaults to false (AWS S3, R2). Coerced via envBoolean at the consumption site + // Dynamic config - AWS AppConfig (hosted source of truth for signup/login gating lists; unset => env-var fallback) + APPCONFIG_APPLICATION: z.string().optional(), // AppConfig application id/name. When set with APPCONFIG_ENVIRONMENT, gating lists come from AppConfig instead of env vars + APPCONFIG_ENVIRONMENT: z.string().optional(), // AppConfig environment id/name. Profile name is an app-side constant ('access-control'), not an env var + // Cloud Storage - Azure Blob AZURE_ACCOUNT_NAME: z.string().optional(), // Azure storage account name AZURE_ACCOUNT_KEY: z.string().optional(), // Azure storage account key diff --git a/apps/sim/lib/messaging/email/validation.server.test.ts b/apps/sim/lib/messaging/email/validation.server.test.ts index 9fcfb4de6d4..f95676ced7d 100644 --- a/apps/sim/lib/messaging/email/validation.server.test.ts +++ b/apps/sim/lib/messaging/email/validation.server.test.ts @@ -3,23 +3,14 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockResolveMx, envRef } = vi.hoisted(() => ({ +const { mockResolveMx } = vi.hoisted(() => ({ mockResolveMx: vi.fn(), - envRef: { - BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined, - }, })) vi.mock('dns/promises', () => ({ default: { resolveMx: mockResolveMx }, })) -vi.mock('@/lib/core/config/env', () => ({ - get env() { - return envRef - }, -})) - import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server' const mx = (...hosts: string[]) => @@ -28,29 +19,29 @@ const mx = (...hosts: string[]) => describe('validateSignupEmailMx', () => { beforeEach(() => { vi.clearAllMocks() - envRef.BLOCKED_EMAIL_MX_HOSTS = undefined }) it('blocks a domain whose MX backend is on the configured denylist', async () => { - envRef.BLOCKED_EMAIL_MX_HOSTS = 'blocked-backend.example' mockResolveMx.mockResolvedValue(mx('smtp.blocked-backend.example')) - const result = await validateSignupEmailMx('user@rotated-domain.test') + const result = await validateSignupEmailMx('user@rotated-domain.test', [ + 'blocked-backend.example', + ]) expect(result.allowed).toBe(false) expect(result.reason).toBe('blocked_mx_backend') }) it('matches the denylist as a case-insensitive substring of the MX exchange', async () => { - envRef.BLOCKED_EMAIL_MX_HOSTS = 'Blocked-Backend.Example' - mockResolveMx.mockResolvedValue(mx('mx1.blocked-backend.example')) - const result = await validateSignupEmailMx('user@another-domain.test') + mockResolveMx.mockResolvedValue(mx('MX1.Blocked-Backend.Example')) + const result = await validateSignupEmailMx('user@another-domain.test', [ + 'blocked-backend.example', + ]) expect(result.allowed).toBe(false) expect(result.reason).toBe('blocked_mx_backend') }) it('does not block any backend when the denylist is empty (no hardcoded defaults)', async () => { - envRef.BLOCKED_EMAIL_MX_HOSTS = undefined mockResolveMx.mockResolvedValue(mx('smtp.blocked-backend.example')) - const result = await validateSignupEmailMx('user@rotated-domain.test') + const result = await validateSignupEmailMx('user@rotated-domain.test', []) expect(result.allowed).toBe(true) }) @@ -58,32 +49,32 @@ describe('validateSignupEmailMx', () => { mockResolveMx.mockResolvedValue( mx('gmail-smtp-in.l.google.com', 'alt1.gmail-smtp-in.l.google.com') ) - const result = await validateSignupEmailMx('real.person@gmail.com') + const result = await validateSignupEmailMx('real.person@gmail.com', ['blocked-backend.example']) expect(result.allowed).toBe(true) }) it('blocks a domain with no MX records (ENOTFOUND)', async () => { mockResolveMx.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOTFOUND' })) - const result = await validateSignupEmailMx('x@no-such-domain.invalid') + const result = await validateSignupEmailMx('x@no-such-domain.invalid', []) expect(result.allowed).toBe(false) expect(result.reason).toBe('no_mx') }) it('blocks a domain that resolves to an empty MX set', async () => { mockResolveMx.mockResolvedValue([]) - const result = await validateSignupEmailMx('x@empty.example') + const result = await validateSignupEmailMx('x@empty.example', []) expect(result.allowed).toBe(false) expect(result.reason).toBe('no_mx') }) it('fails open on a transient DNS error (does not block legit users)', async () => { mockResolveMx.mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' })) - const result = await validateSignupEmailMx('user@some-real-domain.com') + const result = await validateSignupEmailMx('user@some-real-domain.com', []) expect(result.allowed).toBe(true) }) it('allows when the email has no domain (defers to other validation)', async () => { - const result = await validateSignupEmailMx('not-an-email') + const result = await validateSignupEmailMx('not-an-email', []) expect(result.allowed).toBe(true) expect(mockResolveMx).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/messaging/email/validation.server.ts b/apps/sim/lib/messaging/email/validation.server.ts index 2d1df5b3048..67d2a26045f 100644 --- a/apps/sim/lib/messaging/email/validation.server.ts +++ b/apps/sim/lib/messaging/email/validation.server.ts @@ -2,29 +2,11 @@ import type { MxRecord } from 'dns' import dns from 'dns/promises' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { env } from '@/lib/core/config/env' const logger = createLogger('EmailValidationServer') const MX_LOOKUP_TIMEOUT_MS = 3000 -/** - * MX-host substrings to block, supplied at runtime via `BLOCKED_EMAIL_MX_HOSTS`. - * - * Signup-spam botnets rotate throwaway domains rapidly but funnel them through a - * small number of shared catch-all mail providers, so the resolved MX host is a - * far more stable signal than the domain itself. Each entry is matched as a - * case-insensitive substring against the domain's resolved MX exchanges. No - * hosts are hardcoded — operators configure their own denylist out of band. - */ -function getBlockedMxHosts(): string[] { - return ( - env.BLOCKED_EMAIL_MX_HOSTS?.split(',') - .map((h) => h.trim().toLowerCase()) - .filter(Boolean) ?? [] - ) -} - export interface SignupEmailCheck { /** Whether the email may proceed to signup. */ allowed: boolean @@ -41,10 +23,18 @@ export interface SignupEmailCheck { * users are never blocked by an infrastructure blip. Only a definitive * "domain has no MX" answer (`ENOTFOUND` / `ENODATA`) blocks. * + * `blockedMxHosts` are case-insensitive substrings matched against each resolved + * MX exchange — signup-spam botnets rotate throwaway domains but funnel them + * through a few shared catch-all backends, so the MX host is a more stable signal + * than the domain. Sourced from access-control config (AppConfig or env fallback). + * * Server-only — imports `dns/promises`. Never import from client code. Gated by the caller * behind `isSignupMxValidationEnabled`; this function performs the check unconditionally. */ -export async function validateSignupEmailMx(email: string): Promise { +export async function validateSignupEmailMx( + email: string, + blockedMxHosts: string[] +): Promise { const domain = email.split('@')[1]?.toLowerCase() if (!domain) return { allowed: true } @@ -80,10 +70,9 @@ export async function validateSignupEmailMx(email: string): Promise { const exchange = record.exchange.toLowerCase() - return blocked.some((host) => exchange.includes(host)) + return blockedMxHosts.some((host) => exchange.includes(host)) }) if (match) { diff --git a/apps/sim/lib/uploads/providers/s3/client.ts b/apps/sim/lib/uploads/providers/s3/client.ts index cff13eee067..978070ea24b 100644 --- a/apps/sim/lib/uploads/providers/s3/client.ts +++ b/apps/sim/lib/uploads/providers/s3/client.ts @@ -14,7 +14,7 @@ import { import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { env } from '@/lib/core/config/env' +import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws' import { assertKnownSizeWithinLimit, readNodeStreamToBufferWithLimit, @@ -57,13 +57,7 @@ export function getS3Client(): S3Client { region, endpoint: S3_CONFIG.endpoint, forcePathStyle: S3_CONFIG.forcePathStyle, - credentials: - env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY - ? { - accessKeyId: env.AWS_ACCESS_KEY_ID, - secretAccessKey: env.AWS_SECRET_ACCESS_KEY, - } - : undefined, + credentials: getAwsCredentialsFromEnv(), }) return _s3Client diff --git a/apps/sim/package.json b/apps/sim/package.json index c9df40e6897..055fcb8cab3 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -34,6 +34,7 @@ "@1password/sdk": "0.3.1", "@a2a-js/sdk": "0.3.7", "@anthropic-ai/sdk": "0.71.2", + "@aws-sdk/client-appconfigdata": "3.1032.0", "@aws-sdk/client-athena": "3.1032.0", "@aws-sdk/client-bedrock-runtime": "3.1032.0", "@aws-sdk/client-cloudformation": "3.1032.0", diff --git a/bun.lock b/bun.lock index 2a293215c7e..13b8b05394c 100644 --- a/bun.lock +++ b/bun.lock @@ -88,6 +88,7 @@ "@1password/sdk": "0.3.1", "@a2a-js/sdk": "0.3.7", "@anthropic-ai/sdk": "0.71.2", + "@aws-sdk/client-appconfigdata": "3.1032.0", "@aws-sdk/client-athena": "3.1032.0", "@aws-sdk/client-bedrock-runtime": "3.1032.0", "@aws-sdk/client-cloudformation": "3.1032.0", @@ -564,6 +565,8 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-sdk/client-appconfigdata": ["@aws-sdk/client-appconfigdata@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-stream": "^4.5.23", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-gh/cmEFDN97XJBWRLT0usnWnTDEm+cqgOIffTJGP68xCgj28EkSHnN5vtdy2QaZjj7/n/sKOlqIKONZUeonRpA=="], + "@aws-sdk/client-athena": ["@aws-sdk/client-athena@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-/3RrC4J644U1ZlqcGyGCRf2cyCH/xWs2B6PewlKWeyTq2uWSRtY+v5CkEQ51fRm2Y5wfhuxoU9FO1jKIKm9fSA=="], "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/eventstream-handler-node": "^3.972.14", "@aws-sdk/middleware-eventstream": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/middleware-websocket": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/token-providers": "3.1032.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-stream": "^4.5.23", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fSRz/48As9c3DeS+9ZWd7kk9171pJntCCuehHBDeprD9CPF+C+ATaVNJ5SOLE5RIBR2IHOVTwjAgJt/nkS/6Yg=="], From 9ee5b1fe133a860980cb28d33db222034a02b45b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 8 Jun 2026 14:58:52 -0700 Subject: [PATCH 2/4] fix(auth): scope bannedEmails to signup only; harden AppConfig cache - Remove bannedEmails sign-in check: better-auth's admin plugin already blocks banned users at sign-in (session.create.before, all providers). bannedEmails is now a signup-only denylist via user.create.before, which also closed the OAuth/email-OTP sign-in bypass the bots flagged. - AppConfig cache: track a 'loaded' flag so an empty/unseeded profile warms the cache instead of re-polling on every request; honor NextPollIntervalInSeconds to avoid throttling; dedupe concurrent cold fetches behind one in-flight poll to avoid racing the rotating session token. --- apps/sim/lib/auth/auth.ts | 10 ++--- apps/sim/lib/core/config/appconfig.test.ts | 38 ++++++++++++++++ apps/sim/lib/core/config/appconfig.ts | 50 ++++++++++++++-------- 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index ca0a3cac7d3..ca0bb9eafea 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -812,12 +812,10 @@ export const auth = betterAuth({ const accessControl = await getAccessControlConfig() const requestEmail = ctx.body?.email?.toLowerCase() - if (requestEmail && accessControl.bannedEmails.includes(requestEmail)) { - throw new APIError('FORBIDDEN', { - message: 'Access restricted. Please contact your administrator.', - }) - } - + // Note: banning an existing account is owned by better-auth's admin plugin + // (a `session.create.before` hook that blocks banned users at sign-in across + // all providers). `bannedEmails` here is a signup-only denylist enforced in + // `databaseHooks.user.create.before`, so it is not checked on sign-in. const hasAllowlist = accessControl.allowedLoginEmails.length > 0 || accessControl.allowedLoginDomains.length > 0 diff --git a/apps/sim/lib/core/config/appconfig.test.ts b/apps/sim/lib/core/config/appconfig.test.ts index 1c795a25307..3e0d278fec9 100644 --- a/apps/sim/lib/core/config/appconfig.test.ts +++ b/apps/sim/lib/core/config/appconfig.test.ts @@ -77,4 +77,42 @@ describe('fetchAppConfigProfile', () => { ) expect(result).toBe(20) }) + + it('warms the cache on an empty payload and does not re-poll (unseeded profile)', async () => { + mockSend.mockImplementation((command: { __type: string }) => { + if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' }) + return Promise.resolve({ + Configuration: new Uint8Array(), + NextPollConfigurationToken: 'tok-2', + NextPollIntervalInSeconds: 60, + }) + }) + + const ids = uniqueIds() + expect(await fetchAppConfigProfile(ids, (json) => json)).toBeNull() + const callsAfterFirst = mockSend.mock.calls.length + + expect(await fetchAppConfigProfile(ids, (json) => json)).toBeNull() + expect(mockSend.mock.calls.length).toBe(callsAfterFirst) + }) + + it('dedupes concurrent cold fetches into a single poll', async () => { + mockSend.mockImplementation((command: { __type: string }) => { + if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' }) + return Promise.resolve({ + Configuration: encode({ x: 1 }), + NextPollConfigurationToken: 'tok-2', + }) + }) + + const ids = uniqueIds() + const [a, b] = await Promise.all([ + fetchAppConfigProfile(ids, (json) => json), + fetchAppConfigProfile(ids, (json) => json), + ]) + + expect(a).toEqual({ x: 1 }) + expect(b).toEqual({ x: 1 }) + expect(mockSend.mock.calls.map(([c]) => c.__type)).toEqual(['start', 'get']) + }) }) diff --git a/apps/sim/lib/core/config/appconfig.ts b/apps/sim/lib/core/config/appconfig.ts index f4ceeada7c5..a43a9e2ad8f 100644 --- a/apps/sim/lib/core/config/appconfig.ts +++ b/apps/sim/lib/core/config/appconfig.ts @@ -19,12 +19,15 @@ export interface AppConfigProfileIdentifiers { } interface CacheEntry { - /** Last successfully parsed value, or `null` if no successful fetch yet. */ + /** Last successfully parsed value, or `null` if the config is empty/unseeded. */ value: T | null + /** True once any poll has completed (success, empty payload, or error). */ + loaded: boolean /** Token for the next `GetLatestConfiguration` poll, rotated on each call. */ nextToken: string | undefined expiresAt: number - refreshing: boolean + /** In-flight poll, shared so concurrent callers don't each hit AppConfig. */ + inflight: Promise | null } const cache = new Map>() @@ -52,9 +55,11 @@ function cacheKey(ids: AppConfigProfileIdentifiers): string { /** * Run one AppConfig poll for `entry`: starts a session if no token is held, then - * calls `GetLatestConfiguration`. An empty payload means "unchanged" and the - * previous value is kept. Any error is logged and the last good value is - * retained. Returns the (possibly unchanged) value. + * calls `GetLatestConfiguration`. An empty payload means "unchanged" (or an + * unseeded profile) and the previous value is kept. Any error is logged and the + * last good value is retained. Marks the entry `loaded` on any outcome so callers + * never re-block on the cold path, and honors AppConfig's `NextPollInterval` so we + * don't poll faster than the server allows (which would throttle). */ async function poll( ids: AppConfigProfileIdentifiers, @@ -85,13 +90,17 @@ async function poll( entry.value = parse(JSON.parse(text)) } - entry.expiresAt = Date.now() + DEFAULT_TTL_MS + const intervalMs = (response.NextPollIntervalInSeconds ?? 60) * 1000 + entry.expiresAt = Date.now() + Math.max(DEFAULT_TTL_MS, intervalMs) + entry.loaded = true return entry.value } catch (error) { // Drop the token so the next attempt starts a fresh session (handles expired - // or invalid tokens). Keep the last good value rather than failing the caller. + // or invalid tokens). Mark loaded + back off so we serve the fallback and + // retry in the background rather than blocking every request during an outage. entry.nextToken = undefined entry.expiresAt = Date.now() + DEFAULT_TTL_MS + entry.loaded = true logger.error('AppConfig fetch failed; serving last known value', { profile: cacheKey(ids), error: getErrorMessage(error), @@ -107,8 +116,9 @@ async function poll( * `profile` constant owned by the calling feature. Uses an in-process TTL cache * with stale-while-revalidate — a warm cache returns immediately and refreshes * in the background once the TTL lapses, so no request blocks on the AppConfig - * round trip after the first (cold) fetch. Returns `null` only when the very - * first fetch fails before any value is cached. + * round trip after the first (cold) fetch. Concurrent callers share one in-flight + * poll (avoids racing the rotating session token). Returns `null` when the config + * is empty/unseeded or the first fetch fails. */ export async function fetchAppConfigProfile( ids: AppConfigProfileIdentifiers, @@ -117,22 +127,26 @@ export async function fetchAppConfigProfile( const key = cacheKey(ids) const entry = (cache.get(key) as CacheEntry | undefined) ?? { value: null, + loaded: false, nextToken: undefined, expiresAt: 0, - refreshing: false, + inflight: null, } cache.set(key, entry) - // Cold: no value yet — fetch synchronously so the caller gets real data. - if (entry.value === null) { - return poll(ids, parse, entry) + // Cold: never polled — await a single shared poll so concurrent callers don't + // each hit AppConfig (and don't race the rotating session token). + if (!entry.loaded) { + entry.inflight ??= poll(ids, parse, entry).finally(() => { + entry.inflight = null + }) + return entry.inflight } - // Warm but stale: serve cached value, refresh in the background. - if (Date.now() >= entry.expiresAt && !entry.refreshing) { - entry.refreshing = true - void poll(ids, parse, entry).finally(() => { - entry.refreshing = false + // Warm but stale: serve cached value, refresh once in the background. + if (Date.now() >= entry.expiresAt && !entry.inflight) { + entry.inflight = poll(ids, parse, entry).finally(() => { + entry.inflight = null }) } From 19631cf8526321a4a27f1916092c718fae5de47b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 8 Jun 2026 15:12:24 -0700 Subject: [PATCH 3/4] refactor(auth): drop bannedEmails; gate AppConfig on isHosted - Remove the bannedEmails denylist entirely (better-auth banning + blockedSignupDomains cover the cases). - Move isAppConfigEnabled into feature-flags.ts and gate it on isHosted, so AppConfig is hosted-only; self-hosted/OSS always uses the env-var fallback and never constructs the AWS client. --- apps/sim/lib/auth/access-control.test.ts | 27 +++++++++++----------- apps/sim/lib/auth/access-control.ts | 28 +++++++---------------- apps/sim/lib/auth/auth.ts | 11 +++------ apps/sim/lib/core/config/env.ts | 2 +- apps/sim/lib/core/config/feature-flags.ts | 9 ++++++++ 5 files changed, 35 insertions(+), 42 deletions(-) diff --git a/apps/sim/lib/auth/access-control.test.ts b/apps/sim/lib/auth/access-control.test.ts index c5a9263a05b..3dee79ab4e7 100644 --- a/apps/sim/lib/auth/access-control.test.ts +++ b/apps/sim/lib/auth/access-control.test.ts @@ -4,16 +4,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AccessControlConfig } from '@/lib/auth/access-control' -const { mockFetch, envRef } = vi.hoisted(() => ({ +const { mockFetch, envRef, flagRef } = vi.hoisted(() => ({ mockFetch: vi.fn(), envRef: { - APPCONFIG_APPLICATION: undefined as string | undefined, - APPCONFIG_ENVIRONMENT: undefined as string | undefined, + APPCONFIG_APPLICATION: 'sim-staging' as string | undefined, + APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined, ALLOWED_LOGIN_EMAILS: undefined as string | undefined, ALLOWED_LOGIN_DOMAINS: undefined as string | undefined, BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined, }, + flagRef: { isAppConfigEnabled: false }, })) vi.mock('@/lib/core/config/appconfig', () => ({ @@ -26,6 +27,12 @@ vi.mock('@/lib/core/config/env', () => ({ }, })) +vi.mock('@/lib/core/config/feature-flags', () => ({ + get isAppConfigEnabled() { + return flagRef.isAppConfigEnabled + }, +})) + import { getAccessControlConfig } from '@/lib/auth/access-control' const empty: AccessControlConfig = { @@ -33,15 +40,13 @@ const empty: AccessControlConfig = { allowedLoginEmails: [], allowedLoginDomains: [], blockedEmailMxHosts: [], - bannedEmails: [], } describe('getAccessControlConfig', () => { beforeEach(() => { vi.clearAllMocks() + flagRef.isAppConfigEnabled = false Object.assign(envRef, { - APPCONFIG_APPLICATION: undefined, - APPCONFIG_ENVIRONMENT: undefined, BLOCKED_SIGNUP_DOMAINS: undefined, ALLOWED_LOGIN_EMAILS: undefined, ALLOWED_LOGIN_DOMAINS: undefined, @@ -49,7 +54,7 @@ describe('getAccessControlConfig', () => { }) }) - describe('env fallback (AppConfig not configured)', () => { + describe('env fallback (AppConfig disabled)', () => { it('returns empty lists when nothing is set', async () => { expect(await getAccessControlConfig()).toEqual(empty) expect(mockFetch).not.toHaveBeenCalled() @@ -61,15 +66,13 @@ describe('getAccessControlConfig', () => { const result = await getAccessControlConfig() expect(result.blockedSignupDomains).toEqual(['gmail.com', 'yahoo.com']) expect(result.allowedLoginDomains).toEqual(['sim.ai']) - expect(result.bannedEmails).toEqual([]) expect(mockFetch).not.toHaveBeenCalled() }) }) - describe('AppConfig source', () => { + describe('AppConfig source (enabled)', () => { beforeEach(() => { - envRef.APPCONFIG_APPLICATION = 'sim-staging' - envRef.APPCONFIG_ENVIRONMENT = 'staging' + flagRef.isAppConfigEnabled = true }) it('reads the access-control profile and normalizes the payload', async () => { @@ -78,7 +81,6 @@ describe('getAccessControlConfig', () => { parse({ blockedSignupDomains: ['X.com'], allowedLoginDomains: ['sim.ai'], - bannedEmails: ['A@B.com', 'a@b.com', ' '], blockedEmailMxHosts: 'not-an-array', }) ) @@ -87,7 +89,6 @@ describe('getAccessControlConfig', () => { const result = await getAccessControlConfig() expect(result.blockedSignupDomains).toEqual(['x.com']) expect(result.allowedLoginDomains).toEqual(['sim.ai']) - expect(result.bannedEmails).toEqual(['a@b.com']) expect(result.blockedEmailMxHosts).toEqual([]) expect(mockFetch).toHaveBeenCalledWith( { application: 'sim-staging', environment: 'staging', profile: 'access-control' }, diff --git a/apps/sim/lib/auth/access-control.ts b/apps/sim/lib/auth/access-control.ts index cafc17775ba..685b5075790 100644 --- a/apps/sim/lib/auth/access-control.ts +++ b/apps/sim/lib/auth/access-control.ts @@ -1,5 +1,6 @@ import { fetchAppConfigProfile } from '@/lib/core/config/appconfig' import { env } from '@/lib/core/config/env' +import { isAppConfigEnabled } from '@/lib/core/config/feature-flags' /** * Name of the AppConfig configuration profile holding the signup/login gating @@ -10,15 +11,14 @@ const ACCESS_CONTROL_PROFILE = 'access-control' /** * Normalized signup/login gating lists. All entries are trimmed, lowercased, and - * de-duplicated. Domains are bare hostnames; emails are full addresses; MX hosts - * are substrings matched against resolved MX exchanges. + * de-duplicated. Domains are bare hostnames; MX hosts are substrings matched + * against resolved MX exchanges; emails are full addresses. */ export interface AccessControlConfig { blockedSignupDomains: string[] allowedLoginEmails: string[] allowedLoginDomains: string[] blockedEmailMxHosts: string[] - bannedEmails: string[] } function normalizeList(values: unknown): string[] { @@ -32,8 +32,7 @@ function parseCsv(value: string | undefined): string[] { /** * Fallback source for self-hosted/OSS/local deployments that have no AppConfig. - * Reads the same env vars the app used before AppConfig. There is no env - * equivalent for `bannedEmails` — that list is AppConfig-only. + * Reads the same env vars the app used before AppConfig. */ function fromEnv(): AccessControlConfig { return { @@ -41,7 +40,6 @@ function fromEnv(): AccessControlConfig { allowedLoginEmails: parseCsv(env.ALLOWED_LOGIN_EMAILS), allowedLoginDomains: parseCsv(env.ALLOWED_LOGIN_DOMAINS), blockedEmailMxHosts: parseCsv(env.BLOCKED_EMAIL_MX_HOSTS), - bannedEmails: [], } } @@ -52,26 +50,16 @@ function parseConfig(json: unknown): AccessControlConfig { allowedLoginEmails: normalizeList(obj.allowedLoginEmails), allowedLoginDomains: normalizeList(obj.allowedLoginDomains), blockedEmailMxHosts: normalizeList(obj.blockedEmailMxHosts), - bannedEmails: normalizeList(obj.bannedEmails), } } /** - * AppConfig is the source of truth only when both identifiers are present - * (injected by the infra stack). Mirrors the `hasS3Config` presence-check - * pattern — the AppConfig client is never constructed otherwise. - */ -function isAppConfigEnabled(): boolean { - return Boolean(env.APPCONFIG_APPLICATION && env.APPCONFIG_ENVIRONMENT) -} - -/** - * Resolve the current signup/login gating lists. Reads from AWS AppConfig when - * configured (cached, ~30s TTL, never blocks after the first fetch), otherwise - * falls back to env vars so self-hosted/OSS deployments work with no AWS. + * Resolve the current signup/login gating lists. Reads from AWS AppConfig on + * hosted deployments (cached, ~30s TTL, never blocks after the first fetch), + * otherwise falls back to env vars so self-hosted/OSS works with no AWS. */ export async function getAccessControlConfig(): Promise { - if (!isAppConfigEnabled()) return fromEnv() + if (!isAppConfigEnabled) return fromEnv() const value = await fetchAppConfigProfile( { diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index ca0bb9eafea..ad7ce553d3d 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -234,10 +234,6 @@ export const auth = betterAuth({ create: { before: async (user) => { const accessControl = await getAccessControlConfig() - const email = user.email?.toLowerCase() - if (email && accessControl.bannedEmails.includes(email)) { - throw new Error('Sign-ups from this email domain are not allowed.') - } if (isEmailInDenylist(user.email, accessControl.blockedSignupDomains)) { throw new Error('Sign-ups from this email domain are not allowed.') } @@ -812,10 +808,9 @@ export const auth = betterAuth({ const accessControl = await getAccessControlConfig() const requestEmail = ctx.body?.email?.toLowerCase() - // Note: banning an existing account is owned by better-auth's admin plugin - // (a `session.create.before` hook that blocks banned users at sign-in across - // all providers). `bannedEmails` here is a signup-only denylist enforced in - // `databaseHooks.user.create.before`, so it is not checked on sign-in. + // Banning an existing account is owned by better-auth's admin plugin (a + // `session.create.before` hook that blocks banned users at sign-in across + // all providers), so it is not re-checked here. const hasAllowlist = accessControl.allowedLoginEmails.length > 0 || accessControl.allowedLoginDomains.length > 0 diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index f83c534644c..19f6674013b 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -223,7 +223,7 @@ export const env = createEnv({ S3_FORCE_PATH_STYLE: z.string().optional(), // Force path-style addressing (MinIO/Ceph RGW). Defaults to false (AWS S3, R2). Coerced via envBoolean at the consumption site // Dynamic config - AWS AppConfig (hosted source of truth for signup/login gating lists; unset => env-var fallback) - APPCONFIG_APPLICATION: z.string().optional(), // AppConfig application id/name. When set with APPCONFIG_ENVIRONMENT, gating lists come from AppConfig instead of env vars + APPCONFIG_APPLICATION: z.string().optional(), // AppConfig application id/name. On hosted deployments, when set with APPCONFIG_ENVIRONMENT, gating lists come from AppConfig instead of env vars APPCONFIG_ENVIRONMENT: z.string().optional(), // AppConfig environment id/name. Profile name is an app-side constant ('access-control'), not an env var // Cloud Storage - Azure Blob diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index b9580772b79..1cc0ab6cda5 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -96,6 +96,15 @@ export const isSignupEmailValidationEnabled = isTruthy(env.SIGNUP_EMAIL_VALIDATI */ export const isSignupMxValidationEnabled = isTruthy(env.SIGNUP_MX_VALIDATION_ENABLED) +/** + * Is AWS AppConfig the source of truth for the signup/login gating lists. + * Hosted-only and requires both AppConfig identifiers (injected by the infra + * stack). Self-hosted/OSS deployments always use the env-var fallback, so the + * AppConfig client is never reached off-hosted. + */ +export const isAppConfigEnabled = + isHosted && Boolean(env.APPCONFIG_APPLICATION && env.APPCONFIG_ENVIRONMENT) + /** * Is Trigger.dev enabled for async job processing */ From c66a2a4de1d89505b769870f265a377555f46bab Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 8 Jun 2026 16:56:09 -0700 Subject: [PATCH 4/4] fix(appconfig): preserve session token on parse error Narrow the token-resetting catch to only the network calls. A JSON/parse failure no longer discards the already-rotated session token (the round trip succeeded), so the next poll reuses it instead of opening a new StartConfigurationSession. --- apps/sim/lib/core/config/appconfig.test.ts | 18 ++++++++++ apps/sim/lib/core/config/appconfig.ts | 41 ++++++++++++++-------- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/core/config/appconfig.test.ts b/apps/sim/lib/core/config/appconfig.test.ts index 3e0d278fec9..fb497fe1788 100644 --- a/apps/sim/lib/core/config/appconfig.test.ts +++ b/apps/sim/lib/core/config/appconfig.test.ts @@ -96,6 +96,24 @@ describe('fetchAppConfigProfile', () => { expect(mockSend.mock.calls.length).toBe(callsAfterFirst) }) + it('keeps the session on a parse error (no re-StartConfigurationSession, no throw)', async () => { + mockSend.mockImplementation((command: { __type: string }) => { + if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' }) + return Promise.resolve({ + Configuration: new TextEncoder().encode('not json{'), + NextPollConfigurationToken: 'tok-2', + NextPollIntervalInSeconds: 60, + }) + }) + + const ids = uniqueIds() + expect(await fetchAppConfigProfile(ids, (json) => json)).toBeNull() + + // Network round trip succeeded, so exactly one session was started despite the + // parse failure — the rotated token was preserved, not discarded. + expect(mockSend.mock.calls.filter(([c]) => c.__type === 'start')).toHaveLength(1) + }) + it('dedupes concurrent cold fetches into a single poll', async () => { mockSend.mockImplementation((command: { __type: string }) => { if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' }) diff --git a/apps/sim/lib/core/config/appconfig.ts b/apps/sim/lib/core/config/appconfig.ts index a43a9e2ad8f..61aa093c375 100644 --- a/apps/sim/lib/core/config/appconfig.ts +++ b/apps/sim/lib/core/config/appconfig.ts @@ -1,6 +1,7 @@ import { AppConfigDataClient, GetLatestConfigurationCommand, + type GetLatestConfigurationCommandOutput, StartConfigurationSessionCommand, } from '@aws-sdk/client-appconfigdata' import { createLogger } from '@sim/logger' @@ -66,6 +67,7 @@ async function poll( parse: (json: unknown) => T, entry: CacheEntry ): Promise { + let response: GetLatestConfigurationCommandOutput try { const dataClient = getClient() @@ -80,24 +82,15 @@ async function poll( entry.nextToken = session.InitialConfigurationToken } - const response = await dataClient.send( + response = await dataClient.send( new GetLatestConfigurationCommand({ ConfigurationToken: entry.nextToken }) ) entry.nextToken = response.NextPollConfigurationToken ?? entry.nextToken - - if (response.Configuration && response.Configuration.length > 0) { - const text = new TextDecoder().decode(response.Configuration) - entry.value = parse(JSON.parse(text)) - } - - const intervalMs = (response.NextPollIntervalInSeconds ?? 60) * 1000 - entry.expiresAt = Date.now() + Math.max(DEFAULT_TTL_MS, intervalMs) - entry.loaded = true - return entry.value } catch (error) { - // Drop the token so the next attempt starts a fresh session (handles expired - // or invalid tokens). Mark loaded + back off so we serve the fallback and - // retry in the background rather than blocking every request during an outage. + // Network/session failure: drop the token so the next attempt starts a fresh + // session (handles expired or invalid tokens). Mark loaded + back off so we + // serve the fallback and retry in the background rather than blocking every + // request during an outage. entry.nextToken = undefined entry.expiresAt = Date.now() + DEFAULT_TTL_MS entry.loaded = true @@ -107,6 +100,26 @@ async function poll( }) return entry.value } + + // Parse outside the network try: a decode/parse error must NOT discard the + // already-rotated session token — the round trip succeeded, so the next poll + // can reuse it instead of opening a new session. Keep the last good value. + try { + if (response.Configuration && response.Configuration.length > 0) { + const text = new TextDecoder().decode(response.Configuration) + entry.value = parse(JSON.parse(text)) + } + } catch (error) { + logger.error('AppConfig response parse failed; serving last known value', { + profile: cacheKey(ids), + error: getErrorMessage(error), + }) + } + + const intervalMs = (response.NextPollIntervalInSeconds ?? 60) * 1000 + entry.expiresAt = Date.now() + Math.max(DEFAULT_TTL_MS, intervalMs) + entry.loaded = true + return entry.value } /**