mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(auth): enforce domain and account bans on sign-in and workflow executions (#4948)
* feat(auth): enforce domain and account bans on sign-in and workflow executions * fix(mothership): fail inbox ban checks closed without emailing, gate blocked senders * fix(mothership): also block inbox senders whose own account is banned * fix(execution): always ban-check the workflow owner so schedules are covered * feat(auth): support banning specific emails via appconfig blockedEmails list * fix(mothership): ban-check the workspace billed account on inbox tasks
This commit is contained in:
@@ -10,6 +10,7 @@ const { mockFetch, envRef, flagRef } = vi.hoisted(() => ({
|
||||
APPCONFIG_APPLICATION: 'sim-staging' as string | undefined,
|
||||
APPCONFIG_ENVIRONMENT: 'staging' as string | undefined,
|
||||
BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined,
|
||||
BLOCKED_EMAILS: 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,
|
||||
@@ -33,10 +34,15 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
import { getAccessControlConfig } from '@/lib/auth/access-control'
|
||||
import {
|
||||
getAccessControlConfig,
|
||||
isEmailBlockedByAccessControl,
|
||||
isEmailInDenylist,
|
||||
} from '@/lib/auth/access-control'
|
||||
|
||||
const empty: AccessControlConfig = {
|
||||
blockedSignupDomains: [],
|
||||
blockedEmails: [],
|
||||
allowedLoginEmails: [],
|
||||
allowedLoginDomains: [],
|
||||
blockedEmailMxHosts: [],
|
||||
@@ -48,6 +54,7 @@ describe('getAccessControlConfig', () => {
|
||||
flagRef.isAppConfigEnabled = false
|
||||
Object.assign(envRef, {
|
||||
BLOCKED_SIGNUP_DOMAINS: undefined,
|
||||
BLOCKED_EMAILS: undefined,
|
||||
ALLOWED_LOGIN_EMAILS: undefined,
|
||||
ALLOWED_LOGIN_DOMAINS: undefined,
|
||||
BLOCKED_EMAIL_MX_HOSTS: undefined,
|
||||
@@ -62,9 +69,11 @@ describe('getAccessControlConfig', () => {
|
||||
|
||||
it('parses, trims, lowercases, and dedupes csv env vars', async () => {
|
||||
envRef.BLOCKED_SIGNUP_DOMAINS = 'Gmail.com, yahoo.com ,gmail.com,'
|
||||
envRef.BLOCKED_EMAILS = 'Spam@Evil.com, spam@evil.com'
|
||||
envRef.ALLOWED_LOGIN_DOMAINS = 'Sim.ai'
|
||||
const result = await getAccessControlConfig()
|
||||
expect(result.blockedSignupDomains).toEqual(['gmail.com', 'yahoo.com'])
|
||||
expect(result.blockedEmails).toEqual(['spam@evil.com'])
|
||||
expect(result.allowedLoginDomains).toEqual(['sim.ai'])
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -104,3 +113,71 @@ describe('getAccessControlConfig', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEmailInDenylist', () => {
|
||||
it('returns false when denylist is null, empty, or email is missing', () => {
|
||||
expect(isEmailInDenylist('a@example.com', null)).toBe(false)
|
||||
expect(isEmailInDenylist('a@example.com', [])).toBe(false)
|
||||
expect(isEmailInDenylist(null, ['example.com'])).toBe(false)
|
||||
expect(isEmailInDenylist(undefined, ['example.com'])).toBe(false)
|
||||
expect(isEmailInDenylist('', ['example.com'])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when email has no @', () => {
|
||||
expect(isEmailInDenylist('not-an-email', ['example.com'])).toBe(false)
|
||||
})
|
||||
|
||||
it('matches exact domain', () => {
|
||||
expect(isEmailInDenylist('user@dpdns.org', ['dpdns.org'])).toBe(true)
|
||||
expect(isEmailInDenylist('user@DPDNS.ORG', ['dpdns.org'])).toBe(true)
|
||||
})
|
||||
|
||||
it('matches arbitrary-depth subdomains of a listed parent zone', () => {
|
||||
expect(isEmailInDenylist('user@xx.lucky04.dpdns.org', ['dpdns.org'])).toBe(true)
|
||||
expect(isEmailInDenylist('user@a.b.c.qzz.io', ['qzz.io'])).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match look-alike domains', () => {
|
||||
expect(isEmailInDenylist('user@xdpdns.org', ['dpdns.org'])).toBe(false)
|
||||
expect(isEmailInDenylist('user@notdpdns.org', ['dpdns.org'])).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match disallowed domains', () => {
|
||||
expect(isEmailInDenylist('user@gmail.com', ['dpdns.org', 'qzz.io'])).toBe(false)
|
||||
expect(isEmailInDenylist('user@example.com', ['dpdns.org'])).toBe(false)
|
||||
})
|
||||
|
||||
it('handles multiple denylist entries', () => {
|
||||
const denylist = ['dpdns.org', 'qzz.io', 'cc.cd']
|
||||
expect(isEmailInDenylist('user@foo.dpdns.org', denylist)).toBe(true)
|
||||
expect(isEmailInDenylist('user@bar.qzz.io', denylist)).toBe(true)
|
||||
expect(isEmailInDenylist('user@baz.cc.cd', denylist)).toBe(true)
|
||||
expect(isEmailInDenylist('user@example.com', denylist)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEmailBlockedByAccessControl', () => {
|
||||
const config: AccessControlConfig = {
|
||||
...empty,
|
||||
blockedSignupDomains: ['bad.com'],
|
||||
blockedEmails: ['spam@evil.com'],
|
||||
}
|
||||
|
||||
it('matches individually blocked emails case-insensitively', () => {
|
||||
expect(isEmailBlockedByAccessControl('spam@evil.com', config)).toBe(true)
|
||||
expect(isEmailBlockedByAccessControl(' Spam@Evil.com ', config)).toBe(true)
|
||||
expect(isEmailBlockedByAccessControl('other@evil.com', config)).toBe(false)
|
||||
})
|
||||
|
||||
it('matches blocked domains and subdomains', () => {
|
||||
expect(isEmailBlockedByAccessControl('a@bad.com', config)).toBe(true)
|
||||
expect(isEmailBlockedByAccessControl('a@mail.bad.com', config)).toBe(true)
|
||||
expect(isEmailBlockedByAccessControl('a@good.com', config)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for missing emails and empty config', () => {
|
||||
expect(isEmailBlockedByAccessControl(null, config)).toBe(false)
|
||||
expect(isEmailBlockedByAccessControl(undefined, config)).toBe(false)
|
||||
expect(isEmailBlockedByAccessControl('a@bad.com', empty)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,11 +16,41 @@ const ACCESS_CONTROL_PROFILE = 'access-control'
|
||||
*/
|
||||
export interface AccessControlConfig {
|
||||
blockedSignupDomains: string[]
|
||||
blockedEmails: string[]
|
||||
allowedLoginEmails: string[]
|
||||
allowedLoginDomains: string[]
|
||||
blockedEmailMxHosts: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the email's domain matches a denylist entry exactly or is a
|
||||
* subdomain of one.
|
||||
*/
|
||||
export function isEmailInDenylist(
|
||||
email: string | undefined | null,
|
||||
denylist: readonly string[] | null
|
||||
): boolean {
|
||||
if (!denylist || denylist.length === 0 || !email) return false
|
||||
const domain = email.split('@')[1]?.toLowerCase()
|
||||
if (!domain) return false
|
||||
return denylist.some((entry) => domain === entry || domain.endsWith(`.${entry}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the email is individually banned (`blockedEmails`) or its domain
|
||||
* is in the blocked-domains list. The single predicate for "this email must
|
||||
* not sign up, sign in, or execute anything".
|
||||
*/
|
||||
export function isEmailBlockedByAccessControl(
|
||||
email: string | undefined | null,
|
||||
config: AccessControlConfig
|
||||
): boolean {
|
||||
if (!email) return false
|
||||
const normalized = email.trim().toLowerCase()
|
||||
if (config.blockedEmails.includes(normalized)) return true
|
||||
return isEmailInDenylist(normalized, config.blockedSignupDomains)
|
||||
}
|
||||
|
||||
function normalizeList(values: unknown): string[] {
|
||||
if (!Array.isArray(values)) return []
|
||||
return Array.from(new Set(values.map((v) => String(v).trim().toLowerCase()).filter(Boolean)))
|
||||
@@ -37,6 +67,7 @@ function parseCsv(value: string | undefined): string[] {
|
||||
function fromEnv(): AccessControlConfig {
|
||||
return {
|
||||
blockedSignupDomains: parseCsv(env.BLOCKED_SIGNUP_DOMAINS),
|
||||
blockedEmails: parseCsv(env.BLOCKED_EMAILS),
|
||||
allowedLoginEmails: parseCsv(env.ALLOWED_LOGIN_EMAILS),
|
||||
allowedLoginDomains: parseCsv(env.ALLOWED_LOGIN_DOMAINS),
|
||||
blockedEmailMxHosts: parseCsv(env.BLOCKED_EMAIL_MX_HOSTS),
|
||||
@@ -47,6 +78,7 @@ function parseConfig(json: unknown): AccessControlConfig {
|
||||
const obj = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
|
||||
return {
|
||||
blockedSignupDomains: normalizeList(obj.blockedSignupDomains),
|
||||
blockedEmails: normalizeList(obj.blockedEmails),
|
||||
allowedLoginEmails: normalizeList(obj.allowedLoginEmails),
|
||||
allowedLoginDomains: normalizeList(obj.allowedLoginDomains),
|
||||
blockedEmailMxHosts: normalizeList(obj.blockedEmailMxHosts),
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isEmailInDenylist } from '@/lib/auth/auth'
|
||||
|
||||
describe('isEmailInDenylist', () => {
|
||||
it('returns false when denylist is null, empty, or email is missing', () => {
|
||||
expect(isEmailInDenylist('a@example.com', null)).toBe(false)
|
||||
expect(isEmailInDenylist('a@example.com', [])).toBe(false)
|
||||
expect(isEmailInDenylist(null, ['example.com'])).toBe(false)
|
||||
expect(isEmailInDenylist(undefined, ['example.com'])).toBe(false)
|
||||
expect(isEmailInDenylist('', ['example.com'])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when email has no @', () => {
|
||||
expect(isEmailInDenylist('not-an-email', ['example.com'])).toBe(false)
|
||||
})
|
||||
|
||||
it('matches exact domain', () => {
|
||||
expect(isEmailInDenylist('user@dpdns.org', ['dpdns.org'])).toBe(true)
|
||||
expect(isEmailInDenylist('user@DPDNS.ORG', ['dpdns.org'])).toBe(true)
|
||||
})
|
||||
|
||||
it('matches arbitrary-depth subdomains of a listed parent zone', () => {
|
||||
expect(isEmailInDenylist('user@xx.lucky04.dpdns.org', ['dpdns.org'])).toBe(true)
|
||||
expect(isEmailInDenylist('user@a.b.c.qzz.io', ['qzz.io'])).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match look-alike domains', () => {
|
||||
expect(isEmailInDenylist('user@xdpdns.org', ['dpdns.org'])).toBe(false)
|
||||
expect(isEmailInDenylist('user@notdpdns.org', ['dpdns.org'])).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match disallowed domains', () => {
|
||||
expect(isEmailInDenylist('user@gmail.com', ['dpdns.org', 'qzz.io'])).toBe(false)
|
||||
expect(isEmailInDenylist('user@example.com', ['dpdns.org'])).toBe(false)
|
||||
})
|
||||
|
||||
it('handles multiple denylist entries', () => {
|
||||
const denylist = ['dpdns.org', 'qzz.io', 'cc.cd']
|
||||
expect(isEmailInDenylist('user@foo.dpdns.org', denylist)).toBe(true)
|
||||
expect(isEmailInDenylist('user@bar.qzz.io', denylist)).toBe(true)
|
||||
expect(isEmailInDenylist('user@baz.cc.cd', denylist)).toBe(true)
|
||||
expect(isEmailInDenylist('user@example.com', denylist)).toBe(false)
|
||||
})
|
||||
})
|
||||
+32
-15
@@ -31,7 +31,7 @@ import {
|
||||
renderPasswordResetEmail,
|
||||
renderWelcomeEmail,
|
||||
} from '@/components/emails'
|
||||
import { getAccessControlConfig } from '@/lib/auth/access-control'
|
||||
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
|
||||
import { sendPlanWelcomeEmail } from '@/lib/billing'
|
||||
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
|
||||
import {
|
||||
@@ -139,16 +139,6 @@ function getMicrosoftUserInfoFromIdToken(tokens: { accessToken?: string }, provi
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmailInDenylist(
|
||||
email: string | undefined | null,
|
||||
denylist: readonly string[] | null
|
||||
): boolean {
|
||||
if (!denylist || denylist.length === 0 || !email) return false
|
||||
const domain = email.split('@')[1]?.toLowerCase()
|
||||
if (!domain) return false
|
||||
return denylist.some((entry) => domain === entry || domain.endsWith(`.${entry}`))
|
||||
}
|
||||
|
||||
const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) =>
|
||||
logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value })
|
||||
)
|
||||
@@ -235,8 +225,8 @@ export const auth = betterAuth({
|
||||
create: {
|
||||
before: async (user) => {
|
||||
const accessControl = await getAccessControlConfig()
|
||||
if (isEmailInDenylist(user.email, accessControl.blockedSignupDomains)) {
|
||||
throw new Error('Sign-ups from this email domain are not allowed.')
|
||||
if (isEmailBlockedByAccessControl(user.email, accessControl)) {
|
||||
throw new Error('Sign-ups from this email are not allowed.')
|
||||
}
|
||||
return { data: user }
|
||||
},
|
||||
@@ -593,6 +583,29 @@ export const auth = betterAuth({
|
||||
session: {
|
||||
create: {
|
||||
before: async (session) => {
|
||||
// Blocked emails/domains must not establish sessions, regardless of
|
||||
// provider (email/password, OAuth, SSO). Deliberately outside the
|
||||
// try below — a thrown APIError must propagate, not be swallowed.
|
||||
const accessControl = await getAccessControlConfig()
|
||||
if (
|
||||
accessControl.blockedSignupDomains.length > 0 ||
|
||||
accessControl.blockedEmails.length > 0
|
||||
) {
|
||||
const [sessionUser] = await db
|
||||
.select({ email: schema.user.email })
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.id, session.userId))
|
||||
.limit(1)
|
||||
if (isEmailBlockedByAccessControl(sessionUser?.email, accessControl)) {
|
||||
logger.warn('Blocking session creation for blocked account', {
|
||||
userId: session.userId,
|
||||
})
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message: 'Access restricted. Please contact your administrator.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the first organization this user is a member of
|
||||
const members = await db
|
||||
@@ -886,9 +899,13 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
if (isSignUp && isEmailInDenylist(ctx.body?.email, accessControl.blockedSignupDomains)) {
|
||||
// Blocked emails/domains gate both signup and sign-in. OAuth/SSO sign-ins
|
||||
// have no email in the body here; the session.create.before hook covers them.
|
||||
if (isEmailBlockedByAccessControl(requestEmail, accessControl)) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message: 'Sign-ups from this email domain are not allowed.',
|
||||
message: isSignUp
|
||||
? 'Sign-ups from this email are not allowed.'
|
||||
: 'Access restricted. Please contact your administrator.',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockWhere, envRef } = vi.hoisted(() => ({
|
||||
mockWhere: vi.fn(),
|
||||
envRef: {
|
||||
BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined,
|
||||
BLOCKED_EMAILS: undefined as string | undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: { select: vi.fn(() => ({ from: vi.fn(() => ({ where: mockWhere })) })) },
|
||||
user: { id: 'id', email: 'email', banned: 'banned', banExpires: 'banExpires' },
|
||||
}))
|
||||
vi.mock('drizzle-orm', () => ({ inArray: vi.fn(), sql: vi.fn() }))
|
||||
vi.mock('@/lib/core/config/appconfig', () => ({ fetchAppConfigProfile: vi.fn() }))
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
get env() {
|
||||
return envRef
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({ isAppConfigEnabled: false }))
|
||||
|
||||
import { getActivelyBannedUserIds, isBanActive, isEmailBlocked } from '@/lib/auth/ban'
|
||||
|
||||
describe('isBanActive', () => {
|
||||
it('returns true for a permanent ban', () => {
|
||||
expect(isBanActive({ banned: true, banExpires: null })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for an expired temporary ban', () => {
|
||||
expect(isBanActive({ banned: true, banExpires: new Date(Date.now() - 1000) })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for an unexpired temporary ban', () => {
|
||||
expect(isBanActive({ banned: true, banExpires: new Date(Date.now() + 60_000) })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when not banned', () => {
|
||||
expect(isBanActive({ banned: false, banExpires: null })).toBe(false)
|
||||
expect(isBanActive({ banned: null, banExpires: null })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEmailBlocked', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com'
|
||||
envRef.BLOCKED_EMAILS = 'spam@evil.com'
|
||||
mockWhere.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('returns true for blocked domains and subdomains without querying users', async () => {
|
||||
expect(await isEmailBlocked('a@bad.com')).toBe(true)
|
||||
expect(await isEmailBlocked('a@mail.bad.com')).toBe(true)
|
||||
expect(mockWhere).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns true for individually blocked emails without querying users', async () => {
|
||||
expect(await isEmailBlocked('spam@evil.com')).toBe(true)
|
||||
expect(mockWhere).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns true when the email belongs to an actively banned account', async () => {
|
||||
mockWhere.mockResolvedValue([{ banned: true, banExpires: null }])
|
||||
expect(await isEmailBlocked('a@good.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for clean accounts and missing emails', async () => {
|
||||
expect(await isEmailBlocked('a@good.com')).toBe(false)
|
||||
expect(await isEmailBlocked(null)).toBe(false)
|
||||
expect(await isEmailBlocked(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActivelyBannedUserIds', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
envRef.BLOCKED_SIGNUP_DOMAINS = undefined
|
||||
envRef.BLOCKED_EMAILS = undefined
|
||||
mockWhere.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('short-circuits on empty input without querying', async () => {
|
||||
expect(await getActivelyBannedUserIds([])).toEqual([])
|
||||
expect(await getActivelyBannedUserIds([''])).toEqual([])
|
||||
expect(mockWhere).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns ids with an active db ban', async () => {
|
||||
mockWhere.mockResolvedValue([
|
||||
{ id: 'u1', email: 'a@ok.com', banned: true, banExpires: null },
|
||||
{ id: 'u2', email: 'b@ok.com', banned: false, banExpires: null },
|
||||
])
|
||||
expect(await getActivelyBannedUserIds(['u1', 'u2'])).toEqual(['u1'])
|
||||
})
|
||||
|
||||
it('treats an expired ban as lifted', async () => {
|
||||
mockWhere.mockResolvedValue([
|
||||
{ id: 'u1', email: 'a@ok.com', banned: true, banExpires: new Date(Date.now() - 1000) },
|
||||
])
|
||||
expect(await getActivelyBannedUserIds(['u1'])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns ids whose email is individually blocked', async () => {
|
||||
envRef.BLOCKED_EMAILS = 'spam@evil.com'
|
||||
mockWhere.mockResolvedValue([
|
||||
{ id: 'u1', email: 'spam@evil.com', banned: false, banExpires: null },
|
||||
{ id: 'u2', email: 'ok@evil.com', banned: false, banExpires: null },
|
||||
])
|
||||
expect(await getActivelyBannedUserIds(['u1', 'u2'])).toEqual(['u1'])
|
||||
})
|
||||
|
||||
it('returns ids whose email domain is in the blocked-domains list, including subdomains', async () => {
|
||||
envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com'
|
||||
mockWhere.mockResolvedValue([
|
||||
{ id: 'u1', email: 'a@bad.com', banned: false, banExpires: null },
|
||||
{ id: 'u2', email: 'b@mail.bad.com', banned: false, banExpires: null },
|
||||
{ id: 'u3', email: 'c@good.com', banned: false, banExpires: null },
|
||||
])
|
||||
expect(await getActivelyBannedUserIds(['u1', 'u2', 'u3'])).toEqual(['u1', 'u2'])
|
||||
})
|
||||
|
||||
it('propagates db failures so callers fail closed', async () => {
|
||||
mockWhere.mockRejectedValue(new Error('db down'))
|
||||
await expect(getActivelyBannedUserIds(['u1'])).rejects.toThrow('db down')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { db, user } from '@sim/db'
|
||||
import { inArray, sql } from 'drizzle-orm'
|
||||
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
|
||||
|
||||
/**
|
||||
* True when a ban is currently in effect. Mirrors better-auth admin-plugin
|
||||
* semantics: a ban whose `banExpires` is in the past is treated as lifted.
|
||||
*/
|
||||
export function isBanActive(row: { banned: boolean | null; banExpires: Date | null }): boolean {
|
||||
if (!row.banned) return false
|
||||
if (row.banExpires && row.banExpires.getTime() <= Date.now()) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a raw email (e.g. an inbound sender) is blocked: it is in the
|
||||
* appconfig blocked-emails list, its domain is in the blocked-domains list,
|
||||
* or it belongs to an account with an active ban. Covers senders that don't
|
||||
* resolve to a known user id.
|
||||
*/
|
||||
export async function isEmailBlocked(email: string | null | undefined): Promise<boolean> {
|
||||
if (!email) return false
|
||||
const accessControl = await getAccessControlConfig()
|
||||
if (isEmailBlockedByAccessControl(email, accessControl)) return true
|
||||
const rows = await db
|
||||
.select({ banned: user.banned, banExpires: user.banExpires })
|
||||
.from(user)
|
||||
.where(sql`lower(${user.email}) = ${email.toLowerCase()}`)
|
||||
return rows.some(isBanActive)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subset of the given user ids that are currently blocked: an
|
||||
* active account ban, or an email/domain in the appconfig blocked lists.
|
||||
* One user query plus the cached access-control fetch. Throws on db
|
||||
* failure — callers must fail closed.
|
||||
*/
|
||||
export async function getActivelyBannedUserIds(userIds: string[]): Promise<string[]> {
|
||||
const ids = [...new Set(userIds.filter(Boolean))]
|
||||
if (ids.length === 0) return []
|
||||
|
||||
const [accessControl, rows] = await Promise.all([
|
||||
getAccessControlConfig(),
|
||||
db
|
||||
.select({ id: user.id, email: user.email, banned: user.banned, banExpires: user.banExpires })
|
||||
.from(user)
|
||||
.where(inArray(user.id, ids)),
|
||||
])
|
||||
|
||||
return rows
|
||||
.filter((row) => isBanActive(row) || isEmailBlockedByAccessControl(row.email, accessControl))
|
||||
.map((row) => row.id)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export const env = createEnv({
|
||||
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
|
||||
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
|
||||
BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com")
|
||||
BLOCKED_EMAILS: z.string().optional(), // Comma-separated list of specific email addresses banned from the platform (signup, sign-in, executions)
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -5,15 +5,21 @@
|
||||
import { loggingSessionMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetWorkspaceBilledAccountUserId, mockCheckRateLimit } = vi.hoisted(() => ({
|
||||
mockGetWorkspaceBilledAccountUserId: vi.fn(),
|
||||
mockCheckRateLimit: vi.fn(),
|
||||
}))
|
||||
const { mockGetWorkspaceBilledAccountUserId, mockCheckRateLimit, mockGetActivelyBannedUserIds } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetWorkspaceBilledAccountUserId: vi.fn(),
|
||||
mockCheckRateLimit: vi.fn(),
|
||||
mockGetActivelyBannedUserIds: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({ db: {} }))
|
||||
vi.mock('drizzle-orm', () => ({ eq: vi.fn() }))
|
||||
vi.mock('@/lib/auth/ban', () => ({
|
||||
getActivelyBannedUserIds: mockGetActivelyBannedUserIds,
|
||||
}))
|
||||
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
|
||||
checkServerSideUsageLimits: vi.fn(),
|
||||
checkOrgMemberUsageLimit: vi.fn().mockResolvedValue({ isExceeded: false }),
|
||||
}))
|
||||
vi.mock('@/lib/billing/core/subscription', () => ({
|
||||
getHighestPrioritySubscription: vi.fn(),
|
||||
@@ -32,6 +38,7 @@ vi.mock('@/lib/workspaces/utils', () => ({
|
||||
vi.mock('@sim/workflow-authz', () => ({
|
||||
getActiveWorkflowRecord: vi.fn().mockResolvedValue({
|
||||
id: 'workflow-1',
|
||||
userId: 'creator-1',
|
||||
workspaceId: 'workspace-1',
|
||||
isDeployed: true,
|
||||
}),
|
||||
@@ -144,3 +151,89 @@ describe('preprocessExecution logPreprocessingErrors option', () => {
|
||||
expect(loggingSession.safeStart).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('preprocessExecution ban gate', () => {
|
||||
const baseOptions = {
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'owner-1',
|
||||
triggerType: 'workflow' as const,
|
||||
executionId: 'execution-1',
|
||||
requestId: 'request-1',
|
||||
checkDeployment: false,
|
||||
checkRateLimit: false,
|
||||
workflowRecord: { id: 'workflow-1', workspaceId: 'workspace-1', isDeployed: true } as any,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-account-1')
|
||||
mockGetActivelyBannedUserIds.mockResolvedValue([])
|
||||
vi.mocked(getHighestPrioritySubscription).mockResolvedValue({ plan: 'free' } as any)
|
||||
vi.mocked(checkServerSideUsageLimits).mockResolvedValue({
|
||||
isExceeded: false,
|
||||
currentUsage: 1,
|
||||
limit: 10,
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('blocks execution with 403 when the actor is banned, before any billing queries', async () => {
|
||||
mockGetActivelyBannedUserIds.mockResolvedValue(['billed-account-1'])
|
||||
|
||||
const loggingSession = {
|
||||
safeStart: vi.fn().mockResolvedValue(true),
|
||||
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const result = await preprocessExecution({
|
||||
...baseOptions,
|
||||
loggingSession: loggingSession as any,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { statusCode: 403, logCreated: true, message: 'Account suspended' },
|
||||
})
|
||||
expect(loggingSession.safeStart).toHaveBeenCalled()
|
||||
expect(getHighestPrioritySubscription).not.toHaveBeenCalled()
|
||||
expect(checkServerSideUsageLimits).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks the billing actor, caller-provided userId, and workflow owner in one call', async () => {
|
||||
const result = await preprocessExecution(baseOptions)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledTimes(1)
|
||||
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith([
|
||||
'billed-account-1',
|
||||
'owner-1',
|
||||
'creator-1',
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes the "unknown" sentinel userId but still checks the workflow owner', async () => {
|
||||
const result = await preprocessExecution({ ...baseOptions, userId: 'unknown' })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1', 'creator-1'])
|
||||
})
|
||||
|
||||
it('fails closed with 500 when the ban check errors', async () => {
|
||||
mockGetActivelyBannedUserIds.mockRejectedValue(new Error('db down'))
|
||||
|
||||
const loggingSession = {
|
||||
safeStart: vi.fn().mockResolvedValue(true),
|
||||
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const result = await preprocessExecution({
|
||||
...baseOptions,
|
||||
loggingSession: loggingSession as any,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { statusCode: 500, logCreated: true },
|
||||
})
|
||||
expect(checkServerSideUsageLimits).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getActiveWorkflowRecord } from '@sim/workflow-authz'
|
||||
import { getActivelyBannedUserIds } from '@/lib/auth/ban'
|
||||
import {
|
||||
checkOrgMemberUsageLimit,
|
||||
checkServerSideUsageLimits,
|
||||
@@ -321,6 +322,75 @@ export async function preprocessExecution(
|
||||
}
|
||||
}
|
||||
|
||||
// ========== STEP 3.5: Reject Banned Accounts ==========
|
||||
// Blocks executions when the billing actor, the workflow owner, or the
|
||||
// caller-provided userId (chat deployer, authenticated caller) has an
|
||||
// active ban or a blocked email domain. The owner comes from the workflow
|
||||
// record so schedules — which pass the 'unknown' sentinel — are covered.
|
||||
const banCandidateIds = [actorUserId]
|
||||
if (userId && userId !== 'unknown' && userId !== actorUserId) {
|
||||
banCandidateIds.push(userId)
|
||||
}
|
||||
if (workflowRecord.userId && !banCandidateIds.includes(workflowRecord.userId)) {
|
||||
banCandidateIds.push(workflowRecord.userId)
|
||||
}
|
||||
try {
|
||||
const bannedUserIds = await getActivelyBannedUserIds(banCandidateIds)
|
||||
if (bannedUserIds.length > 0) {
|
||||
logger.warn(`[${requestId}] Execution blocked: banned account`, {
|
||||
workflowId,
|
||||
bannedUserIds,
|
||||
triggerType,
|
||||
})
|
||||
|
||||
await recordPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
errorMessage: 'This account has been suspended. Workflow executions are blocked.',
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Account suspended',
|
||||
statusCode: 403,
|
||||
logCreated: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error checking account ban status`, { error, actorUserId })
|
||||
|
||||
await recordPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
errorMessage: 'Unable to verify account status. Execution blocked for security.',
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Unable to verify account status. Execution blocked for security.',
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
retryable: isRetryableInfrastructureError(error),
|
||||
cause: describeRetryableInfrastructureError(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ========== STEP 4: Get Subscription ==========
|
||||
const userSubscription = await getHighestPrioritySubscription(actorUserId)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { getActivelyBannedUserIds, isEmailBlocked } from '@/lib/auth/ban'
|
||||
import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle'
|
||||
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
|
||||
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload'
|
||||
@@ -24,6 +25,7 @@ import { buildUserSkillTool } from '@/lib/mothership/skills'
|
||||
import { uploadFile } from '@/lib/uploads/core/storage-service'
|
||||
import { createFileContent, type MessageContent } from '@/lib/uploads/utils/file-utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
|
||||
const logger = createLogger('InboxExecutor')
|
||||
|
||||
@@ -93,6 +95,43 @@ export async function executeInboxTask(taskId: string): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// Blocked senders and banned accounts must not drive the agent; the sender
|
||||
// email is checked directly (domain list + the sender's own account ban)
|
||||
// because non-members resolve to the workspace owner, and the workspace
|
||||
// billed account is checked to match preprocessExecution's gate. Fails
|
||||
// closed on lookup errors. No email response in any of these paths —
|
||||
// never mail a suspended account.
|
||||
let blockReason: string | null = null
|
||||
try {
|
||||
const [senderBlocked, billedAccountUserId] = await Promise.all([
|
||||
isEmailBlocked(inboxTask.fromEmail),
|
||||
getWorkspaceBilledAccountUserId(ws.id),
|
||||
])
|
||||
const bannedUserIds = await getActivelyBannedUserIds(
|
||||
billedAccountUserId ? [userId, billedAccountUserId] : [userId]
|
||||
)
|
||||
if (senderBlocked || bannedUserIds.length > 0) {
|
||||
logger.warn('Blocking inbox task: sender, resolved user, or billed account is banned', {
|
||||
taskId,
|
||||
userId,
|
||||
senderBlocked,
|
||||
bannedUserIds,
|
||||
})
|
||||
blockReason = 'User account is suspended'
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Inbox task ban check failed; failing closed', {
|
||||
taskId,
|
||||
error: getErrorMessage(error, 'Unknown error'),
|
||||
})
|
||||
blockReason = 'Unable to verify account status'
|
||||
}
|
||||
if (blockReason) {
|
||||
responseSent = true
|
||||
await markTaskFailed(taskId, blockReason)
|
||||
return
|
||||
}
|
||||
|
||||
if (!chatId) {
|
||||
const chatResult = await resolveOrCreateChat({
|
||||
userId,
|
||||
|
||||
Reference in New Issue
Block a user