diff --git a/apps/docs/content/docs/en/platform/enterprise/access-control.mdx b/apps/docs/content/docs/en/platform/enterprise/access-control.mdx
index 0de440497c..5b54a40cb5 100644
--- a/apps/docs/content/docs/en/platform/enterprise/access-control.mdx
+++ b/apps/docs/content/docs/en/platform/enterprise/access-control.mdx
@@ -7,15 +7,22 @@ import { Callout } from 'fumadocs-ui/components/callout'
import { FAQ } from '@/components/ui/faq'
import { Image } from '@/components/ui/image'
-Access Control lets organization admins define permission groups that restrict what each set of organization members can do — which AI model providers they can use, which workflow blocks they can place, and which platform features are visible to them. Permission groups are scoped to the **organization** and apply to every workspace under it: a user belongs to at most one group per organization. Restrictions are enforced both in the workflow executor and in Mothership, based on the organization that owns the workflow's workspace.
+Access Control lets organization admins define permission groups that restrict what each set of organization members can do — which AI model providers they can use, which workflow blocks they can place, and which platform features are visible to them. Permission groups are scoped to the **organization** and can govern either every workspace in the organization or a specific subset of workspaces. A user can belong to multiple groups, but is governed by exactly one group in any given workspace. Restrictions are enforced both in the workflow executor and in Mothership, based on the organization that owns the workflow's workspace.
---
## How it works
-Access control is built around **permission groups**. Each group belongs to a specific organization and has a name, an optional description, and a configuration that defines what its members can and cannot do. A user can belong to at most one permission group **per organization**, and that group governs them in every workspace under the organization. Personal workspaces that do not belong to an organization have no permission groups.
+Access control is built around **permission groups**. Each group belongs to a specific organization and has a name, an optional description, a **workspace scope** (all workspaces or a specific subset), and a configuration that defines what its members can and cannot do. A user can belong to multiple permission groups, but at most one group governs them in any given workspace. Personal workspaces that do not belong to an organization have no permission groups.
-Sim resolves the governing group deterministically, with no per-workspace fallbacks: a user's explicitly assigned group takes precedence; otherwise the organization's **default group** applies (if one is set); otherwise no restrictions apply.
+Sim resolves the governing group for a user in a workspace deterministically, with **specific-over-all precedence**:
+
+1. a group the user belongs to that **specifically targets that workspace** takes precedence; otherwise
+2. a group they belong to that applies to **all workspaces** applies; otherwise
+3. the organization's **default group** applies (if one is set); otherwise
+4. no restrictions apply.
+
+Because a user's specific-scope groups may not overlap on a workspace, and a user may belong to at most one all-workspaces group, the governing group is always unambiguous.
When a user runs a workflow or uses Mothership, Sim reads the resolved group's configuration and applies it:
@@ -34,7 +41,7 @@ Go to **Settings → Enterprise → Access Control** from any workspace in your
### 2. Create a permission group
-Click **+ Create** and enter a name (required) and optional description. You can also mark the group as the **organization default group** — when set, it governs every organization member who is not explicitly assigned to another group, as well as external workspace members operating in the organization's workspaces. Only one group per organization can be the default at a time.
+Click **+ Create** and enter a name (required) and optional description. Choose whether the group applies to **all workspaces** in the organization or only a **specific set** of workspaces — when specific, pick the workspaces from the multi-select. You can also mark the group as the **organization default group** — when set, it governs every organization member who is not explicitly assigned to another group, as well as external workspace members operating in the organization's workspaces. Only one group per organization can be the default at a time, and the default group always applies to all workspaces.
### 3. Configure permissions
@@ -130,7 +137,9 @@ Controls visibility of platform features and modules.
### 4. Add members
-Open the group's **Details** view and add members by searching for users by name or email. The member picker lists your organization's members. A user can belong to at most one group per organization — adding a user to a new group removes them from their current group.
+Open the group's **Details** view and add members by searching for users by name or email. The member picker lists your organization's members. A user can belong to multiple groups, but only one group can govern them in any given workspace — so adding a user to a group is rejected when it would conflict with another of their groups on a workspace (two all-workspaces groups, or two specific groups that share a workspace). In bulk adds, conflicting users are skipped rather than added.
+
+You can also change a group's workspace scope at any time from the **Workspaces** row in the Details view.
External workspace members (people who have access to a workspace but belong to a different organization) are not assigned to groups individually. They are governed by the organization's **default group** when one is set; otherwise no restrictions apply to them.
@@ -159,10 +168,11 @@ When a user opens Mothership, their permission group is read before any block or
## User membership rules
-- A user can belong to **at most one** permission group **per organization**, and that group governs them in every workspace under the organization.
-- Moving a user to a new group automatically removes them from their previous group.
-- Users not assigned to any group fall under the organization's **default group** if one is set; otherwise no restrictions are applied to them.
-- Only one group per organization can be marked as the **default group**. The default also governs external workspace members operating in the organization's workspaces.
+- A user can belong to **multiple** permission groups, but **at most one** group governs them in any given workspace.
+- For a given workspace, a group that **specifically targets that workspace** takes precedence over a group that applies to **all workspaces**, which takes precedence over the organization's **default group**.
+- A user's specific-scope groups may not overlap on a workspace, and a user may belong to at most one all-workspaces group. Adding a user in a way that would violate this is rejected (single add) or skipped (bulk add) — memberships are never silently moved between groups.
+- Users not governed by any group fall under the organization's **default group** if one is set; otherwise no restrictions are applied to them.
+- Only one group per organization can be marked as the **default group**, and it always applies to all workspaces. The default also governs external workspace members operating in the organization's workspaces.
- Personal or grandfathered workspaces that do not belong to an organization have no permission groups.
---
@@ -178,7 +188,11 @@ When a user opens Mothership, their permission group is read before any block or
},
{
question: "Can a user be in multiple permission groups?",
- answer: "A user can belong to at most one permission group per organization, and that group governs them across every workspace under the organization. Adding a user to a new group automatically removes them from their previous group."
+ answer: "Yes. A user can belong to multiple permission groups, but only one group governs them in any given workspace. A group that specifically targets a workspace takes precedence over an all-workspaces group, which takes precedence over the organization's default group. A user's specific-scope groups may not overlap on a workspace, and a user may belong to at most one all-workspaces group."
+ },
+ {
+ question: "Can a permission group apply to only some workspaces?",
+ answer: "Yes. When creating or editing a group, choose 'Specific workspaces' and select the workspaces it should govern. A specific-scope group governs its members only in those workspaces; elsewhere those members fall back to their all-workspaces group (if any) or the organization default. The default group always applies to all workspaces."
},
{
question: "What governs a user who has no permission group assigned?",
@@ -189,8 +203,8 @@ When a user opens Mothership, their permission group is read before any block or
answer: "Yes. Mothership reads the user's permission group for the workspace's organization before suggesting blocks or tools. Disallowed blocks are filtered out of the block picker, and disallowed tool types are skipped during workflow generation."
},
{
- question: "Can I apply different restrictions to different people?",
- answer: "Permission groups apply across the entire organization, so assign different sets of users to different permission groups to give them different restrictions. Who can access a given workspace is still controlled separately by workspace invitations and permissions."
+ question: "Can I apply different restrictions to different people or workspaces?",
+ answer: "Yes. Assign different sets of users to different permission groups to give them different restrictions, and scope each group to all workspaces or a specific subset to vary restrictions per workspace. A user can be in an all-workspaces group for a baseline plus a specific-workspace group that overrides it in select workspaces. Who can access a given workspace is still controlled separately by workspace invitations and permissions."
},
{
question: "What is the default group?",
diff --git a/apps/docs/content/docs/en/platform/permissions.mdx b/apps/docs/content/docs/en/platform/permissions.mdx
index bba3ba664b..7dc4980ace 100644
--- a/apps/docs/content/docs/en/platform/permissions.mdx
+++ b/apps/docs/content/docs/en/platform/permissions.mdx
@@ -191,9 +191,9 @@ import { FAQ } from '@/components/ui/faq'
\ No newline at end of file
diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts
index edbf9619db..f3380ef2b7 100644
--- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts
+++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts
@@ -1,6 +1,6 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
-import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
+import { member, permissionGroupMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
@@ -12,8 +12,13 @@ import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/types'
import {
+ acquirePermissionGroupOrgLock,
authorizeOrgAccessControl,
+ findScopeConflicts,
+ formatScopeConflictError,
+ getGroupWorkspaces,
loadGroupInOrganization,
+ type ScopeConflict,
} from '@/app/api/organizations/[id]/permission-groups/utils'
const logger = createLogger('OrganizationPermissionGroupBulkMembers')
@@ -27,6 +32,10 @@ export const POST = withRouteHandler(
const { id: organizationId, groupId: id } = await context.params
+ // Populated inside the transaction when a scope conflict is detected, so the
+ // catch can format the 409 after the rollback.
+ let scopeConflicts: ScopeConflict[] = []
+
try {
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
if (denied) return denied
@@ -65,48 +74,60 @@ export const POST = withRouteHandler(
}
if (targetUserIds.length === 0) {
- return NextResponse.json({ added: 0, moved: 0 })
+ return NextResponse.json({ added: 0, skipped: 0 })
}
- const { addedUserIds, movedCount } = await db.transaction(async (tx) => {
- const existingMemberships = await tx
- .select({
- id: permissionGroupMember.id,
- userId: permissionGroupMember.userId,
- permissionGroupId: permissionGroupMember.permissionGroupId,
- })
+ const { addedUserIds } = await db.transaction(async (tx) => {
+ // Serialize all permission-group writes for this org so the conflict
+ // check and inserts are atomic against concurrent adds or scope changes.
+ await acquirePermissionGroupOrgLock(tx, organizationId)
+
+ // Re-read the group's scope under the lock: a concurrent scope change may
+ // have flipped all-vs-specific (and cleared its workspaces) since the
+ // pre-transaction load, so the conflict check must use one consistent
+ // snapshot of appliesToAllWorkspaces + workspaces.
+ const lockedGroup = await loadGroupInOrganization(id, organizationId, tx)
+ if (!lockedGroup) {
+ throw new Error('GROUP_NOT_FOUND')
+ }
+
+ // Bulk add is all-or-nothing for conflicts: if any selected user would be
+ // governed by two groups on the same workspace (all-vs-all, or specific
+ // groups sharing a workspace), add nobody and surface the conflict so the
+ // admin can fix the selection. Members already in this group are no-ops.
+ const groupWorkspaceIds = lockedGroup.appliesToAllWorkspaces
+ ? []
+ : (await getGroupWorkspaces(id, tx)).map((ws) => ws.id)
+ const conflicts = await findScopeConflicts(
+ {
+ organizationId,
+ excludeGroupId: id,
+ appliesToAllWorkspaces: lockedGroup.appliesToAllWorkspaces,
+ workspaceIds: groupWorkspaceIds,
+ candidateUserIds: targetUserIds,
+ },
+ tx
+ )
+ if (conflicts.length > 0) {
+ scopeConflicts = conflicts
+ throw new Error('SCOPE_CONFLICT')
+ }
+
+ const existingInGroup = await tx
+ .select({ userId: permissionGroupMember.userId })
.from(permissionGroupMember)
- .innerJoin(
- permissionGroup,
- eq(permissionGroupMember.permissionGroupId, permissionGroup.id)
- )
.where(
and(
- eq(permissionGroup.organizationId, organizationId),
+ eq(permissionGroupMember.permissionGroupId, id),
inArray(permissionGroupMember.userId, targetUserIds)
)
)
+ const alreadyInThisGroup = new Set(existingInGroup.map((m) => m.userId))
- const alreadyInThisGroup = new Set(
- existingMemberships.filter((m) => m.permissionGroupId === id).map((m) => m.userId)
- )
const usersToAdd = targetUserIds.filter((uid) => !alreadyInThisGroup.has(uid))
if (usersToAdd.length === 0) {
- return { addedUserIds: [] as string[], movedCount: 0 }
- }
-
- const membershipsToDelete = existingMemberships.filter(
- (m) => m.permissionGroupId !== id && usersToAdd.includes(m.userId)
- )
-
- if (membershipsToDelete.length > 0) {
- await tx.delete(permissionGroupMember).where(
- inArray(
- permissionGroupMember.id,
- membershipsToDelete.map((m) => m.id)
- )
- )
+ return { addedUserIds: [] as string[] }
}
const newMembers = usersToAdd.map((userId) => ({
@@ -120,18 +141,20 @@ export const POST = withRouteHandler(
await tx.insert(permissionGroupMember).values(newMembers)
- return { addedUserIds: usersToAdd, movedCount: membershipsToDelete.length }
+ return { addedUserIds: usersToAdd }
})
+ const skipped = targetUserIds.length - addedUserIds.length
+
if (addedUserIds.length === 0) {
- return NextResponse.json({ added: 0, moved: 0 })
+ return NextResponse.json({ added: 0, skipped })
}
logger.info('Bulk added members to permission group', {
permissionGroupId: id,
organizationId,
addedCount: addedUserIds.length,
- movedCount,
+ skipped,
assignedBy: session.user.id,
})
@@ -148,27 +171,40 @@ export const POST = withRouteHandler(
organizationId,
permissionGroupId: id,
addedUserIds,
- movedCount,
+ skipped,
},
request: req,
})
- return NextResponse.json({ added: addedUserIds.length, moved: movedCount })
+ return NextResponse.json({ added: addedUserIds.length, skipped })
} catch (error) {
- if (getPostgresErrorCode(error) === '23505') {
- const constraint = getPostgresConstraintName(error)
- if (
- constraint === PERMISSION_GROUP_MEMBER_CONSTRAINTS.organizationUser ||
- constraint === PERMISSION_GROUP_MEMBER_CONSTRAINTS.groupUser
- ) {
- return NextResponse.json(
- {
- error:
- 'One or more users were concurrently added to a group in this organization. Please refresh and try again.',
- },
- { status: 409 }
- )
- }
+ if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') {
+ return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
+ }
+ if (error instanceof Error && error.message === 'SCOPE_CONFLICT') {
+ return NextResponse.json(
+ { error: formatScopeConflictError(scopeConflicts) },
+ { status: 409 }
+ )
+ }
+ if (
+ getPostgresErrorCode(error) === '23505' &&
+ getPostgresConstraintName(error) === PERMISSION_GROUP_MEMBER_CONSTRAINTS.groupUser
+ ) {
+ return NextResponse.json(
+ {
+ error:
+ 'One or more users were concurrently added to this group. Please refresh and try again.',
+ },
+ { status: 409 }
+ )
+ }
+ // Advisory lock wait exceeded (lock_timeout) — transient contention.
+ if (getPostgresErrorCode(error) === '55P03') {
+ return NextResponse.json(
+ { error: 'This group is being updated by another request. Please try again.' },
+ { status: 503 }
+ )
}
logger.error('Error bulk adding members to permission group', error)
return NextResponse.json({ error: 'Failed to add members' }, { status: 500 })
diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts
index 2e9643b677..4d7c961805 100644
--- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts
+++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts
@@ -1,10 +1,10 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
-import { permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
+import { permissionGroupMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
-import { and, eq, inArray } from 'drizzle-orm'
+import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { addPermissionGroupMemberContract } from '@/lib/api/contracts/permission-groups'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
@@ -13,8 +13,13 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/types'
import { isOrganizationMember } from '@/lib/workspaces/permissions/utils'
import {
+ acquirePermissionGroupOrgLock,
authorizeOrgAccessControl,
+ findScopeConflicts,
+ formatScopeConflictError,
+ getGroupWorkspaces,
loadGroupInOrganization,
+ type ScopeConflict,
} from '@/app/api/organizations/[id]/permission-groups/utils'
const logger = createLogger('OrganizationPermissionGroupMembers')
@@ -62,6 +67,10 @@ export const POST = withRouteHandler(
const { id: organizationId, groupId: id } = await context.params
+ // Populated inside the transaction when a scope conflict is detected, so the
+ // catch can format the 409 after the rollback.
+ let scopeConflicts: ScopeConflict[] = []
+
try {
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
if (denied) return denied
@@ -87,34 +96,55 @@ export const POST = withRouteHandler(
}
const newMember = await db.transaction(async (tx) => {
- const existingInOrganization = await tx
- .select({
- id: permissionGroupMember.id,
- permissionGroupId: permissionGroupMember.permissionGroupId,
- })
+ // Serialize all permission-group writes for this org so the conflict
+ // check and insert are atomic. Without it, two concurrent adds (or a
+ // concurrent scope change) could both pass findScopeConflicts and place
+ // the user in two groups that overlap on a workspace.
+ await acquirePermissionGroupOrgLock(tx, organizationId)
+
+ // Re-read the group's scope under the lock: a concurrent scope change may
+ // have flipped all-vs-specific (and cleared its workspaces) since the
+ // pre-transaction load, so the conflict check must use one consistent
+ // snapshot of appliesToAllWorkspaces + workspaces.
+ const lockedGroup = await loadGroupInOrganization(id, organizationId, tx)
+ if (!lockedGroup) {
+ throw new Error('GROUP_NOT_FOUND')
+ }
+
+ const [existingInGroup] = await tx
+ .select({ id: permissionGroupMember.id })
.from(permissionGroupMember)
- .innerJoin(
- permissionGroup,
- eq(permissionGroupMember.permissionGroupId, permissionGroup.id)
- )
.where(
and(
- eq(permissionGroupMember.userId, userId),
- eq(permissionGroup.organizationId, organizationId)
+ eq(permissionGroupMember.permissionGroupId, id),
+ eq(permissionGroupMember.userId, userId)
)
)
+ .limit(1)
- if (existingInOrganization.some((row) => row.permissionGroupId === id)) {
+ if (existingInGroup) {
throw new Error('ALREADY_IN_GROUP')
}
- if (existingInOrganization.length > 0) {
- await tx.delete(permissionGroupMember).where(
- inArray(
- permissionGroupMember.id,
- existingInOrganization.map((row) => row.id)
- )
- )
+ // A user may belong to multiple groups, but only one may govern any given
+ // workspace. Reject when this group's scope would overlap a group the user
+ // is already in (all-vs-all, or specific groups sharing a workspace).
+ const groupWorkspaceIds = lockedGroup.appliesToAllWorkspaces
+ ? []
+ : (await getGroupWorkspaces(id, tx)).map((ws) => ws.id)
+ const conflicts = await findScopeConflicts(
+ {
+ organizationId,
+ excludeGroupId: id,
+ appliesToAllWorkspaces: lockedGroup.appliesToAllWorkspaces,
+ workspaceIds: groupWorkspaceIds,
+ candidateUserIds: [userId],
+ },
+ tx
+ )
+ if (conflicts.length > 0) {
+ scopeConflicts = conflicts
+ throw new Error('SCOPE_CONFLICT')
}
const memberData = {
@@ -156,29 +186,36 @@ export const POST = withRouteHandler(
return NextResponse.json({ member: newMember }, { status: 201 })
} catch (error) {
+ if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') {
+ return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
+ }
if (error instanceof Error && error.message === 'ALREADY_IN_GROUP') {
return NextResponse.json(
{ error: 'User is already in this permission group' },
{ status: 409 }
)
}
- if (getPostgresErrorCode(error) === '23505') {
- const constraint = getPostgresConstraintName(error)
- if (constraint === PERMISSION_GROUP_MEMBER_CONSTRAINTS.organizationUser) {
- return NextResponse.json(
- {
- error:
- 'User was concurrently added to another group in this organization. Please refresh and try again.',
- },
- { status: 409 }
- )
- }
- if (constraint === PERMISSION_GROUP_MEMBER_CONSTRAINTS.groupUser) {
- return NextResponse.json(
- { error: 'User is already in this permission group' },
- { status: 409 }
- )
- }
+ if (error instanceof Error && error.message === 'SCOPE_CONFLICT') {
+ return NextResponse.json(
+ { error: formatScopeConflictError(scopeConflicts) },
+ { status: 409 }
+ )
+ }
+ if (
+ getPostgresErrorCode(error) === '23505' &&
+ getPostgresConstraintName(error) === PERMISSION_GROUP_MEMBER_CONSTRAINTS.groupUser
+ ) {
+ return NextResponse.json(
+ { error: 'User is already in this permission group' },
+ { status: 409 }
+ )
+ }
+ // Advisory lock wait exceeded (lock_timeout) — transient contention.
+ if (getPostgresErrorCode(error) === '55P03') {
+ return NextResponse.json(
+ { error: 'This group is being updated by another request. Please try again.' },
+ { status: 503 }
+ )
}
logger.error('Error adding member to permission group', error)
return NextResponse.json({ error: 'Failed to add member' }, { status: 500 })
diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts
index 31528f0ba5..2899d27bcf 100644
--- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts
+++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts
@@ -1,8 +1,9 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
-import { permissionGroup, permissionGroupMember } from '@sim/db/schema'
+import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { updatePermissionGroupContract } from '@/lib/api/contracts/permission-groups'
@@ -15,8 +16,14 @@ import {
parsePermissionGroupConfig,
} from '@/lib/permission-groups/types'
import {
+ acquirePermissionGroupOrgLock,
authorizeOrgAccessControl,
+ findScopeConflicts,
+ findWorkspacesNotInOrganization,
+ formatScopeConflictError,
+ getGroupWorkspaces,
loadGroupInOrganization,
+ type ScopeConflict,
} from '@/app/api/organizations/[id]/permission-groups/utils'
const logger = createLogger('OrganizationPermissionGroup')
@@ -38,10 +45,13 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
+ const workspaces = group.appliesToAllWorkspaces ? [] : await getGroupWorkspaces(id)
+
return NextResponse.json({
permissionGroup: {
...group,
config: parsePermissionGroupConfig(group.config),
+ workspaces,
},
})
}
@@ -56,6 +66,10 @@ export const PUT = withRouteHandler(
const { id: organizationId, groupId: id } = await context.params
+ // Populated inside the transaction when a scope conflict is detected, so the
+ // catch can format the 409 after the rollback.
+ let scopeConflicts: ScopeConflict[] = []
+
try {
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
if (denied) return denied
@@ -97,9 +111,98 @@ export const PUT = withRouteHandler(
? { ...currentConfig, ...updates.config }
: currentConfig
+ // Resolve the target workspace scope. Setting the group as default forces
+ // all-workspaces; otherwise an explicit `appliesToAllWorkspaces` wins, and
+ // supplying `workspaceIds` alone implies a specific scope.
+ const scopeProvided =
+ updates.appliesToAllWorkspaces !== undefined ||
+ updates.workspaceIds !== undefined ||
+ updates.isDefault === true
+
+ const resolvedAppliesToAll =
+ updates.isDefault === true
+ ? true
+ : updates.appliesToAllWorkspaces !== undefined
+ ? updates.appliesToAllWorkspaces
+ : updates.workspaceIds !== undefined
+ ? false
+ : group.appliesToAllWorkspaces
+
+ const effectiveIsDefault =
+ updates.isDefault !== undefined ? updates.isDefault : group.isDefault
+ if (effectiveIsDefault && !resolvedAppliesToAll) {
+ return NextResponse.json(
+ { error: 'The default group must apply to all workspaces' },
+ { status: 400 }
+ )
+ }
+
+ // Resolve and validate explicitly-provided workspaceIds before the
+ // transaction. When the request omits them for a specific-scope group
+ // ("keep current"), they're read under the lock instead (see below) so the
+ // conflict check and the write share one consistent snapshot.
+ let providedWorkspaceIds: string[] | null = null
+ if (!resolvedAppliesToAll && updates.workspaceIds !== undefined) {
+ providedWorkspaceIds = Array.from(new Set(updates.workspaceIds))
+ if (providedWorkspaceIds.length === 0) {
+ return NextResponse.json(
+ { error: 'Select at least one workspace when the group targets specific workspaces' },
+ { status: 400 }
+ )
+ }
+ const invalid = await findWorkspacesNotInOrganization(providedWorkspaceIds, organizationId)
+ if (invalid.length > 0) {
+ return NextResponse.json(
+ { error: 'One or more selected workspaces do not belong to this organization' },
+ { status: 400 }
+ )
+ }
+ }
+
const now = new Date()
await db.transaction(async (tx) => {
+ // For a specific-scope group the target workspaces are the request's
+ // explicit ids, or — when omitted ("keep current") — the group's current
+ // workspaces read under the lock so the conflict check and write share
+ // one snapshot.
+ let resolvedWorkspaceIds: string[] = []
+
+ // When the scope changes, serialize against other permission-group writes
+ // for this org and re-check membership conflicts atomically with the
+ // write, so a concurrent member add (or scope change) can't slip a user
+ // into two groups that overlap on a workspace.
+ if (scopeProvided) {
+ await acquirePermissionGroupOrgLock(tx, organizationId)
+
+ if (!resolvedAppliesToAll) {
+ resolvedWorkspaceIds =
+ providedWorkspaceIds ?? (await getGroupWorkspaces(id, tx)).map((ws) => ws.id)
+ if (resolvedWorkspaceIds.length === 0) {
+ throw new Error('NO_WORKSPACES')
+ }
+ }
+
+ const members = await tx
+ .select({ userId: permissionGroupMember.userId })
+ .from(permissionGroupMember)
+ .where(eq(permissionGroupMember.permissionGroupId, id))
+ const conflicts = await findScopeConflicts(
+ {
+ organizationId,
+ excludeGroupId: id,
+ appliesToAllWorkspaces: resolvedAppliesToAll,
+ workspaceIds: resolvedWorkspaceIds,
+ candidateUserIds: members.map((m) => m.userId),
+ },
+ tx
+ )
+ if (conflicts.length > 0) {
+ scopeConflicts = conflicts
+ throw new Error('SCOPE_CONFLICT')
+ }
+ }
+
if (updates.isDefault === true) {
await tx
.update(permissionGroup)
@@ -118,10 +221,28 @@ export const PUT = withRouteHandler(
...(updates.name !== undefined && { name: updates.name }),
...(updates.description !== undefined && { description: updates.description }),
...(updates.isDefault !== undefined && { isDefault: updates.isDefault }),
+ ...(scopeProvided && { appliesToAllWorkspaces: resolvedAppliesToAll }),
config: newConfig,
updatedAt: now,
})
.where(eq(permissionGroup.id, id))
+
+ if (scopeProvided) {
+ await tx
+ .delete(permissionGroupWorkspace)
+ .where(eq(permissionGroupWorkspace.permissionGroupId, id))
+ if (!resolvedAppliesToAll && resolvedWorkspaceIds.length > 0) {
+ await tx.insert(permissionGroupWorkspace).values(
+ resolvedWorkspaceIds.map((workspaceId) => ({
+ id: generateId(),
+ permissionGroupId: id,
+ workspaceId,
+ organizationId,
+ createdAt: now,
+ }))
+ )
+ }
+ }
})
const [updated] = await db
@@ -130,6 +251,10 @@ export const PUT = withRouteHandler(
.where(eq(permissionGroup.id, id))
.limit(1)
+ const finalWorkspaceIds = updated.appliesToAllWorkspaces
+ ? []
+ : (await getGroupWorkspaces(id)).map((ws) => ws.id)
+
recordAudit({
actorId: session.user.id,
action: AuditAction.PERMISSION_GROUP_UPDATED,
@@ -152,9 +277,22 @@ export const PUT = withRouteHandler(
permissionGroup: {
...updated,
config: parsePermissionGroupConfig(updated.config),
+ workspaceIds: finalWorkspaceIds,
},
})
} catch (error) {
+ if (error instanceof Error && error.message === 'SCOPE_CONFLICT') {
+ return NextResponse.json(
+ { error: formatScopeConflictError(scopeConflicts) },
+ { status: 409 }
+ )
+ }
+ if (error instanceof Error && error.message === 'NO_WORKSPACES') {
+ return NextResponse.json(
+ { error: 'Select at least one workspace when the group targets specific workspaces' },
+ { status: 400 }
+ )
+ }
if (getPostgresErrorCode(error) === '23505') {
const constraint = getPostgresConstraintName(error)
if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationName) {
@@ -173,6 +311,13 @@ export const PUT = withRouteHandler(
)
}
}
+ // Advisory lock wait exceeded (lock_timeout) — transient contention.
+ if (getPostgresErrorCode(error) === '55P03') {
+ return NextResponse.json(
+ { error: 'This group is being updated by another request. Please try again.' },
+ { status: 503 }
+ )
+ }
logger.error('Error updating permission group', error)
return NextResponse.json({ error: 'Failed to update permission group' }, { status: 500 })
}
@@ -198,6 +343,7 @@ export const DELETE = withRouteHandler(
}
await db.transaction(async (tx) => {
+ await acquirePermissionGroupOrgLock(tx, organizationId)
await tx
.delete(permissionGroupMember)
.where(eq(permissionGroupMember.permissionGroupId, id))
@@ -225,6 +371,13 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ success: true })
} catch (error) {
+ // Advisory lock wait exceeded (lock_timeout) — transient contention.
+ if (getPostgresErrorCode(error) === '55P03') {
+ return NextResponse.json(
+ { error: 'This group is being updated by another request. Please try again.' },
+ { status: 503 }
+ )
+ }
logger.error('Error deleting permission group', error)
return NextResponse.json({ error: 'Failed to delete permission group' }, { status: 500 })
}
diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts
index 2f511d8e57..3e947d06c3 100644
--- a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts
+++ b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts
@@ -1,6 +1,11 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
-import { permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
+import {
+ permissionGroup,
+ permissionGroupMember,
+ permissionGroupWorkspace,
+ user,
+} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
@@ -16,7 +21,11 @@ import {
type PermissionGroupConfig,
parsePermissionGroupConfig,
} from '@/lib/permission-groups/types'
-import { authorizeOrgAccessControl } from '@/app/api/organizations/[id]/permission-groups/utils'
+import {
+ authorizeOrgAccessControl,
+ findWorkspacesNotInOrganization,
+ getWorkspacesForGroups,
+} from '@/app/api/organizations/[id]/permission-groups/utils'
const logger = createLogger('OrganizationPermissionGroups')
@@ -42,6 +51,7 @@ export const GET = withRouteHandler(
createdAt: permissionGroup.createdAt,
updatedAt: permissionGroup.updatedAt,
isDefault: permissionGroup.isDefault,
+ appliesToAllWorkspaces: permissionGroup.appliesToAllWorkspaces,
creatorName: user.name,
creatorEmail: user.email,
})
@@ -62,11 +72,13 @@ export const GET = withRouteHandler(
.groupBy(permissionGroupMember.permissionGroupId)
: []
const countByGroupId = new Map(memberCounts.map((row) => [row.permissionGroupId, row.count]))
+ const workspacesByGroupId = await getWorkspacesForGroups(groupIds)
const groupsWithCounts = groups.map((group) => ({
...group,
config: parsePermissionGroupConfig(group.config),
memberCount: countByGroupId.get(group.id) ?? 0,
+ workspaces: workspacesByGroupId.get(group.id) ?? [],
}))
return NextResponse.json({ permissionGroups: groupsWithCounts })
@@ -93,6 +105,28 @@ export const POST = withRouteHandler(
if (!parsed.success) return parsed.response
const { name, description, config, isDefault } = parsed.data.body
+ // Resolve scope the same way the update route does: the default group is
+ // always organization-wide; otherwise an explicit `appliesToAllWorkspaces`
+ // wins, and supplying `workspaceIds` alone implies a specific scope (so
+ // those ids are never silently dropped).
+ const requestedWorkspaceIds = parsed.data.body.workspaceIds ?? []
+ const appliesToAllWorkspaces = isDefault
+ ? true
+ : parsed.data.body.appliesToAllWorkspaces !== undefined
+ ? parsed.data.body.appliesToAllWorkspaces
+ : requestedWorkspaceIds.length === 0
+ const workspaceIds = appliesToAllWorkspaces ? [] : Array.from(new Set(requestedWorkspaceIds))
+
+ if (!appliesToAllWorkspaces) {
+ const invalid = await findWorkspacesNotInOrganization(workspaceIds, organizationId)
+ if (invalid.length > 0) {
+ return NextResponse.json(
+ { error: 'One or more selected workspaces do not belong to this organization' },
+ { status: 400 }
+ )
+ }
+ }
+
const existingGroup = await db
.select({ id: permissionGroup.id })
.from(permissionGroup)
@@ -124,6 +158,7 @@ export const POST = withRouteHandler(
createdAt: now,
updatedAt: now,
isDefault: isDefault || false,
+ appliesToAllWorkspaces,
}
await db.transaction(async (tx) => {
@@ -139,12 +174,25 @@ export const POST = withRouteHandler(
)
}
await tx.insert(permissionGroup).values(newGroup)
+ if (workspaceIds.length > 0) {
+ await tx.insert(permissionGroupWorkspace).values(
+ workspaceIds.map((workspaceId) => ({
+ id: generateId(),
+ permissionGroupId: newGroup.id,
+ workspaceId,
+ organizationId,
+ createdAt: now,
+ }))
+ )
+ }
})
logger.info('Created permission group', {
permissionGroupId: newGroup.id,
organizationId,
userId: session.user.id,
+ appliesToAllWorkspaces,
+ workspaceCount: workspaceIds.length,
})
recordAudit({
@@ -156,11 +204,16 @@ export const POST = withRouteHandler(
actorEmail: session.user.email ?? undefined,
resourceName: name,
description: `Created permission group "${name}"`,
- metadata: { organizationId, isDefault: isDefault || false },
+ metadata: {
+ organizationId,
+ isDefault: isDefault || false,
+ appliesToAllWorkspaces,
+ workspaceCount: workspaceIds.length,
+ },
request: req,
})
- return NextResponse.json({ permissionGroup: newGroup }, { status: 201 })
+ return NextResponse.json({ permissionGroup: { ...newGroup, workspaceIds } }, { status: 201 })
} catch (error) {
if (getPostgresErrorCode(error) === '23505') {
const constraint = getPostgresConstraintName(error)
diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts
index dd087350e2..a0740b4048 100644
--- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts
+++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts
@@ -3,10 +3,22 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockIsOrganizationAdminOrOwner, mockIsOrganizationOnEnterprisePlan } = vi.hoisted(() => ({
- mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(),
- mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(),
-}))
+const { mockIsOrganizationAdminOrOwner, mockIsOrganizationOnEnterprisePlan, mockConflictRows } =
+ vi.hoisted(() => ({
+ mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(),
+ mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(),
+ mockConflictRows: {
+ value: [] as Array<{
+ userId: string
+ userName: string | null
+ userEmail: string | null
+ otherGroupId: string
+ otherGroupName: string
+ otherAppliesToAll: boolean
+ otherWorkspaceId: string | null
+ }>,
+ },
+ }))
vi.mock('@/lib/billing', () => ({
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
@@ -16,7 +28,37 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
isOrganizationAdminOrOwner: mockIsOrganizationAdminOrOwner,
}))
-import { authorizeOrgAccessControl } from './utils'
+vi.mock('@sim/db', () => ({
+ db: {
+ select: vi.fn(() => {
+ const chain: Record = {}
+ chain.from = vi.fn(() => chain)
+ chain.innerJoin = vi.fn(() => chain)
+ chain.leftJoin = vi.fn(() => chain)
+ // findScopeConflicts awaits the builder directly after `where`.
+ chain.where = vi.fn(() => Promise.resolve(mockConflictRows.value))
+ return chain
+ }),
+ },
+}))
+
+vi.mock('@sim/db/schema', () => ({
+ permissionGroup: {},
+ permissionGroupMember: {},
+ permissionGroupWorkspace: {},
+ user: {},
+ workspace: {},
+}))
+
+vi.mock('drizzle-orm', () => ({
+ and: vi.fn(),
+ asc: vi.fn(),
+ eq: vi.fn(),
+ inArray: vi.fn(),
+ ne: vi.fn(),
+}))
+
+import { authorizeOrgAccessControl, findScopeConflicts } from './utils'
describe('authorizeOrgAccessControl', () => {
beforeEach(() => {
@@ -57,3 +99,101 @@ describe('authorizeOrgAccessControl', () => {
expect(response).toBeNull()
})
})
+
+describe('findScopeConflicts', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockConflictRows.value = []
+ })
+
+ const baseParams = {
+ organizationId: 'org-1',
+ excludeGroupId: 'group-1',
+ candidateUserIds: ['user-1'],
+ }
+
+ /** Build a conflict-query row with sensible defaults. */
+ const row = (overrides: { otherAppliesToAll: boolean; otherWorkspaceId: string | null }) => ({
+ userId: 'user-1',
+ userName: 'User One',
+ userEmail: 'user-1@example.com',
+ otherGroupId: 'group-2',
+ otherGroupName: 'Marketing',
+ ...overrides,
+ })
+
+ it('returns no conflicts when there are no candidate users', async () => {
+ mockConflictRows.value = [row({ otherAppliesToAll: true, otherWorkspaceId: null })]
+
+ const conflicts = await findScopeConflicts({
+ ...baseParams,
+ appliesToAllWorkspaces: true,
+ workspaceIds: [],
+ candidateUserIds: [],
+ })
+
+ expect(conflicts).toEqual([])
+ })
+
+ it('flags an all-workspaces target when the user is in another all-workspaces group', async () => {
+ mockConflictRows.value = [row({ otherAppliesToAll: true, otherWorkspaceId: null })]
+
+ const conflicts = await findScopeConflicts({
+ ...baseParams,
+ appliesToAllWorkspaces: true,
+ workspaceIds: [],
+ })
+
+ expect(conflicts.map((c) => c.userId)).toEqual(['user-1'])
+ expect(conflicts[0].conflictingGroupName).toBe('Marketing')
+ })
+
+ it('allows an all-workspaces target when the user is only in a specific group', async () => {
+ mockConflictRows.value = [row({ otherAppliesToAll: false, otherWorkspaceId: 'ws-1' })]
+
+ const conflicts = await findScopeConflicts({
+ ...baseParams,
+ appliesToAllWorkspaces: true,
+ workspaceIds: [],
+ })
+
+ expect(conflicts).toEqual([])
+ })
+
+ it('flags a specific target that shares a workspace with another specific group', async () => {
+ mockConflictRows.value = [row({ otherAppliesToAll: false, otherWorkspaceId: 'ws-1' })]
+
+ const conflicts = await findScopeConflicts({
+ ...baseParams,
+ appliesToAllWorkspaces: false,
+ workspaceIds: ['ws-1', 'ws-2'],
+ })
+
+ expect(conflicts.map((c) => c.userId)).toEqual(['user-1'])
+ expect(conflicts[0].conflictingGroupName).toBe('Marketing')
+ })
+
+ it('allows a specific target whose workspaces are disjoint from the user other specific group', async () => {
+ mockConflictRows.value = [row({ otherAppliesToAll: false, otherWorkspaceId: 'ws-3' })]
+
+ const conflicts = await findScopeConflicts({
+ ...baseParams,
+ appliesToAllWorkspaces: false,
+ workspaceIds: ['ws-1', 'ws-2'],
+ })
+
+ expect(conflicts).toEqual([])
+ })
+
+ it('allows a specific target when the user is only in an all-workspaces group', async () => {
+ mockConflictRows.value = [row({ otherAppliesToAll: true, otherWorkspaceId: null })]
+
+ const conflicts = await findScopeConflicts({
+ ...baseParams,
+ appliesToAllWorkspaces: false,
+ workspaceIds: ['ws-1'],
+ })
+
+ expect(conflicts).toEqual([])
+ })
+})
diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts
index 0ef34e98fc..dfc0332e33 100644
--- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts
+++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts
@@ -1,10 +1,23 @@
import { db } from '@sim/db'
-import { permissionGroup } from '@sim/db/schema'
-import { and, eq } from 'drizzle-orm'
+import {
+ permissionGroup,
+ permissionGroupMember,
+ permissionGroupWorkspace,
+ user,
+ workspace,
+} from '@sim/db/schema'
+import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
+import type { DbOrTx } from '@/lib/db/types'
import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils'
+/** A workspace reference (id + display name). */
+export interface WorkspaceRef {
+ id: string
+ name: string
+}
+
/**
* Authorize an organization-scoped access-control management request. The caller
* must be an organization owner/admin and the organization must be entitled to
@@ -28,9 +41,43 @@ export async function authorizeOrgAccessControl(
return null
}
+const PERMISSION_GROUP_LOCK_TIMEOUT_MS = 5_000
+
+/**
+ * Serialize all permission-group membership and scope writes for an organization
+ * via a transaction-scoped Postgres advisory lock. Callers acquire it at the top
+ * of the transaction that both checks (`findScopeConflicts`) and mutates, so a
+ * concurrent member add or scope change can't commit in the check-to-write
+ * window and leave a user governed by two groups on the same workspace.
+ *
+ * The invariant (one effective group per user per workspace) spans users and
+ * groups in ways a unique constraint can't express, and these are low-frequency
+ * admin writes, so a single org-scoped lock is simpler and more obviously
+ * correct than fine-grained per-user/per-group locks with acquire-ordering.
+ *
+ * `pg_advisory_xact_lock` auto-releases at transaction end (safe on pooled
+ * connections), and `lock_timeout` bounds the wait (raising SQLSTATE 55P03)
+ * instead of hanging if a holder is stuck.
+ */
+export async function acquirePermissionGroupOrgLock(
+ tx: DbOrTx,
+ organizationId: string
+): Promise {
+ await tx.execute(
+ sql`select set_config('lock_timeout', ${`${PERMISSION_GROUP_LOCK_TIMEOUT_MS}ms`}, true)`
+ )
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtextextended(${`permission_group:${organizationId}`}, 0))`
+ )
+}
+
/** Load a permission group only if it belongs to the given organization. */
-export async function loadGroupInOrganization(groupId: string, organizationId: string) {
- const [group] = await db
+export async function loadGroupInOrganization(
+ groupId: string,
+ organizationId: string,
+ executor: DbOrTx = db
+) {
+ const [group] = await executor
.select({
id: permissionGroup.id,
organizationId: permissionGroup.organizationId,
@@ -41,6 +88,7 @@ export async function loadGroupInOrganization(groupId: string, organizationId: s
createdAt: permissionGroup.createdAt,
updatedAt: permissionGroup.updatedAt,
isDefault: permissionGroup.isDefault,
+ appliesToAllWorkspaces: permissionGroup.appliesToAllWorkspaces,
})
.from(permissionGroup)
.where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId)))
@@ -48,3 +96,165 @@ export async function loadGroupInOrganization(groupId: string, organizationId: s
return group ?? null
}
+
+/** The workspaces ({id, name}) a specific-scope group targets. */
+export async function getGroupWorkspaces(
+ groupId: string,
+ executor: DbOrTx = db
+): Promise {
+ return executor
+ .select({ id: workspace.id, name: workspace.name })
+ .from(permissionGroupWorkspace)
+ .innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id))
+ .where(eq(permissionGroupWorkspace.permissionGroupId, groupId))
+ .orderBy(asc(workspace.name))
+}
+
+/** Batched map of `groupId -> targeted workspaces` for a list of groups. */
+export async function getWorkspacesForGroups(
+ groupIds: string[]
+): Promise