Skip to content

Commit 979f096

Browse files
committed
improvement(access-control): migrate to workspace scope
1 parent d9209f9 commit 979f096

42 files changed

Lines changed: 16185 additions & 771 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/guardrails/validate/route.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,32 @@ export async function POST(request: NextRequest) {
109109
})
110110
}
111111

112+
if (validationType === 'hallucination' && model && workspaceId) {
113+
const { assertPermissionsAllowed, ProviderNotAllowedError } = await import(
114+
'@/ee/access-control/utils/permission-check'
115+
)
116+
try {
117+
await assertPermissionsAllowed({
118+
userId: auth.userId,
119+
workspaceId,
120+
model,
121+
})
122+
} catch (err) {
123+
if (err instanceof ProviderNotAllowedError) {
124+
return NextResponse.json({
125+
success: true,
126+
output: {
127+
passed: false,
128+
validationType,
129+
input: input || '',
130+
error: err.message,
131+
},
132+
})
133+
}
134+
throw err
135+
}
136+
}
137+
112138
const inputStr = convertInputToString(input)
113139

114140
logger.info(`[${requestId}] Executing validation locally`, {

apps/sim/app/api/mothership/execute/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export async function POST(req: NextRequest) {
8181
})
8282
const [workspaceContext, integrationTools, userPermission] = await Promise.all([
8383
generateWorkspaceContext(workspaceId, userId),
84-
buildIntegrationToolSchemas(userId, messageId),
84+
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
8585
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
8686
])
8787

apps/sim/app/api/organizations/[id]/invitations/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
209209
)
210210
}
211211

