diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 4855893e97d..772747f0252 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -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' @@ -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() - 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 diff --git a/apps/sim/lib/auth/platform-admin.ts b/apps/sim/lib/auth/platform-admin.ts new file mode 100644 index 00000000000..1581c8d42fc --- /dev/null +++ b/apps/sim/lib/auth/platform-admin.ts @@ -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 { + 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> { + 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)) +} diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index 6e053067715..a8a090cfc11 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -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, @@ -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, + } + } + const usageData = await checkUsageStatus(userId, preloadedSubscription) const formattedUsage = (usageData.currentUsage ?? 0).toFixed(2) diff --git a/apps/sim/lib/billing/cleanup-dispatcher.ts b/apps/sim/lib/billing/cleanup-dispatcher.ts index b752e725515..aaaf8dbe2c8 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.ts @@ -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' @@ -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) } @@ -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) diff --git a/apps/sim/lib/billing/storage/limits.ts b/apps/sim/lib/billing/storage/limits.ts index 7cfb6b19ff6..63203279e22 100644 --- a/apps/sim/lib/billing/storage/limits.ts +++ b/apps/sim/lib/billing/storage/limits.ts @@ -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' @@ -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 diff --git a/apps/sim/lib/table/billing.ts b/apps/sim/lib/table/billing.ts index dbe0bde215a..51d364e6999 100644 --- a/apps/sim/lib/table/billing.ts +++ b/apps/sim/lib/table/billing.ts @@ -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' @@ -32,7 +33,22 @@ export async function getWorkspaceTableLimits(workspaceId: string): Promise { }) 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' }) @@ -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' }) @@ -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' }) @@ -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' }) @@ -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' }]] @@ -243,6 +305,7 @@ describe('getWorkspaceInvitePolicy', () => { vi.clearAllMocks() mockFeatureFlags.isBillingEnabled = true mockGetHighestPrioritySubscription.mockResolvedValue(null) + mockDbResults.value = [[{ role: 'user' }]] }) const baseState = { @@ -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) + }) }) diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 100ac51035f..4ff8a645162 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { member, type WorkspaceMode, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, isNull } from 'drizzle-orm' +import { isPlatformAdmin } from '@/lib/auth/platform-admin' import { getOrganizationSubscription } from '@/lib/billing/core/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { getUserOrganization } from '@/lib/billing/organizations/membership' @@ -84,12 +85,22 @@ export function isOrganizationWorkspace( export async function getWorkspaceInvitePolicy( workspaceState: WorkspaceOwnershipState ): Promise { - const requiresSubscriptionLookup = - isBillingEnabled && workspaceState.workspaceMode === WORKSPACE_MODE.GRANDFATHERED_SHARED - const billedUserHasTeamOrEnterprise = requiresSubscriptionLookup - ? await hasActiveTeamOrEnterpriseSubscription(workspaceState.billedAccountUserId) - : false - return evaluateWorkspaceInvitePolicy(workspaceState, { billedUserHasTeamOrEnterprise }) + if (!isBillingEnabled) { + return evaluateWorkspaceInvitePolicy(workspaceState, { + billedUserHasTeamOrEnterprise: false, + billedUserIsPlatformAdmin: false, + }) + } + const [billedUserHasTeamOrEnterprise, billedUserIsPlatformAdmin] = await Promise.all([ + workspaceState.workspaceMode === WORKSPACE_MODE.GRANDFATHERED_SHARED + ? hasActiveTeamOrEnterpriseSubscription(workspaceState.billedAccountUserId) + : Promise.resolve(false), + isPlatformAdmin(workspaceState.billedAccountUserId), + ]) + return evaluateWorkspaceInvitePolicy(workspaceState, { + billedUserHasTeamOrEnterprise, + billedUserIsPlatformAdmin, + }) } /** @@ -100,7 +111,7 @@ export async function getWorkspaceInvitePolicy( */ export function evaluateWorkspaceInvitePolicy( workspaceState: WorkspaceOwnershipState, - context: { billedUserHasTeamOrEnterprise: boolean } + context: { billedUserHasTeamOrEnterprise: boolean; billedUserIsPlatformAdmin: boolean } ): WorkspaceInvitePolicy { if (!isBillingEnabled) { return { @@ -112,6 +123,16 @@ export function evaluateWorkspaceInvitePolicy( } } + if (context.billedUserIsPlatformAdmin) { + return { + allowed: true, + reason: null, + requiresSeat: workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION, + organizationId: workspaceState.organizationId, + upgradeRequired: false, + } + } + if (workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION) { if (workspaceState.organizationId === null) { return { @@ -276,6 +297,32 @@ export async function getWorkspaceCreationPolicy({ } } + if (await isPlatformAdmin(userId)) { + if (organizationId && orgRole && ['owner', 'admin'].includes(orgRole)) { + return { + canCreate: true, + workspaceMode: WORKSPACE_MODE.ORGANIZATION, + organizationId, + billedAccountUserId: await requireOrganizationOwnerId(organizationId), + maxWorkspaces: null, + currentWorkspaceCount: 0, + reason: null, + status: 200, + } + } + + return { + canCreate: true, + workspaceMode: WORKSPACE_MODE.PERSONAL, + organizationId: null, + billedAccountUserId: userId, + maxWorkspaces: null, + currentWorkspaceCount: await countNonOrganizationOwnedWorkspaces(userId), + reason: null, + status: 200, + } + } + const highestPrioritySubscription = await getHighestPrioritySubscription(userId) const plan = highestPrioritySubscription?.plan const maxWorkspaces = isMax(plan) ? 10 : isPro(plan) ? 3 : 1