Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/sim/lib/api-key/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([])

Expand Down
8 changes: 7 additions & 1 deletion apps/sim/lib/api-key/service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -47,6 +47,7 @@ interface HashCandidate {
workspaceId: string | null
type: string
expiresAt: Date | null
userBanned: boolean | null
}

/**
Expand Down Expand Up @@ -82,15 +83,20 @@ 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

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
Expand Down
106 changes: 106 additions & 0 deletions apps/sim/lib/auth/access-control.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AccessControlConfig } from '@/lib/auth/access-control'

const { mockFetch, envRef, flagRef } = vi.hoisted(() => ({
mockFetch: vi.fn(),
envRef: {
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', () => ({
fetchAppConfigProfile: mockFetch,
}))

vi.mock('@/lib/core/config/env', () => ({
get env() {
return envRef
},
}))

vi.mock('@/lib/core/config/feature-flags', () => ({
get isAppConfigEnabled() {
return flagRef.isAppConfigEnabled
},
}))

import { getAccessControlConfig } from '@/lib/auth/access-control'

const empty: AccessControlConfig = {
blockedSignupDomains: [],
allowedLoginEmails: [],
allowedLoginDomains: [],
blockedEmailMxHosts: [],
}

describe('getAccessControlConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
flagRef.isAppConfigEnabled = false
Object.assign(envRef, {
BLOCKED_SIGNUP_DOMAINS: undefined,
ALLOWED_LOGIN_EMAILS: undefined,
ALLOWED_LOGIN_DOMAINS: undefined,
BLOCKED_EMAIL_MX_HOSTS: undefined,
})
})

describe('env fallback (AppConfig disabled)', () => {
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(mockFetch).not.toHaveBeenCalled()
})
})

describe('AppConfig source (enabled)', () => {
beforeEach(() => {
flagRef.isAppConfigEnabled = true
})

it('reads the access-control profile and normalizes the payload', async () => {
mockFetch.mockImplementation((_ids, parse) =>
Promise.resolve(
parse({
blockedSignupDomains: ['X.com'],
allowedLoginDomains: ['sim.ai'],
blockedEmailMxHosts: 'not-an-array',
})
)
)

const result = await getAccessControlConfig()
expect(result.blockedSignupDomains).toEqual(['x.com'])
expect(result.allowedLoginDomains).toEqual(['sim.ai'])
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'])
})
})
})
74 changes: 74 additions & 0 deletions apps/sim/lib/auth/access-control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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
* 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; MX hosts are substrings matched
* against resolved MX exchanges; emails are full addresses.
*/
export interface AccessControlConfig {
blockedSignupDomains: string[]
allowedLoginEmails: string[]
allowedLoginDomains: string[]
blockedEmailMxHosts: string[]
}
Comment on lines +17 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 bannedEmails denylist from PR description is absent

The PR description explicitly promises "Add a new bannedEmails denylist that blocks a specific address at both sign-in and sign-up." The AccessControlConfig interface has no bannedEmails field, parseConfig does not extract it, fromEnv does not read it, and auth.ts has no corresponding check. Any bannedEmails array seeded into the AppConfig profile by the paired infra PR (simstudioai/infra#201) will be silently dropped — operators who add a specific address to that list expecting it to be blocked at sign-in will get no enforcement.


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.
*/
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),
}
}

function parseConfig(json: unknown): AccessControlConfig {
const obj = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
return {
blockedSignupDomains: normalizeList(obj.blockedSignupDomains),
allowedLoginEmails: normalizeList(obj.allowedLoginEmails),
allowedLoginDomains: normalizeList(obj.allowedLoginDomains),
blockedEmailMxHosts: normalizeList(obj.blockedEmailMxHosts),
}
}

/**
* 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<AccessControlConfig> {
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()
}
83 changes: 36 additions & 47 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 })
)
Expand Down Expand Up @@ -246,7 +233,8 @@ export const auth = betterAuth({
user: {
create: {
before: async (user) => {
if (isSignupEmailBlocked(user.email)) {
const accessControl = await getAccessControlConfig()
if (isEmailInDenylist(user.email, accessControl.blockedSignupDomains)) {
throw new Error('Sign-ups from this email domain are not allowed.')
}
return { data: user }
Expand Down Expand Up @@ -813,51 +801,52 @@ 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 (!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 (isSignIn || isSignUp) {
const accessControl = await getAccessControlConfig()
const requestEmail = ctx.body?.email?.toLowerCase()

// 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
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) {
Expand Down
Loading
Loading