Skip to content
Closed
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
18 changes: 13 additions & 5 deletions apps/sim/app/api/workspaces/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { listWorkspacesQuerySchema } from '@/lib/api/contracts'
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getPlatformAdminUserIds } from '@/lib/auth/platform-admin'
import { PlatformEvents } from '@/lib/core/telemetry'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
Expand Down Expand Up @@ -124,18 +125,25 @@ export const GET = withRouteHandler(async (request: Request) => {
.map(({ workspace: ws }) => ws.billedAccountUserId)
),
]
const allBilledUserIds = [
...new Set(userWorkspaces.map(({ workspace: ws }) => ws.billedAccountUserId)),
]
const teamOrEnterpriseByUser = new Map<string, boolean>()
await Promise.all(
grandfatheredBilledUserIds.map(async (userId) => {
teamOrEnterpriseByUser.set(userId, await hasActiveTeamOrEnterpriseSubscription(userId))
})
)
const [, adminBilledUserIds] = await Promise.all([
Promise.all(
grandfatheredBilledUserIds.map(async (userId) => {
teamOrEnterpriseByUser.set(userId, await hasActiveTeamOrEnterpriseSubscription(userId))
})
),
getPlatformAdminUserIds(allBilledUserIds),
])

const workspacesWithPermissions = userWorkspaces.map(
({ workspace: workspaceDetails, permissionType }) => {
const invitePolicy = evaluateWorkspaceInvitePolicy(workspaceDetails, {
billedUserHasTeamOrEnterprise:
teamOrEnterpriseByUser.get(workspaceDetails.billedAccountUserId) ?? false,
billedUserIsPlatformAdmin: adminBilledUserIds.has(workspaceDetails.billedAccountUserId),
})
const callerIsBilledUser = workspaceDetails.billedAccountUserId === session.user.id

Expand Down
27 changes: 27 additions & 0 deletions apps/sim/lib/auth/platform-admin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { and, eq, inArray } from 'drizzle-orm'

/**
* Returns true when the user has the platform-level `admin` role. Platform
* admins are Sim employees with elevated access; many subscription gates are
* bypassed for them so internal usage isn't paywalled.
*/
export async function isPlatformAdmin(userId: string): Promise<boolean> {
const [row] = await db.select({ role: user.role }).from(user).where(eq(user.id, userId)).limit(1)
return row?.role === 'admin'
}

/**
* Bulk variant. Returns the set of userIds (from the input) that are platform
* admins. Used by callers that need to evaluate many users at once (e.g.
* listing every workspace a user can see and resolving invite policy).
*/
export async function getPlatformAdminUserIds(userIds: string[]): Promise<Set<string>> {
if (userIds.length === 0) return new Set()
const rows = await db
.select({ id: user.id })
.from(user)
.where(and(inArray(user.id, userIds), eq(user.role, 'admin')))
return new Set(rows.map((r) => r.id))
}
10 changes: 10 additions & 0 deletions apps/sim/lib/billing/calculations/usage-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { member, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq } from 'drizzle-orm'
import { isPlatformAdmin } from '@/lib/auth/platform-admin'
import {
getHighestPrioritySubscription,
type HighestPrioritySubscription,
Expand Down Expand Up @@ -337,6 +338,15 @@ export async function checkServerSideUsageLimits(
}
}

if (await isPlatformAdmin(userId)) {
logger.info('Bypassing usage cap for platform admin', { userId, currentUsage })
return {
isExceeded: false,
currentUsage,
limit: Number.MAX_SAFE_INTEGER,
}
}
Comment on lines +341 to +348
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 Admin bypass after org-owner billing-block check

The isPlatformAdmin guard is placed after the org-owner billing-block loop (lines 304–338). That loop iterates every organization the calling user belongs to and returns isExceeded: true if any org owner is billing-blocked. A platform admin who has been added as a member to a customer org (e.g., for support or testing) whose owner has a billing dispute or frozen account will hit that early-return and never reach the admin bypass — causing their own workflow runs to be rejected as usage-exceeded. Moving the isPlatformAdmin check to before the org-owner loop (or parallelizing it with the initial stats fetch) would prevent the false block.


const usageData = await checkUsageStatus(userId, preloadedSubscription)

const formattedUsage = (usageData.currentUsage ?? 0).toFixed(2)
Expand Down
16 changes: 12 additions & 4 deletions apps/sim/lib/billing/cleanup-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { db } from '@sim/db'
import { organization, subscription, workspace } from '@sim/db/schema'
import { organization, subscription, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { tasks } from '@trigger.dev/sdk'
import { and, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
import { and, eq, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm'
import { type PlanCategory, sqlIsPaid, sqlIsPro, sqlIsTeam } from '@/lib/billing/plan-helpers'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { getJobQueue } from '@/lib/core/async-jobs'
Expand Down Expand Up @@ -78,7 +78,14 @@ export async function resolveWorkspaceIdsForPlan(plan: NonEnterprisePlan): Promi
sqlIsPaid(subscription.plan)
)
)
.where(and(isNull(subscription.id), isNull(workspace.archivedAt)))
.leftJoin(user, eq(user.id, workspace.billedAccountUserId))
.where(
and(
isNull(subscription.id),
isNull(workspace.archivedAt),
or(isNull(user.role), ne(user.role, 'admin'))
)
)

return rows.map((r) => r.id)
}
Expand All @@ -95,7 +102,8 @@ export async function resolveWorkspaceIdsForPlan(plan: NonEnterprisePlan): Promi
planPredicate!
)
)
.where(isNull(workspace.archivedAt))
.leftJoin(user, eq(user.id, workspace.billedAccountUserId))
.where(and(isNull(workspace.archivedAt), or(isNull(user.role), ne(user.role, 'admin'))))
.groupBy(workspace.id)

