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:
Theodore Li
2026-06-11 15:04:36 -04:00
committed by GitHub
parent d2cc13d7b6
commit 5fb37b4adb
10 changed files with 533 additions and 67 deletions
+78 -1
View File
@@ -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)
})
})
+32
View File
@@ -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),
-47
View File
@@ -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
View File
@@ -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.',
})
}
+131
View File
@@ -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')
})
})
+53
View File
@@ -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)
}
+1
View File
@@ -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.
+97 -4
View File
@@ -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()
})
})
+70
View File
@@ -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)
+39
View File
@@ -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,