212+
await validateInvitationsAllowed(session.user.id, wsInvitation.workspaceId)
213+
212214
validGrants.push({
213215
workspaceId: wsInvitation.workspaceId,
214216
permission: wsInvitation.permission,

apps/sim/app/api/permission-groups/user/route.ts

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,34 @@
11
import { db } from '@sim/db'
2-
import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
3-
import { and, eq } from 'drizzle-orm'
2+
import { permissionGroup, permissionGroupMember } from '@sim/db/schema'
3+
import { and, asc, eq } from 'drizzle-orm'
44
import { NextResponse } from 'next/server'
55
import { getSession } from '@/lib/auth'
6-
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
6+
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing'
77
import { parsePermissionGroupConfig } from '@/lib/permission-groups/types'
8+
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
89

910
export async function GET(req: Request) {
1011
const session = await getSession()
11-
1212
if (!session?.user?.id) {
1313
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
1414
}
1515

1616
const { searchParams } = new URL(req.url)
17-
const organizationId = searchParams.get('organizationId')
17+
const workspaceId = searchParams.get('workspaceId')
1818

19-
if (!organizationId) {
20-
return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
19+
if (!workspaceId) {
20+
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
2121
}
2222

23-
const [membership] = await db
24-
.select({ id: member.id })
25-
.from(member)
26-
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
27-
.limit(1)
28-
29-
if (!membership) {
30-
return NextResponse.json({ error: 'Not a member of this organization' }, { status: 403 })
23+
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
24+
if (!access.exists) {
25+
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
26+
}
27+
if (!access.hasAccess) {
28+
return NextResponse.json({ error: 'Not a member of this workspace' }, { status: 403 })
3129
}
3230

33-
// Short-circuit: if org is not on enterprise plan, ignore permission configs
34-
const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
31+
const isEnterprise = await isWorkspaceOnEnterprisePlan(workspaceId)
3532
if (!isEnterprise) {
3633
return NextResponse.json({
3734
permissionGroupId: null,
@@ -51,9 +48,10 @@ export async function GET(req: Request) {
5148
.where(
5249
and(
5350
eq(permissionGroupMember.userId, session.user.id),
54-
eq(permissionGroup.organizationId, organizationId)
51+
eq(permissionGroup.workspaceId, workspaceId)
5552
)
5653
)
54+
.orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id))
5755
.limit(1)
5856

5957
if (!groupMembership) {

apps/sim/app/api/providers/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,21 @@ export async function POST(request: NextRequest) {
102102
if (!workspaceAccess.hasAccess) {
103103
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
104104
}
105+
106+
const { assertPermissionsAllowed, IntegrationNotAllowedError, ProviderNotAllowedError } =
107+
await import('@/ee/access-control/utils/permission-check')
108+
try {
109+
await assertPermissionsAllowed({
110+
userId: auth.userId,
111+
workspaceId,
112+
model,
113+
})
114+
} catch (err) {
115+
if (err instanceof ProviderNotAllowedError || err instanceof IntegrationNotAllowedError) {
116+
return NextResponse.json({ error: err.message }, { status: 403 })
117+
}
118+
throw err
119+
}
105120
}
106121

107122
let finalApiKey: string | undefined = apiKey

apps/sim/app/api/v1/admin/access-control/route.ts

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,28 @@
55
* List all permission groups with optional filtering.
66
*
77
* Query Parameters:
8-
* - organizationId?: string - Filter by organization ID
8+
* - workspaceId?: string - Filter by workspace ID
9+
* - organizationId?: string - Filter by organization ID (joins via workspace)
910
*
1011
* Response: { data: AdminPermissionGroup[], pagination: PaginationMeta }
1112
*
1213
* DELETE /api/v1/admin/access-control
13-
* Delete permission groups for an organization.
14+
* Delete permission groups scoped to a workspace or organization (via workspace join).
1415
* Used when an enterprise plan churns to clean up access control data.
1516
*
1617
* Query Parameters:
17-
* - organizationId: string - Delete all permission groups for this organization
18+
* - workspaceId?: string - Delete all permission groups for this workspace
19+
* - organizationId?: string - Delete all permission groups for every workspace in this org
20+
* - reason?: string - Reason recorded in audit log (default: "Enterprise plan churn cleanup")
1821
*
1922
* Response: { success: true, deletedCount: number, membersRemoved: number }
2023
*/
2124

2225
import { db } from '@sim/db'
23-
import { organization, permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
26+
import { permissionGroup, permissionGroupMember, user, workspace } from '@sim/db/schema'
2427
import { createLogger } from '@sim/logger'
2528
import { count, eq, inArray, sql } from 'drizzle-orm'
29+
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
2630
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
2731
import {
2832
badRequestResponse,
@@ -34,8 +38,9 @@ const logger = createLogger('AdminAccessControlAPI')
3438

3539
export interface AdminPermissionGroup {
3640
id: string
37-
organizationId: string
38-
organizationName: string | null
41+
workspaceId: string
42+
workspaceName: string | null
43+
organizationId: string | null
3944
name: string
4045
description: string | null
4146
memberCount: number
@@ -46,27 +51,31 @@ export interface AdminPermissionGroup {
4651

4752
export const GET = withAdminAuth(async (request) => {
4853
const url = new URL(request.url)
54+
const workspaceId = url.searchParams.get('workspaceId')
4955
const organizationId = url.searchParams.get('organizationId')
5056

5157
try {
5258
const baseQuery = db
5359
.select({
5460
id: permissionGroup.id,
55-
organizationId: permissionGroup.organizationId,
56-
organizationName: organization.name,
61+
workspaceId: permissionGroup.workspaceId,
62+
workspaceName: workspace.name,
63+
workspaceOrganizationId: workspace.organizationId,
5764
name: permissionGroup.name,
5865
description: permissionGroup.description,
5966
createdAt: permissionGroup.createdAt,
6067
createdByUserId: permissionGroup.createdBy,
6168
createdByEmail: user.email,
6269
})
6370
.from(permissionGroup)
64-
.leftJoin(organization, eq(permissionGroup.organizationId, organization.id))
71+
.leftJoin(workspace, eq(permissionGroup.workspaceId, workspace.id))
6572
.leftJoin(user, eq(permissionGroup.createdBy, user.id))
6673

6774
let groups
68-
if (organizationId) {
69-
groups = await baseQuery.where(eq(permissionGroup.organizationId, organizationId))
75+
if (workspaceId) {
76+
groups = await baseQuery.where(eq(permissionGroup.workspaceId, workspaceId))
77+
} else if (organizationId) {
78+
groups = await baseQuery.where(eq(workspace.organizationId, organizationId))
7079
} else {
7180
groups = await baseQuery
7281
}
@@ -80,8 +89,9 @@ export const GET = withAdminAuth(async (request) => {
8089

8190
return {
8291
id: group.id,
83-
organizationId: group.organizationId,
84-
organizationName: group.organizationName,
92+
workspaceId: group.workspaceId,
93+
workspaceName: group.workspaceName,
94+
organizationId: group.workspaceOrganizationId,
8595
name: group.name,
8696
description: group.description,
8797
memberCount: memberCount?.count ?? 0,
@@ -93,6 +103,7 @@ export const GET = withAdminAuth(async (request) => {
93103
)
94104

95105
logger.info('Admin API: Listed permission groups', {
106+
workspaceId,
96107
organizationId,
97108
count: groupsWithCounts.length,
98109
})
@@ -107,33 +118,47 @@ export const GET = withAdminAuth(async (request) => {
107118
},
108119
})
109120
} catch (error) {
110-
logger.error('Admin API: Failed to list permission groups', { error, organizationId })
121+
logger.error('Admin API: Failed to list permission groups', {
122+
error,
123+
workspaceId,
124+
organizationId,
125+
})
111126
return internalErrorResponse('Failed to list permission groups')
112127
}
113128
})
114129

115130
export const DELETE = withAdminAuth(async (request) => {
116131
const url = new URL(request.url)
132+
const workspaceId = url.searchParams.get('workspaceId')
117133
const organizationId = url.searchParams.get('organizationId')
118134
const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup'
119135

120-
if (!organizationId) {
121-
return badRequestResponse('organizationId is required')
136+
if (!workspaceId && !organizationId) {
137+
return badRequestResponse('workspaceId or organizationId is required')
122138
}
123139

124140
try {
125-
const existingGroups = await db
126-
.select({ id: permissionGroup.id })
141+
const selectBase = db
142+
.select({
143+
id: permissionGroup.id,
144+
workspaceId: permissionGroup.workspaceId,
145+
name: permissionGroup.name,
146+
})
127147
.from(permissionGroup)
128-
.where(eq(permissionGroup.organizationId, organizationId))
148+
149+
const existingGroups = workspaceId
150+
? await selectBase.where(eq(permissionGroup.workspaceId, workspaceId))
151+
: await selectBase
152+
.innerJoin(workspace, eq(workspace.id, permissionGroup.workspaceId))
153+
.where(eq(workspace.organizationId, organizationId!))
129154

130155
if (existingGroups.length === 0) {
131-
logger.info('Admin API: No permission groups to delete', { organizationId })
156+
logger.info('Admin API: No permission groups to delete', { workspaceId, organizationId })
132157
return singleResponse({
133158
success: true,
134159
deletedCount: 0,
135160
membersRemoved: 0,
136-
message: 'No permission groups found for the given organization',
161+
message: 'No permission groups found for the given scope',
137162
})
138163
}
139164

@@ -146,10 +171,24 @@ export const DELETE = withAdminAuth(async (request) => {
146171

147172
const membersToRemove = Number(memberCountResult?.count ?? 0)
148173

149-
// Members are deleted via cascade when permission groups are deleted
150-
await db.delete(permissionGroup).where(eq(permissionGroup.organizationId, organizationId))
174+
await db.delete(permissionGroup).where(inArray(permissionGroup.id, groupIds))
175+
176+
for (const group of existingGroups) {
177+
recordAudit({
178+
workspaceId: group.workspaceId,
179+
actorId: 'admin-api',
180+
action: AuditAction.PERMISSION_GROUP_DELETED,
181+
resourceType: AuditResourceType.PERMISSION_GROUP,
182+
resourceId: group.id,
183+
resourceName: group.name,
184+
description: `Admin API deleted permission group "${group.name}"`,
185+
metadata: { reason, workspaceId: group.workspaceId, organizationId },
186+
request,
187+
})
188+
}
151189

152190
logger.info('Admin API: Deleted permission groups', {
191+
workspaceId,
153192
organizationId,
154193
deletedCount: existingGroups.length,
155194
membersRemoved: membersToRemove,
@@ -163,7 +202,11 @@ export const DELETE = withAdminAuth(async (request) => {
163202
reason,
164203
})
165204
} catch (error) {
166-
logger.error('Admin API: Failed to delete permission groups', { error, organizationId })
205+
logger.error('Admin API: Failed to delete permission groups', {
206+
error,
207+
workspaceId,
208+
organizationId,
209+
})
167210
return internalErrorResponse('Failed to delete permission groups')
168211
}
169212
})

apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { createLogger } from '@sim/logger'
3636
import { generateId } from '@sim/utils/id'
3737
import { and, count, eq } from 'drizzle-orm'
3838
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
39+
import { applyWorkspaceAutoAddGroup } from '@/lib/permission-groups/auto-add'
3940
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
4041
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
4142
import {
@@ -221,6 +222,8 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
221222
updatedAt: now,
222223
})
223224

225+
await applyWorkspaceAutoAddGroup(db, workspaceId, body.userId)
226+
224227
logger.info(`Admin API: Added user ${body.userId} to workspace ${workspaceId}`, {
225228
permissions: body.permissions,
226229
permissionId,

apps/sim/app/api/workflows/[id]/deploy/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
150150
'@/ee/access-control/utils/permission-check'
151151
)
152152
try {
153-
await validatePublicApiAllowed(session?.user?.id)
153+
await validatePublicApiAllowed(session?.user?.id, workflowData?.workspaceId ?? undefined)
154154
} catch (err) {
155155
if (err instanceof PublicApiNotAllowedError) {
156156
return createErrorResponse('Public API access is disabled', 403)

apps/sim/app/api/workflows/[id]/execute/route.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ async function handleExecutePost(
316316
isPublicApi: workflowTable.isPublicApi,
317317
isDeployed: workflowTable.isDeployed,
318318
userId: workflowTable.userId,
319+
workspaceId: workflowTable.workspaceId,
319320
})
320321
.from(workflowTable)
321322
.where(eq(workflowTable.id, workflowId))
@@ -330,10 +331,14 @@ async function handleExecutePost(
330331
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
331332
}
332333

333-
const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check')
334-
const ownerConfig = await getUserPermissionConfig(wf.userId)
335-
if (ownerConfig?.disablePublicApi) {
336-
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
334+
if (wf.workspaceId) {
335+
const { getUserPermissionConfig } = await import(
336+
'@/ee/access-control/utils/permission-check'
337+
)
338+
const ownerConfig = await getUserPermissionConfig(wf.userId, wf.workspaceId)
339+
if (ownerConfig?.disablePublicApi) {
340+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
341+
}
337342
}
338343

339344
userId = wf.userId

0 commit comments

Comments
 (0)