return rows.map((r) => r.id)
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/lib/billing/storage/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { organization, subscription, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { isPlatformAdmin } from '@/lib/auth/platform-admin'
import { getPlanTypeForLimits, isEnterprise, isFree } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { getEnv } from '@/lib/core/config/env'
Expand Down Expand Up @@ -172,11 +173,21 @@ export async function checkStorageQuota(
}

try {
const [currentUsage, limit] = await Promise.all([
const [adminBypass, currentUsage, limit] = await Promise.all([
isPlatformAdmin(userId),
getUserStorageUsage(userId),
getUserStorageLimit(userId),
])

if (adminBypass) {
logger.info('Bypassing storage quota for platform admin', { userId })
return {
allowed: true,
currentUsage,
limit: Number.MAX_SAFE_INTEGER,
}
}

const newUsage = currentUsage + additionalBytes
const allowed = newUsage <= limit

Expand Down
18 changes: 17 additions & 1 deletion apps/sim/lib/table/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { createLogger } from '@sim/logger'
import { isPlatformAdmin } from '@/lib/auth/platform-admin'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
Expand Down Expand Up @@ -32,7 +33,22 @@ export async function getWorkspaceTableLimits(workspaceId: string): Promise<Tabl
return planLimits.free
}

const subscription = await getHighestPrioritySubscription(billedAccountUserId)
const [adminBypass, subscription] = await Promise.all([
isPlatformAdmin(billedAccountUserId),
getHighestPrioritySubscription(billedAccountUserId),
])

if (adminBypass) {
logger.info('Bypassing table limits for platform-admin-owned workspace', {
workspaceId,
billedAccountUserId,
})
return {
maxTables: Number.MAX_SAFE_INTEGER,
maxRowsPerTable: Number.MAX_SAFE_INTEGER,
}
}

const planName = getPlanTypeForLimits(subscription?.plan) as PlanName

const limits = planLimits[planName] ?? planLimits.free
Expand Down
94 changes: 90 additions & 4 deletions apps/sim/lib/workspaces/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe('getWorkspaceCreationPolicy', () => {
})

it('blocks free users once they already own one non-organization workspace', async () => {
mockDbResults.value = [[{ value: 1 }]]
mockDbResults.value = [[], [{ value: 1 }]]

const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })

Expand All @@ -98,7 +98,7 @@ describe('getWorkspaceCreationPolicy', () => {
plan: 'pro_6000',
status: 'active',
})
mockDbResults.value = [[{ value: 2 }]]
mockDbResults.value = [[], [{ value: 2 }]]

const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })

Expand All @@ -114,7 +114,7 @@ describe('getWorkspaceCreationPolicy', () => {
plan: 'pro_25000',
status: 'active',
})
mockDbResults.value = [[{ value: 5 }]]
mockDbResults.value = [[], [{ value: 5 }]]

const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })

