mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-24 01:23:02 +08:00
improvement(permissions): permission groups scoped to organization level (#5035)
* improvement(permissions): permission groups scoped to organization level * chore(db): drop 0235 permission-groups migration to regenerate after staging merge * merge latest staging
This commit is contained in:
@@ -7,15 +7,17 @@ import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
Access Control lets workspace admins define permission groups that restrict what each set of workspace 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 a single workspace: a user can be in different groups (or no group) in different workspaces. Restrictions are enforced both in the workflow executor and in Mothership, based on 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 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.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Access control is built around **permission groups**. Each group belongs to a specific workspace 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 workspace**, but can belong to different groups in different workspaces.
|
||||
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.
|
||||
|
||||
When a user runs a workflow or uses Mothership, Sim reads their group's configuration and applies it:
|
||||
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.
|
||||
|
||||
When a user runs a workflow or uses Mothership, Sim reads the resolved group's configuration and applies it:
|
||||
|
||||
- **In the executor:** If a workflow uses a disallowed block type or model provider, execution halts immediately with an error. This applies to both manual runs and scheduled or API-triggered deployments.
|
||||
- **In Mothership:** Disallowed blocks are filtered out of the block list so they cannot be added to a workflow. Disallowed tool types (MCP, custom tools, skills) are skipped if Mothership attempts to use them.
|
||||
@@ -26,13 +28,13 @@ When a user runs a workflow or uses Mothership, Sim reads their group's configur
|
||||
|
||||
### 1. Open Access Control settings
|
||||
|
||||
Go to **Settings → Enterprise → Access Control** in the workspace you want to manage. Each workspace has its own set of permission groups.
|
||||
Go to **Settings → Enterprise → Access Control** from any workspace in your organization. Permission groups are defined once at the organization level and apply to every workspace under it. Only organization owners and admins can manage them.
|
||||
|
||||
<Image src="/static/enterprise/access-control-groups.png" alt="Access Control settings showing a list of permission groups: Contractors, Sales, Engineering, and Marketing, each with Details and Delete actions" width={900} height={500} />
|
||||
|
||||
### 2. Create a permission group
|
||||
|
||||
Click **+ Create** and enter a name (required) and optional description. You can also enable **Auto-add new members** — when active, any new member who joins this workspace is automatically added to this group. Only one group per workspace can have this setting enabled at a time.
|
||||
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.
|
||||
|
||||
### 3. Configure permissions
|
||||
|
||||
@@ -128,9 +130,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. Only users who already have workspace-level access can be added. A user can only belong to one group per workspace — adding a user to a new group within the same workspace removes them from their current group for that workspace.
|
||||
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.
|
||||
|
||||
External workspace members are treated like other workspace members for access-control purposes. They can be assigned to permission groups in any workspace they have access to, but they do not become organization members or appear in the organization roster.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -157,18 +159,18 @@ 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 workspace**, but may be in different groups across different workspaces.
|
||||
- Moving a user to a new group within a workspace automatically removes them from their previous group in that workspace.
|
||||
- Users not assigned to any group in a workspace have no restrictions applied in that workspace (all blocks, providers, and features are available to them there).
|
||||
- If **Auto-add new members** is enabled on a group, new members of that workspace are automatically placed in the group. Only one group per workspace can have this setting active.
|
||||
- External workspace members follow the same per-workspace permission group rules as internal members.
|
||||
- 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.
|
||||
- Personal or grandfathered workspaces that do not belong to an organization have no permission groups.
|
||||
|
||||
---
|
||||
|
||||
<FAQ items={[
|
||||
{
|
||||
question: "Who can create and manage permission groups?",
|
||||
answer: "Any workspace admin on an Enterprise-entitled workspace can create, edit, and delete permission groups for that workspace. The workspace's billed account must be on the Enterprise plan."
|
||||
answer: "Any organization owner or admin on an Enterprise-entitled organization can create, edit, and delete permission groups. The organization must be on the Enterprise plan."
|
||||
},
|
||||
{
|
||||
question: "What happens to a workflow that was built before a block was restricted?",
|
||||
@@ -176,23 +178,23 @@ 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 workspace, but can belong to different groups in different workspaces. Adding a user to a new group within the same workspace automatically removes them from their previous group in that workspace."
|
||||
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."
|
||||
},
|
||||
{
|
||||
question: "What does a user see if they have no permission group assigned in a workspace?",
|
||||
answer: "Users with no group in a given workspace have no restrictions in that workspace. All blocks, model providers, and platform features are fully available to them there. Restrictions only apply in the specific workspaces where they are assigned to a group."
|
||||
question: "What governs a user who has no permission group assigned?",
|
||||
answer: "If the organization has a default group, it governs every member (and external workspace member) who is not explicitly assigned to another group. If no default group is set, those users have no restrictions — all blocks, model providers, and platform features are available to them."
|
||||
},
|
||||
{
|
||||
question: "Does Mothership respect the same restrictions as the executor?",
|
||||
answer: "Yes. Mothership reads the user's permission group for the active workspace before suggesting blocks or tools. Disallowed blocks are filtered out of the block picker, and disallowed tool types are skipped during workflow generation."
|
||||
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 restrict access to specific workflows or workspaces?",
|
||||
answer: "Access Control operates at the feature and block level within a workspace. To restrict who can access the workspace itself, use workspace invitations and permissions. To apply different restrictions to different workflows, put them in different workspaces with distinct permission groups."
|
||||
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: "What is Auto-add new members?",
|
||||
answer: "When a group has Auto-add new members enabled, any new member who joins the workspace is automatically added to that group. Only one group per workspace can have this setting enabled at a time."
|
||||
question: "What is the default group?",
|
||||
answer: "The default group is the single group per organization that governs everyone not explicitly assigned to another group, including external workspace members operating in the organization's workspaces. Only one group per organization can be the default at a time. If no default is set, ungrouped users have no restrictions."
|
||||
}
|
||||
]} />
|
||||
|
||||
|
||||
@@ -191,9 +191,9 @@ import { FAQ } from '@/components/ui/faq'
|
||||
<FAQ items={[
|
||||
{ question: "What is the difference between organization roles and workspace permissions?", answer: "Organization roles (Owner, Admin, or Member) control who can manage the organization itself, including inviting people, creating shared workspaces, and handling billing. Workspace permissions (Read, Write, Admin) control what a user can do within a specific workspace, such as viewing, editing, or managing workflows. Internal members need both an organization role and a workspace permission to work within a shared workspace. External workspace members do not have an organization role in your org; they only have workspace-level access." },
|
||||
{ question: "What happens to my shared workspaces if I cancel or downgrade my Team plan?", answer: "Existing shared workspaces remain accessible to current members, but new invitations are disabled until you upgrade back to a Team or Enterprise plan. No workspaces or members are deleted — the organization is simply dormant until billing is re-enabled." },
|
||||
{ question: "Can I restrict which integrations or model providers a team member can use?", answer: "Yes, on Enterprise-entitled workspaces. Any workspace admin can create permission groups with fine-grained controls, including restricting allowed integrations and allowed model providers to specific lists. You can also disable access to MCP tools, custom tools, skills, and various platform features like the knowledge base, API keys, or Copilot on a per-group basis. Permission groups are scoped per workspace — a user can belong to different groups in different workspaces." },
|
||||
{ question: "Can I restrict which integrations or model providers a team member can use?", answer: "Yes, on Enterprise-entitled organizations. Any organization owner or admin can create permission groups with fine-grained controls, including restricting allowed integrations and allowed model providers to specific lists. You can also disable access to MCP tools, custom tools, skills, and various platform features like the knowledge base, API keys, or Copilot on a per-group basis. Permission groups are scoped to the organization and apply to every workspace under it — a user belongs to at most one group per organization." },
|
||||
{ question: "What happens when a personal environment variable has the same name as a workspace variable?", answer: "The personal environment variable takes priority. When a workflow runs, if both a personal and workspace variable share the same name, the personal value is used. This allows individual users to override shared workspace configuration when needed." },
|
||||
{ question: "Can an Admin remove the workspace owner?", answer: "No. The workspace owner cannot be removed from the workspace by anyone. Only the workspace owner can delete the workspace or transfer ownership to another user. Admins can do everything else, including inviting and removing other users and managing workspace settings." },
|
||||
{ question: "What are permission groups and how do they work?", answer: "Permission groups are an Enterprise access control feature that lets workspace admins define granular restrictions beyond the standard Read/Write/Admin roles. Groups are scoped to a single workspace: each user can be in at most one group per workspace, and a user can be in different groups across different workspaces. A permission group can hide UI sections (like trace spans, knowledge base, API keys, or deployment options), disable features (MCP tools, custom tools, skills, invitations), and restrict which integrations and model providers its members can access. Members can be assigned manually, and new members can be auto-added on join. Execution-time enforcement is based on the workflow's workspace, not the user's current UI context." },
|
||||
{ question: "What are permission groups and how do they work?", answer: "Permission groups are an Enterprise access control feature that lets organization owners and admins define granular restrictions beyond the standard Read/Write/Admin roles. Groups are scoped to the organization and apply to every workspace under it: each user belongs to at most one group per organization. A permission group can hide UI sections (like trace spans, knowledge base, API keys, or deployment options), disable features (MCP tools, custom tools, skills, invitations), and restrict which integrations and model providers its members can access. Members are assigned manually, and an organization can designate one group as the default that governs everyone not explicitly assigned — including external workspace members. Execution-time enforcement is based on the organization that owns the workflow's workspace, not the user's current UI context." },
|
||||
{ question: "How should I set up permissions for a new team member?", answer: "Start with the lowest permission level they need. Invite teammates to the organization as Members, then add them to the relevant workspace with Read permission if they only need visibility, Write if they need to create and run workflows, or Admin if they need to manage the workspace and its users. For clients, partners, or users who already belong to another Sim organization, use external workspace access so they can collaborate without joining your organization or consuming a seat." },
|
||||
]} />
|
||||
+26
-54
@@ -1,6 +1,6 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { permissionGroup, permissionGroupMember, permissions } from '@sim/db/schema'
|
||||
import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
@@ -9,26 +9,14 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { bulkAddPermissionGroupMembersContract } from '@/lib/api/contracts/permission-groups'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/types'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
authorizeOrgAccessControl,
|
||||
loadGroupInOrganization,
|
||||
} from '@/app/api/organizations/[id]/permission-groups/utils'
|
||||
|
||||
const logger = createLogger('WorkspacePermissionGroupBulkMembers')
|
||||
|
||||
async function loadGroupInWorkspace(groupId: string, workspaceId: string) {
|
||||
const [group] = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
workspaceId: permissionGroup.workspaceId,
|
||||
name: permissionGroup.name,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.workspaceId, workspaceId)))
|
||||
.limit(1)
|
||||
|
||||
return group ?? null
|
||||
}
|
||||
const logger = createLogger('OrganizationPermissionGroupBulkMembers')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string; groupId: string }> }) => {
|
||||
@@ -37,23 +25,13 @@ export const POST = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: workspaceId, groupId: id } = await context.params
|
||||
const { id: organizationId, groupId: id } = await context.params
|
||||
|
||||
try {
|
||||
const isWorkspaceAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
|
||||
if (!isWorkspaceAdmin) {
|
||||
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
|
||||
}
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const entitled = await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
if (!entitled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const group = await loadGroupInWorkspace(id, workspaceId)
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
@@ -63,30 +41,24 @@ export const POST = withRouteHandler(
|
||||
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { userIds, addAllWorkspaceMembers } = parsed.data.body
|
||||
const { userIds, addAllOrganizationMembers } = parsed.data.body
|
||||
|
||||
let targetUserIds: string[] = []
|
||||
|
||||
if (addAllWorkspaceMembers) {
|
||||
const workspaceMembers = await db
|
||||
.select({ userId: permissions.userId })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))
|
||||
)
|
||||
if (addAllOrganizationMembers) {
|
||||
const orgMembers = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
targetUserIds = Array.from(new Set(workspaceMembers.map((m) => m.userId)))
|
||||
targetUserIds = Array.from(new Set(orgMembers.map((m) => m.userId)))
|
||||
} else if (userIds && userIds.length > 0) {
|
||||
const uniqueUserIds = Array.from(new Set(userIds))
|
||||
const validMembers = await db
|
||||
.select({ userId: permissions.userId })
|
||||
.from(permissions)
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId),
|
||||
inArray(permissions.userId, uniqueUserIds)
|
||||
)
|
||||
and(eq(member.organizationId, organizationId), inArray(member.userId, uniqueUserIds))
|
||||
)
|
||||
|
||||
targetUserIds = Array.from(new Set(validMembers.map((m) => m.userId)))
|
||||
@@ -110,7 +82,7 @@ export const POST = withRouteHandler(
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.workspaceId, workspaceId),
|
||||
eq(permissionGroup.organizationId, organizationId),
|
||||
inArray(permissionGroupMember.userId, targetUserIds)
|
||||
)
|
||||
)
|
||||
@@ -140,7 +112,7 @@ export const POST = withRouteHandler(
|
||||
const newMembers = usersToAdd.map((userId) => ({
|
||||
id: generateId(),
|
||||
permissionGroupId: id,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
userId,
|
||||
assignedBy: session.user.id,
|
||||
assignedAt: new Date(),
|
||||
@@ -157,14 +129,13 @@ export const POST = withRouteHandler(
|
||||
|
||||
logger.info('Bulk added members to permission group', {
|
||||
permissionGroupId: id,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
addedCount: addedUserIds.length,
|
||||
movedCount,
|
||||
assignedBy: session.user.id,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
@@ -174,6 +145,7 @@ export const POST = withRouteHandler(
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
description: `Bulk added ${addedUserIds.length} member(s) to permission group "${group.name}"`,
|
||||
metadata: {
|
||||
organizationId,
|
||||
permissionGroupId: id,
|
||||
addedUserIds,
|
||||
movedCount,
|
||||
@@ -186,13 +158,13 @@ export const POST = withRouteHandler(
|
||||
if (getPostgresErrorCode(error) === '23505') {
|
||||
const constraint = getPostgresConstraintName(error)
|
||||
if (
|
||||
constraint === PERMISSION_GROUP_MEMBER_CONSTRAINTS.workspaceUser ||
|
||||
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 workspace. Please refresh and try again.',
|
||||
'One or more users were concurrently added to a group in this organization. Please refresh and try again.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
+34
-91
@@ -1,6 +1,6 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { permissionGroup, permissionGroupMember, permissions, user } from '@sim/db/schema'
|
||||
import { permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
@@ -9,26 +9,15 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { addPermissionGroupMemberContract } from '@/lib/api/contracts/permission-groups'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/types'
|
||||
import { checkWorkspaceAccess, hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { isOrganizationMember } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
authorizeOrgAccessControl,
|
||||
loadGroupInOrganization,
|
||||
} from '@/app/api/organizations/[id]/permission-groups/utils'
|
||||
|
||||
const logger = createLogger('WorkspacePermissionGroupMembers')
|
||||
|
||||
async function loadGroupInWorkspace(groupId: string, workspaceId: string) {
|
||||
const [group] = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
name: permissionGroup.name,
|
||||
workspaceId: permissionGroup.workspaceId,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.workspaceId, workspaceId)))
|
||||
.limit(1)
|
||||
|
||||
return group ?? null
|
||||
}
|
||||
const logger = createLogger('OrganizationPermissionGroupMembers')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (_req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => {
|
||||
@@ -37,25 +26,12 @@ export const GET = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: workspaceId, groupId: id } = await params
|
||||
const { id: organizationId, groupId: id } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
|
||||
if (!access.exists) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
if (!access.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const entitled = await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
if (!entitled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const group = await loadGroupInWorkspace(id, workspaceId)
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
@@ -84,23 +60,13 @@ export const POST = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: workspaceId, groupId: id } = await context.params
|
||||
const { id: organizationId, groupId: id } = await context.params
|
||||
|
||||
try {
|
||||
const isWorkspaceAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
|
||||
if (!isWorkspaceAdmin) {
|
||||
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
|
||||
}
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const entitled = await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
if (!entitled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const group = await loadGroupInWorkspace(id, workspaceId)
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
@@ -112,28 +78,16 @@ export const POST = withRouteHandler(
|
||||
if (!parsed.success) return parsed.response
|
||||
const { userId } = parsed.data.body
|
||||
|
||||
const [workspaceMember] = await db
|
||||
.select({ email: user.email })
|
||||
.from(permissions)
|
||||
.innerJoin(user, eq(permissions.userId, user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceMember) {
|
||||
const isMember = await isOrganizationMember(userId, organizationId)
|
||||
if (!isMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User does not have access to this workspace' },
|
||||
{ error: 'User is not a member of this organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const newMember = await db.transaction(async (tx) => {
|
||||
const existingInWorkspace = await tx
|
||||
const existingInOrganization = await tx
|
||||
.select({
|
||||
id: permissionGroupMember.id,
|
||||
permissionGroupId: permissionGroupMember.permissionGroupId,
|
||||
@@ -146,19 +100,19 @@ export const POST = withRouteHandler(
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.userId, userId),
|
||||
eq(permissionGroup.workspaceId, workspaceId)
|
||||
eq(permissionGroup.organizationId, organizationId)
|
||||
)
|
||||
)
|
||||
|
||||
if (existingInWorkspace.some((row) => row.permissionGroupId === id)) {
|
||||
if (existingInOrganization.some((row) => row.permissionGroupId === id)) {
|
||||
throw new Error('ALREADY_IN_GROUP')
|
||||
}
|
||||
|
||||
if (existingInWorkspace.length > 0) {
|
||||
if (existingInOrganization.length > 0) {
|
||||
await tx.delete(permissionGroupMember).where(
|
||||
inArray(
|
||||
permissionGroupMember.id,
|
||||
existingInWorkspace.map((row) => row.id)
|
||||
existingInOrganization.map((row) => row.id)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -166,7 +120,7 @@ export const POST = withRouteHandler(
|
||||
const memberData = {
|
||||
id: generateId(),
|
||||
permissionGroupId: id,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
userId,
|
||||
assignedBy: session.user.id,
|
||||
assignedAt: new Date(),
|
||||
@@ -178,13 +132,12 @@ export const POST = withRouteHandler(
|
||||
|
||||
logger.info('Added member to permission group', {
|
||||
permissionGroupId: id,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
userId,
|
||||
assignedBy: session.user.id,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
@@ -194,8 +147,8 @@ export const POST = withRouteHandler(
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
description: `Added member ${userId} to permission group "${group.name}"`,
|
||||
metadata: {
|
||||
organizationId,
|
||||
targetUserId: userId,
|
||||
targetEmail: workspaceMember.email ?? undefined,
|
||||
permissionGroupId: id,
|
||||
},
|
||||
request: req,
|
||||
@@ -211,11 +164,11 @@ export const POST = withRouteHandler(
|
||||
}
|
||||
if (getPostgresErrorCode(error) === '23505') {
|
||||
const constraint = getPostgresConstraintName(error)
|
||||
if (constraint === PERMISSION_GROUP_MEMBER_CONSTRAINTS.workspaceUser) {
|
||||
if (constraint === PERMISSION_GROUP_MEMBER_CONSTRAINTS.organizationUser) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'User was concurrently added to another group in this workspace. Please refresh and try again.',
|
||||
'User was concurrently added to another group in this organization. Please refresh and try again.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
@@ -240,7 +193,7 @@ export const DELETE = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: workspaceId, groupId: id } = await params
|
||||
const { id: organizationId, groupId: id } = await params
|
||||
const { searchParams } = new URL(req.url)
|
||||
const memberId = searchParams.get('memberId')
|
||||
|
||||
@@ -249,20 +202,10 @@ export const DELETE = withRouteHandler(
|
||||
}
|
||||
|
||||
try {
|
||||
const isWorkspaceAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
|
||||
if (!isWorkspaceAdmin) {
|
||||
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
|
||||
}
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const entitled = await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
if (!entitled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const group = await loadGroupInWorkspace(id, workspaceId)
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
@@ -291,13 +234,12 @@ export const DELETE = withRouteHandler(
|
||||
|
||||
logger.info('Removed member from permission group', {
|
||||
permissionGroupId: id,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
memberId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.PERMISSION_GROUP_MEMBER_REMOVED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
@@ -307,6 +249,7 @@ export const DELETE = withRouteHandler(
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
description: `Removed member ${memberToRemove.userId} from permission group "${group.name}"`,
|
||||
metadata: {
|
||||
organizationId,
|
||||
targetUserId: memberToRemove.userId,
|
||||
targetEmail: memberToRemove.email ?? undefined,
|
||||
memberId,
|
||||
+46
-82
@@ -2,40 +2,24 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { permissionGroup, permissionGroupMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updatePermissionGroupContract } from '@/lib/api/contracts/permission-groups'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
PERMISSION_GROUP_CONSTRAINTS,
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
import { checkWorkspaceAccess, hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
authorizeOrgAccessControl,
|
||||
loadGroupInOrganization,
|
||||
} from '@/app/api/organizations/[id]/permission-groups/utils'
|
||||
|
||||
const logger = createLogger('WorkspacePermissionGroup')
|
||||
|
||||
async function loadGroupInWorkspace(groupId: string, workspaceId: string) {
|
||||
const [group] = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
workspaceId: permissionGroup.workspaceId,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
config: permissionGroup.config,
|
||||
createdBy: permissionGroup.createdBy,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
autoAddNewMembers: permissionGroup.autoAddNewMembers,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.workspaceId, workspaceId)))
|
||||
.limit(1)
|
||||
|
||||
return group ?? null
|
||||
}
|
||||
const logger = createLogger('OrganizationPermissionGroup')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (_req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => {
|
||||
@@ -44,26 +28,12 @@ export const GET = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: workspaceId, groupId: id } = await params
|
||||
const { id: organizationId, groupId: id } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
|
||||
if (!access.exists) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
if (!access.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const entitled = await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
if (!entitled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const group = await loadGroupInWorkspace(id, workspaceId)
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
@@ -84,23 +54,13 @@ export const PUT = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: workspaceId, groupId: id } = await context.params
|
||||
const { id: organizationId, groupId: id } = await context.params
|
||||
|
||||
try {
|
||||
const isWorkspaceAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
|
||||
if (!isWorkspaceAdmin) {
|
||||
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
|
||||
}
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const entitled = await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
if (!entitled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const group = await loadGroupInWorkspace(id, workspaceId)
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
@@ -118,7 +78,7 @@ export const PUT = withRouteHandler(
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.workspaceId, workspaceId),
|
||||
eq(permissionGroup.organizationId, organizationId),
|
||||
eq(permissionGroup.name, updates.name)
|
||||
)
|
||||
)
|
||||
@@ -140,14 +100,14 @@ export const PUT = withRouteHandler(
|
||||
const now = new Date()
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (updates.autoAddNewMembers === true) {
|
||||
if (updates.isDefault === true) {
|
||||
await tx
|
||||
.update(permissionGroup)
|
||||
.set({ autoAddNewMembers: false, updatedAt: now })
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.workspaceId, workspaceId),
|
||||
eq(permissionGroup.autoAddNewMembers, true)
|
||||
eq(permissionGroup.organizationId, organizationId),
|
||||
eq(permissionGroup.isDefault, true)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -157,9 +117,7 @@ export const PUT = withRouteHandler(
|
||||
.set({
|
||||
...(updates.name !== undefined && { name: updates.name }),
|
||||
...(updates.description !== undefined && { description: updates.description }),
|
||||
...(updates.autoAddNewMembers !== undefined && {
|
||||
autoAddNewMembers: updates.autoAddNewMembers,
|
||||
}),
|
||||
...(updates.isDefault !== undefined && { isDefault: updates.isDefault }),
|
||||
config: newConfig,
|
||||
updatedAt: now,
|
||||
})
|
||||
@@ -173,7 +131,6 @@ export const PUT = withRouteHandler(
|
||||
.limit(1)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.PERMISSION_GROUP_UPDATED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
@@ -183,7 +140,7 @@ export const PUT = withRouteHandler(
|
||||
resourceName: updated.name,
|
||||
description: `Updated permission group "${updated.name}"`,
|
||||
metadata: {
|
||||
workspaceId,
|
||||
organizationId,
|
||||
updatedFields: Object.keys(updates).filter(
|
||||
(k) => updates[k as keyof typeof updates] !== undefined
|
||||
),
|
||||
@@ -198,6 +155,24 @@ export const PUT = withRouteHandler(
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (getPostgresErrorCode(error) === '23505') {
|
||||
const constraint = getPostgresConstraintName(error)
|
||||
if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A permission group with this name already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationDefault) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Another group was concurrently set as the default. Please refresh and try again.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
logger.error('Error updating permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to update permission group' }, { status: 500 })
|
||||
}
|
||||
@@ -211,23 +186,13 @@ export const DELETE = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: workspaceId, groupId: id } = await params
|
||||
const { id: organizationId, groupId: id } = await params
|
||||
|
||||
try {
|
||||
const isWorkspaceAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
|
||||
if (!isWorkspaceAdmin) {
|
||||
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
|
||||
}
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const entitled = await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
if (!entitled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const group = await loadGroupInWorkspace(id, workspaceId)
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
@@ -241,12 +206,11 @@ export const DELETE = withRouteHandler(
|
||||
|
||||
logger.info('Deleted permission group', {
|
||||
permissionGroupId: id,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.PERMISSION_GROUP_DELETED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
@@ -255,7 +219,7 @@ export const DELETE = withRouteHandler(
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: group.name,
|
||||
description: `Deleted permission group "${group.name}"`,
|
||||
metadata: { workspaceId },
|
||||
metadata: { organizationId },
|
||||
request: req,
|
||||
})
|
||||
|
||||
+59
-58
@@ -2,22 +2,23 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { permissionGroup, 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, count, desc, eq } from 'drizzle-orm'
|
||||
import { and, count, desc, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createPermissionGroupContract } from '@/lib/api/contracts/permission-groups'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
PERMISSION_GROUP_CONSTRAINTS,
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
import { checkWorkspaceAccess, hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { authorizeOrgAccessControl } from '@/app/api/organizations/[id]/permission-groups/utils'
|
||||
|
||||
const logger = createLogger('WorkspacePermissionGroups')
|
||||
const logger = createLogger('OrganizationPermissionGroups')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (_req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
@@ -26,23 +27,10 @@ export const GET = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: workspaceId } = await params
|
||||
const { id: organizationId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
|
||||
if (!access.exists) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
if (!access.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const entitled = await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
if (!entitled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const groups = await db
|
||||
.select({
|
||||
@@ -53,29 +41,33 @@ export const GET = withRouteHandler(
|
||||
createdBy: permissionGroup.createdBy,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
autoAddNewMembers: permissionGroup.autoAddNewMembers,
|
||||
isDefault: permissionGroup.isDefault,
|
||||
creatorName: user.name,
|
||||
creatorEmail: user.email,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.leftJoin(user, eq(permissionGroup.createdBy, user.id))
|
||||
.where(eq(permissionGroup.workspaceId, workspaceId))
|
||||
.where(eq(permissionGroup.organizationId, organizationId))
|
||||
.orderBy(desc(permissionGroup.createdAt))
|
||||
|
||||
const groupsWithCounts = await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const [memberCount] = await db
|
||||
.select({ count: count() })
|
||||
const groupIds = groups.map((group) => group.id)
|
||||
const memberCounts = groupIds.length
|
||||
? await db
|
||||
.select({
|
||||
permissionGroupId: permissionGroupMember.permissionGroupId,
|
||||
count: count(),
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.where(eq(permissionGroupMember.permissionGroupId, group.id))
|
||||
.where(inArray(permissionGroupMember.permissionGroupId, groupIds))
|
||||
.groupBy(permissionGroupMember.permissionGroupId)
|
||||
: []
|
||||
const countByGroupId = new Map(memberCounts.map((row) => [row.permissionGroupId, row.count]))
|
||||
|
||||
return {
|
||||
...group,
|
||||
config: parsePermissionGroupConfig(group.config),
|
||||
memberCount: memberCount?.count ?? 0,
|
||||
}
|
||||
})
|
||||
)
|
||||
const groupsWithCounts = groups.map((group) => ({
|
||||
...group,
|
||||
config: parsePermissionGroupConfig(group.config),
|
||||
memberCount: countByGroupId.get(group.id) ?? 0,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ permissionGroups: groupsWithCounts })
|
||||
}
|
||||
@@ -88,33 +80,25 @@ export const POST = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: workspaceId } = await context.params
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
try {
|
||||
const isWorkspaceAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
|
||||
if (!isWorkspaceAdmin) {
|
||||
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const entitled = await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
if (!entitled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const parsed = await parseRequest(createPermissionGroupContract, req, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { name, description, config, autoAddNewMembers } = parsed.data.body
|
||||
const { name, description, config, isDefault } = parsed.data.body
|
||||
|
||||
const existingGroup = await db
|
||||
.select({ id: permissionGroup.id })
|
||||
.from(permissionGroup)
|
||||
.where(and(eq(permissionGroup.workspaceId, workspaceId), eq(permissionGroup.name, name)))
|
||||
.where(
|
||||
and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.name, name))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingGroup.length > 0) {
|
||||
@@ -132,25 +116,25 @@ export const POST = withRouteHandler(
|
||||
const now = new Date()
|
||||
const newGroup = {
|
||||
id: generateId(),
|
||||
workspaceId,
|
||||
organizationId,
|
||||
name,
|
||||
description: description || null,
|
||||
config: groupConfig,
|
||||
createdBy: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
autoAddNewMembers: autoAddNewMembers || false,
|
||||
isDefault: isDefault || false,
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (autoAddNewMembers) {
|
||||
if (isDefault) {
|
||||
await tx
|
||||
.update(permissionGroup)
|
||||
.set({ autoAddNewMembers: false, updatedAt: now })
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.workspaceId, workspaceId),
|
||||
eq(permissionGroup.autoAddNewMembers, true)
|
||||
eq(permissionGroup.organizationId, organizationId),
|
||||
eq(permissionGroup.isDefault, true)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -159,12 +143,11 @@ export const POST = withRouteHandler(
|
||||
|
||||
logger.info('Created permission group', {
|
||||
permissionGroupId: newGroup.id,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.PERMISSION_GROUP_CREATED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
@@ -173,12 +156,30 @@ export const POST = withRouteHandler(
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: name,
|
||||
description: `Created permission group "${name}"`,
|
||||
metadata: { workspaceId, autoAddNewMembers: autoAddNewMembers || false },
|
||||
metadata: { organizationId, isDefault: isDefault || false },
|
||||
request: req,
|
||||
})
|
||||
|
||||
return NextResponse.json({ permissionGroup: newGroup }, { status: 201 })
|
||||
} catch (error) {
|
||||
if (getPostgresErrorCode(error) === '23505') {
|
||||
const constraint = getPostgresConstraintName(error)
|
||||
if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A permission group with this name already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationDefault) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Another group was concurrently set as the default. Please refresh and try again.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
logger.error('Error creating permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to create permission group' }, { status: 500 })
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockIsOrganizationAdminOrOwner, mockIsOrganizationOnEnterprisePlan } = vi.hoisted(() => ({
|
||||
mockIsOrganizationAdminOrOwner: vi.fn<() => Promise<boolean>>(),
|
||||
mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise<boolean>>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
isOrganizationAdminOrOwner: mockIsOrganizationAdminOrOwner,
|
||||
}))
|
||||
|
||||
import { authorizeOrgAccessControl } from './utils'
|
||||
|
||||
describe('authorizeOrgAccessControl', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns a 403 when the user is not an organization admin/owner', async () => {
|
||||
mockIsOrganizationAdminOrOwner.mockResolvedValue(false)
|
||||
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
|
||||
|
||||
const response = await authorizeOrgAccessControl('user-1', 'org-1')
|
||||
|
||||
expect(response).not.toBeNull()
|
||||
expect(response?.status).toBe(403)
|
||||
await expect(response?.json()).resolves.toEqual({ error: 'Admin permissions required' })
|
||||
// Entitlement is only checked after the admin gate passes.
|
||||
expect(mockIsOrganizationOnEnterprisePlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a 403 when the organization is not on an enterprise plan', async () => {
|
||||
mockIsOrganizationAdminOrOwner.mockResolvedValue(true)
|
||||
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false)
|
||||
|
||||
const response = await authorizeOrgAccessControl('user-1', 'org-1')
|
||||
|
||||
expect(response?.status).toBe(403)
|
||||
await expect(response?.json()).resolves.toEqual({
|
||||
error: 'Access Control is an Enterprise feature',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the user is an admin and the org is entitled', async () => {
|
||||
mockIsOrganizationAdminOrOwner.mockResolvedValue(true)
|
||||
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
|
||||
|
||||
const response = await authorizeOrgAccessControl('user-1', 'org-1')
|
||||
|
||||
expect(response).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissionGroup } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
|
||||
import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
/**
|
||||
* Authorize an organization-scoped access-control management request. The caller
|
||||
* must be an organization owner/admin and the organization must be entitled to
|
||||
* the Access Control (Permission Groups) enterprise feature. Returns a
|
||||
* `NextResponse` to short-circuit on failure, or `null` when authorized.
|
||||
*/
|
||||
export async function authorizeOrgAccessControl(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<NextResponse | null> {
|
||||
const isAdmin = await isOrganizationAdminOrOwner(userId, organizationId)
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const entitled = await isOrganizationOnEnterprisePlan(organizationId)
|
||||
if (!entitled) {
|
||||
return NextResponse.json({ error: 'Access Control is an Enterprise feature' }, { status: 403 })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Load a permission group only if it belongs to the given organization. */
|
||||
export async function loadGroupInOrganization(groupId: string, organizationId: string) {
|
||||
const [group] = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
config: permissionGroup.config,
|
||||
createdBy: permissionGroup.createdBy,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
isDefault: permissionGroup.isDefault,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
return group ?? null
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { and, asc, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { userPermissionConfigQuerySchema } from '@/lib/api/contracts/permission-groups'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { parsePermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -31,8 +31,8 @@ export const GET = withRouteHandler(async (req: Request) => {
|
||||
return NextResponse.json({ error: 'Not a member of this workspace' }, { status: 403 })
|
||||
}
|
||||
|
||||
const isEnterprise = await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
if (!isEnterprise) {
|
||||
const organizationId = access.workspace?.organizationId ?? null
|
||||
if (!organizationId || !(await isOrganizationOnEnterprisePlan(organizationId))) {
|
||||
return NextResponse.json({
|
||||
permissionGroupId: null,
|
||||
groupName: null,
|
||||
@@ -41,7 +41,7 @@ export const GET = withRouteHandler(async (req: Request) => {
|
||||
})
|
||||
}
|
||||
|
||||
const [groupMembership] = await db
|
||||
const [explicit] = await db
|
||||
.select({
|
||||
permissionGroupId: permissionGroupMember.permissionGroupId,
|
||||
config: permissionGroup.config,
|
||||
@@ -52,13 +52,29 @@ export const GET = withRouteHandler(async (req: Request) => {
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.userId, session.user.id),
|
||||
eq(permissionGroup.workspaceId, workspaceId)
|
||||
eq(permissionGroup.organizationId, organizationId)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id))
|
||||
.limit(1)
|
||||
|
||||
if (!groupMembership) {
|
||||
let resolved = explicit
|
||||
if (!resolved) {
|
||||
const [defaultGroup] = await db
|
||||
.select({
|
||||
permissionGroupId: permissionGroup.id,
|
||||
config: permissionGroup.config,
|
||||
groupName: permissionGroup.name,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.isDefault, true))
|
||||
)
|
||||
.limit(1)
|
||||
resolved = defaultGroup
|
||||
}
|
||||
|
||||
if (!resolved) {
|
||||
return NextResponse.json({
|
||||
permissionGroupId: null,
|
||||
groupName: null,
|
||||
@@ -68,9 +84,9 @@ export const GET = withRouteHandler(async (req: Request) => {
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
permissionGroupId: groupMembership.permissionGroupId,
|
||||
groupName: groupMembership.groupName,
|
||||
config: parsePermissionGroupConfig(groupMembership.config),
|
||||
permissionGroupId: resolved.permissionGroupId,
|
||||
groupName: resolved.groupName,
|
||||
config: parsePermissionGroupConfig(resolved.config),
|
||||
entitled: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,18 +5,16 @@
|
||||
* List all permission groups with optional filtering.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - workspaceId?: string - Filter by workspace ID
|
||||
* - organizationId?: string - Filter by organization ID (joins via workspace)
|
||||
* - organizationId?: string - Filter by organization ID
|
||||
*
|
||||
* Response: { data: AdminPermissionGroup[], pagination: PaginationMeta }
|
||||
*
|
||||
* DELETE /api/v1/admin/access-control
|
||||
* Delete permission groups scoped to a workspace or organization (via workspace join).
|
||||
* Delete permission groups scoped to an organization.
|
||||
* Used when an enterprise plan churns to clean up access control data.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - workspaceId?: string - Delete all permission groups for this workspace
|
||||
* - organizationId?: string - Delete all permission groups for every workspace in this org
|
||||
* - organizationId: string - Delete all permission groups for this organization
|
||||
* - reason?: string - Reason recorded in audit log (default: "Enterprise plan churn cleanup")
|
||||
*
|
||||
* Response: { success: true, deletedCount: number, membersRemoved: number }
|
||||
@@ -24,7 +22,7 @@
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { permissionGroup, permissionGroupMember, user, workspace } from '@sim/db/schema'
|
||||
import { organization, permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq, inArray, sql } from 'drizzle-orm'
|
||||
import {
|
||||
@@ -53,33 +51,28 @@ export const GET = withRouteHandler(
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { workspaceId, organizationId } = parsed.data.query
|
||||
const { organizationId } = parsed.data.query
|
||||
|
||||
try {
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
workspaceId: permissionGroup.workspaceId,
|
||||
workspaceName: workspace.name,
|
||||
workspaceOrganizationId: workspace.organizationId,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
organizationName: organization.name,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
isDefault: permissionGroup.isDefault,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
createdByUserId: permissionGroup.createdBy,
|
||||
createdByEmail: user.email,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.leftJoin(workspace, eq(permissionGroup.workspaceId, workspace.id))
|
||||
.leftJoin(organization, eq(permissionGroup.organizationId, organization.id))
|
||||
.leftJoin(user, eq(permissionGroup.createdBy, user.id))
|
||||
|
||||
let groups
|
||||
if (workspaceId) {
|
||||
groups = await baseQuery.where(eq(permissionGroup.workspaceId, workspaceId))
|
||||
} else if (organizationId) {
|
||||
groups = await baseQuery.where(eq(workspace.organizationId, organizationId))
|
||||
} else {
|
||||
groups = await baseQuery
|
||||
}
|
||||
const groups = organizationId
|
||||
? await baseQuery.where(eq(permissionGroup.organizationId, organizationId))
|
||||
: await baseQuery
|
||||
|
||||
const groupsWithCounts = await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
@@ -90,11 +83,11 @@ export const GET = withRouteHandler(
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
workspaceId: group.workspaceId,
|
||||
workspaceName: group.workspaceName,
|
||||
organizationId: group.workspaceOrganizationId,
|
||||
organizationId: group.organizationId,
|
||||
organizationName: group.organizationName,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
isDefault: group.isDefault,
|
||||
memberCount: memberCount?.count ?? 0,
|
||||
createdAt: group.createdAt.toISOString(),
|
||||
createdByUserId: group.createdByUserId,
|
||||
@@ -104,7 +97,6 @@ export const GET = withRouteHandler(
|
||||
)
|
||||
|
||||
logger.info('Admin API: Listed permission groups', {
|
||||
workspaceId,
|
||||
organizationId,
|
||||
count: groupsWithCounts.length,
|
||||
})
|
||||
@@ -121,7 +113,6 @@ export const GET = withRouteHandler(
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list permission groups', {
|
||||
error,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
})
|
||||
return internalErrorResponse('Failed to list permission groups')
|
||||
@@ -141,26 +132,20 @@ export const DELETE = withRouteHandler(
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { workspaceId, organizationId, reason: rawReason } = parsed.data.query
|
||||
const { organizationId, reason: rawReason } = parsed.data.query
|
||||
const reason = rawReason || 'Enterprise plan churn cleanup'
|
||||
|
||||
try {
|
||||
const selectBase = db
|
||||
const existingGroups = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
workspaceId: permissionGroup.workspaceId,
|
||||
name: permissionGroup.name,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
|
||||
const existingGroups = workspaceId
|
||||
? await selectBase.where(eq(permissionGroup.workspaceId, workspaceId))
|
||||
: await selectBase
|
||||
.innerJoin(workspace, eq(workspace.id, permissionGroup.workspaceId))
|
||||
.where(eq(workspace.organizationId, organizationId!))
|
||||
.where(eq(permissionGroup.organizationId, organizationId!))
|
||||
|
||||
if (existingGroups.length === 0) {
|
||||
logger.info('Admin API: No permission groups to delete', { workspaceId, organizationId })
|
||||
logger.info('Admin API: No permission groups to delete', { organizationId })
|
||||
return singleResponse({
|
||||
success: true,
|
||||
deletedCount: 0,
|
||||
@@ -182,20 +167,18 @@ export const DELETE = withRouteHandler(
|
||||
|
||||
for (const group of existingGroups) {
|
||||
recordAudit({
|
||||
workspaceId: group.workspaceId,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.PERMISSION_GROUP_DELETED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
resourceId: group.id,
|
||||
resourceName: group.name,
|
||||
description: `Admin API deleted permission group "${group.name}"`,
|
||||
metadata: { reason, workspaceId: group.workspaceId, organizationId },
|
||||
metadata: { reason, organizationId },
|
||||
request,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Admin API: Deleted permission groups', {
|
||||
workspaceId,
|
||||
organizationId,
|
||||
deletedCount: existingGroups.length,
|
||||
membersRemoved: membersToRemove,
|
||||
@@ -211,7 +194,6 @@ export const DELETE = withRouteHandler(
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to delete permission groups', {
|
||||
error,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
})
|
||||
return internalErrorResponse('Failed to delete permission groups')
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { permissionGroupMember, permissions, user, workspace } from '@sim/db/schema'
|
||||
import { permissions, user, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import {
|
||||
@@ -269,15 +269,6 @@ export const DELETE = withRouteHandler(
|
||||
await tx.delete(permissions).where(eq(permissions.id, memberId))
|
||||
|
||||
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, existingMember.userId)
|
||||
|
||||
await tx
|
||||
.delete(permissionGroupMember)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.userId, existingMember.userId),
|
||||
eq(permissionGroupMember.workspaceId, workspaceId)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, {
|
||||
|
||||
@@ -31,13 +31,7 @@
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
permissionGroupMember,
|
||||
permissions,
|
||||
user,
|
||||
workspace,
|
||||
workspaceEnvironment,
|
||||
} from '@sim/db/schema'
|
||||
import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, count, eq } from 'drizzle-orm'
|
||||
@@ -47,11 +41,9 @@ import {
|
||||
adminV1ListWorkspaceMembersContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import { applyWorkspaceAutoAddGroup } from '@/lib/permission-groups/auto-add'
|
||||
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
|
||||
@@ -248,13 +240,6 @@ export const POST = withRouteHandler(
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await applyWorkspaceAutoAddGroup(
|
||||
db,
|
||||
workspaceId,
|
||||
userId,
|
||||
await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
)
|
||||
|
||||
logger.info(`Admin API: Added user ${userId} to workspace ${workspaceId}`, {
|
||||
permissions: permissionLevel,
|
||||
permissionId,
|
||||
@@ -359,15 +344,6 @@ export const DELETE = withRouteHandler(
|
||||
await tx.delete(permissions).where(eq(permissions.id, existingPermission.id))
|
||||
|
||||
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId)
|
||||
|
||||
await tx
|
||||
.delete(permissionGroupMember)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.userId, userId),
|
||||
eq(permissionGroupMember.workspaceId, workspaceId)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
logger.info(`Admin API: Removed user ${userId} from workspace ${workspaceId}`)
|
||||
|
||||
@@ -8,10 +8,8 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import { applyWorkspaceAutoAddGroup } from '@/lib/permission-groups/auto-add'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
checkWorkspaceAccess,
|
||||
@@ -160,16 +158,8 @@ export const PATCH = withRouteHandler(
|
||||
existingPerms.map((p) => [p.userId, { permission: p.permissionType, email: p.email }])
|
||||
)
|
||||
|
||||
// Resolved before the transaction: the entitlement check reads billing
|
||||
// tables on the global pool and must not run while the tx holds a
|
||||
// pooled connection.
|
||||
const hasNewMembers = body.updates.some((update) => !permLookup.has(update.userId))
|
||||
const autoAddEntitled = hasNewMembers ? await isWorkspaceOnEnterprisePlan(workspaceId) : false
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const update of body.updates) {
|
||||
const isNew = !permLookup.has(update.userId)
|
||||
|
||||
await tx
|
||||
.delete(permissions)
|
||||
.where(
|
||||
@@ -189,10 +179,6 @@ export const PATCH = withRouteHandler(
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
if (isNew) {
|
||||
await applyWorkspaceAutoAddGroup(tx, workspaceId, update.userId, autoAddEntitled)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissionGroupMember, permissions, workspace } from '@sim/db/schema'
|
||||
import { member, permissions, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
@@ -151,15 +151,6 @@ export const DELETE = withRouteHandler(
|
||||
|
||||
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId)
|
||||
|
||||
await tx
|
||||
.delete(permissionGroupMember)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.userId, userId),
|
||||
eq(permissionGroupMember.workspaceId, workspaceId)
|
||||
)
|
||||
)
|
||||
|
||||
return { ownershipTransferred: didTransferOwnership, workflowOwnershipReassignment }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -25,10 +25,12 @@ import {
|
||||
Switch,
|
||||
} from '@/components/emcn'
|
||||
import { ArrowLeft } from '@/components/emcn/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import { getUserRole } from '@/lib/workspaces/organization'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import {
|
||||
type PermissionGroup,
|
||||
@@ -42,8 +44,8 @@ import {
|
||||
useUserPermissionConfig,
|
||||
} from '@/ee/access-control/hooks/permission-groups'
|
||||
import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers'
|
||||
import { useOrganizationRoster, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useProviderModels } from '@/hooks/queries/providers'
|
||||
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
|
||||
import {
|
||||
DYNAMIC_MODEL_PROVIDERS,
|
||||
getProviderModels,
|
||||
@@ -55,7 +57,7 @@ import type { ProviderName } from '@/stores/providers'
|
||||
|
||||
const logger = createLogger('AccessControl')
|
||||
|
||||
interface WorkspaceMemberOption {
|
||||
interface OrganizationMemberOption {
|
||||
userId: string
|
||||
user: {
|
||||
name: string | null
|
||||
@@ -67,7 +69,7 @@ interface WorkspaceMemberOption {
|
||||
interface AddMembersModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
availableMembers: WorkspaceMemberOption[]
|
||||
availableMembers: OrganizationMemberOption[]
|
||||
selectedMemberIds: Set<string>
|
||||
setSelectedMemberIds: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||
onAddMembers: () => void
|
||||
@@ -145,7 +147,7 @@ function AddMembersModal({
|
||||
<ChipModalBody>
|
||||
{availableMembers.length === 0 ? (
|
||||
<p className='px-2 text-[var(--text-muted)] text-sm'>
|
||||
All workspace members are already in this group.
|
||||
All organization members are already in this group.
|
||||
</p>
|
||||
) : (
|
||||
<ChipModalField type='custom' title='Members'>
|
||||
@@ -405,23 +407,27 @@ export function AccessControl() {
|
||||
const params = useParams()
|
||||
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
|
||||
|
||||
const { data: session } = useSession()
|
||||
const { data: organizationsData, isPending: orgLoading } = useOrganizations()
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
const organizationId = activeOrganization?.id
|
||||
|
||||
const { data: permissionGroups = [], isPending: groupsLoading } = usePermissionGroups(
|
||||
workspaceId,
|
||||
!!workspaceId
|
||||
)
|
||||
const { data: workspacePermissionsData, isPending: permsLoading } = useWorkspacePermissionsQuery(
|
||||
workspaceId ?? null
|
||||
organizationId,
|
||||
!!organizationId
|
||||
)
|
||||
const { data: roster } = useOrganizationRoster(organizationId)
|
||||
const { data: userPermissionConfig, isPending: entitlementLoading } =
|
||||
useUserPermissionConfig(workspaceId)
|
||||
|
||||
const currentUserIsWorkspaceAdmin = workspacePermissionsData?.viewer?.isAdmin ?? false
|
||||
const userRole = getUserRole(activeOrganization, session?.user?.email ?? undefined)
|
||||
const currentUserIsOrgAdmin = userRole === 'owner' || userRole === 'admin'
|
||||
|
||||
const accessControlEnabledLocally = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED'))
|
||||
const isEntitled = accessControlEnabledLocally || !!userPermissionConfig?.entitled
|
||||
const canManage = isEntitled && currentUserIsWorkspaceAdmin
|
||||
const canManage = isEntitled && currentUserIsOrgAdmin && !!organizationId
|
||||
|
||||
const isLoading = !workspaceId || groupsLoading || permsLoading || entitlementLoading
|
||||
const isLoading = !workspaceId || orgLoading || groupsLoading || entitlementLoading
|
||||
|
||||
const createPermissionGroup = useCreatePermissionGroup()
|
||||
const updatePermissionGroup = useUpdatePermissionGroup()
|
||||
@@ -433,13 +439,13 @@ export function AccessControl() {
|
||||
const [viewingGroup, setViewingGroup] = useState<PermissionGroup | null>(null)
|
||||
const [newGroupName, setNewGroupName] = useState('')
|
||||
const [newGroupDescription, setNewGroupDescription] = useState('')
|
||||
const [newGroupAutoAdd, setNewGroupAutoAdd] = useState(false)
|
||||
const [newGroupIsDefault, setNewGroupIsDefault] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [deletingGroup, setDeletingGroup] = useState<{ id: string; name: string } | null>(null)
|
||||
const [deletingGroupIds, setDeletingGroupIds] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const { data: members = [], isPending: membersLoading } = usePermissionGroupMembers(
|
||||
workspaceId,
|
||||
organizationId,
|
||||
viewingGroup?.id
|
||||
)
|
||||
const removeMember = useRemovePermissionGroupMember()
|
||||
@@ -664,17 +670,19 @@ export function AccessControl() {
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [filteredBlocks])
|
||||
|
||||
const workspaceMembers = useMemo<WorkspaceMemberOption[]>(() => {
|
||||
if (!workspacePermissionsData) return []
|
||||
return workspacePermissionsData.users.map((u) => ({
|
||||
userId: u.userId,
|
||||
user: {
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
image: u.image,
|
||||
},
|
||||
}))
|
||||
}, [workspacePermissionsData])
|
||||
const organizationMembers = useMemo<OrganizationMemberOption[]>(() => {
|
||||
if (!roster?.members) return []
|
||||
return roster.members
|
||||
.filter((m) => m.role !== 'external')
|
||||
.map((m) => ({
|
||||
userId: m.userId,
|
||||
user: {
|
||||
name: m.name,
|
||||
email: m.email,
|
||||
image: m.image,
|
||||
},
|
||||
}))
|
||||
}, [roster])
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (!searchTerm.trim()) return permissionGroups
|
||||
@@ -683,19 +691,19 @@ export function AccessControl() {
|
||||
}, [permissionGroups, searchTerm])
|
||||
|
||||
const handleCreatePermissionGroup = useCallback(async () => {
|
||||
if (!newGroupName.trim() || !workspaceId) return
|
||||
if (!newGroupName.trim() || !organizationId) return
|
||||
setCreateError(null)
|
||||
try {
|
||||
await createPermissionGroup.mutateAsync({
|
||||
workspaceId,
|
||||
organizationId,
|
||||
name: newGroupName.trim(),
|
||||
description: newGroupDescription.trim() || undefined,
|
||||
autoAddNewMembers: newGroupAutoAdd,
|
||||
isDefault: newGroupIsDefault,
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
setNewGroupName('')
|
||||
setNewGroupDescription('')
|
||||
setNewGroupAutoAdd(false)
|
||||
setNewGroupIsDefault(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to create permission group', error)
|
||||
if (error instanceof Error) {
|
||||
@@ -704,13 +712,13 @@ export function AccessControl() {
|
||||
setCreateError('Failed to create permission group')
|
||||
}
|
||||
}
|
||||
}, [newGroupName, newGroupDescription, newGroupAutoAdd, workspaceId, createPermissionGroup])
|
||||
}, [newGroupName, newGroupDescription, newGroupIsDefault, organizationId, createPermissionGroup])
|
||||
|
||||
const handleCloseCreateModal = useCallback(() => {
|
||||
setShowCreateModal(false)
|
||||
setNewGroupName('')
|
||||
setNewGroupDescription('')
|
||||
setNewGroupAutoAdd(false)
|
||||
setNewGroupIsDefault(false)
|
||||
setCreateError(null)
|
||||
}, [])
|
||||
|
||||
@@ -723,12 +731,12 @@ export function AccessControl() {
|
||||
}, [])
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (!deletingGroup || !workspaceId) return
|
||||
if (!deletingGroup || !organizationId) return
|
||||
setDeletingGroupIds((prev) => new Set(prev).add(deletingGroup.id))
|
||||
try {
|
||||
await deletePermissionGroup.mutateAsync({
|
||||
permissionGroupId: deletingGroup.id,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
})
|
||||
setDeletingGroup(null)
|
||||
if (viewingGroup?.id === deletingGroup.id) {
|
||||
@@ -743,14 +751,14 @@ export function AccessControl() {
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [deletingGroup, workspaceId, deletePermissionGroup, viewingGroup?.id])
|
||||
}, [deletingGroup, organizationId, deletePermissionGroup, viewingGroup?.id])
|
||||
|
||||
const handleRemoveMember = useCallback(
|
||||
async (memberId: string) => {
|
||||
if (!viewingGroup || !workspaceId) return
|
||||
if (!viewingGroup || !organizationId) return
|
||||
try {
|
||||
await removeMember.mutateAsync({
|
||||
workspaceId,
|
||||
organizationId,
|
||||
permissionGroupId: viewingGroup.id,
|
||||
memberId,
|
||||
})
|
||||
@@ -758,7 +766,7 @@ export function AccessControl() {
|
||||
logger.error('Failed to remove member', error)
|
||||
}
|
||||
},
|
||||
[viewingGroup, workspaceId, removeMember]
|
||||
[viewingGroup, organizationId, removeMember]
|
||||
)
|
||||
|
||||
const handleOpenConfigModal = useCallback(() => {
|
||||
@@ -768,11 +776,11 @@ export function AccessControl() {
|
||||
}, [viewingGroup])
|
||||
|
||||
const handleSaveConfig = useCallback(async () => {
|
||||
if (!viewingGroup || !editingConfig || !workspaceId) return
|
||||
if (!viewingGroup || !editingConfig || !organizationId) return
|
||||
try {
|
||||
await updatePermissionGroup.mutateAsync({
|
||||
id: viewingGroup.id,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
config: editingConfig,
|
||||
})
|
||||
setShowConfigModal(false)
|
||||
@@ -784,7 +792,7 @@ export function AccessControl() {
|
||||
} catch (error) {
|
||||
logger.error('Failed to update config', error)
|
||||
}
|
||||
}, [viewingGroup, editingConfig, workspaceId, updatePermissionGroup])
|
||||
}, [viewingGroup, editingConfig, organizationId, updatePermissionGroup])
|
||||
|
||||
const handleCloseConfigModal = useCallback(() => {
|
||||
if (hasConfigChanges) {
|
||||
@@ -818,11 +826,11 @@ export function AccessControl() {
|
||||
}, [])
|
||||
|
||||
const handleAddSelectedMembers = useCallback(async () => {
|
||||
if (!viewingGroup || !workspaceId || selectedMemberIds.size === 0) return
|
||||
if (!viewingGroup || !organizationId || selectedMemberIds.size === 0) return
|
||||
setAddMembersError(null)
|
||||
try {
|
||||
await bulkAddMembers.mutateAsync({
|
||||
workspaceId,
|
||||
organizationId,
|
||||
permissionGroupId: viewingGroup.id,
|
||||
userIds: Array.from(selectedMemberIds),
|
||||
})
|
||||
@@ -834,23 +842,23 @@ export function AccessControl() {
|
||||
error instanceof Error && error.message ? error.message : 'Failed to add members'
|
||||
)
|
||||
}
|
||||
}, [viewingGroup, workspaceId, selectedMemberIds, bulkAddMembers])
|
||||
}, [viewingGroup, organizationId, selectedMemberIds, bulkAddMembers])
|
||||
|
||||
const handleToggleAutoAdd = useCallback(
|
||||
const handleToggleDefault = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
if (!viewingGroup || !workspaceId) return
|
||||
if (!viewingGroup || !organizationId) return
|
||||
try {
|
||||
await updatePermissionGroup.mutateAsync({
|
||||
id: viewingGroup.id,
|
||||
workspaceId,
|
||||
autoAddNewMembers: enabled,
|
||||
organizationId,
|
||||
isDefault: enabled,
|
||||
})
|
||||
setViewingGroup((prev) => (prev ? { ...prev, autoAddNewMembers: enabled } : null))
|
||||
setViewingGroup((prev) => (prev ? { ...prev, isDefault: enabled } : null))
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle auto-add', error)
|
||||
logger.error('Failed to toggle default group', error)
|
||||
}
|
||||
},
|
||||
[viewingGroup, workspaceId, updatePermissionGroup]
|
||||
[viewingGroup, organizationId, updatePermissionGroup]
|
||||
)
|
||||
|
||||
const toggleIntegration = useCallback(
|
||||
@@ -984,8 +992,8 @@ export function AccessControl() {
|
||||
|
||||
const availableMembersToAdd = useMemo(() => {
|
||||
const existingMemberUserIds = new Set(members.map((m) => m.userId))
|
||||
return workspaceMembers.filter((m) => !existingMemberUserIds.has(m.userId))
|
||||
}, [workspaceMembers, members])
|
||||
return organizationMembers.filter((m) => !existingMemberUserIds.has(m.userId))
|
||||
}, [organizationMembers, members])
|
||||
|
||||
if (isLoading) {
|
||||
return null
|
||||
@@ -994,7 +1002,7 @@ export function AccessControl() {
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
|
||||
Only workspace admins on Enterprise plans can manage Access Control settings.
|
||||
Only organization admins on Enterprise plans can manage Access Control settings.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1033,15 +1041,16 @@ export function AccessControl() {
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
Auto-add new members
|
||||
Default group
|
||||
</span>
|
||||
<span className='text-[var(--text-muted)] text-small'>
|
||||
Automatically add new workspace members to this group
|
||||
Applies to everyone in the organization not assigned to another group, including
|
||||
external workspace members
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={viewingGroup.autoAddNewMembers}
|
||||
onCheckedChange={(checked) => handleToggleAutoAdd(checked)}
|
||||
checked={viewingGroup.isDefault}
|
||||
onCheckedChange={(checked) => handleToggleDefault(checked)}
|
||||
disabled={updatePermissionGroup.isPending}
|
||||
/>
|
||||
</div>
|
||||
@@ -1486,9 +1495,9 @@ export function AccessControl() {
|
||||
<span className='truncate text-[14px] text-[var(--text-body)]'>
|
||||
{group.name}
|
||||
</span>
|
||||
{group.autoAddNewMembers && (
|
||||
{group.isDefault && (
|
||||
<span className='flex-shrink-0 rounded-sm bg-[var(--surface-3)] px-1.5 py-0.5 text-[var(--text-muted)] text-micro'>
|
||||
Auto-enrolls
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1533,12 +1542,12 @@ export function AccessControl() {
|
||||
<ChipModalField type='custom' title='Membership'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
id='auto-add-members'
|
||||
checked={newGroupAutoAdd}
|
||||
onCheckedChange={(checked) => setNewGroupAutoAdd(checked === true)}
|
||||
id='default-group'
|
||||
checked={newGroupIsDefault}
|
||||
onCheckedChange={(checked) => setNewGroupIsDefault(checked === true)}
|
||||
/>
|
||||
<Label htmlFor='auto-add-members' className='cursor-pointer font-normal'>
|
||||
Auto-add new workspace members
|
||||
<Label htmlFor='default-group' className='cursor-pointer font-normal'>
|
||||
Make this the organization default group
|
||||
</Label>
|
||||
</div>
|
||||
</ChipModalField>
|
||||
|
||||
@@ -22,44 +22,45 @@ export type { PermissionGroup, PermissionGroupMember, UserPermissionConfig }
|
||||
export const permissionGroupKeys = {
|
||||
all: ['permissionGroups'] as const,
|
||||
lists: () => [...permissionGroupKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string) => [...permissionGroupKeys.lists(), workspaceId ?? ''] as const,
|
||||
list: (organizationId?: string) =>
|
||||
[...permissionGroupKeys.lists(), organizationId ?? ''] as const,
|
||||
details: () => [...permissionGroupKeys.all, 'detail'] as const,
|
||||
detail: (workspaceId?: string, id?: string) =>
|
||||
[...permissionGroupKeys.details(), workspaceId ?? '', id ?? ''] as const,
|
||||
members: (workspaceId?: string, id?: string) =>
|
||||
[...permissionGroupKeys.detail(workspaceId, id), 'members'] as const,
|
||||
detail: (organizationId?: string, id?: string) =>
|
||||
[...permissionGroupKeys.details(), organizationId ?? '', id ?? ''] as const,
|
||||
members: (organizationId?: string, id?: string) =>
|
||||
[...permissionGroupKeys.detail(organizationId, id), 'members'] as const,
|
||||
userConfig: (workspaceId?: string) =>
|
||||
[...permissionGroupKeys.all, 'userConfig', workspaceId ?? ''] as const,
|
||||
}
|
||||
|
||||
export function usePermissionGroups(workspaceId?: string, enabled = true) {
|
||||
export function usePermissionGroups(organizationId?: string, enabled = true) {
|
||||
return useQuery<PermissionGroup[]>({
|
||||
queryKey: permissionGroupKeys.list(workspaceId),
|
||||
queryKey: permissionGroupKeys.list(organizationId),
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!workspaceId) return []
|
||||
if (!organizationId) return []
|
||||
const data = await requestJson(listPermissionGroupsContract, {
|
||||
params: { id: workspaceId },
|
||||
params: { id: organizationId },
|
||||
signal,
|
||||
})
|
||||
return data.permissionGroups ?? []
|
||||
},
|
||||
enabled: Boolean(workspaceId) && enabled,
|
||||
enabled: Boolean(organizationId) && enabled,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePermissionGroupMembers(workspaceId?: string, permissionGroupId?: string) {
|
||||
export function usePermissionGroupMembers(organizationId?: string, permissionGroupId?: string) {
|
||||
return useQuery<PermissionGroupMember[]>({
|
||||
queryKey: permissionGroupKeys.members(workspaceId, permissionGroupId),
|
||||
queryKey: permissionGroupKeys.members(organizationId, permissionGroupId),
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!workspaceId || !permissionGroupId) return []
|
||||
if (!organizationId || !permissionGroupId) return []
|
||||
const data = await requestJson(listPermissionGroupMembersContract, {
|
||||
params: { id: workspaceId, groupId: permissionGroupId },
|
||||
params: { id: organizationId, groupId: permissionGroupId },
|
||||
signal,
|
||||
})
|
||||
return data.members ?? []
|
||||
},
|
||||
enabled: Boolean(workspaceId) && Boolean(permissionGroupId),
|
||||
enabled: Boolean(organizationId) && Boolean(permissionGroupId),
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
@@ -80,26 +81,26 @@ export function useUserPermissionConfig(workspaceId?: string) {
|
||||
}
|
||||
|
||||
export interface CreatePermissionGroupData {
|
||||
workspaceId: string
|
||||
organizationId: string
|
||||
name: string
|
||||
description?: string
|
||||
config?: Partial<PermissionGroupConfig>
|
||||
autoAddNewMembers?: boolean
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
export function useCreatePermissionGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, ...data }: CreatePermissionGroupData) => {
|
||||
mutationFn: async ({ organizationId, ...data }: CreatePermissionGroupData) => {
|
||||
return requestJson(createPermissionGroupContract, {
|
||||
params: { id: workspaceId },
|
||||
params: { id: organizationId },
|
||||
body: data,
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.list(variables.workspaceId),
|
||||
queryKey: permissionGroupKeys.list(variables.organizationId),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -107,58 +108,48 @@ export function useCreatePermissionGroup() {
|
||||
|
||||
export interface UpdatePermissionGroupData {
|
||||
id: string
|
||||
workspaceId: string
|
||||
organizationId: string
|
||||
name?: string
|
||||
description?: string | null
|
||||
config?: Partial<PermissionGroupConfig>
|
||||
autoAddNewMembers?: boolean
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
export function useUpdatePermissionGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, workspaceId, ...data }: UpdatePermissionGroupData) => {
|
||||
mutationFn: async ({ id, organizationId, ...data }: UpdatePermissionGroupData) => {
|
||||
return requestJson(updatePermissionGroupContract, {
|
||||
params: { id: workspaceId, groupId: id },
|
||||
params: { id: organizationId, groupId: id },
|
||||
body: data,
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.list(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.detail(variables.workspaceId, variables.id),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.userConfig(variables.workspaceId),
|
||||
})
|
||||
onSettled: () => {
|
||||
// `all` is the prefix of every key in the factory (list/detail/members/userConfig),
|
||||
// so a single invalidation covers them — including the workspace-keyed userConfig
|
||||
// entries a mutation that only knows organizationId cannot target directly.
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface DeletePermissionGroupParams {
|
||||
permissionGroupId: string
|
||||
workspaceId: string
|
||||
organizationId: string
|
||||
}
|
||||
|
||||
export function useDeletePermissionGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ permissionGroupId, workspaceId }: DeletePermissionGroupParams) => {
|
||||
mutationFn: async ({ permissionGroupId, organizationId }: DeletePermissionGroupParams) => {
|
||||
return requestJson(deletePermissionGroupContract, {
|
||||
params: { id: workspaceId, groupId: permissionGroupId },
|
||||
params: { id: organizationId, groupId: permissionGroupId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.list(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.userConfig(variables.workspaceId),
|
||||
})
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -168,52 +159,40 @@ export function useRemovePermissionGroupMember() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: {
|
||||
workspaceId: string
|
||||
organizationId: string
|
||||
permissionGroupId: string
|
||||
memberId: string
|
||||
}) => {
|
||||
return requestJson(removePermissionGroupMemberContract, {
|
||||
params: { id: data.workspaceId, groupId: data.permissionGroupId },
|
||||
params: { id: data.organizationId, groupId: data.permissionGroupId },
|
||||
query: { memberId: data.memberId },
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.members(variables.workspaceId, variables.permissionGroupId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.userConfig(variables.workspaceId),
|
||||
})
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface BulkAddMembersData {
|
||||
workspaceId: string
|
||||
organizationId: string
|
||||
permissionGroupId: string
|
||||
userIds?: string[]
|
||||
addAllWorkspaceMembers?: boolean
|
||||
addAllOrganizationMembers?: boolean
|
||||
}
|
||||
|
||||
export function useBulkAddPermissionGroupMembers() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, permissionGroupId, ...data }: BulkAddMembersData) => {
|
||||
mutationFn: async ({ organizationId, permissionGroupId, ...data }: BulkAddMembersData) => {
|
||||
return requestJson(bulkAddPermissionGroupMembersContract, {
|
||||
params: { id: workspaceId, groupId: permissionGroupId },
|
||||
params: { id: organizationId, groupId: permissionGroupId },
|
||||
body: data,
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.members(variables.workspaceId, variables.permissionGroupId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.userConfig(variables.workspaceId),
|
||||
})
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
mockGetAllowedIntegrationsFromEnv,
|
||||
mockIsWorkspaceOnEnterprisePlan,
|
||||
mockIsOrganizationOnEnterprisePlan,
|
||||
mockGetWorkspaceWithOwner,
|
||||
mockGetProviderFromModel,
|
||||
mockDbGroupMembership,
|
||||
mockExplicitGroup,
|
||||
mockDefaultGroup,
|
||||
} = vi.hoisted(() => ({
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG: {
|
||||
allowedIntegrations: null,
|
||||
@@ -35,20 +37,33 @@ const {
|
||||
hideDeployTemplate: false,
|
||||
},
|
||||
mockGetAllowedIntegrationsFromEnv: vi.fn<() => string[] | null>(),
|
||||
mockIsWorkspaceOnEnterprisePlan: vi.fn<() => Promise<boolean>>(),
|
||||
mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise<boolean>>(),
|
||||
mockGetWorkspaceWithOwner: vi.fn<() => Promise<{ organizationId: string | null } | null>>(),
|
||||
mockGetProviderFromModel: vi.fn<(model: string) => string>(),
|
||||
mockDbGroupMembership: { value: [] as Array<{ config: Record<string, unknown> }> },
|
||||
// The explicit-group query joins permission_group_member -> permission_group;
|
||||
// the org-default query selects permission_group directly. The db mock returns
|
||||
// the explicit rows when `innerJoin` was called and the default rows otherwise.
|
||||
mockExplicitGroup: { value: [] as Array<{ config: Record<string, unknown> }> },
|
||||
mockDefaultGroup: { value: [] as Array<{ config: Record<string, unknown> }> },
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
let usedInnerJoin = false
|
||||
chain.from = vi.fn().mockReturnValue(chain)
|
||||
chain.innerJoin = vi.fn().mockReturnValue(chain)
|
||||
chain.innerJoin = vi.fn().mockImplementation(() => {
|
||||
usedInnerJoin = true
|
||||
return chain
|
||||
})
|
||||
chain.where = vi.fn().mockReturnValue(chain)
|
||||
chain.orderBy = vi.fn().mockReturnValue(chain)
|
||||
chain.limit = vi.fn().mockImplementation(() => Promise.resolve(mockDbGroupMembership.value))
|
||||
chain.limit = vi
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve(usedInnerJoin ? mockExplicitGroup.value : mockDefaultGroup.value)
|
||||
)
|
||||
return chain
|
||||
}),
|
||||
},
|
||||
@@ -66,7 +81,11 @@ vi.mock('drizzle-orm', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
isWorkspaceOnEnterprisePlan: mockIsWorkspaceOnEnterprisePlan,
|
||||
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getWorkspaceWithOwner: mockGetWorkspaceWithOwner,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
@@ -103,6 +122,12 @@ import {
|
||||
validateModelProvider,
|
||||
} from './permission-check'
|
||||
|
||||
/** Default an org-backed, enterprise-entitled workspace so resolution reaches the group queries. */
|
||||
function setEnterpriseOrgWorkspace() {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: 'org-1' })
|
||||
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
|
||||
}
|
||||
|
||||
describe('IntegrationNotAllowedError', () => {
|
||||
it.concurrent('creates error with correct name and message', () => {
|
||||
const error = new IntegrationNotAllowedError('discord')
|
||||
@@ -119,35 +144,97 @@ describe('IntegrationNotAllowedError', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUserPermissionConfig', () => {
|
||||
describe('getUserPermissionConfig (org-scoped resolution)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbGroupMembership.value = []
|
||||
})
|
||||
|
||||
it('returns env allowlist config when access control is disabled locally', async () => {
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack'])
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config).not.toBeNull()
|
||||
expect(config!.allowedIntegrations).toEqual(['slack'])
|
||||
})
|
||||
|
||||
it('returns env allowlist config when workspace is not on enterprise plan', async () => {
|
||||
mockExplicitGroup.value = []
|
||||
mockDefaultGroup.value = []
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
mockIsWorkspaceOnEnterprisePlan.mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it('returns null when the workspace has no organization', async () => {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: null })
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config).toBeNull()
|
||||
expect(mockIsOrganizationOnEnterprisePlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still applies the env allowlist on a no-org workspace', async () => {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: null })
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack'])
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config?.allowedIntegrations).toEqual(['slack'])
|
||||
})
|
||||
|
||||
it('returns null when the organization is not on an enterprise plan', async () => {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: 'org-1' })
|
||||
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false)
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the explicit group config when the user is assigned to one', async () => {
|
||||
setEnterpriseOrgWorkspace()
|
||||
mockExplicitGroup.value = [{ config: { disableMcpTools: true } }]
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config?.disableMcpTools).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the org default group when the user has no explicit group', async () => {
|
||||
setEnterpriseOrgWorkspace()
|
||||
mockExplicitGroup.value = []
|
||||
mockDefaultGroup.value = [{ config: { disableSkills: true } }]
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config?.disableSkills).toBe(true)
|
||||
})
|
||||
|
||||
it('governs an external member (no explicit group) via the org default group', async () => {
|
||||
setEnterpriseOrgWorkspace()
|
||||
mockExplicitGroup.value = []
|
||||
mockDefaultGroup.value = [{ config: { disableCustomTools: true } }]
|
||||
|
||||
const config = await getUserPermissionConfig('external-user', 'workspace-1')
|
||||
|
||||
expect(config?.disableCustomTools).toBe(true)
|
||||
})
|
||||
|
||||
it('returns null when there is no explicit group and no default group', async () => {
|
||||
setEnterpriseOrgWorkspace()
|
||||
mockExplicitGroup.value = []
|
||||
mockDefaultGroup.value = []
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers the explicit group over the org default group', async () => {
|
||||
setEnterpriseOrgWorkspace()
|
||||
mockExplicitGroup.value = [{ config: { disableMcpTools: true } }]
|
||||
mockDefaultGroup.value = [{ config: { disableSkills: true } }]
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config?.disableMcpTools).toBe(true)
|
||||
expect(config?.disableSkills).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateBlockType', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbGroupMembership.value = []
|
||||
mockExplicitGroup.value = []
|
||||
mockDefaultGroup.value = []
|
||||
})
|
||||
|
||||
describe('when no env allowlist is configured', () => {
|
||||
@@ -215,8 +302,10 @@ describe('validateBlockType', () => {
|
||||
describe('validateModelProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExplicitGroup.value = []
|
||||
mockDefaultGroup.value = []
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
mockIsWorkspaceOnEnterprisePlan.mockResolvedValue(true)
|
||||
setEnterpriseOrgWorkspace()
|
||||
})
|
||||
|
||||
it('no-ops when user or workspace is missing', async () => {
|
||||
@@ -225,7 +314,7 @@ describe('validateModelProvider', () => {
|
||||
})
|
||||
|
||||
it('throws ProviderNotAllowedError when provider is not in allowlist', async () => {
|
||||
mockDbGroupMembership.value = [{ config: { allowedModelProviders: ['anthropic'] } }]
|
||||
mockExplicitGroup.value = [{ config: { allowedModelProviders: ['anthropic'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf(
|
||||
@@ -234,14 +323,14 @@ describe('validateModelProvider', () => {
|
||||
})
|
||||
|
||||
it('allows when provider is on the allowlist', async () => {
|
||||
mockDbGroupMembership.value = [{ config: { allowedModelProviders: ['anthropic', 'openai'] } }]
|
||||
mockExplicitGroup.value = [{ config: { allowedModelProviders: ['anthropic', 'openai'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await validateModelProvider('user-123', 'workspace-1', 'gpt-4')
|
||||
})
|
||||
|
||||
it('throws ModelNotAllowedError when the model is on the denylist', async () => {
|
||||
mockDbGroupMembership.value = [{ config: { deniedModels: ['gpt-4'] } }]
|
||||
mockExplicitGroup.value = [{ config: { deniedModels: ['gpt-4'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf(
|
||||
@@ -250,7 +339,7 @@ describe('validateModelProvider', () => {
|
||||
})
|
||||
|
||||
it('denylist match is case-insensitive', async () => {
|
||||
mockDbGroupMembership.value = [{ config: { deniedModels: ['Ollama/Llama3'] } }]
|
||||
mockExplicitGroup.value = [{ config: { deniedModels: ['Ollama/Llama3'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('ollama')
|
||||
|
||||
await expect(
|
||||
@@ -259,9 +348,7 @@ describe('validateModelProvider', () => {
|
||||
})
|
||||
|
||||
it('enforces the denylist even when no provider allowlist is set', async () => {
|
||||
mockDbGroupMembership.value = [
|
||||
{ config: { allowedModelProviders: null, deniedModels: ['gpt-4'] } },
|
||||
]
|
||||
mockExplicitGroup.value = [{ config: { allowedModelProviders: null, deniedModels: ['gpt-4'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf(
|
||||
@@ -270,22 +357,34 @@ describe('validateModelProvider', () => {
|
||||
})
|
||||
|
||||
it('allows a model that is not on the denylist', async () => {
|
||||
mockDbGroupMembership.value = [{ config: { deniedModels: ['gpt-4'] } }]
|
||||
mockExplicitGroup.value = [{ config: { deniedModels: ['gpt-4'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await validateModelProvider('user-123', 'workspace-1', 'gpt-4o')
|
||||
})
|
||||
|
||||
it('applies the org default group when the user has no explicit group', async () => {
|
||||
mockExplicitGroup.value = []
|
||||
mockDefaultGroup.value = [{ config: { allowedModelProviders: ['anthropic'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf(
|
||||
ProviderNotAllowedError
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateMcpToolsAllowed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExplicitGroup.value = []
|
||||
mockDefaultGroup.value = []
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
mockIsWorkspaceOnEnterprisePlan.mockResolvedValue(true)
|
||||
setEnterpriseOrgWorkspace()
|
||||
})
|
||||
|
||||
it('throws McpToolsNotAllowedError when disableMcpTools is set', async () => {
|
||||
mockDbGroupMembership.value = [{ config: { disableMcpTools: true } }]
|
||||
mockExplicitGroup.value = [{ config: { disableMcpTools: true } }]
|
||||
|
||||
await expect(validateMcpToolsAllowed('user-123', 'workspace-1')).rejects.toBeInstanceOf(
|
||||
McpToolsNotAllowedError
|
||||
@@ -293,7 +392,7 @@ describe('validateMcpToolsAllowed', () => {
|
||||
})
|
||||
|
||||
it('no-ops when disableMcpTools is false', async () => {
|
||||
mockDbGroupMembership.value = [{ config: {} }]
|
||||
mockExplicitGroup.value = [{ config: {} }]
|
||||
|
||||
await validateMcpToolsAllowed('user-123', 'workspace-1')
|
||||
})
|
||||
@@ -302,12 +401,14 @@ describe('validateMcpToolsAllowed', () => {
|
||||
describe('assertPermissionsAllowed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExplicitGroup.value = []
|
||||
mockDefaultGroup.value = []
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
mockIsWorkspaceOnEnterprisePlan.mockResolvedValue(true)
|
||||
setEnterpriseOrgWorkspace()
|
||||
})
|
||||
|
||||
it('throws ProviderNotAllowedError when model provider is blocked', async () => {
|
||||
mockDbGroupMembership.value = [{ config: { allowedModelProviders: ['anthropic'] } }]
|
||||
mockExplicitGroup.value = [{ config: { allowedModelProviders: ['anthropic'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await expect(
|
||||
@@ -320,7 +421,7 @@ describe('assertPermissionsAllowed', () => {
|
||||
})
|
||||
|
||||
it('throws ModelNotAllowedError when the model is on the denylist', async () => {
|
||||
mockDbGroupMembership.value = [{ config: { deniedModels: ['gpt-4'] } }]
|
||||
mockExplicitGroup.value = [{ config: { deniedModels: ['gpt-4'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await expect(
|
||||
@@ -333,7 +434,7 @@ describe('assertPermissionsAllowed', () => {
|
||||
})
|
||||
|
||||
it('throws IntegrationNotAllowedError when block type is blocked', async () => {
|
||||
mockDbGroupMembership.value = [{ config: { allowedIntegrations: ['slack'] } }]
|
||||
mockExplicitGroup.value = [{ config: { allowedIntegrations: ['slack'] } }]
|
||||
|
||||
await expect(
|
||||
assertPermissionsAllowed({
|
||||
@@ -345,7 +446,7 @@ describe('assertPermissionsAllowed', () => {
|
||||
})
|
||||
|
||||
it('throws CustomToolsNotAllowedError when custom tools are disabled', async () => {
|
||||
mockDbGroupMembership.value = [{ config: { disableCustomTools: true } }]
|
||||
mockExplicitGroup.value = [{ config: { disableCustomTools: true } }]
|
||||
|
||||
await expect(
|
||||
assertPermissionsAllowed({
|
||||
@@ -357,7 +458,7 @@ describe('assertPermissionsAllowed', () => {
|
||||
})
|
||||
|
||||
it('throws SkillsNotAllowedError when skills are disabled', async () => {
|
||||
mockDbGroupMembership.value = [{ config: { disableSkills: true } }]
|
||||
mockExplicitGroup.value = [{ config: { disableSkills: true } }]
|
||||
|
||||
await expect(
|
||||
assertPermissionsAllowed({
|
||||
@@ -369,7 +470,8 @@ describe('assertPermissionsAllowed', () => {
|
||||
})
|
||||
|
||||
it('passes when the workspace has no blocking config', async () => {
|
||||
mockDbGroupMembership.value = []
|
||||
mockExplicitGroup.value = []
|
||||
mockDefaultGroup.value = []
|
||||
|
||||
await assertPermissionsAllowed({
|
||||
userId: 'user-123',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissionGroup, permissionGroupMember, workspace } from '@sim/db/schema'
|
||||
import { permissionGroup, permissionGroupMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, asc, eq, sql } from 'drizzle-orm'
|
||||
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
|
||||
import {
|
||||
getAllowedIntegrationsFromEnv,
|
||||
isAccessControlEnabled,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
|
||||
@@ -109,10 +110,60 @@ function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGrou
|
||||
return { ...config, allowedIntegrations: merged }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the raw (pre-env-merge) permission-group config that governs `userId`
|
||||
* within `organizationId`. Resolution is deterministic with no workspace-level
|
||||
* fallbacks:
|
||||
* 1. the user's explicitly assigned group in the organization, else
|
||||
* 2. the organization's default group (`isDefault`), which also governs
|
||||
* external members operating in the org's workspaces, else
|
||||
* 3. `null` (unrestricted).
|
||||
*
|
||||
* Callers are responsible for the enterprise-entitlement gate before invoking
|
||||
* this and for merging the env allowlist afterwards.
|
||||
*/
|
||||
async function resolveOrganizationGroupConfig(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<PermissionGroupConfig | null> {
|
||||
const [explicit] = await db
|
||||
.select({ config: permissionGroup.config })
|
||||
.from(permissionGroupMember)
|
||||
.innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.userId, userId),
|
||||
eq(permissionGroupMember.organizationId, organizationId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (explicit) {
|
||||
return parsePermissionGroupConfig(explicit.config)
|
||||
}
|
||||
|
||||
const [defaultGroup] = await db
|
||||
.select({ config: permissionGroup.config })
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.isDefault, true))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (defaultGroup) {
|
||||
return parsePermissionGroupConfig(defaultGroup.config)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective permission-group config for a user in the context of a
|
||||
* specific workspace. Returns `null` when the workspace isn't on an enterprise
|
||||
* plan or the user isn't a member of any permission group in that workspace.
|
||||
* specific workspace. The workspace is mapped to its organization and the
|
||||
* org-scoped config is resolved (explicit group -> org default -> none).
|
||||
*
|
||||
* Returns `null` (after env merge) when the workspace has no organization, the
|
||||
* organization isn't on an enterprise plan, or no group governs the user.
|
||||
*
|
||||
* The env-level integration allowlist is always merged last so self-hosted
|
||||
* deployments can constrain integrations without touching the DB.
|
||||
@@ -125,26 +176,33 @@ export async function getUserPermissionConfig(
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
const isEnterprise = await isWorkspaceOnEnterprisePlan(workspaceId)
|
||||
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
|
||||
if (!ws?.organizationId) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
return getUserPermissionConfigForOrganization(userId, ws.organizationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Org-addressed variant of {@link getUserPermissionConfig}. Use when the
|
||||
* organization id is already known (e.g. organization-level invitations) so no
|
||||
* workspace -> organization lookup is needed.
|
||||
*/
|
||||
export async function getUserPermissionConfigForOrganization(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<PermissionGroupConfig | null> {
|
||||
if (!isHosted && !isAccessControlEnabled) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
|
||||
if (!isEnterprise) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
const [groupMembership] = await db
|
||||
.select({ config: permissionGroup.config })
|
||||
.from(permissionGroupMember)
|
||||
.innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id))
|
||||
.where(
|
||||
and(eq(permissionGroupMember.userId, userId), eq(permissionGroup.workspaceId, workspaceId))
|
||||
)
|
||||
.orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id))
|
||||
.limit(1)
|
||||
|
||||
if (!groupMembership) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
return mergeEnvAllowlist(parsePermissionGroupConfig(groupMembership.config))
|
||||
return mergeEnvAllowlist(await resolveOrganizationGroupConfig(userId, organizationId))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,12 +381,10 @@ export async function validateSkillsAllowed(
|
||||
|
||||
/**
|
||||
* Validates if the user is allowed to send invitations. Pass one of:
|
||||
* - `workspaceId` — workspace-scoped invite: block when the user's group in that workspace has
|
||||
* `disableInvitations`.
|
||||
* - `workspaceId` — workspace-scoped invite: block when the user's governing group (explicit or
|
||||
* org default) for the workspace's organization has `disableInvitations`.
|
||||
* - `organizationId` — organization-level invite (no specific workspace target): block when the
|
||||
* user has `disableInvitations` set on their group in any organization-owned workspace. This
|
||||
* mirrors the pre-refactor behavior where `disableInvitations` was an organization-level
|
||||
* policy.
|
||||
* user's group in that organization (explicit or the org default) has `disableInvitations`.
|
||||
* - neither — only the global feature flag is checked.
|
||||
*/
|
||||
export async function validateInvitationsAllowed(
|
||||
@@ -357,25 +413,11 @@ export async function validateInvitationsAllowed(
|
||||
}
|
||||
|
||||
if (organizationId) {
|
||||
const [row] = await db
|
||||
.select({ id: permissionGroup.id })
|
||||
.from(permissionGroupMember)
|
||||
.innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id))
|
||||
.innerJoin(workspace, eq(permissionGroup.workspaceId, workspace.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.userId, userId),
|
||||
eq(workspace.organizationId, organizationId),
|
||||
sql`${permissionGroup.config} @> '{"disableInvitations": true}'::jsonb`
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (row) {
|
||||
const config = await getUserPermissionConfigForOrganization(userId, organizationId)
|
||||
if (config?.disableInvitations) {
|
||||
logger.warn('Invitations blocked by permission group (organization-wide)', {
|
||||
userId,
|
||||
organizationId,
|
||||
permissionGroupId: row.id,
|
||||
})
|
||||
throw new InvitationsNotAllowedError()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { organizationIdSchema } from '@/lib/api/contracts/primitives'
|
||||
import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
import { permissionGroupConfigSchema } from '@/lib/permission-groups/types'
|
||||
|
||||
@@ -31,12 +32,14 @@ export const addPermissionGroupMemberBodySchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
})
|
||||
|
||||
/** Route params for organization-scoped permission-group collection routes (`id` = organizationId). */
|
||||
export const permissionGroupParamsSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
id: organizationIdSchema,
|
||||
})
|
||||
|
||||
/** Route params for a single permission group (`id` = organizationId, `groupId` = permission group id). */
|
||||
export const permissionGroupDetailParamsSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
id: organizationIdSchema,
|
||||
groupId: z.string().min(1),
|
||||
})
|
||||
|
||||
@@ -51,20 +54,20 @@ export const permissionGroupSchema = z.object({
|
||||
creatorName: z.string().nullable(),
|
||||
creatorEmail: z.string().nullable(),
|
||||
memberCount: z.number(),
|
||||
autoAddNewMembers: z.boolean(),
|
||||
isDefault: z.boolean(),
|
||||
})
|
||||
export type PermissionGroup = z.output<typeof permissionGroupSchema>
|
||||
|
||||
export const permissionGroupWriteSchema = z.object({
|
||||
id: z.string(),
|
||||
workspaceId: z.string(),
|
||||
organizationId: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
config: permissionGroupFullConfigSchema,
|
||||
createdBy: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
autoAddNewMembers: z.boolean(),
|
||||
isDefault: z.boolean(),
|
||||
})
|
||||
export type PermissionGroupWrite = z.output<typeof permissionGroupWriteSchema>
|
||||
|
||||
@@ -94,14 +97,14 @@ export const createPermissionGroupBodySchema = z.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
config: permissionGroupConfigSchema.optional(),
|
||||
autoAddNewMembers: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const updatePermissionGroupBodySchema = z.object({
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
config: permissionGroupConfigSchema.optional(),
|
||||
autoAddNewMembers: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const removePermissionGroupMemberQuerySchema = z.object({
|
||||
@@ -110,7 +113,7 @@ export const removePermissionGroupMemberQuerySchema = z.object({
|
||||
|
||||
export const bulkAddPermissionGroupMembersBodySchema = z.object({
|
||||
userIds: z.array(z.string()).optional(),
|
||||
addAllWorkspaceMembers: z.boolean().optional(),
|
||||
addAllOrganizationMembers: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const successResponseSchema = z.object({
|
||||
@@ -119,7 +122,7 @@ const successResponseSchema = z.object({
|
||||
|
||||
export const listPermissionGroupsContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/workspaces/[id]/permission-groups',
|
||||
path: '/api/organizations/[id]/permission-groups',
|
||||
params: permissionGroupParamsSchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
@@ -131,7 +134,7 @@ export const listPermissionGroupsContract = defineRouteContract({
|
||||
|
||||
export const createPermissionGroupContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/workspaces/[id]/permission-groups',
|
||||
path: '/api/organizations/[id]/permission-groups',
|
||||
params: permissionGroupParamsSchema,
|
||||
body: createPermissionGroupBodySchema,
|
||||
response: {
|
||||
@@ -154,7 +157,7 @@ export const getUserPermissionConfigContract = defineRouteContract({
|
||||
|
||||
export const updatePermissionGroupContract = defineRouteContract({
|
||||
method: 'PUT',
|
||||
path: '/api/workspaces/[id]/permission-groups/[groupId]',
|
||||
path: '/api/organizations/[id]/permission-groups/[groupId]',
|
||||
params: permissionGroupDetailParamsSchema,
|
||||
body: updatePermissionGroupBodySchema,
|
||||
response: {
|
||||
@@ -167,7 +170,7 @@ export const updatePermissionGroupContract = defineRouteContract({
|
||||
|
||||
export const deletePermissionGroupContract = defineRouteContract({
|
||||
method: 'DELETE',
|
||||
path: '/api/workspaces/[id]/permission-groups/[groupId]',
|
||||
path: '/api/organizations/[id]/permission-groups/[groupId]',
|
||||
params: permissionGroupDetailParamsSchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
@@ -177,7 +180,7 @@ export const deletePermissionGroupContract = defineRouteContract({
|
||||
|
||||
export const listPermissionGroupMembersContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/workspaces/[id]/permission-groups/[groupId]/members',
|
||||
path: '/api/organizations/[id]/permission-groups/[groupId]/members',
|
||||
params: permissionGroupDetailParamsSchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
@@ -189,7 +192,7 @@ export const listPermissionGroupMembersContract = defineRouteContract({
|
||||
|
||||
export const removePermissionGroupMemberContract = defineRouteContract({
|
||||
method: 'DELETE',
|
||||
path: '/api/workspaces/[id]/permission-groups/[groupId]/members',
|
||||
path: '/api/organizations/[id]/permission-groups/[groupId]/members',
|
||||
params: permissionGroupDetailParamsSchema,
|
||||
query: removePermissionGroupMemberQuerySchema,
|
||||
response: {
|
||||
@@ -200,7 +203,7 @@ export const removePermissionGroupMemberContract = defineRouteContract({
|
||||
|
||||
export const addPermissionGroupMemberContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/workspaces/[id]/permission-groups/[groupId]/members',
|
||||
path: '/api/organizations/[id]/permission-groups/[groupId]/members',
|
||||
params: permissionGroupDetailParamsSchema,
|
||||
body: addPermissionGroupMemberBodySchema,
|
||||
response: {
|
||||
@@ -209,7 +212,7 @@ export const addPermissionGroupMemberContract = defineRouteContract({
|
||||
member: z.object({
|
||||
id: z.string(),
|
||||
permissionGroupId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
organizationId: z.string(),
|
||||
userId: z.string(),
|
||||
assignedBy: z.string(),
|
||||
assignedAt: z.string(),
|
||||
@@ -220,7 +223,7 @@ export const addPermissionGroupMemberContract = defineRouteContract({
|
||||
|
||||
export const bulkAddPermissionGroupMembersContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/workspaces/[id]/permission-groups/[groupId]/members/bulk',
|
||||
path: '/api/organizations/[id]/permission-groups/[groupId]/members/bulk',
|
||||
params: permissionGroupDetailParamsSchema,
|
||||
body: bulkAddPermissionGroupMembersBodySchema,
|
||||
response: {
|
||||
|
||||
@@ -37,6 +37,12 @@ export const nonEmptyIdSchema = z.string().min(1)
|
||||
*/
|
||||
export const workspaceIdSchema = z.string().min(1, 'Workspace ID is required')
|
||||
|
||||
/**
|
||||
* Non-empty `organizationId` field. Same constraint as `nonEmptyIdSchema` with a
|
||||
* stable, human-readable message.
|
||||
*/
|
||||
export const organizationIdSchema = z.string().min(1, 'Organization ID is required')
|
||||
|
||||
/**
|
||||
* Non-empty `workflowId` field. Same constraint as `nonEmptyIdSchema` with a
|
||||
* stable, human-readable message.
|
||||
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
|
||||
export const adminV1PermissionGroupSchema = z.object({
|
||||
id: z.string(),
|
||||
workspaceId: z.string(),
|
||||
workspaceName: z.string().nullable(),
|
||||
organizationId: z.string().nullable(),
|
||||
organizationId: z.string(),
|
||||
organizationName: z.string().nullable(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
isDefault: z.boolean(),
|
||||
memberCount: z.number(),
|
||||
createdAt: z.string(),
|
||||
createdByUserId: z.string(),
|
||||
@@ -25,7 +25,6 @@ export const adminV1PermissionGroupSchema = z.object({
|
||||
})
|
||||
|
||||
export const adminV1AccessControlQuerySchema = z.object({
|
||||
workspaceId: adminV1QueryStringSchema,
|
||||
organizationId: adminV1QueryStringSchema,
|
||||
})
|
||||
|
||||
@@ -33,8 +32,8 @@ export const adminV1AccessControlDeleteQuerySchema = adminV1AccessControlQuerySc
|
||||
.extend({
|
||||
reason: adminV1QueryStringSchema.default('Enterprise plan churn cleanup'),
|
||||
})
|
||||
.refine((query) => query.workspaceId || query.organizationId, {
|
||||
error: 'workspaceId or organizationId is required',
|
||||
.refine((query) => Boolean(query.organizationId), {
|
||||
error: 'organizationId is required',
|
||||
})
|
||||
|
||||
const adminV1AccessControlListResultSchema = z.object({
|
||||
|
||||
@@ -1061,6 +1061,19 @@ export async function removeUserFromOrganization(
|
||||
return usage
|
||||
}
|
||||
|
||||
// Permission groups are organization-scoped, so a departing member's group
|
||||
// membership must be cleared whenever they leave the org — including the
|
||||
// zero-workspace early return below (a group can exist with members but no
|
||||
// workspaces).
|
||||
await tx
|
||||
.delete(permissionGroupMember)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.userId, userId),
|
||||
eq(permissionGroupMember.organizationId, organizationId)
|
||||
)
|
||||
)
|
||||
|
||||
if (workspaceIds.length === 0) {
|
||||
const capturedUsage = await captureDepartedUsage()
|
||||
|
||||
@@ -1101,15 +1114,6 @@ export async function removeUserFromOrganization(
|
||||
)
|
||||
.returning({ entityId: permissions.entityId })
|
||||
|
||||
await tx
|
||||
.delete(permissionGroupMember)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.userId, userId),
|
||||
inArray(permissionGroupMember.workspaceId, workspaceIds)
|
||||
)
|
||||
)
|
||||
|
||||
const credentialMembershipsRevoked = await revokeWorkspaceCredentialMembershipsTx({
|
||||
tx,
|
||||
workspaceIds,
|
||||
@@ -1299,7 +1303,7 @@ export async function removeExternalUserFromOrganizationWorkspaces(params: {
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.userId, userId),
|
||||
inArray(permissionGroupMember.workspaceId, workspaceIds)
|
||||
eq(permissionGroupMember.organizationId, organizationId)
|
||||
)
|
||||
)
|
||||
.returning({ id: permissionGroupMember.id })
|
||||
|
||||
@@ -14,7 +14,6 @@ const {
|
||||
mockSetActiveOrganizationForCurrentSession,
|
||||
mockSyncUsageLimitsFromSubscription,
|
||||
mockSyncWorkspaceEnvCredentials,
|
||||
mockApplyWorkspaceAutoAddGroup,
|
||||
mockIsWorkspaceOnEnterprisePlan,
|
||||
mockFeatureFlags,
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -27,7 +26,6 @@ const {
|
||||
mockSetActiveOrganizationForCurrentSession: vi.fn(),
|
||||
mockSyncUsageLimitsFromSubscription: vi.fn(),
|
||||
mockSyncWorkspaceEnvCredentials: vi.fn(),
|
||||
mockApplyWorkspaceAutoAddGroup: vi.fn(),
|
||||
mockIsWorkspaceOnEnterprisePlan: vi.fn(async () => true),
|
||||
mockFeatureFlags: { isBillingEnabled: true },
|
||||
}))
|
||||
@@ -74,10 +72,6 @@ vi.mock('@/lib/credentials/environment', () => ({
|
||||
syncWorkspaceEnvCredentials: mockSyncWorkspaceEnvCredentials,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/permission-groups/auto-add', () => ({
|
||||
applyWorkspaceAutoAddGroup: mockApplyWorkspaceAutoAddGroup,
|
||||
}))
|
||||
|
||||
import { acceptInvitation } from '@/lib/invitations/core'
|
||||
|
||||
function queueWhereResponses(responses: unknown[][]) {
|
||||
@@ -171,7 +165,6 @@ describe('acceptInvitation', () => {
|
||||
expect(mockEnsureUserInOrganization).not.toHaveBeenCalled()
|
||||
expect(mockSetActiveOrganizationForCurrentSession).not.toHaveBeenCalled()
|
||||
expect(mockSyncUsageLimitsFromSubscription).not.toHaveBeenCalled()
|
||||
expect(mockApplyWorkspaceAutoAddGroup).toHaveBeenCalled()
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'external-user',
|
||||
@@ -503,7 +496,6 @@ describe('acceptInvitation', () => {
|
||||
expect(result.kind).toBe('already-processed')
|
||||
}
|
||||
// Aborted before granting workspace access — no zombie permission write.
|
||||
expect(mockApplyWorkspaceAutoAddGroup).not.toHaveBeenCalled()
|
||||
expect(mockSetActiveOrganizationForCurrentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,6 @@ import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray, lte } from 'drizzle-orm'
|
||||
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
|
||||
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import {
|
||||
acquireOrgMembershipLock,
|
||||
@@ -28,7 +27,6 @@ import { ensureTeamOrganizationForAcceptance } from '@/lib/billing/organizations
|
||||
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import { applyWorkspaceAutoAddGroup } from '@/lib/permission-groups/auto-add'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
@@ -434,14 +432,6 @@ export async function acceptInvitation(
|
||||
|
||||
const acceptedWorkspaceIds: string[] = []
|
||||
|
||||
// Resolved before the transaction: the entitlement check reads billing
|
||||
// tables on the global pool, which must not run while the tx below holds a
|
||||
// pooled connection and the org-membership advisory lock.
|
||||
const autoAddEntitlementByWorkspace = new Map<string, boolean>()
|
||||
for (const workspaceId of new Set(inv.grants.map((grant) => grant.workspaceId))) {
|
||||
autoAddEntitlementByWorkspace.set(workspaceId, await isWorkspaceOnEnterprisePlan(workspaceId))
|
||||
}
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
/**
|
||||
@@ -511,13 +501,6 @@ export async function acceptInvitation(
|
||||
})
|
||||
}
|
||||
|
||||
await applyWorkspaceAutoAddGroup(
|
||||
tx,
|
||||
grant.workspaceId,
|
||||
input.userId,
|
||||
autoAddEntitlementByWorkspace.get(grant.workspaceId) ?? false
|
||||
)
|
||||
|
||||
acceptedWorkspaceIds.push(grant.workspaceId)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { db } from '@sim/db'
|
||||
import { permissionGroup, permissionGroupMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
|
||||
const logger = createLogger('PermissionGroupsAutoAdd')
|
||||
|
||||
type DbClient = typeof db
|
||||
type TransactionClient = Parameters<Parameters<DbClient['transaction']>[0]>[0]
|
||||
type Client = DbClient | TransactionClient
|
||||
|
||||
/**
|
||||
* Add `userId` to the workspace's `autoAddNewMembers` permission group if one
|
||||
* exists, the workspace is entitled to access control, and the user is not
|
||||
* already a member of a group in that workspace. Safe to call unconditionally
|
||||
* on any workspace-permission grant; no-ops when access control isn't
|
||||
* available or no auto-add group is configured.
|
||||
*
|
||||
* `entitled` is the result of `isWorkspaceOnEnterprisePlan(workspaceId)`,
|
||||
* resolved by the caller. Callers running inside a transaction must resolve it
|
||||
* before opening the tx: the entitlement check queries billing tables on the
|
||||
* global pool, which would require a second pooled connection while the
|
||||
* transaction holds the first.
|
||||
*/
|
||||
export async function applyWorkspaceAutoAddGroup(
|
||||
client: Client,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
entitled: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!entitled) return
|
||||
|
||||
const [autoAddGroup] = await client
|
||||
.select({ id: permissionGroup.id })
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.workspaceId, workspaceId),
|
||||
eq(permissionGroup.autoAddNewMembers, true)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!autoAddGroup) return
|
||||
|
||||
const [existingMembership] = await client
|
||||
.select({ id: permissionGroupMember.id })
|
||||
.from(permissionGroupMember)
|
||||
.innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id))
|
||||
.where(
|
||||
and(eq(permissionGroupMember.userId, userId), eq(permissionGroup.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingMembership) return
|
||||
|
||||
await client.insert(permissionGroupMember).values({
|
||||
id: generateId(),
|
||||
permissionGroupId: autoAddGroup.id,
|
||||
workspaceId,
|
||||
userId,
|
||||
assignedBy: null,
|
||||
assignedAt: new Date(),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to auto-assign user to workspace permission group', {
|
||||
userId,
|
||||
workspaceId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const PERMISSION_GROUP_CONSTRAINTS = {
|
||||
organizationName: 'permission_group_organization_name_unique',
|
||||
organizationDefault: 'permission_group_organization_default_unique',
|
||||
} as const
|
||||
|
||||
export const PERMISSION_GROUP_MEMBER_CONSTRAINTS = {
|
||||
groupUser: 'permission_group_member_group_user_unique',
|
||||
workspaceUser: 'permission_group_member_workspace_user_unique',
|
||||
organizationUser: 'permission_group_member_organization_user_unique',
|
||||
} as const
|
||||
|
||||
export const permissionGroupConfigSchema = z.object({
|
||||
|
||||
@@ -371,17 +371,50 @@ export async function hasWorkspaceAdminAccess(
|
||||
return await isOrganizationAdminOrOwnerOfWorkspace(userId, ws)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a user is an owner or admin of a specific organization.
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @param organizationId - The ID of the organization to check
|
||||
* @returns Promise<boolean> - True when the user is the organization owner or an admin
|
||||
*/
|
||||
export async function isOrganizationAdminOrOwner(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
return row?.role === 'owner' || row?.role === 'admin'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a user is a member (any role) of a specific organization.
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @param organizationId - The ID of the organization to check
|
||||
* @returns Promise<boolean> - True when the user has an organization membership row
|
||||
*/
|
||||
export async function isOrganizationMember(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
return !!row
|
||||
}
|
||||
|
||||
async function isOrganizationAdminOrOwnerOfWorkspace(
|
||||
userId: string,
|
||||
ws: Pick<WorkspaceWithOwner, 'organizationId'>
|
||||
): Promise<boolean> {
|
||||
if (!ws.organizationId) return false
|
||||
const [row] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, ws.organizationId)))
|
||||
.limit(1)
|
||||
return row?.role === 'owner' || row?.role === 'admin'
|
||||
return isOrganizationAdminOrOwner(userId, ws.organizationId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
-- Rebase permission groups from workspace-scoped to organization-scoped.
|
||||
-- Hand-edited from the generated migration to preserve existing data: each
|
||||
-- workspace-scoped permission_group is re-pointed at the organization that owns
|
||||
-- its workspace, the legacy `auto_add_new_members` flag is carried into the new
|
||||
-- per-organization `is_default` flag, and per-workspace memberships collapse to
|
||||
-- "one group per user per organization". The final schema state matches the
|
||||
-- generated 0236 snapshot.
|
||||
--
|
||||
-- Deterministic consolidation rules (ordered by created_at, id):
|
||||
-- * groups whose workspace has no organization are dropped (org-only feature).
|
||||
-- * duplicate (organization_id, name) -> oldest keeps its name, later groups
|
||||
-- are suffixed " (2)", " (3)", ...
|
||||
-- * multiple default groups per organization -> only the oldest stays default.
|
||||
-- * duplicate (organization_id, user_id) memberships -> keep the membership in
|
||||
-- the oldest group, drop the rest.
|
||||
|
||||
-- 1. Drop the workspace-scoped foreign keys and unique indexes so the legacy
|
||||
-- columns can be removed and consolidation can run unconstrained.
|
||||
ALTER TABLE "permission_group" DROP CONSTRAINT "permission_group_workspace_id_workspace_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "permission_group_member" DROP CONSTRAINT "permission_group_member_workspace_id_workspace_id_fk";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX "permission_group_workspace_name_unique";--> statement-breakpoint
|
||||
DROP INDEX "permission_group_workspace_auto_add_unique";--> statement-breakpoint
|
||||
DROP INDEX "permission_group_member_workspace_user_unique";--> statement-breakpoint
|
||||
|
||||
-- 2. Add the new columns. organization_id starts NULLABLE so existing rows can be
|
||||
-- backfilled before NOT NULL is enforced (step 6). is_default defaults to
|
||||
-- false and is backfilled from auto_add_new_members in step 3.
|
||||
ALTER TABLE "permission_group" ADD COLUMN "organization_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "permission_group" ADD COLUMN "is_default" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "permission_group_member" ADD COLUMN "organization_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "permission_group" ADD CONSTRAINT "permission_group_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "permission_group_member" ADD CONSTRAINT "permission_group_member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
|
||||
-- 3. Backfill organization_id from the owning workspace, and carry the legacy
|
||||
-- auto-add flag into is_default.
|
||||
UPDATE "permission_group" pg SET "organization_id" = w."organization_id" FROM "workspace" w WHERE pg."workspace_id" = w."id";--> statement-breakpoint
|
||||
UPDATE "permission_group_member" pgm SET "organization_id" = w."organization_id" FROM "workspace" w WHERE pgm."workspace_id" = w."id";--> statement-breakpoint
|
||||
UPDATE "permission_group" SET "is_default" = true WHERE "auto_add_new_members" = true;--> statement-breakpoint
|
||||
|
||||
-- 4. Drop groups (and their members) whose workspace has no organization. The
|
||||
-- feature is organization-only; there is nothing to re-scope for null-org
|
||||
-- (personal / grandfathered) workspaces.
|
||||
DELETE FROM "permission_group_member" WHERE "organization_id" IS NULL;--> statement-breakpoint
|
||||
DELETE FROM "permission_group" WHERE "organization_id" IS NULL;--> statement-breakpoint
|
||||
|
||||
-- 5a. Resolve (organization_id, name) collisions: oldest keeps its name, later
|
||||
-- groups get a numeric suffix.
|
||||
UPDATE "permission_group" pg
|
||||
SET "name" = pg."name" || ' (' || ranked.rn || ')'
|
||||
FROM (
|
||||
SELECT "id",
|
||||
row_number() OVER (PARTITION BY "organization_id", "name" ORDER BY "created_at", "id") AS rn
|
||||
FROM "permission_group"
|
||||
) ranked
|
||||
WHERE pg."id" = ranked."id" AND ranked.rn > 1;--> statement-breakpoint
|
||||
|
||||
-- 5b. Collapse multiple default groups per organization down to the oldest one.
|
||||
UPDATE "permission_group" pg
|
||||
SET "is_default" = false
|
||||
FROM (
|
||||
SELECT "id",
|
||||
row_number() OVER (PARTITION BY "organization_id" ORDER BY "created_at", "id") AS rn
|
||||
FROM "permission_group"
|
||||
WHERE "is_default" = true
|
||||
) ranked
|
||||
WHERE pg."id" = ranked."id" AND ranked.rn > 1;--> statement-breakpoint
|
||||
|
||||
-- 5c. Deduplicate memberships to one group per (organization_id, user_id),
|
||||
-- keeping the membership in the oldest group.
|
||||
DELETE FROM "permission_group_member" pgm
|
||||
USING (
|
||||
SELECT m."id",
|
||||
row_number() OVER (
|
||||
PARTITION BY m."organization_id", m."user_id"
|
||||
ORDER BY g."created_at", g."id", m."assigned_at", m."id"
|
||||
) AS rn
|
||||
FROM "permission_group_member" m
|
||||
JOIN "permission_group" g ON g."id" = m."permission_group_id"
|
||||
) ranked
|
||||
WHERE pgm."id" = ranked."id" AND ranked.rn > 1;--> statement-breakpoint
|
||||
|
||||
-- 6. Enforce NOT NULL now that every surviving row has an organization_id.
|
||||
ALTER TABLE "permission_group" ALTER COLUMN "organization_id" SET NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "permission_group_member" ALTER COLUMN "organization_id" SET NOT NULL;--> statement-breakpoint
|
||||
|
||||
-- 7. Create the organization-scoped indexes (after consolidation so they do not
|
||||
-- trip on the pre-consolidation duplicates).
|
||||
CREATE UNIQUE INDEX "permission_group_organization_name_unique" ON "permission_group" USING btree ("organization_id","name");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "permission_group_organization_default_unique" ON "permission_group" USING btree ("organization_id") WHERE is_default = true;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "permission_group_member_organization_user_unique" ON "permission_group_member" USING btree ("organization_id","user_id");--> statement-breakpoint
|
||||
|
||||
-- 8. Drop the legacy workspace-scoped columns (kept until now for the backfill).
|
||||
ALTER TABLE "permission_group" DROP COLUMN "workspace_id";--> statement-breakpoint
|
||||
ALTER TABLE "permission_group" DROP COLUMN "auto_add_new_members";--> statement-breakpoint
|
||||
ALTER TABLE "permission_group_member" DROP COLUMN "workspace_id";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1646,6 +1646,13 @@
|
||||
"when": 1781386157632,
|
||||
"tag": "0235_schedule_task_contexts_recurrence",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 236,
|
||||
"version": "7",
|
||||
"when": 1781394340181,
|
||||
"tag": "0236_org_scope_permission_groups",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+12
-12
@@ -2926,9 +2926,9 @@ export const permissionGroup = pgTable(
|
||||
'permission_group',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
workspaceId: text('workspace_id')
|
||||
organizationId: text('organization_id')
|
||||
.notNull()
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
.references(() => organization.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
config: jsonb('config').notNull().default('{}'),
|
||||
@@ -2937,17 +2937,17 @@ export const permissionGroup = pgTable(
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
autoAddNewMembers: boolean('auto_add_new_members').notNull().default(false),
|
||||
isDefault: boolean('is_default').notNull().default(false),
|
||||
},
|
||||
(table) => ({
|
||||
createdByIdx: index('permission_group_created_by_idx').on(table.createdBy),
|
||||
workspaceNameUnique: uniqueIndex('permission_group_workspace_name_unique').on(
|
||||
table.workspaceId,
|
||||
organizationNameUnique: uniqueIndex('permission_group_organization_name_unique').on(
|
||||
table.organizationId,
|
||||
table.name
|
||||
),
|
||||
autoAddNewMembersUnique: uniqueIndex('permission_group_workspace_auto_add_unique')
|
||||
.on(table.workspaceId)
|
||||
.where(sql`auto_add_new_members = true`),
|
||||
defaultGroupUnique: uniqueIndex('permission_group_organization_default_unique')
|
||||
.on(table.organizationId)
|
||||
.where(sql`is_default = true`),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -2958,9 +2958,9 @@ export const permissionGroupMember = pgTable(
|
||||
permissionGroupId: text('permission_group_id')
|
||||
.notNull()
|
||||
.references(() => permissionGroup.id, { onDelete: 'cascade' }),
|
||||
workspaceId: text('workspace_id')
|
||||
organizationId: text('organization_id')
|
||||
.notNull()
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
.references(() => organization.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
@@ -2973,8 +2973,8 @@ export const permissionGroupMember = pgTable(
|
||||
table.permissionGroupId,
|
||||
table.userId
|
||||
),
|
||||
workspaceUserUnique: uniqueIndex('permission_group_member_workspace_user_unique').on(
|
||||
table.workspaceId,
|
||||
organizationUserUnique: uniqueIndex('permission_group_member_organization_user_unique').on(
|
||||
table.organizationId,
|
||||
table.userId
|
||||
),
|
||||
})
|
||||
|
||||
@@ -1024,11 +1024,12 @@ export const schemaMock = {
|
||||
createdBy: 'createdBy',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
autoAddNewMembers: 'autoAddNewMembers',
|
||||
isDefault: 'isDefault',
|
||||
},
|
||||
permissionGroupMember: {
|
||||
id: 'id',
|
||||
permissionGroupId: 'permissionGroupId',
|
||||
organizationId: 'organizationId',
|
||||
userId: 'userId',
|
||||
assignedBy: 'assignedBy',
|
||||
assignedAt: 'assignedAt',
|
||||
|
||||
Reference in New Issue
Block a user