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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 66 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 Down
34 changes: 33 additions & 1 deletion apps/sim/lib/workspaces/policy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { member, type WorkspaceMode, workspace } from '@sim/db/schema'
import { member, user, type WorkspaceMode, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, isNull } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
Expand Down Expand Up @@ -276,6 +276,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,
}
}
Comment on lines +279 to +303
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.

P2 Extra DB query for every non-admin personal workspace check

isPlatformAdmin issues a full SELECT round-trip for every call that reaches that branch — i.e., every billing-enabled personal workspace policy check where the user is not in an active org with a team/enterprise subscription. Since admins are rare, the vast majority of these queries will return role: 'user' and the result is discarded immediately before the subscription lookup begins. The admin bypass could be guarded with an early-exit check from a value already in scope (e.g. a cached session attribute or a flag passed in by the caller), avoiding the extra query for the common case.

Comment on lines +279 to +303
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.

P2 Platform admin in an active org context falls back to personal mode silently

When a platform admin calls getWorkspaceCreationPolicy with activeOrganizationId set (e.g. they are an org member but the org lacks a team/enterprise subscription), the code falls through the org-subscription block (lines 243–277), hits isPlatformAdmin, and returns workspaceMode: WORKSPACE_MODE.PERSONAL with organizationId: null. The caller asked for org context but receives a personal policy — no error, no signal, just a silent mode downgrade. This could lead to a workspace being created as personal when the admin intended to create it under the org. There is currently no test covering this path.


const highestPrioritySubscription = await getHighestPrioritySubscription(userId)
const plan = highestPrioritySubscription?.plan
const maxWorkspaces = isMax(plan) ? 10 : isPro(plan) ? 3 : 1
Expand Down Expand Up @@ -331,6 +357,12 @@ export async function getOrganizationOwnerId(organizationId: string): Promise<st
return ownerMembership?.userId ?? null
}

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'
}

/**
* Like `getOrganizationOwnerId` but throws when no owner row exists.
* Use when the caller needs a guaranteed billed-account userId — every
Expand Down
Loading