Expand All @@ -130,7 +130,7 @@ describe('getWorkspaceCreationPolicy', () => {
plan: 'pro_25000',
status: 'active',
})
mockDbResults.value = [[{ value: 10 }]]
mockDbResults.value = [[], [{ value: 10 }]]

const result = await getWorkspaceCreationPolicy({ userId: 'user-1' })

Expand Down Expand Up @@ -220,6 +220,68 @@ describe('getWorkspaceCreationPolicy', () => {
expect(result.reason).toContain('owners and admins')
})

it('grants platform admins unlimited personal workspaces regardless of plan', async () => {
mockDbResults.value = [[{ role: 'admin' }], [{ value: 25 }]]

const result = await getWorkspaceCreationPolicy({ userId: 'admin-user' })

expect(result.canCreate).toBe(true)
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
expect(result.maxWorkspaces).toBeNull()
expect(result.currentWorkspaceCount).toBe(25)
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
})

it('keeps platform admins in org context when the org lacks a team plan', async () => {
mockGetUserOrganization.mockResolvedValueOnce({
organizationId: 'org-1',
role: 'admin',
memberId: 'member-1',
})
mockGetOrganizationSubscription.mockResolvedValueOnce(null)
mockDbResults.value = [[{ role: 'admin' }], [{ userId: 'owner-1' }]]

const result = await getWorkspaceCreationPolicy({
userId: 'admin-user',
activeOrganizationId: 'org-1',
})

expect(result.canCreate).toBe(true)
expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION)
expect(result.organizationId).toBe('org-1')
expect(result.billedAccountUserId).toBe('owner-1')
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
})

it('blocks platform admins who are only org members from creating org workspaces', async () => {
mockGetUserOrganization.mockResolvedValueOnce({
organizationId: 'org-1',
role: 'member',
memberId: 'member-1',
})
mockGetOrganizationSubscription.mockResolvedValueOnce(null)
mockDbResults.value = [[{ role: 'admin' }], [{ value: 0 }]]

const result = await getWorkspaceCreationPolicy({
userId: 'admin-user',
activeOrganizationId: 'org-1',
})

expect(result.canCreate).toBe(true)
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
expect(result.organizationId).toBeNull()
})

it('still enforces plan limits for non-admin users', async () => {
mockDbResults.value = [[{ role: 'user' }], [{ value: 1 }]]

const result = await getWorkspaceCreationPolicy({ userId: 'regular-user' })

expect(result.canCreate).toBe(false)
expect(result.maxWorkspaces).toBe(1)
expect(result.currentWorkspaceCount).toBe(1)
})

it('blocks users without org membership from creating workspaces in the active org context', async () => {
mockDbResults.value = [[], [{ userId: 'owner-1' }]]

Expand All @@ -243,6 +305,7 @@ describe('getWorkspaceInvitePolicy', () => {
vi.clearAllMocks()
mockFeatureFlags.isBillingEnabled = true
mockGetHighestPrioritySubscription.mockResolvedValue(null)
mockDbResults.value = [[{ role: 'user' }]]
})

const baseState = {
Expand Down Expand Up @@ -352,4 +415,27 @@ describe('getWorkspaceInvitePolicy', () => {
expect(result.allowed).toBe(false)
expect(result.upgradeRequired).toBe(true)
})

it('allows invites on a personal workspace billed to a platform admin', async () => {
mockDbResults.value = [[{ role: 'admin' }]]

const result = await getWorkspaceInvitePolicy(baseState)

expect(result.allowed).toBe(true)
expect(result.upgradeRequired).toBe(false)
expect(result.reason).toBeNull()
})

it('allows invites on a grandfathered workspace billed to a platform admin on a free plan', async () => {
mockGetHighestPrioritySubscription.mockResolvedValueOnce(null)
mockDbResults.value = [[{ role: 'admin' }]]

const result = await getWorkspaceInvitePolicy({
...baseState,
workspaceMode: WORKSPACE_MODE.GRANDFATHERED_SHARED,
})

expect(result.allowed).toBe(true)
expect(result.upgradeRequired).toBe(false)
})
})
Loading
Loading