Compare commits

...

1 Commits

Author SHA1 Message Date
Vikhyath Mondreti
47eb060311 feat(enterprise): permission groups, access control (#2736)
* feat(permission-groups): integration/model access controls for enterprise

* feat: enterprise gating for BYOK, SSO, credential sets with org admin/owner checks

* execution time enforcement of mcp and custom tools

* add admin routes to cleanup permission group data

* fix not being on enterprise checks

* separate out orgs from billing system

* update the docs

* add custom tool blockers based on perm configs

* add migrations

* fix

* address greptile comments

* regen migrations

* fix default model picking based on user config

* cleaned up UI
2026-01-09 20:16:22 -08:00
67 changed files with 13669 additions and 478 deletions

View File

@@ -1,6 +1,6 @@
---
title: Enterprise
description: Enterprise features for organizations with advanced security and compliance requirements
description: Enterprise features for business organizations
---
import { Callout } from 'fumadocs-ui/components/callout'
@@ -9,6 +9,28 @@ Sim Studio Enterprise provides advanced features for organizations with enhanced
---
## Access Control
Define permission groups to control what features and integrations team members can use.
### Features
- **Allowed Model Providers** - Restrict which AI providers users can access (OpenAI, Anthropic, Google, etc.)
- **Allowed Blocks** - Control which workflow blocks are available
- **Platform Settings** - Hide Knowledge Base, disable MCP tools, or disable custom tools
### Setup
1. Navigate to **Settings** → **Access Control** in your workspace
2. Create a permission group with your desired restrictions
3. Add team members to the permission group
<Callout type="info">
Users not assigned to any permission group have full access. Permission restrictions are enforced at both UI and execution time.
</Callout>
---
## Bring Your Own Key (BYOK)
Use your own API keys for AI model providers instead of Sim Studio's hosted keys.
@@ -61,15 +83,38 @@ Enterprise authentication with SAML 2.0 and OIDC support for centralized identit
---
## Self-Hosted
## Self-Hosted Configuration
For self-hosted deployments, enterprise features can be enabled via environment variables:
For self-hosted deployments, enterprise features can be enabled via environment variables without requiring billing.
### Environment Variables
| Variable | Description |
|----------|-------------|
| `ORGANIZATIONS_ENABLED`, `NEXT_PUBLIC_ORGANIZATIONS_ENABLED` | Enable team/organization management |
| `ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` | Permission groups for access restrictions |
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On with SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling Groups for email triggers |
<Callout type="warn">
BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables.
</Callout>
### Organization Management
When billing is disabled, use the Admin API to manage organizations:
```bash
# Create an organization
curl -X POST https://your-instance/api/v1/admin/organizations \
-H "x-admin-key: YOUR_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "My Organization", "ownerId": "user-id-here"}'
# Add a member
curl -X POST https://your-instance/api/v1/admin/organizations/{orgId}/members \
-H "x-admin-key: YOUR_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"userId": "user-id-here", "role": "admin"}'
```
### Notes
- Enabling `ACCESS_CONTROL_ENABLED` automatically enables organizations, as access control requires organization membership.
- BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables.

View File

@@ -0,0 +1,166 @@
import { db } from '@sim/db'
import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
const logger = createLogger('PermissionGroupBulkMembers')
async function getPermissionGroupWithAccess(groupId: string, userId: string) {
const [group] = await db
.select({
id: permissionGroup.id,
organizationId: permissionGroup.organizationId,
})
.from(permissionGroup)
.where(eq(permissionGroup.id, groupId))
.limit(1)
if (!group) return null
const [membership] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, group.organizationId)))
.limit(1)
if (!membership) return null
return { group, role: membership.role }
}
const bulkAddSchema = z.object({
userIds: z.array(z.string()).optional(),
addAllOrgMembers: z.boolean().optional(),
})
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const hasAccess = await hasAccessControlAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Access Control is an Enterprise feature' },
{ status: 403 }
)
}
const result = await getPermissionGroupWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
if (result.role !== 'admin' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const body = await req.json()
const { userIds, addAllOrgMembers } = bulkAddSchema.parse(body)
let targetUserIds: string[] = []
if (addAllOrgMembers) {
const orgMembers = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, result.group.organizationId))
targetUserIds = orgMembers.map((m) => m.userId)
} else if (userIds && userIds.length > 0) {
const validMembers = await db
.select({ userId: member.userId })
.from(member)
.where(
and(
eq(member.organizationId, result.group.organizationId),
inArray(member.userId, userIds)
)
)
targetUserIds = validMembers.map((m) => m.userId)
}
if (targetUserIds.length === 0) {
return NextResponse.json({ added: 0, moved: 0 })
}
const existingMemberships = await db
.select({
id: permissionGroupMember.id,
userId: permissionGroupMember.userId,
permissionGroupId: permissionGroupMember.permissionGroupId,
})
.from(permissionGroupMember)
.where(inArray(permissionGroupMember.userId, targetUserIds))
const alreadyInThisGroup = new Set(
existingMemberships.filter((m) => m.permissionGroupId === id).map((m) => m.userId)
)
const usersToAdd = targetUserIds.filter((uid) => !alreadyInThisGroup.has(uid))
if (usersToAdd.length === 0) {
return NextResponse.json({ added: 0, moved: 0 })
}
const membershipsToDelete = existingMemberships.filter(
(m) => m.permissionGroupId !== id && usersToAdd.includes(m.userId)
)
const movedCount = membershipsToDelete.length
await db.transaction(async (tx) => {
if (membershipsToDelete.length > 0) {
await tx.delete(permissionGroupMember).where(
inArray(
permissionGroupMember.id,
membershipsToDelete.map((m) => m.id)
)
)
}
const newMembers = usersToAdd.map((userId) => ({
id: crypto.randomUUID(),
permissionGroupId: id,
userId,
assignedBy: session.user.id,
assignedAt: new Date(),
}))
await tx.insert(permissionGroupMember).values(newMembers)
})
logger.info('Bulk added members to permission group', {
permissionGroupId: id,
addedCount: usersToAdd.length,
movedCount,
assignedBy: session.user.id,
})
return NextResponse.json({ added: usersToAdd.length, moved: movedCount })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
}
if (
error instanceof Error &&
error.message.includes('permission_group_member_user_id_unique')
) {
return NextResponse.json(
{ error: 'One or more users are already in a permission group' },
{ status: 409 }
)
}
logger.error('Error bulk adding members to permission group', error)
return NextResponse.json({ error: 'Failed to add members' }, { status: 500 })
}
}

View File

@@ -0,0 +1,229 @@
import { db } from '@sim/db'
import { member, permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
const logger = createLogger('PermissionGroupMembers')
async function getPermissionGroupWithAccess(groupId: string, userId: string) {
const [group] = await db
.select({
id: permissionGroup.id,
organizationId: permissionGroup.organizationId,
})
.from(permissionGroup)
.where(eq(permissionGroup.id, groupId))
.limit(1)
if (!group) return null
const [membership] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, group.organizationId)))
.limit(1)
if (!membership) return null
return { group, role: membership.role }
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const result = await getPermissionGroupWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
const members = await db
.select({
id: permissionGroupMember.id,
userId: permissionGroupMember.userId,
assignedAt: permissionGroupMember.assignedAt,
userName: user.name,
userEmail: user.email,
userImage: user.image,
})
.from(permissionGroupMember)
.leftJoin(user, eq(permissionGroupMember.userId, user.id))
.where(eq(permissionGroupMember.permissionGroupId, id))
return NextResponse.json({ members })
}
const addMemberSchema = z.object({
userId: z.string().min(1),
})
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const hasAccess = await hasAccessControlAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Access Control is an Enterprise feature' },
{ status: 403 }
)
}
const result = await getPermissionGroupWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
if (result.role !== 'admin' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const body = await req.json()
const { userId } = addMemberSchema.parse(body)
const [orgMember] = await db
.select({ id: member.id })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, result.group.organizationId)))
.limit(1)
if (!orgMember) {
return NextResponse.json(
{ error: 'User is not a member of this organization' },
{ status: 400 }
)
}
const [existingMembership] = await db
.select({
id: permissionGroupMember.id,
permissionGroupId: permissionGroupMember.permissionGroupId,
})
.from(permissionGroupMember)
.where(eq(permissionGroupMember.userId, userId))
.limit(1)
if (existingMembership?.permissionGroupId === id) {
return NextResponse.json(
{ error: 'User is already in this permission group' },
{ status: 409 }
)
}
const newMember = await db.transaction(async (tx) => {
if (existingMembership) {
await tx
.delete(permissionGroupMember)
.where(eq(permissionGroupMember.id, existingMembership.id))
}
const memberData = {
id: crypto.randomUUID(),
permissionGroupId: id,
userId,
assignedBy: session.user.id,
assignedAt: new Date(),
}
await tx.insert(permissionGroupMember).values(memberData)
return memberData
})
logger.info('Added member to permission group', {
permissionGroupId: id,
userId,
assignedBy: session.user.id,
})
return NextResponse.json({ member: newMember }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
}
if (
error instanceof Error &&
error.message.includes('permission_group_member_user_id_unique')
) {
return NextResponse.json({ error: 'User is already in a permission group' }, { status: 409 })
}
logger.error('Error adding member to permission group', error)
return NextResponse.json({ error: 'Failed to add member' }, { status: 500 })
}
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const { searchParams } = new URL(req.url)
const memberId = searchParams.get('memberId')
if (!memberId) {
return NextResponse.json({ error: 'memberId is required' }, { status: 400 })
}
try {
const hasAccess = await hasAccessControlAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Access Control is an Enterprise feature' },
{ status: 403 }
)
}
const result = await getPermissionGroupWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
if (result.role !== 'admin' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const [memberToRemove] = await db
.select()
.from(permissionGroupMember)
.where(
and(eq(permissionGroupMember.id, memberId), eq(permissionGroupMember.permissionGroupId, id))
)
.limit(1)
if (!memberToRemove) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
await db.delete(permissionGroupMember).where(eq(permissionGroupMember.id, memberId))
logger.info('Removed member from permission group', {
permissionGroupId: id,
memberId,
userId: session.user.id,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error removing member from permission group', error)
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
}
}

View File

@@ -0,0 +1,212 @@
import { db } from '@sim/db'
import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import {
type PermissionGroupConfig,
parsePermissionGroupConfig,
} from '@/lib/permission-groups/types'
const logger = createLogger('PermissionGroup')
const configSchema = z.object({
allowedIntegrations: z.array(z.string()).nullable().optional(),
allowedModelProviders: z.array(z.string()).nullable().optional(),
hideTraceSpans: z.boolean().optional(),
hideKnowledgeBaseTab: z.boolean().optional(),
hideCopilot: z.boolean().optional(),
hideApiKeysTab: z.boolean().optional(),
hideEnvironmentTab: z.boolean().optional(),
hideFilesTab: z.boolean().optional(),
disableMcpTools: z.boolean().optional(),
disableCustomTools: z.boolean().optional(),
hideTemplates: z.boolean().optional(),
})
const updateSchema = z.object({
name: z.string().trim().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
config: configSchema.optional(),
})
async function getPermissionGroupWithAccess(groupId: string, userId: 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,
})
.from(permissionGroup)
.where(eq(permissionGroup.id, groupId))
.limit(1)
if (!group) return null
const [membership] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, group.organizationId)))
.limit(1)
if (!membership) return null
return { group, role: membership.role }
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const result = await getPermissionGroupWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
return NextResponse.json({
permissionGroup: {
...result.group,
config: parsePermissionGroupConfig(result.group.config),
},
})
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const hasAccess = await hasAccessControlAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Access Control is an Enterprise feature' },
{ status: 403 }
)
}
const result = await getPermissionGroupWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
if (result.role !== 'admin' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const body = await req.json()
const updates = updateSchema.parse(body)
if (updates.name) {
const existingGroup = await db
.select({ id: permissionGroup.id })
.from(permissionGroup)
.where(
and(
eq(permissionGroup.organizationId, result.group.organizationId),
eq(permissionGroup.name, updates.name)
)
)
.limit(1)
if (existingGroup.length > 0 && existingGroup[0].id !== id) {
return NextResponse.json(
{ error: 'A permission group with this name already exists' },
{ status: 409 }
)
}
}
const currentConfig = parsePermissionGroupConfig(result.group.config)
const newConfig: PermissionGroupConfig = updates.config
? { ...currentConfig, ...updates.config }
: currentConfig
await db
.update(permissionGroup)
.set({
...(updates.name !== undefined && { name: updates.name }),
...(updates.description !== undefined && { description: updates.description }),
config: newConfig,
updatedAt: new Date(),
})
.where(eq(permissionGroup.id, id))
const [updated] = await db
.select()
.from(permissionGroup)
.where(eq(permissionGroup.id, id))
.limit(1)
return NextResponse.json({
permissionGroup: {
...updated,
config: parsePermissionGroupConfig(updated.config),
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
}
logger.error('Error updating permission group', error)
return NextResponse.json({ error: 'Failed to update permission group' }, { status: 500 })
}
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const hasAccess = await hasAccessControlAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Access Control is an Enterprise feature' },
{ status: 403 }
)
}
const result = await getPermissionGroupWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
}
if (result.role !== 'admin' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
await db.delete(permissionGroupMember).where(eq(permissionGroupMember.permissionGroupId, id))
await db.delete(permissionGroup).where(eq(permissionGroup.id, id))
logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id })
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting permission group', error)
return NextResponse.json({ error: 'Failed to delete permission group' }, { status: 500 })
}
}

View File

@@ -0,0 +1,185 @@
import { db } from '@sim/db'
import { member, organization, permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, desc, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import {
DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig,
parsePermissionGroupConfig,
} from '@/lib/permission-groups/types'
const logger = createLogger('PermissionGroups')
const configSchema = z.object({
allowedIntegrations: z.array(z.string()).nullable().optional(),
allowedModelProviders: z.array(z.string()).nullable().optional(),
hideTraceSpans: z.boolean().optional(),
hideKnowledgeBaseTab: z.boolean().optional(),
hideCopilot: z.boolean().optional(),
hideApiKeysTab: z.boolean().optional(),
hideEnvironmentTab: z.boolean().optional(),
hideFilesTab: z.boolean().optional(),
disableMcpTools: z.boolean().optional(),
disableCustomTools: z.boolean().optional(),
hideTemplates: z.boolean().optional(),
})
const createSchema = z.object({
organizationId: z.string().min(1),
name: z.string().trim().min(1).max(100),
description: z.string().max(500).optional(),
config: configSchema.optional(),
})
export async function GET(req: Request) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const organizationId = searchParams.get('organizationId')
if (!organizationId) {
return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
}
const membership = await db
.select({ id: member.id, role: member.role })
.from(member)
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
.limit(1)
if (membership.length === 0) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const groups = await db
.select({
id: permissionGroup.id,
name: permissionGroup.name,
description: permissionGroup.description,
config: permissionGroup.config,
createdBy: permissionGroup.createdBy,
createdAt: permissionGroup.createdAt,
updatedAt: permissionGroup.updatedAt,
creatorName: user.name,
creatorEmail: user.email,
})
.from(permissionGroup)
.leftJoin(user, eq(permissionGroup.createdBy, user.id))
.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() })
.from(permissionGroupMember)
.where(eq(permissionGroupMember.permissionGroupId, group.id))
return {
...group,
config: parsePermissionGroupConfig(group.config),
memberCount: memberCount?.count ?? 0,
}
})
)
return NextResponse.json({ permissionGroups: groupsWithCounts })
}
export async function POST(req: Request) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const hasAccess = await hasAccessControlAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Access Control is an Enterprise feature' },
{ status: 403 }
)
}
const body = await req.json()
const { organizationId, name, description, config } = createSchema.parse(body)
const membership = await db
.select({ id: member.id, role: member.role })
.from(member)
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
.limit(1)
const role = membership[0]?.role
if (membership.length === 0 || (role !== 'admin' && role !== 'owner')) {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const orgExists = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (orgExists.length === 0) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
const existingGroup = await db
.select({ id: permissionGroup.id })
.from(permissionGroup)
.where(
and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.name, name))
)
.limit(1)
if (existingGroup.length > 0) {
return NextResponse.json(
{ error: 'A permission group with this name already exists' },
{ status: 409 }
)
}
const groupConfig: PermissionGroupConfig = {
...DEFAULT_PERMISSION_GROUP_CONFIG,
...config,
}
const now = new Date()
const newGroup = {
id: crypto.randomUUID(),
organizationId,
name,
description: description || null,
config: groupConfig,
createdBy: session.user.id,
createdAt: now,
updatedAt: now,
}
await db.insert(permissionGroup).values(newGroup)
logger.info('Created permission group', {
permissionGroupId: newGroup.id,
organizationId,
userId: session.user.id,
})
return NextResponse.json({ permissionGroup: newGroup }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
}
logger.error('Error creating permission group', error)
return NextResponse.json({ error: 'Failed to create permission group' }, { status: 500 })
}
}

View File

@@ -0,0 +1,72 @@
import { db } from '@sim/db'
import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
import { parsePermissionGroupConfig } from '@/lib/permission-groups/types'
export async function GET(req: Request) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const organizationId = searchParams.get('organizationId')
if (!organizationId) {
return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
}
const [membership] = await db
.select({ id: member.id })
.from(member)
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Not a member of this organization' }, { status: 403 })
}
// Short-circuit: if org is not on enterprise plan, ignore permission configs
const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
if (!isEnterprise) {
return NextResponse.json({
permissionGroupId: null,
groupName: null,
config: null,
})
}
const [groupMembership] = await db
.select({
permissionGroupId: permissionGroupMember.permissionGroupId,
config: permissionGroup.config,
groupName: permissionGroup.name,
})
.from(permissionGroupMember)
.innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id))
.where(
and(
eq(permissionGroupMember.userId, session.user.id),
eq(permissionGroup.organizationId, organizationId)
)
)
.limit(1)
if (!groupMembership) {
return NextResponse.json({
permissionGroupId: null,
groupName: null,
config: null,
})
}
return NextResponse.json({
permissionGroupId: groupMembership.permissionGroupId,
groupName: groupMembership.groupName,
config: parsePermissionGroupConfig(groupMembership.config),
})
}

View File

@@ -0,0 +1,169 @@
/**
* Admin Access Control (Permission Groups) API
*
* GET /api/v1/admin/access-control
* List all permission groups with optional filtering.
*
* Query Parameters:
* - organizationId?: string - Filter by organization ID
*
* Response: { data: AdminPermissionGroup[], pagination: PaginationMeta }
*
* DELETE /api/v1/admin/access-control
* Delete permission groups for an organization.
* Used when an enterprise plan churns to clean up access control data.
*
* Query Parameters:
* - organizationId: string - Delete all permission groups for this organization
*
* Response: { success: true, deletedCount: number, membersRemoved: number }
*/
import { db } from '@sim/db'
import { organization, permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq, inArray, sql } from 'drizzle-orm'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminAccessControlAPI')
export interface AdminPermissionGroup {
id: string
organizationId: string
organizationName: string | null
name: string
description: string | null
memberCount: number
createdAt: string
createdByUserId: string
createdByEmail: string | null
}
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const organizationId = url.searchParams.get('organizationId')
try {
const baseQuery = db
.select({
id: permissionGroup.id,
organizationId: permissionGroup.organizationId,
organizationName: organization.name,
name: permissionGroup.name,
description: permissionGroup.description,
createdAt: permissionGroup.createdAt,
createdByUserId: permissionGroup.createdBy,
createdByEmail: user.email,
})
.from(permissionGroup)
.leftJoin(organization, eq(permissionGroup.organizationId, organization.id))
.leftJoin(user, eq(permissionGroup.createdBy, user.id))
let groups
if (organizationId) {
groups = await baseQuery.where(eq(permissionGroup.organizationId, organizationId))
} else {
groups = await baseQuery
}
const groupsWithCounts = await Promise.all(
groups.map(async (group) => {
const [memberCount] = await db
.select({ count: count() })
.from(permissionGroupMember)
.where(eq(permissionGroupMember.permissionGroupId, group.id))
return {
id: group.id,
organizationId: group.organizationId,
organizationName: group.organizationName,
name: group.name,
description: group.description,
memberCount: memberCount?.count ?? 0,
createdAt: group.createdAt.toISOString(),
createdByUserId: group.createdByUserId,
createdByEmail: group.createdByEmail,
} as AdminPermissionGroup
})
)
logger.info('Admin API: Listed permission groups', {
organizationId,
count: groupsWithCounts.length,
})
return singleResponse({
data: groupsWithCounts,
pagination: {
total: groupsWithCounts.length,
limit: groupsWithCounts.length,
offset: 0,
hasMore: false,
},
})
} catch (error) {
logger.error('Admin API: Failed to list permission groups', { error, organizationId })
return internalErrorResponse('Failed to list permission groups')
}
})
export const DELETE = withAdminAuth(async (request) => {
const url = new URL(request.url)
const organizationId = url.searchParams.get('organizationId')
const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup'
if (!organizationId) {
return badRequestResponse('organizationId is required')
}
try {
const existingGroups = await db
.select({ id: permissionGroup.id })
.from(permissionGroup)
.where(eq(permissionGroup.organizationId, organizationId))
if (existingGroups.length === 0) {
logger.info('Admin API: No permission groups to delete', { organizationId })
return singleResponse({
success: true,
deletedCount: 0,
membersRemoved: 0,
message: 'No permission groups found for the given organization',
})
}
const groupIds = existingGroups.map((g) => g.id)
const [memberCountResult] = await db
.select({ count: sql<number>`count(*)` })
.from(permissionGroupMember)
.where(inArray(permissionGroupMember.permissionGroupId, groupIds))
const membersToRemove = Number(memberCountResult?.count ?? 0)
// Members are deleted via cascade when permission groups are deleted
await db.delete(permissionGroup).where(eq(permissionGroup.organizationId, organizationId))
logger.info('Admin API: Deleted permission groups', {
organizationId,
deletedCount: existingGroups.length,
membersRemoved: membersToRemove,
reason,
})
return singleResponse({
success: true,
deletedCount: existingGroups.length,
membersRemoved: membersToRemove,
reason,
})
} catch (error) {
logger.error('Admin API: Failed to delete permission groups', { error, organizationId })
return internalErrorResponse('Failed to delete permission groups')
}
})

View File

@@ -36,6 +36,7 @@
*
* Organizations:
* GET /api/v1/admin/organizations - List all organizations
* POST /api/v1/admin/organizations - Create organization (requires ownerId)
* GET /api/v1/admin/organizations/:id - Get organization details
* PATCH /api/v1/admin/organizations/:id - Update organization
* GET /api/v1/admin/organizations/:id/members - List organization members
@@ -55,6 +56,10 @@
* BYOK Keys:
* GET /api/v1/admin/byok - List BYOK keys (?organizationId=X or ?workspaceId=X)
* DELETE /api/v1/admin/byok - Delete BYOK keys for org/workspace
*
* Access Control (Permission Groups):
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)
*/
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'

View File

@@ -16,10 +16,11 @@
*/
import { db } from '@sim/db'
import { organization } from '@sim/db/schema'
import { member, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { count, eq } from 'drizzle-orm'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -39,6 +40,42 @@ export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
const { id: organizationId } = await context.params
try {
if (!isBillingEnabled) {
const [[orgData], [memberCount]] = await Promise.all([
db.select().from(organization).where(eq(organization.id, organizationId)).limit(1),
db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)),
])
if (!orgData) {
return notFoundResponse('Organization')
}
const data: AdminOrganizationBillingSummary = {
organizationId: orgData.id,
organizationName: orgData.name,
subscriptionPlan: 'none',
subscriptionStatus: 'none',
totalSeats: Number.MAX_SAFE_INTEGER,
usedSeats: memberCount?.count || 0,
availableSeats: Number.MAX_SAFE_INTEGER,
totalCurrentUsage: 0,
totalUsageLimit: Number.MAX_SAFE_INTEGER,
minimumBillingAmount: 0,
averageUsagePerMember: 0,
usagePercentage: 0,
billingPeriodStart: null,
billingPeriodEnd: null,
membersOverLimit: 0,
membersNearLimit: 0,
}
logger.info(
`Admin API: Retrieved billing summary for organization ${organizationId} (billing disabled)`
)
return singleResponse(data)
}
const billingData = await getOrganizationBillingData(organizationId)
if (!billingData) {

View File

@@ -30,6 +30,7 @@ import { member, organization, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -182,7 +183,7 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: organizationId, memberId } = await context.params
const url = new URL(request.url)
const skipBillingLogic = url.searchParams.get('skipBillingLogic') === 'true'
const skipBillingLogic = !isBillingEnabled || url.searchParams.get('skipBillingLogic') === 'true'
try {
const [orgData] = await db

View File

@@ -34,6 +34,7 @@ import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -221,14 +222,14 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
userId: body.userId,
organizationId,
role: body.role,
skipBillingLogic: !isBillingEnabled,
})
if (!result.success) {
return badRequestResponse(result.error || 'Failed to add member')
}
// Sync Pro subscription cancellation with Stripe (same as invitation flow)
if (result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) {
if (isBillingEnabled && result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) {
try {
const stripe = requireStripeClient()
await stripe.subscriptions.update(

View File

@@ -8,14 +8,32 @@
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminOrganization>
*
* POST /api/v1/admin/organizations
*
* Create a new organization.
*
* Body:
* - name: string - Organization name (required)
* - slug: string - Organization slug (optional, auto-generated from name if not provided)
* - ownerId: string - User ID of the organization owner (required)
*
* Response: AdminSingleResponse<AdminOrganization & { memberId: string }>
*/
import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import { organization } from '@sim/db/schema'
import { member, organization, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count } from 'drizzle-orm'
import { count, eq } from 'drizzle-orm'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminOrganization,
createPaginationMeta,
@@ -47,3 +65,90 @@ export const GET = withAdminAuth(async (request) => {
return internalErrorResponse('Failed to list organizations')
}
})
export const POST = withAdminAuth(async (request) => {
try {
const body = await request.json()
if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
return badRequestResponse('name is required')
}
if (!body.ownerId || typeof body.ownerId !== 'string') {
return badRequestResponse('ownerId is required')
}
const [ownerData] = await db
.select({ id: user.id, name: user.name })
.from(user)
.where(eq(user.id, body.ownerId))
.limit(1)
if (!ownerData) {
return notFoundResponse('Owner user')
}
const [existingMembership] = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, body.ownerId))
.limit(1)
if (existingMembership) {
return badRequestResponse(
'User is already a member of another organization. Users can only belong to one organization at a time.'
)
}
const name = body.name.trim()
const slug =
body.slug?.trim() ||
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
const organizationId = randomUUID()
const memberId = randomUUID()
const now = new Date()
await db.transaction(async (tx) => {
await tx.insert(organization).values({
id: organizationId,
name,
slug,
createdAt: now,
updatedAt: now,
})
await tx.insert(member).values({
id: memberId,
userId: body.ownerId,
organizationId,
role: 'owner',
createdAt: now,
})
})
const [createdOrg] = await db
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
logger.info(`Admin API: Created organization ${organizationId}`, {
name,
slug,
ownerId: body.ownerId,
memberId,
})
return singleResponse({
...toAdminOrganization(createdOrg),
memberId,
})
} catch (error) {
logger.error('Admin API: Failed to create organization', { error })
return internalErrorResponse('Failed to create organization')
}
})

View File

@@ -1 +1,33 @@
export { Knowledge as default } from './knowledge'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { Knowledge } from './knowledge'
interface KnowledgePageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function KnowledgePage({ params }: KnowledgePageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
// Check permission group restrictions
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideKnowledgeBaseTab) {
redirect(`/workspace/${workspaceId}`)
}
return <Knowledge />
}

View File

@@ -17,6 +17,7 @@ import {
StatusBadge,
TriggerBadge,
} from '@/app/workspace/[workspaceId]/logs/utils'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { formatCost } from '@/providers/utils'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useLogDetailsUIStore } from '@/stores/logs/store'
@@ -57,6 +58,7 @@ export const LogDetails = memo(function LogDetails({
const scrollAreaRef = useRef<HTMLDivElement>(null)
const panelWidth = useLogDetailsUIStore((state) => state.panelWidth)
const { handleMouseDown } = useLogDetailsResize()
const { config: permissionConfig } = usePermissionConfig()
useEffect(() => {
if (scrollAreaRef.current) {
@@ -264,7 +266,7 @@ export const LogDetails = memo(function LogDetails({
</div>
{/* Workflow State */}
{isWorkflowExecutionLog && log.executionId && (
{isWorkflowExecutionLog && log.executionId && !permissionConfig.hideTraceSpans && (
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow State
@@ -282,12 +284,14 @@ export const LogDetails = memo(function LogDetails({
)}
{/* Workflow Execution - Trace Spans */}
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
<TraceSpans
traceSpans={log.executionData.traceSpans}
totalDuration={log.executionData.totalDuration}
/>
)}
{isWorkflowExecutionLog &&
log.executionData?.traceSpans &&
!permissionConfig.hideTraceSpans && (
<TraceSpans
traceSpans={log.executionData.traceSpans}
totalDuration={log.executionData.totalDuration}
/>
)}
{/* Files */}
{log.files && log.files.length > 0 && (

View File

@@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
interface TemplatesPageProps {
params: Promise<{
@@ -32,6 +33,12 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
redirect('/')
}
// Check permission group restrictions
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideTemplates) {
redirect(`/workspace/${workspaceId}`)
}
// Determine effective super user (DB flag AND UI mode enabled)
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useShallow } from 'zustand/react/shallow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -92,6 +93,8 @@ interface UseMentionDataProps {
export function useMentionData(props: UseMentionDataProps) {
const { workflowId, workspaceId } = props
const { config, isBlockAllowed } = usePermissionConfig()
const [pastChats, setPastChats] = useState<PastChat[]>([])
const [isLoadingPastChats, setIsLoadingPastChats] = useState(false)
@@ -101,6 +104,11 @@ export function useMentionData(props: UseMentionDataProps) {
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
const [isLoadingBlocks, setIsLoadingBlocks] = useState(false)
// Reset blocks list when permission config changes
useEffect(() => {
setBlocksList([])
}, [config.allowedIntegrations])
const [templatesList, setTemplatesList] = useState<TemplateItem[]>([])
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false)
@@ -252,7 +260,13 @@ export function useMentionData(props: UseMentionDataProps) {
const { getAllBlocks } = await import('@/blocks')
const all = getAllBlocks()
const regularBlocks = all
.filter((b: any) => b.type !== 'starter' && !b.hideFromToolbar && b.category === 'blocks')
.filter(
(b: any) =>
b.type !== 'starter' &&
!b.hideFromToolbar &&
b.category === 'blocks' &&
isBlockAllowed(b.type)
)
.map((b: any) => ({
id: b.type,
name: b.name || b.type,
@@ -262,7 +276,13 @@ export function useMentionData(props: UseMentionDataProps) {
.sort((a: any, b: any) => a.name.localeCompare(b.name))
const toolBlocks = all
.filter((b: any) => b.type !== 'starter' && !b.hideFromToolbar && b.category === 'tools')
.filter(
(b: any) =>
b.type !== 'starter' &&
!b.hideFromToolbar &&
b.category === 'tools' &&
isBlockAllowed(b.type)
)
.map((b: any) => ({
id: b.type,
name: b.name || b.type,
@@ -276,7 +296,7 @@ export function useMentionData(props: UseMentionDataProps) {
} finally {
setIsLoadingBlocks(false)
}
}, [isLoadingBlocks, blocksList.length])
}, [isLoadingBlocks, blocksList.length, isBlockAllowed])
/**
* Ensures templates are loaded

View File

@@ -8,6 +8,8 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { getProviderFromModel } from '@/providers/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -132,10 +134,27 @@ export function ComboBox({
// Determine the active value based on mode (preview vs. controlled vs. store)
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
// Permission-based filtering for model dropdowns
const { isProviderAllowed, isLoading: isPermissionLoading } = usePermissionConfig()
// Evaluate static options if provided as a function
const staticOptions = useMemo(() => {
return typeof options === 'function' ? options() : options
}, [options])
const opts = typeof options === 'function' ? options() : options
if (subBlockId === 'model') {
return opts.filter((opt) => {
const modelId = typeof opt === 'string' ? opt : opt.id
try {
const providerId = getProviderFromModel(modelId)
return isProviderAllowed(providerId)
} catch {
return true
}
})
}
return opts
}, [options, subBlockId, isProviderAllowed])
// Normalize fetched options to match ComboBoxOption format
const normalizedFetchedOptions = useMemo((): ComboBoxOption[] => {
@@ -147,6 +166,18 @@ export function ComboBox({
let opts: ComboBoxOption[] =
fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions
if (subBlockId === 'model' && fetchOptions && normalizedFetchedOptions.length > 0) {
opts = opts.filter((opt) => {
const modelId = typeof opt === 'string' ? opt : opt.id
try {
const providerId = getProviderFromModel(modelId)
return isProviderAllowed(providerId)
} catch {
return true
}
})
}
// Merge hydrated option if not already present
if (hydratedOption) {
const alreadyPresent = opts.some((o) =>
@@ -158,7 +189,14 @@ export function ComboBox({
}
return opts
}, [fetchOptions, normalizedFetchedOptions, staticOptions, hydratedOption])
}, [
fetchOptions,
normalizedFetchedOptions,
staticOptions,
hydratedOption,
subBlockId,
isProviderAllowed,
])
// Convert options to Combobox format
const comboboxOptions = useMemo((): ComboboxOption[] => {
@@ -231,16 +269,34 @@ export function ComboBox({
setStoreInitialized(true)
}, [])
// Set default value once store is initialized and value is undefined
// Check if current value is valid (exists in allowed options)
const isValueValid = useMemo(() => {
if (value === null || value === undefined) return false
return evaluatedOptions.some((opt) => getOptionValue(opt) === value)
}, [value, evaluatedOptions, getOptionValue])
// Set default value once store is initialized and permissions are loaded
// Also reset if current value becomes invalid (e.g., provider was blocked)
useEffect(() => {
if (
storeInitialized &&
(value === null || value === undefined) &&
defaultOptionValue !== undefined
) {
if (isPermissionLoading) return
if (!storeInitialized) return
if (defaultOptionValue === undefined) return
const needsDefault = value === null || value === undefined
const needsReset = subBlockId === 'model' && value && !isValueValid
if (needsDefault || needsReset) {
setStoreValue(defaultOptionValue)
}
}, [storeInitialized, value, defaultOptionValue, setStoreValue])
}, [
storeInitialized,
value,
defaultOptionValue,
setStoreValue,
isPermissionLoading,
subBlockId,
isValueValid,
])
// Clear fetched options and hydrated option when dependencies change
useEffect(() => {

View File

@@ -7,6 +7,7 @@ import { useParams } from 'next/navigation'
import {
Badge,
Combobox,
type ComboboxOption,
type ComboboxOptionGroup,
Popover,
PopoverContent,
@@ -59,6 +60,7 @@ import {
import { useForceRefreshMcpTools, useMcpServers, useStoredMcpTools } from '@/hooks/queries/mcp'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useMcpTools } from '@/hooks/use-mcp-tools'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
import { useSettingsModalStore } from '@/stores/settings-modal/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -1009,18 +1011,23 @@ export function ToolInput({
const provider = model ? getProviderFromModel(model) : ''
const supportsToolControl = provider ? supportsToolUsageControl(provider) : false
const toolBlocks = getAllBlocks().filter(
(block) =>
(block.category === 'tools' ||
block.type === 'api' ||
block.type === 'webhook_request' ||
block.type === 'workflow' ||
block.type === 'knowledge' ||
block.type === 'function') &&
block.type !== 'evaluator' &&
block.type !== 'mcp' &&
block.type !== 'file'
)
const { filterBlocks, config: permissionConfig } = usePermissionConfig()
const toolBlocks = useMemo(() => {
const allToolBlocks = getAllBlocks().filter(
(block) =>
(block.category === 'tools' ||
block.type === 'api' ||
block.type === 'webhook_request' ||
block.type === 'workflow' ||
block.type === 'knowledge' ||
block.type === 'function') &&
block.type !== 'evaluator' &&
block.type !== 'mcp' &&
block.type !== 'file'
)
return filterBlocks(allToolBlocks)
}, [filterBlocks])
const customFilter = useCallback((value: string, search: string) => {
if (!search.trim()) return 1
@@ -1608,33 +1615,37 @@ export function ToolInput({
const groups: ComboboxOptionGroup[] = []
// Actions group (no section header)
groups.push({
items: [
{
label: 'Create Tool',
value: 'action-create-tool',
icon: WrenchIcon,
onSelect: () => {
setCustomToolModalOpen(true)
setOpen(false)
},
disabled: isPreview,
const actionItems: ComboboxOption[] = []
if (!permissionConfig.disableCustomTools) {
actionItems.push({
label: 'Create Tool',
value: 'action-create-tool',
icon: WrenchIcon,
onSelect: () => {
setCustomToolModalOpen(true)
setOpen(false)
},
{
label: 'Add MCP Server',
value: 'action-add-mcp',
icon: McpIcon,
onSelect: () => {
setOpen(false)
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'mcp' } }))
},
disabled: isPreview,
disabled: isPreview,
})
}
if (!permissionConfig.disableMcpTools) {
actionItems.push({
label: 'Add MCP Server',
value: 'action-add-mcp',
icon: McpIcon,
onSelect: () => {
setOpen(false)
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'mcp' } }))
},
],
})
disabled: isPreview,
})
}
if (actionItems.length > 0) {
groups.push({ items: actionItems })
}
// Custom Tools section
if (customTools.length > 0) {
if (!permissionConfig.disableCustomTools && customTools.length > 0) {
groups.push({
section: 'Custom Tools',
items: customTools.map((customTool) => ({
@@ -1659,7 +1670,7 @@ export function ToolInput({
}
// MCP Tools section
if (availableMcpTools.length > 0) {
if (!permissionConfig.disableMcpTools && availableMcpTools.length > 0) {
groups.push({
section: 'MCP Tools',
items: availableMcpTools.map((mcpTool) => {
@@ -1736,6 +1747,8 @@ export function ToolInput({
setStoreValue,
handleMcpToolSelect,
handleSelectTool,
permissionConfig.disableCustomTools,
permissionConfig.disableMcpTools,
])
const toolRequiresOAuth = (toolId: string): boolean => {

View File

@@ -26,6 +26,7 @@ import {
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import type { BlockConfig } from '@/blocks/types'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useToolbarStore } from '@/stores/panel/toolbar/store'
interface BlockItem {
@@ -206,9 +207,16 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
triggersHeaderRef,
})
// Permission config for filtering
const { filterBlocks } = usePermissionConfig()
// Get static data (computed once and cached)
const triggers = getTriggers()
const blocks = getBlocks()
const allTriggers = getTriggers()
const allBlocks = getBlocks()
// Apply permission-based filtering to blocks and triggers
const blocks = useMemo(() => filterBlocks(allBlocks), [filterBlocks, allBlocks])
const triggers = useMemo(() => filterBlocks(allTriggers), [filterBlocks, allTriggers])
// Determine if triggers are at minimum height (blocks are fully expanded)
const isTriggersAtMinimum = toolbarTriggersHeight <= TRIGGERS_MIN_THRESHOLD

View File

@@ -40,6 +40,7 @@ import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/componen
import { useAutoLayout } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useChatStore } from '@/stores/chat/store'
import { usePanelStore } from '@/stores/panel/store'
import type { PanelTab } from '@/stores/panel/types'
@@ -92,6 +93,7 @@ export function Panel() {
// Hooks
const userPermissions = useUserPermissionsContext()
const { config: permissionConfig } = usePermissionConfig()
const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId })
const { workflows, activeWorkflowId, duplicateWorkflow, hydration } = useWorkflowRegistry()
const isRegistryLoading =
@@ -438,18 +440,20 @@ export function Panel() {
{/* Tabs */}
<div className='flex flex-shrink-0 items-center justify-between px-[8px] pt-[14px]'>
<div className='flex gap-[4px]'>
<Button
className={`h-[28px] truncate rounded-[6px] border px-[8px] py-[5px] text-[12.5px] ${
_hasHydrated && activeTab === 'copilot'
? 'border-[var(--border-1)]'
: 'border-transparent hover:border-[var(--border-1)] hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
}`}
variant={_hasHydrated && activeTab === 'copilot' ? 'active' : 'ghost'}
onClick={() => handleTabClick('copilot')}
data-tab-button='copilot'
>
Copilot
</Button>
{!permissionConfig.hideCopilot && (
<Button
className={`h-[28px] truncate rounded-[6px] border px-[8px] py-[5px] text-[12.5px] ${
_hasHydrated && activeTab === 'copilot'
? 'border-[var(--border-1)]'
: 'border-transparent hover:border-[var(--border-1)] hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
}`}
variant={_hasHydrated && activeTab === 'copilot' ? 'active' : 'ghost'}
onClick={() => handleTabClick('copilot')}
data-tab-button='copilot'
>
Copilot
</Button>
)}
<Button
className={`h-[28px] rounded-[6px] border px-[8px] py-[5px] text-[12.5px] ${
_hasHydrated && activeTab === 'toolbar'
@@ -482,18 +486,20 @@ export function Panel() {
{/* Tab Content - Keep all tabs mounted but hidden to preserve state */}
<div className='flex-1 overflow-hidden pt-[12px]'>
<div
className={
_hasHydrated && activeTab === 'copilot'
? 'h-full'
: _hasHydrated
? 'hidden'
: 'h-full'
}
data-tab-content='copilot'
>
<Copilot ref={copilotRef} panelWidth={panelWidth} />
</div>
{!permissionConfig.hideCopilot && (
<div
className={
_hasHydrated && activeTab === 'copilot'
? 'h-full'
: _hasHydrated
? 'hidden'
: 'h-full'
}
data-tab-content='copilot'
>
<Copilot ref={copilotRef} panelWidth={panelWidth} />
</div>
)}
<div
className={
_hasHydrated && activeTab === 'editor'

View File

@@ -12,6 +12,7 @@ import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/tri
import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils'
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { getAllBlocks } from '@/blocks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
interface SearchModalProps {
open: boolean
@@ -99,12 +100,14 @@ export function SearchModal({
const router = useRouter()
const workspaceId = params.workspaceId as string
const brand = useBrandConfig()
const { filterBlocks } = usePermissionConfig()
const blocks = useMemo(() => {
if (!isOnWorkflowPage) return []
const allBlocks = getAllBlocks()
const regularBlocks = allBlocks
const filteredAllBlocks = filterBlocks(allBlocks)
const regularBlocks = filteredAllBlocks
.filter(
(block) => block.type !== 'starter' && !block.hideFromToolbar && block.category === 'blocks'
)
@@ -138,16 +141,17 @@ export function SearchModal({
},
]
return [...regularBlocks, ...specialBlocks]
}, [isOnWorkflowPage])
return [...regularBlocks, ...filterBlocks(specialBlocks)]
}, [isOnWorkflowPage, filterBlocks])
const triggers = useMemo(() => {
if (!isOnWorkflowPage) return []
const allTriggers = getTriggersForSidebar()
const filteredTriggers = filterBlocks(allTriggers)
const priorityOrder = ['Start', 'Schedule', 'Webhook']
const sortedTriggers = allTriggers.sort((a, b) => {
const sortedTriggers = filteredTriggers.sort((a, b) => {
const aIndex = priorityOrder.indexOf(a.name)
const bIndex = priorityOrder.indexOf(b.name)
const aHasPriority = aIndex !== -1
@@ -170,13 +174,14 @@ export function SearchModal({
config: block,
})
)
}, [isOnWorkflowPage])
}, [isOnWorkflowPage, filterBlocks])
const tools = useMemo(() => {
if (!isOnWorkflowPage) return []
const allBlocks = getAllBlocks()
return allBlocks
const filteredAllBlocks = filterBlocks(allBlocks)
return filteredAllBlocks
.filter((block) => block.category === 'tools')
.map(
(block): ToolItem => ({
@@ -188,7 +193,7 @@ export function SearchModal({
type: block.type,
})
)
}, [isOnWorkflowPage])
}, [isOnWorkflowPage, filterBlocks])
const pages = useMemo(
(): PageItem[] => [

View File

@@ -631,7 +631,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
Cancel
</Button>
<Button
variant='destructive'
variant='ghost'
onClick={handleDeleteKey}
disabled={deleteApiKeyMutation.isPending}
>

View File

@@ -193,14 +193,14 @@ export function BYOK() {
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='font-medium text-[14px]'>{provider.name}</span>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{existingKey ? existingKey.maskedKey : provider.description}
{provider.description}
</p>
</div>
</div>
{existingKey ? (
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<Button variant='ghost' onClick={() => openEditModal(provider.id)}>
<Button variant='default' onClick={() => openEditModal(provider.id)}>
Update
</Button>
<Button

View File

@@ -334,7 +334,7 @@ export function Copilot() {
Cancel
</Button>
<Button
variant='destructive'
variant='ghost'
onClick={handleDeleteKey}
disabled={deleteKeyMutation.isPending}
>

View File

@@ -1018,11 +1018,11 @@ export function CredentialSets() {
</div>
</div>
<div className='flex items-center gap-[8px]'>
<Button variant='ghost' onClick={() => setViewingSet(set)}>
<Button variant='default' onClick={() => setViewingSet(set)}>
Details
</Button>
<Button
variant='destructive'
variant='ghost'
onClick={() => handleDeleteClick(set)}
disabled={deletingSetIds.has(set.id)}
>

View File

@@ -159,11 +159,11 @@ export function CustomTools() {
)}
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<Button variant='ghost' onClick={() => setEditingTool(tool.id)}>
<Button variant='default' onClick={() => setEditingTool(tool.id)}>
Edit
</Button>
<Button
variant='destructive'
variant='ghost'
onClick={() => handleDeleteClick(tool.id)}
disabled={deletingTools.has(tool.id)}
>

View File

@@ -1,3 +1,4 @@
export { AccessControl } from './access-control/access-control'
export { ApiKeys } from './api-keys/api-keys'
export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot'

View File

@@ -22,6 +22,7 @@ import {
useDisconnectOAuthService,
useOAuthConnections,
} from '@/hooks/queries/oauth-connections'
import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('Integrations')
@@ -100,6 +101,7 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
const { data: services = [], isPending } = useOAuthConnections()
const connectService = useConnectOAuthService()
const disconnectService = useDisconnectOAuthService()
const { config: permissionConfig } = usePermissionConfig()
const [searchTerm, setSearchTerm] = useState('')
const [pendingService, setPendingService] = useState<string | null>(null)
@@ -221,9 +223,17 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
}
}
// Group services by provider
// Group services by provider, filtering by permission config
const groupedServices = services.reduce(
(acc, service) => {
// Filter based on allowedIntegrations
if (
permissionConfig.allowedIntegrations !== null &&
!permissionConfig.allowedIntegrations.includes(service.id)
) {
return acc
}
// Find the provider for this service
const providerKey =
Object.keys(OAUTH_PROVIDERS).find((key) =>

View File

@@ -1,66 +0,0 @@
import { Input } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import type { EnvVarDropdownConfig } from '../types'
interface FormattedInputProps {
ref?: React.RefObject<HTMLInputElement | null>
placeholder: string
value: string
scrollLeft: number
showEnvVars: boolean
envVarProps: EnvVarDropdownConfig
className?: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onScroll: (scrollLeft: number) => void
}
export function FormattedInput({
ref,
placeholder,
value,
scrollLeft,
showEnvVars,
envVarProps,
className,
onChange,
onScroll,
}: FormattedInputProps) {
const handleScroll = (e: React.UIEvent<HTMLInputElement>) => {
onScroll(e.currentTarget.scrollLeft)
}
return (
<div className={cn('relative', className)}>
<Input
ref={ref}
placeholder={placeholder}
value={value}
onChange={onChange}
onScroll={handleScroll}
onInput={handleScroll}
className='h-9 text-transparent caret-foreground placeholder:text-[var(--text-muted)]'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-[8px] py-[6px] font-medium font-sans text-sm'>
<div className='whitespace-nowrap' style={{ transform: `translateX(-${scrollLeft}px)` }}>
{formatDisplayText(value)}
</div>
</div>
{showEnvVars && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={envVarProps.onSelect}
searchTerm={envVarProps.searchTerm}
inputValue={value}
cursorPosition={envVarProps.cursorPosition}
workspaceId={envVarProps.workspaceId}
onClose={envVarProps.onClose}
className='w-full'
maxHeight='200px'
style={{ position: 'absolute', top: '100%', left: 0, zIndex: 99999 }}
/>
)}
</div>
)
}

View File

@@ -1,81 +0,0 @@
import { X } from 'lucide-react'
import { Button } from '@/components/emcn'
import { FormattedInput } from '../formatted-input/formatted-input'
import type { EnvVarDropdownConfig, HeaderEntry, InputFieldType } from '../types'
interface HeaderRowProps {
header: HeaderEntry
index: number
headerScrollLeft: Record<string, number>
showEnvVars: boolean
activeInputField: InputFieldType | null
activeHeaderIndex: number | null
envSearchTerm: string
cursorPosition: number
workspaceId: string
onInputChange: (field: InputFieldType, value: string, index?: number) => void
onHeaderScroll: (key: string, scrollLeft: number) => void
onEnvVarSelect: (value: string) => void
onEnvVarClose: () => void
onRemove: () => void
}
export function HeaderRow({
header,
index,
headerScrollLeft,
showEnvVars,
activeInputField,
activeHeaderIndex,
envSearchTerm,
cursorPosition,
workspaceId,
onInputChange,
onHeaderScroll,
onEnvVarSelect,
onEnvVarClose,
onRemove,
}: HeaderRowProps) {
const isKeyActive =
showEnvVars && activeInputField === 'header-key' && activeHeaderIndex === index
const isValueActive =
showEnvVars && activeInputField === 'header-value' && activeHeaderIndex === index
const envVarProps: EnvVarDropdownConfig = {
searchTerm: envSearchTerm,
cursorPosition,
workspaceId,
onSelect: onEnvVarSelect,
onClose: onEnvVarClose,
}
return (
<div className='relative flex items-center gap-[8px]'>
<FormattedInput
placeholder='Name'
value={header.key || ''}
scrollLeft={headerScrollLeft[`key-${index}`] || 0}
showEnvVars={isKeyActive}
envVarProps={envVarProps}
className='flex-1'
onChange={(e) => onInputChange('header-key', e.target.value, index)}
onScroll={(scrollLeft) => onHeaderScroll(`key-${index}`, scrollLeft)}
/>
<FormattedInput
placeholder='Value'
value={header.value || ''}
scrollLeft={headerScrollLeft[`value-${index}`] || 0}
showEnvVars={isValueActive}
envVarProps={envVarProps}
className='flex-1'
onChange={(e) => onInputChange('header-value', e.target.value, index)}
onScroll={(scrollLeft) => onHeaderScroll(`value-${index}`, scrollLeft)}
/>
<Button type='button' variant='ghost' onClick={onRemove} className='h-6 w-6 shrink-0 p-0'>
<X className='h-3 w-3' />
</Button>
</div>
)
}

View File

@@ -1,12 +1,2 @@
export { FormField } from './form-field/form-field'
export { FormattedInput } from './formatted-input/formatted-input'
export { HeaderRow } from './header-row/header-row'
export { McpServerSkeleton } from './mcp-server-skeleton/mcp-server-skeleton'
export { formatTransportLabel, ServerListItem } from './server-list-item/server-list-item'
export type {
EnvVarDropdownConfig,
HeaderEntry,
InputFieldType,
McpServerFormData,
McpServerTestResult,
} from './types'

View File

@@ -1,76 +0,0 @@
import { Button } from '@/components/emcn'
export function formatTransportLabel(transport: string): string {
return transport
.split('-')
.map((word) =>
['http', 'sse', 'stdio'].includes(word.toLowerCase())
? word.toUpperCase()
: word.charAt(0).toUpperCase() + word.slice(1)
)
.join('-')
}
function formatToolsLabel(tools: any[], connectionStatus?: string): string {
if (connectionStatus === 'error') {
return 'Unable to connect'
}
const count = tools.length
const plural = count !== 1 ? 's' : ''
const names = count > 0 ? `: ${tools.map((t) => t.name).join(', ')}` : ''
return `${count} tool${plural}${names}`
}
interface ServerListItemProps {
server: any
tools: any[]
isDeleting: boolean
isLoadingTools?: boolean
isRefreshing?: boolean
onRemove: () => void
onViewDetails: () => void
}
export function ServerListItem({
server,
tools,
isDeleting,
isLoadingTools = false,
isRefreshing = false,
onRemove,
onViewDetails,
}: ServerListItemProps) {
const transportLabel = formatTransportLabel(server.transport || 'http')
const toolsLabel = formatToolsLabel(tools, server.connectionStatus)
const isError = server.connectionStatus === 'error'
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[6px]'>
<span className='max-w-[200px] truncate font-medium text-[14px]'>
{server.name || 'Unnamed Server'}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>({transportLabel})</span>
</div>
<p
className={`truncate text-[13px] ${isError ? 'text-red-500 dark:text-red-400' : 'text-[var(--text-muted)]'}`}
>
{isRefreshing
? 'Refreshing...'
: isLoadingTools && tools.length === 0
? 'Loading...'
: toolsLabel}
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
<Button variant='ghost' onClick={onViewDetails}>
Details
</Button>
<Button variant='destructive' onClick={onRemove} disabled={isDeleting}>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
)
}

View File

@@ -1,35 +0,0 @@
import type { McpTransport } from '@/lib/mcp/types'
/**
* Represents a single header entry in the form.
* Using an array of objects allows duplicate keys during editing.
*/
export interface HeaderEntry {
key: string
value: string
}
export interface McpServerFormData {
name: string
transport: McpTransport
url?: string
timeout?: number
headers?: HeaderEntry[]
}
export interface McpServerTestResult {
success: boolean
message?: string
error?: string
warnings?: string[]
}
export type InputFieldType = 'url' | 'header-key' | 'header-value'
export interface EnvVarDropdownConfig {
searchTerm: string
cursorPosition: number
workspaceId: string
onSelect: (value: string) => void
onClose: () => void
}

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, Search } from 'lucide-react'
import { Plus, Search, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
@@ -16,13 +16,19 @@ import {
Tooltip,
} from '@/components/emcn'
import { Input } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import {
getIssueBadgeLabel,
getIssueBadgeVariant,
getMcpToolIssue,
type McpToolIssue,
} from '@/lib/mcp/tool-validation'
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import type { McpTransport } from '@/lib/mcp/types'
import {
checkEnvVarTrigger,
EnvVarDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import {
useCreateMcpServer,
useDeleteMcpServer,
@@ -35,15 +41,41 @@ import {
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { InputFieldType, McpServerFormData, McpServerTestResult } from './components'
import {
FormattedInput,
FormField,
formatTransportLabel,
HeaderRow,
McpServerSkeleton,
ServerListItem,
} from './components'
import { FormField, McpServerSkeleton } from './components'
/**
* Represents a single header entry in the form.
* Using an array of objects allows duplicate keys during editing.
*/
interface HeaderEntry {
key: string
value: string
}
interface McpServerFormData {
name: string
transport: McpTransport
url?: string
timeout?: number
headers?: HeaderEntry[]
}
interface McpServerTestResult {
success: boolean
message?: string
error?: string
warnings?: string[]
}
type InputFieldType = 'url' | 'header-key' | 'header-value'
interface EnvVarDropdownConfig {
searchTerm: string
cursorPosition: number
workspaceId: string
onSelect: (value: string) => void
onClose: () => void
}
interface McpTool {
name: string
@@ -71,6 +103,33 @@ const DEFAULT_FORM_DATA: McpServerFormData = {
headers: [{ key: '', value: '' }],
}
/**
* Formats a transport type string for display.
*/
function formatTransportLabel(transport: string): string {
return transport
.split('-')
.map((word) =>
['http', 'sse', 'stdio'].includes(word.toLowerCase())
? word.toUpperCase()
: word.charAt(0).toUpperCase() + word.slice(1)
)
.join('-')
}
/**
* Formats a tools list for display in the server list.
*/
function formatToolsLabel(tools: McpTool[], connectionStatus?: string): string {
if (connectionStatus === 'error') {
return 'Unable to connect'
}
const count = tools.length
const plural = count !== 1 ? 's' : ''
const names = count > 0 ? `: ${tools.map((t) => t.name).join(', ')}` : ''
return `${count} tool${plural}${names}`
}
/**
* Determines the label for the test connection button based on current state.
*/
@@ -84,6 +143,198 @@ function getTestButtonLabel(
return 'Test Connection'
}
interface FormattedInputProps {
ref?: React.RefObject<HTMLInputElement | null>
placeholder: string
value: string
scrollLeft: number
showEnvVars: boolean
envVarProps: EnvVarDropdownConfig
className?: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onScroll: (scrollLeft: number) => void
}
function FormattedInput({
ref,
placeholder,
value,
scrollLeft,
showEnvVars,
envVarProps,
className,
onChange,
onScroll,
}: FormattedInputProps) {
const handleScroll = (e: React.UIEvent<HTMLInputElement>) => {
onScroll(e.currentTarget.scrollLeft)
}
return (
<div className={cn('relative', className)}>
<EmcnInput
ref={ref}
placeholder={placeholder}
value={value}
onChange={onChange}
onScroll={handleScroll}
onInput={handleScroll}
className='h-9 text-transparent caret-foreground placeholder:text-[var(--text-muted)]'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-[8px] py-[6px] font-medium font-sans text-sm'>
<div className='whitespace-nowrap' style={{ transform: `translateX(-${scrollLeft}px)` }}>
{formatDisplayText(value)}
</div>
</div>
{showEnvVars && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={envVarProps.onSelect}
searchTerm={envVarProps.searchTerm}
inputValue={value}
cursorPosition={envVarProps.cursorPosition}
workspaceId={envVarProps.workspaceId}
onClose={envVarProps.onClose}
className='w-full'
maxHeight='200px'
style={{ position: 'absolute', top: '100%', left: 0, zIndex: 99999 }}
/>
)}
</div>
)
}
interface HeaderRowProps {
header: HeaderEntry
index: number
headerScrollLeft: Record<string, number>
showEnvVars: boolean
activeInputField: InputFieldType | null
activeHeaderIndex: number | null
envSearchTerm: string
cursorPosition: number
workspaceId: string
onInputChange: (field: InputFieldType, value: string, index?: number) => void
onHeaderScroll: (key: string, scrollLeft: number) => void
onEnvVarSelect: (value: string) => void
onEnvVarClose: () => void
onRemove: () => void
}
function HeaderRow({
header,
index,
headerScrollLeft,
showEnvVars,
activeInputField,
activeHeaderIndex,
envSearchTerm,
cursorPosition,
workspaceId,
onInputChange,
onHeaderScroll,
onEnvVarSelect,
onEnvVarClose,
onRemove,
}: HeaderRowProps) {
const isKeyActive =
showEnvVars && activeInputField === 'header-key' && activeHeaderIndex === index
const isValueActive =
showEnvVars && activeInputField === 'header-value' && activeHeaderIndex === index
const envVarProps: EnvVarDropdownConfig = {
searchTerm: envSearchTerm,
cursorPosition,
workspaceId,
onSelect: onEnvVarSelect,
onClose: onEnvVarClose,
}
return (
<div className='relative flex items-center gap-[8px]'>
<FormattedInput
placeholder='Name'
value={header.key || ''}
scrollLeft={headerScrollLeft[`key-${index}`] || 0}
showEnvVars={isKeyActive}
envVarProps={envVarProps}
className='flex-1'
onChange={(e) => onInputChange('header-key', e.target.value, index)}
onScroll={(scrollLeft) => onHeaderScroll(`key-${index}`, scrollLeft)}
/>
<FormattedInput
placeholder='Value'
value={header.value || ''}
scrollLeft={headerScrollLeft[`value-${index}`] || 0}
showEnvVars={isValueActive}
envVarProps={envVarProps}
className='flex-1'
onChange={(e) => onInputChange('header-value', e.target.value, index)}
onScroll={(scrollLeft) => onHeaderScroll(`value-${index}`, scrollLeft)}
/>
<Button type='button' variant='ghost' onClick={onRemove} className='h-6 w-6 shrink-0 p-0'>
<X className='h-3 w-3' />
</Button>
</div>
)
}
interface ServerListItemProps {
server: McpServer
tools: McpTool[]
isDeleting: boolean
isLoadingTools?: boolean
isRefreshing?: boolean
onRemove: () => void
onViewDetails: () => void
}
function ServerListItem({
server,
tools,
isDeleting,
isLoadingTools = false,
isRefreshing = false,
onRemove,
onViewDetails,
}: ServerListItemProps) {
const transportLabel = formatTransportLabel(server.transport || 'http')
const toolsLabel = formatToolsLabel(tools, server.connectionStatus)
const isError = server.connectionStatus === 'error'
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<div className='flex items-center gap-[6px]'>
<span className='max-w-[200px] truncate font-medium text-[14px]'>
{server.name || 'Unnamed Server'}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>({transportLabel})</span>
</div>
<p
className={`truncate text-[13px] ${isError ? 'text-red-500 dark:text-red-400' : 'text-[var(--text-muted)]'}`}
>
{isRefreshing
? 'Refreshing...'
: isLoadingTools && tools.length === 0
? 'Loading...'
: toolsLabel}
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
<Button variant='default' onClick={onViewDetails}>
Details
</Button>
<Button variant='ghost' onClick={onRemove} disabled={isDeleting}>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
)
}
interface MCPProps {
initialServerId?: string | null
}

View File

@@ -208,11 +208,11 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
<Button variant='ghost' onClick={() => setToolToView(tool)}>
<Button variant='default' onClick={() => setToolToView(tool)}>
Details
</Button>
<Button
variant='destructive'
variant='ghost'
onClick={() => setToolToDelete(tool)}
disabled={deleteToolMutation.isPending}
>
@@ -605,11 +605,11 @@ export function WorkflowMcpServers() {
<p className='truncate text-[13px] text-[var(--text-muted)]'>{toolsLabel}</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
<Button variant='ghost' onClick={() => setSelectedServerId(server.id)}>
<Button variant='default' onClick={() => setSelectedServerId(server.id)}>
Details
</Button>
<Button
variant='destructive'
variant='ghost'
onClick={() => setServerToDelete(server)}
disabled={isDeleting}
>

View File

@@ -4,7 +4,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { useQueryClient } from '@tanstack/react-query'
import { Files, KeySquare, LogIn, Mail, Server, Settings, User, Users, Wrench } from 'lucide-react'
import {
Files,
KeySquare,
LogIn,
Mail,
Server,
Settings,
ShieldCheck,
User,
Users,
Wrench,
} from 'lucide-react'
import {
Card,
Connections,
@@ -29,6 +40,7 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getUserRole } from '@/lib/workspaces/organization'
import {
AccessControl,
ApiKeys,
BYOK,
Copilot,
@@ -49,11 +61,13 @@ import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsModalStore } from '@/stores/settings-modal/store'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const isCredentialSetsEnabled = isTruthy(getEnv('NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED'))
const isAccessControlEnabled = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED'))
interface SettingsModalProps {
open: boolean
@@ -66,6 +80,7 @@ type SettingsSection =
| 'template-profile'
| 'integrations'
| 'credential-sets'
| 'access-control'
| 'apikeys'
| 'byok'
| 'files'
@@ -77,7 +92,7 @@ type SettingsSection =
| 'custom-tools'
| 'workflow-mcp-servers'
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system'
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system' | 'enterprise'
type NavigationItem = {
id: SettingsSection
@@ -96,11 +111,21 @@ const sectionConfig: { key: NavigationSection; title: string }[] = [
{ key: 'tools', title: 'Tools' },
{ key: 'subscription', title: 'Subscription' },
{ key: 'system', title: 'System' },
{ key: 'enterprise', title: 'Enterprise' },
]
const allNavigationItems: NavigationItem[] = [
{ id: 'general', label: 'General', icon: Settings, section: 'account' },
{ id: 'template-profile', label: 'Template Profile', icon: User, section: 'account' },
{
id: 'access-control',
label: 'Access Control',
icon: ShieldCheck,
section: 'enterprise',
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: isAccessControlEnabled,
},
{
id: 'subscription',
label: 'Subscription',
@@ -120,14 +145,6 @@ const allNavigationItems: NavigationItem[] = [
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
{
id: 'credential-sets',
label: 'Email Polling',
icon: Mail,
section: 'system',
requiresHosted: true,
selfHostedOverride: isCredentialSetsEnabled,
},
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'Deployed MCPs', icon: Server, section: 'system' },
@@ -135,7 +152,7 @@ const allNavigationItems: NavigationItem[] = [
id: 'byok',
label: 'BYOK',
icon: KeySquare,
section: 'system',
section: 'enterprise',
requiresHosted: true,
requiresEnterprise: true,
},
@@ -147,11 +164,19 @@ const allNavigationItems: NavigationItem[] = [
requiresHosted: true,
},
{ id: 'files', label: 'Files', icon: Files, section: 'system' },
{
id: 'credential-sets',
label: 'Email Polling',
icon: Mail,
section: 'system',
requiresHosted: true,
selfHostedOverride: isCredentialSetsEnabled,
},
{
id: 'sso',
label: 'Single Sign-On',
icon: LogIn,
section: 'system',
section: 'enterprise',
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: isSSOEnabled,
@@ -169,6 +194,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
const activeOrganization = organizationsData?.activeOrganization
const { config: permissionConfig } = usePermissionConfig()
const environmentBeforeLeaveHandler = useRef<((onProceed: () => void) => void) | null>(null)
const integrationsCloseHandler = useRef<((open: boolean) => void) | null>(null)
@@ -198,7 +224,29 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return false
}
// Permission group-based filtering
if (item.id === 'template-profile' && permissionConfig.hideTemplates) {
return false
}
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
return false
}
if (item.id === 'environment' && permissionConfig.hideEnvironmentTab) {
return false
}
if (item.id === 'files' && permissionConfig.hideFilesTab) {
return false
}
if (item.id === 'mcp' && permissionConfig.disableMcpTools) {
return false
}
if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) {
return false
}
// Self-hosted override allows showing the item when not on hosted
if (item.selfHostedOverride && !isHosted) {
// SSO has special logic: only show if no providers or user owns a provider
if (item.id === 'sso') {
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
return !hasProviders || isSSOProviderOwner === true
@@ -206,14 +254,17 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return true
}
// requiresTeam: must have team/enterprise plan AND be org admin/owner
if (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) {
return false
}
// requiresEnterprise: must have enterprise plan AND be org admin/owner
if (item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) {
return false
}
// requiresHosted: only show on hosted environments
if (item.requiresHosted && !isHosted) {
return false
}
@@ -226,9 +277,10 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
hasEnterprisePlan,
isOrgAdminOrOwner,
isSSOProviderOwner,
isSSOEnabled,
ssoProvidersData?.providers?.length,
isOwner,
isAdmin,
permissionConfig,
])
// Memoized callbacks to prevent infinite loops in child components
@@ -461,6 +513,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
/>
)}
{activeSection === 'credential-sets' && <CredentialSets />}
{activeSection === 'access-control' && <AccessControl />}
{activeSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
{activeSection === 'files' && <FileUploads />}
{isBillingEnabled && activeSection === 'subscription' && <Subscription />}

View File

@@ -32,6 +32,7 @@ import {
useExportWorkspace,
useImportWorkspace,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useFolderStore } from '@/stores/folders/store'
import { useSearchModalStore } from '@/stores/search-modal/store'
@@ -71,6 +72,7 @@ export function Sidebar() {
const { data: sessionData, isPending: sessionLoading } = useSession()
const { canEdit } = useUserPermissionsContext()
const { config: permissionConfig } = usePermissionConfig()
/**
* Sidebar state from store with hydration tracking to prevent SSR mismatch.
@@ -238,39 +240,42 @@ export function Sidebar() {
)
const footerNavigationItems = useMemo(
() => [
{
id: 'logs',
label: 'Logs',
icon: Library,
href: `/workspace/${workspaceId}/logs`,
},
{
id: 'templates',
label: 'Templates',
icon: Layout,
href: `/workspace/${workspaceId}/templates`,
},
{
id: 'knowledge-base',
label: 'Knowledge Base',
icon: Database,
href: `/workspace/${workspaceId}/knowledge`,
},
{
id: 'help',
label: 'Help',
icon: HelpCircle,
onClick: () => setIsHelpModalOpen(true),
},
{
id: 'settings',
label: 'Settings',
icon: Settings,
onClick: () => openSettingsModal(),
},
],
[workspaceId]
() =>
[
{
id: 'logs',
label: 'Logs',
icon: Library,
href: `/workspace/${workspaceId}/logs`,
},
{
id: 'templates',
label: 'Templates',
icon: Layout,
href: `/workspace/${workspaceId}/templates`,
hidden: permissionConfig.hideTemplates,
},
{
id: 'knowledge-base',
label: 'Knowledge Base',
icon: Database,
href: `/workspace/${workspaceId}/knowledge`,
hidden: permissionConfig.hideKnowledgeBaseTab,
},
{
id: 'help',
label: 'Help',
icon: HelpCircle,
onClick: () => setIsHelpModalOpen(true),
},
{
id: 'settings',
label: 'Settings',
icon: Settings,
onClick: () => openSettingsModal(),
},
].filter((item) => !item.hidden),
[workspaceId, permissionConfig.hideTemplates, permissionConfig.hideKnowledgeBaseTab]
)
const isLoading = workflowsLoading || sessionLoading

View File

@@ -26,6 +26,7 @@ import type {
} from '@/executor/types'
import { streamingResponseFormatProcessor } from '@/executor/utils'
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
import { validateBlockType } from '@/executor/utils/permission-check'
import type { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import type { SubflowType } from '@/stores/workflows/workflow/types'
@@ -54,7 +55,8 @@ export class BlockExecutor {
})
}
const isSentinel = isSentinelBlockType(block.metadata?.id ?? '')
const blockType = block.metadata?.id ?? ''
const isSentinel = isSentinelBlockType(blockType)
let blockLog: BlockLog | undefined
if (!isSentinel) {
@@ -74,6 +76,10 @@ export class BlockExecutor {
}
try {
if (!isSentinel && blockType) {
await validateBlockType(ctx.userId, blockType, ctx)
}
resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block)
if (block.metadata?.id === BlockType.AGENT && resolvedInputs.tools) {

View File

@@ -18,6 +18,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
getCostMultiplier: vi.fn().mockReturnValue(1),
isEmailVerificationEnabled: false,
isBillingEnabled: false,
isOrganizationsEnabled: false,
}))
vi.mock('@/providers/utils', () => ({

View File

@@ -25,6 +25,12 @@ import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/execu
import { collectBlockData } from '@/executor/utils/block-data'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import { stringifyJSON } from '@/executor/utils/json'
import {
validateBlockType,
validateCustomToolsAllowed,
validateMcpToolsAllowed,
validateModelProvider,
} from '@/executor/utils/permission-check'
import { executeProviderRequest } from '@/providers'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
@@ -50,8 +56,14 @@ export class AgentBlockHandler implements BlockHandler {
const filteredTools = await this.filterUnavailableMcpTools(ctx, inputs.tools || [])
const filteredInputs = { ...inputs, tools: filteredTools }
// Validate tool permissions before processing
await this.validateToolPermissions(ctx, filteredInputs.tools || [])
const responseFormat = this.parseResponseFormat(filteredInputs.responseFormat)
const model = filteredInputs.model || AGENT.DEFAULT_MODEL
await validateModelProvider(ctx.userId, model, ctx)
const providerId = getProviderFromModel(model)
const formattedTools = await this.formatTools(ctx, filteredInputs.tools || [])
const streamingConfig = this.getStreamingConfig(ctx, block)
@@ -143,6 +155,21 @@ export class AgentBlockHandler implements BlockHandler {
return undefined
}
private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise<void> {
if (!Array.isArray(tools) || tools.length === 0) return
const hasMcpTools = tools.some((t) => t.type === 'mcp')
const hasCustomTools = tools.some((t) => t.type === 'custom-tool')
if (hasMcpTools) {
await validateMcpToolsAllowed(ctx.userId, ctx)
}
if (hasCustomTools) {
await validateCustomToolsAllowed(ctx.userId, ctx)
}
}
private async filterUnavailableMcpTools(
ctx: ExecutionContext,
tools: ToolInput[]
@@ -212,6 +239,9 @@ export class AgentBlockHandler implements BlockHandler {
const otherResults = await Promise.all(
otherTools.map(async (tool) => {
try {
if (tool.type && tool.type !== 'custom-tool' && tool.type !== 'mcp') {
await validateBlockType(ctx.userId, tool.type, ctx)
}
if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) {
return await this.createCustomTool(ctx, tool)
}

View File

@@ -8,6 +8,7 @@ import { BlockType, DEFAULTS, EVALUATOR, HTTP } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAPIUrl, extractAPIErrorMessage } from '@/executor/utils/http'
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
@@ -36,6 +37,9 @@ export class EvaluatorBlockHandler implements BlockHandler {
bedrockSecretKey: inputs.bedrockSecretKey,
bedrockRegion: inputs.bedrockRegion,
}
await validateModelProvider(ctx.userId, evaluatorConfig.model, ctx)
const providerId = getProviderFromModel(evaluatorConfig.model)
let finalApiKey: string | undefined = evaluatorConfig.apiKey

View File

@@ -15,6 +15,7 @@ import {
ROUTER,
} from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
@@ -73,6 +74,8 @@ export class RouterBlockHandler implements BlockHandler {
bedrockRegion: inputs.bedrockRegion,
}
await validateModelProvider(ctx.userId, routerConfig.model, ctx)
const providerId = getProviderFromModel(routerConfig.model)
try {
@@ -211,6 +214,8 @@ export class RouterBlockHandler implements BlockHandler {
bedrockRegion: inputs.bedrockRegion,
}
await validateModelProvider(ctx.userId, routerConfig.model, ctx)
const providerId = getProviderFromModel(routerConfig.model)
try {

View File

@@ -1,4 +1,5 @@
import type { TraceSpan } from '@/lib/logs/types'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import type { BlockOutput } from '@/blocks/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
@@ -152,6 +153,9 @@ export interface ExecutionContext {
userId?: string
isDeployedContext?: boolean
permissionConfig?: PermissionGroupConfig | null
permissionConfigLoaded?: boolean
blockStates: ReadonlyMap<string, BlockState>
executedBlocks: ReadonlySet<string>

View File

@@ -0,0 +1,186 @@
import { db } from '@sim/db'
import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
import {
type PermissionGroupConfig,
parsePermissionGroupConfig,
} from '@/lib/permission-groups/types'
import type { ExecutionContext } from '@/executor/types'
import { getProviderFromModel } from '@/providers/utils'
const logger = createLogger('PermissionCheck')
export class ProviderNotAllowedError extends Error {
constructor(providerId: string, model: string) {
super(
`Provider "${providerId}" is not allowed for model "${model}" based on your permission group settings`
)
this.name = 'ProviderNotAllowedError'
}
}
export class IntegrationNotAllowedError extends Error {
constructor(blockType: string) {
super(`Integration "${blockType}" is not allowed based on your permission group settings`)
this.name = 'IntegrationNotAllowedError'
}
}
export class McpToolsNotAllowedError extends Error {
constructor() {
super('MCP tools are not allowed based on your permission group settings')
this.name = 'McpToolsNotAllowedError'
}
}
export class CustomToolsNotAllowedError extends Error {
constructor() {
super('Custom tools are not allowed based on your permission group settings')
this.name = 'CustomToolsNotAllowedError'
}
}
export async function getUserPermissionConfig(
userId: string
): Promise<PermissionGroupConfig | null> {
const [membership] = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (!membership) {
return null
}
const isEnterprise = await isOrganizationOnEnterprisePlan(membership.organizationId)
if (!isEnterprise) {
return 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.organizationId, membership.organizationId)
)
)
.limit(1)
if (!groupMembership) {
return null
}
return parsePermissionGroupConfig(groupMembership.config)
}
export async function getPermissionConfig(
userId: string | undefined,
ctx?: ExecutionContext
): Promise<PermissionGroupConfig | null> {
if (!userId) {
return null
}
if (ctx) {
if (ctx.permissionConfigLoaded) {
return ctx.permissionConfig ?? null
}
const config = await getUserPermissionConfig(userId)
ctx.permissionConfig = config
ctx.permissionConfigLoaded = true
return config
}
return getUserPermissionConfig(userId)
}
export async function validateModelProvider(
userId: string | undefined,
model: string,
ctx?: ExecutionContext
): Promise<void> {
if (!userId) {
return
}
const config = await getPermissionConfig(userId, ctx)
if (!config || config.allowedModelProviders === null) {
return
}
const providerId = getProviderFromModel(model)
if (!config.allowedModelProviders.includes(providerId)) {
logger.warn('Model provider blocked by permission group', { userId, model, providerId })
throw new ProviderNotAllowedError(providerId, model)
}
}
export async function validateBlockType(
userId: string | undefined,
blockType: string,
ctx?: ExecutionContext
): Promise<void> {
if (!userId) {
return
}
const config = await getPermissionConfig(userId, ctx)
if (!config || config.allowedIntegrations === null) {
return
}
if (!config.allowedIntegrations.includes(blockType)) {
logger.warn('Integration blocked by permission group', { userId, blockType })
throw new IntegrationNotAllowedError(blockType)
}
}
export async function validateMcpToolsAllowed(
userId: string | undefined,
ctx?: ExecutionContext
): Promise<void> {
if (!userId) {
return
}
const config = await getPermissionConfig(userId, ctx)
if (!config) {
return
}
if (config.disableMcpTools) {
logger.warn('MCP tools blocked by permission group', { userId })
throw new McpToolsNotAllowedError()
}
}
export async function validateCustomToolsAllowed(
userId: string | undefined,
ctx?: ExecutionContext
): Promise<void> {
if (!userId) {
return
}
const config = await getPermissionConfig(userId, ctx)
if (!config) {
return
}
if (config.disableCustomTools) {
logger.warn('Custom tools blocked by permission group', { userId })
throw new CustomToolsNotAllowedError()
}
}

View File

@@ -0,0 +1,282 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { fetchJson } from '@/hooks/selectors/helpers'
export interface PermissionGroup {
id: string
name: string
description: string | null
config: PermissionGroupConfig
createdBy: string
createdAt: string
updatedAt: string
creatorName: string | null
creatorEmail: string | null
memberCount: number
}
export interface PermissionGroupMember {
id: string
userId: string
assignedAt: string
userName: string | null
userEmail: string | null
userImage: string | null
}
export interface UserPermissionConfig {
permissionGroupId: string | null
groupName: string | null
config: PermissionGroupConfig | null
}
export const permissionGroupKeys = {
all: ['permissionGroups'] as const,
list: (organizationId?: string) =>
['permissionGroups', 'list', organizationId ?? 'none'] as const,
detail: (id?: string) => ['permissionGroups', 'detail', id ?? 'none'] as const,
members: (id?: string) => ['permissionGroups', 'members', id ?? 'none'] as const,
userConfig: (organizationId?: string) =>
['permissionGroups', 'userConfig', organizationId ?? 'none'] as const,
}
interface PermissionGroupsResponse {
permissionGroups?: PermissionGroup[]
}
export function usePermissionGroups(organizationId?: string, enabled = true) {
return useQuery<PermissionGroup[]>({
queryKey: permissionGroupKeys.list(organizationId),
queryFn: async () => {
const data = await fetchJson<PermissionGroupsResponse>('/api/permission-groups', {
searchParams: { organizationId: organizationId ?? '' },
})
return data.permissionGroups ?? []
},
enabled: Boolean(organizationId) && enabled,
staleTime: 60 * 1000,
})
}
interface PermissionGroupDetailResponse {
permissionGroup?: PermissionGroup
}
export function usePermissionGroup(id?: string, enabled = true) {
return useQuery<PermissionGroup | null>({
queryKey: permissionGroupKeys.detail(id),
queryFn: async () => {
const data = await fetchJson<PermissionGroupDetailResponse>(`/api/permission-groups/${id}`)
return data.permissionGroup ?? null
},
enabled: Boolean(id) && enabled,
staleTime: 60 * 1000,
})
}
interface MembersResponse {
members?: PermissionGroupMember[]
}
export function usePermissionGroupMembers(permissionGroupId?: string) {
return useQuery<PermissionGroupMember[]>({
queryKey: permissionGroupKeys.members(permissionGroupId),
queryFn: async () => {
const data = await fetchJson<MembersResponse>(
`/api/permission-groups/${permissionGroupId}/members`
)
return data.members ?? []
},
enabled: Boolean(permissionGroupId),
staleTime: 30 * 1000,
})
}
export function useUserPermissionConfig(organizationId?: string) {
return useQuery<UserPermissionConfig>({
queryKey: permissionGroupKeys.userConfig(organizationId),
queryFn: async () => {
const data = await fetchJson<UserPermissionConfig>('/api/permission-groups/user', {
searchParams: { organizationId: organizationId ?? '' },
})
return data
},
enabled: Boolean(organizationId),
staleTime: 60 * 1000,
})
}
export interface CreatePermissionGroupData {
organizationId: string
name: string
description?: string
config?: Partial<PermissionGroupConfig>
}
export function useCreatePermissionGroup() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: CreatePermissionGroupData) => {
const response = await fetch('/api/permission-groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create permission group')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: permissionGroupKeys.list(variables.organizationId),
})
},
})
}
export interface UpdatePermissionGroupData {
id: string
organizationId: string
name?: string
description?: string | null
config?: Partial<PermissionGroupConfig>
}
export function useUpdatePermissionGroup() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...data }: UpdatePermissionGroupData) => {
const response = await fetch(`/api/permission-groups/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update permission group')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: permissionGroupKeys.list(variables.organizationId),
})
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.detail(variables.id) })
queryClient.invalidateQueries({ queryKey: ['permissionGroups', 'userConfig'] })
},
})
}
export interface DeletePermissionGroupParams {
permissionGroupId: string
organizationId: string
}
export function useDeletePermissionGroup() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ permissionGroupId }: DeletePermissionGroupParams) => {
const response = await fetch(`/api/permission-groups/${permissionGroupId}`, {
method: 'DELETE',
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete permission group')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: permissionGroupKeys.list(variables.organizationId),
})
queryClient.invalidateQueries({ queryKey: ['permissionGroups', 'userConfig'] })
},
})
}
export function useAddPermissionGroupMember() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: { permissionGroupId: string; userId: string }) => {
const response = await fetch(`/api/permission-groups/${data.permissionGroupId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: data.userId }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to add member')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: permissionGroupKeys.members(variables.permissionGroupId),
})
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
},
})
}
export function useRemovePermissionGroupMember() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: { permissionGroupId: string; memberId: string }) => {
const response = await fetch(
`/api/permission-groups/${data.permissionGroupId}/members?memberId=${data.memberId}`,
{ method: 'DELETE' }
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to remove member')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: permissionGroupKeys.members(variables.permissionGroupId),
})
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
queryClient.invalidateQueries({ queryKey: ['permissionGroups', 'userConfig'] })
},
})
}
export interface BulkAddMembersData {
permissionGroupId: string
userIds?: string[]
addAllOrgMembers?: boolean
}
export function useBulkAddPermissionGroupMembers() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ permissionGroupId, ...data }: BulkAddMembersData) => {
const response = await fetch(`/api/permission-groups/${permissionGroupId}/members/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to add members')
}
return response.json() as Promise<{ added: number; moved: number }>
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: permissionGroupKeys.members(variables.permissionGroupId),
})
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
queryClient.invalidateQueries({ queryKey: ['permissionGroups', 'userConfig'] })
},
})
}

View File

@@ -0,0 +1,71 @@
import { useMemo } from 'react'
import {
DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig,
} from '@/lib/permission-groups/types'
import { useOrganizations } from '@/hooks/queries/organization'
import { useUserPermissionConfig } from '@/hooks/queries/permission-groups'
export interface PermissionConfigResult {
config: PermissionGroupConfig
isLoading: boolean
isInPermissionGroup: boolean
filterBlocks: <T extends { type: string }>(blocks: T[]) => T[]
filterProviders: (providerIds: string[]) => string[]
isBlockAllowed: (blockType: string) => boolean
isProviderAllowed: (providerId: string) => boolean
}
export function usePermissionConfig(): PermissionConfigResult {
const { data: organizationsData } = useOrganizations()
const activeOrganization = organizationsData?.activeOrganization
const { data: permissionData, isLoading } = useUserPermissionConfig(activeOrganization?.id)
const config = useMemo(() => {
if (!permissionData?.config) {
return DEFAULT_PERMISSION_GROUP_CONFIG
}
return permissionData.config
}, [permissionData])
const isInPermissionGroup = !!permissionData?.permissionGroupId
const isBlockAllowed = useMemo(() => {
return (blockType: string) => {
if (config.allowedIntegrations === null) return true
return config.allowedIntegrations.includes(blockType)
}
}, [config.allowedIntegrations])
const isProviderAllowed = useMemo(() => {
return (providerId: string) => {
if (config.allowedModelProviders === null) return true
return config.allowedModelProviders.includes(providerId)
}
}, [config.allowedModelProviders])
const filterBlocks = useMemo(() => {
return <T extends { type: string }>(blocks: T[]): T[] => {
if (config.allowedIntegrations === null) return blocks
return blocks.filter((block) => config.allowedIntegrations!.includes(block.type))
}
}, [config.allowedIntegrations])
const filterProviders = useMemo(() => {
return (providerIds: string[]): string[] => {
if (config.allowedModelProviders === null) return providerIds
return providerIds.filter((id) => config.allowedModelProviders!.includes(id))
}
}, [config.allowedModelProviders])
return {
config,
isLoading,
isInPermissionGroup,
filterBlocks,
filterProviders,
isBlockAllowed,
isProviderAllowed,
}
}

View File

@@ -50,6 +50,7 @@ import {
isEmailPasswordEnabled,
isEmailVerificationEnabled,
isHosted,
isOrganizationsEnabled,
isRegistrationDisabled,
} from '@/lib/core/config/feature-flags'
import { PlatformEvents } from '@/lib/core/telemetry'
@@ -2352,8 +2353,15 @@ export const auth = betterAuth({
}
},
}),
]
: []),
...(isOrganizationsEnabled
? [
organization({
allowUserToCreateOrganization: async (user) => {
if (!isBillingEnabled) {
return true
}
const dbSubscriptions = await db
.select()
.from(schema.subscription)

View File

@@ -13,6 +13,7 @@ import {
} from '@/lib/billing/subscriptions/utils'
import type { UserSubscriptionState } from '@/lib/billing/types'
import {
isAccessControlEnabled,
isCredentialSetsEnabled,
isHosted,
isProd,
@@ -274,6 +275,33 @@ export async function isOrganizationOnTeamOrEnterprisePlan(
}
}
/**
* Check if an organization has an enterprise plan
* Used for Access Control (Permission Groups) feature gating
*/
export async function isOrganizationOnEnterprisePlan(organizationId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
if (isAccessControlEnabled && !isHosted) {
return true
}
const [orgSub] = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
.limit(1)
return !!orgSub && checkEnterprisePlan(orgSub)
} catch (error) {
logger.error('Error checking organization enterprise plan status', { error, organizationId })
return false
}
}
/**
* Check if user has access to credential sets (email polling) feature
* Returns true if:
@@ -316,6 +344,27 @@ export async function hasSSOAccess(userId: string): Promise<boolean> {
}
}
/**
* Check if user has access to Access Control (Permission Groups) feature
* Returns true if:
* - ACCESS_CONTROL_ENABLED env var is set (self-hosted override), OR
* - User is admin/owner of an enterprise organization
*
* In non-production environments, returns true for convenience.
*/
export async function hasAccessControlAccess(userId: string): Promise<boolean> {
try {
if (isAccessControlEnabled && !isHosted) {
return true
}
return isEnterpriseOrgAdminOrOwner(userId)
} catch (error) {
logger.error('Error checking access control access', { error, userId })
return false
}
}
/**
* Check if user has exceeded their cost limit based on current period usage
*/

View File

@@ -10,10 +10,12 @@ export * from '@/lib/billing/core/subscription'
export {
getHighestPrioritySubscription as getActiveSubscription,
getUserSubscriptionState as getSubscriptionState,
hasAccessControlAccess,
hasCredentialSetsAccess,
hasSSOAccess,
isEnterpriseOrgAdminOrOwner,
isEnterprisePlan as hasEnterprisePlan,
isOrganizationOnEnterprisePlan,
isOrganizationOnTeamOrEnterprisePlan,
isProPlan as hasProPlan,
isTeamOrgAdminOrOwner,

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, count, eq } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
const logger = createLogger('SeatManagement')
@@ -34,7 +35,20 @@ export async function validateSeatAvailability(
additionalSeats = 1
): Promise<SeatValidationResult> {
try {
// Get organization subscription directly (referenceId = organizationId)
if (!isBillingEnabled) {
const memberCount = await db
.select({ count: count() })
.from(member)
.where(eq(member.organizationId, organizationId))
const currentSeats = memberCount[0]?.count || 0
return {
canInvite: true,
currentSeats,
maxSeats: Number.MAX_SAFE_INTEGER,
availableSeats: Number.MAX_SAFE_INTEGER,
}
}
const subscription = await getOrganizationSubscription(organizationId)
if (!subscription) {

View File

@@ -5,6 +5,7 @@ import { and, eq, isNull } from 'drizzle-orm'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { escapeRegExp } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import type { ChatContext } from '@/stores/panel/copilot/types'
export type AgentContextType =
@@ -104,7 +105,11 @@ export async function processContextsServer(
)
}
if (ctx.kind === 'blocks' && (ctx as any).blockId) {
return await processBlockMetadata((ctx as any).blockId, ctx.label ? `@${ctx.label}` : '@')
return await processBlockMetadata(
(ctx as any).blockId,
ctx.label ? `@${ctx.label}` : '@',
userId
)
}
if (ctx.kind === 'templates' && (ctx as any).templateId) {
return await processTemplateFromDb(
@@ -355,8 +360,21 @@ async function processKnowledgeFromDb(
}
}
async function processBlockMetadata(blockId: string, tag: string): Promise<AgentContext | null> {
async function processBlockMetadata(
blockId: string,
tag: string,
userId?: string
): Promise<AgentContext | null> {
try {
if (userId) {
const permissionConfig = await getUserPermissionConfig(userId)
const allowedIntegrations = permissionConfig?.allowedIntegrations
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
logger.debug('Block not allowed by permission group', { blockId, userId })
return null
}
}
// Reuse registry to match get_blocks_metadata tool result
const { registry: blockRegistry } = await import('@/blocks/registry')
const { tools: toolsRegistry } = await import('@/tools/registry')

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { Check, Loader2, Plus, X, XCircle } from 'lucide-react'
import { client } from '@/lib/auth/auth-client'
import {
BaseClientTool,
type BaseClientToolMetadata,
@@ -31,6 +32,20 @@ interface ManageCustomToolArgs {
const API_ENDPOINT = '/api/tools/custom'
async function checkCustomToolsPermission(): Promise<void> {
const activeOrgResponse = await client.organization.getFullOrganization()
const organizationId = activeOrgResponse.data?.id
if (!organizationId) return
const response = await fetch(`/api/permission-groups/user?organizationId=${organizationId}`)
if (!response.ok) return
const data = await response.json()
if (data?.config?.disableCustomTools) {
throw new Error('Custom tools are not allowed based on your permission group settings')
}
}
/**
* Client tool for creating, editing, and deleting custom tools via the copilot.
*/
@@ -164,7 +179,10 @@ export class ManageCustomToolClientTool extends BaseClientTool {
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to manage custom tool')
await this.markToolComplete(500, e?.message || 'Failed to manage custom tool', {
success: false,
error: e?.message || 'Failed to manage custom tool',
})
}
}
@@ -189,6 +207,8 @@ export class ManageCustomToolClientTool extends BaseClientTool {
throw new Error('Operation is required')
}
await checkCustomToolsPermission()
const { operation, toolId, schema, code } = args
// Get workspace ID from the workflow registry

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { Check, Loader2, Server, X, XCircle } from 'lucide-react'
import { client } from '@/lib/auth/auth-client'
import {
BaseClientTool,
type BaseClientToolMetadata,
@@ -25,6 +26,20 @@ interface ManageMcpToolArgs {
const API_ENDPOINT = '/api/mcp/servers'
async function checkMcpToolsPermission(): Promise<void> {
const activeOrgResponse = await client.organization.getFullOrganization()
const organizationId = activeOrgResponse.data?.id
if (!organizationId) return
const response = await fetch(`/api/permission-groups/user?organizationId=${organizationId}`)
if (!response.ok) return
const data = await response.json()
if (data?.config?.disableMcpTools) {
throw new Error('MCP tools are not allowed based on your permission group settings')
}
}
/**
* Client tool for creating, editing, and deleting MCP tool servers via the copilot.
*/
@@ -145,7 +160,10 @@ export class ManageMcpToolClientTool extends BaseClientTool {
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to manage MCP tool')
await this.markToolComplete(500, e?.message || 'Failed to manage MCP tool', {
success: false,
error: e?.message || 'Failed to manage MCP tool',
})
}
}
@@ -167,6 +185,8 @@ export class ManageMcpToolClientTool extends BaseClientTool {
throw new Error('Operation is required')
}
await checkMcpToolsPermission()
const { operation, serverId, config } = args
const { hydration } = useWorkflowRegistry.getState()

View File

@@ -7,6 +7,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry'
@@ -298,13 +299,20 @@ export const getBlockConfigServerTool: BaseServerTool<
GetBlockConfigResultType
> = {
name: 'get_block_config',
async execute({
blockType,
operation,
}: GetBlockConfigInputType): Promise<GetBlockConfigResultType> {
async execute(
{ blockType, operation }: GetBlockConfigInputType,
context?: { userId: string }
): Promise<GetBlockConfigResultType> {
const logger = createLogger('GetBlockConfigServerTool')
logger.debug('Executing get_block_config', { blockType, operation })
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) {
throw new Error(`Block "${blockType}" is not available`)
}
const blockConfig = blockRegistry[blockType]
if (!blockConfig) {
throw new Error(`Block not found: ${blockType}`)

View File

@@ -6,6 +6,7 @@ import {
type GetBlockOptionsResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { tools as toolsRegistry } from '@/tools/registry'
export const getBlockOptionsServerTool: BaseServerTool<
@@ -13,10 +14,20 @@ export const getBlockOptionsServerTool: BaseServerTool<
GetBlockOptionsResultType
> = {
name: 'get_block_options',
async execute({ blockId }: GetBlockOptionsInputType): Promise<GetBlockOptionsResultType> {
async execute(
{ blockId }: GetBlockOptionsInputType,
context?: { userId: string }
): Promise<GetBlockOptionsResultType> {
const logger = createLogger('GetBlockOptionsServerTool')
logger.debug('Executing get_block_options', { blockId })
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
throw new Error(`Block "${blockId}" is not available`)
}
const blockConfig = blockRegistry[blockId]
if (!blockConfig) {
throw new Error(`Block not found: ${blockId}`)

View File

@@ -6,16 +6,20 @@ import {
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
export const getBlocksAndToolsServerTool: BaseServerTool<
ReturnType<typeof GetBlocksAndToolsInput.parse>,
ReturnType<typeof GetBlocksAndToolsResult.parse>
> = {
name: 'get_blocks_and_tools',
async execute() {
async execute(_args: unknown, context?: { userId: string }) {
const logger = createLogger('GetBlocksAndToolsServerTool')
logger.debug('Executing get_blocks_and_tools')
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
type BlockListItem = {
type: string
name: string
@@ -25,7 +29,11 @@ export const getBlocksAndToolsServerTool: BaseServerTool<
const blocks: BlockListItem[] = []
Object.entries(blockRegistry)
.filter(([, blockConfig]: [string, BlockConfig]) => !blockConfig.hideFromToolbar)
.filter(([blockType, blockConfig]: [string, BlockConfig]) => {
if (blockConfig.hideFromToolbar) return false
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) return false
return true
})
.forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
blocks.push({
type: blockType,

View File

@@ -9,6 +9,7 @@ import {
import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry'
import { getTrigger, isTriggerValid } from '@/triggers'
@@ -105,16 +106,23 @@ export const getBlocksMetadataServerTool: BaseServerTool<
ReturnType<typeof GetBlocksMetadataResult.parse>
> = {
name: 'get_blocks_metadata',
async execute({
blockIds,
}: ReturnType<typeof GetBlocksMetadataInput.parse>): Promise<
ReturnType<typeof GetBlocksMetadataResult.parse>
> {
async execute(
{ blockIds }: ReturnType<typeof GetBlocksMetadataInput.parse>,
context?: { userId: string }
): Promise<ReturnType<typeof GetBlocksMetadataResult.parse>> {
const logger = createLogger('GetBlocksMetadataServerTool')
logger.debug('Executing get_blocks_metadata', { count: blockIds?.length })
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
const result: Record<string, CopilotBlockMetadata> = {}
for (const blockId of blockIds || []) {
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
logger.debug('Block not allowed by permission group', { blockId })
continue
}
let metadata: any
if (SPECIAL_BLOCKS_METADATA[blockId]) {

View File

@@ -3,6 +3,7 @@ import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
export const GetTriggerBlocksInput = z.object({})
export const GetTriggerBlocksResult = z.object({
@@ -14,14 +15,18 @@ export const getTriggerBlocksServerTool: BaseServerTool<
ReturnType<typeof GetTriggerBlocksResult.parse>
> = {
name: 'get_trigger_blocks',
async execute() {
async execute(_args: unknown, context?: { userId: string }) {
const logger = createLogger('GetTriggerBlocksServerTool')
logger.debug('Executing get_trigger_blocks')
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
const triggerBlockIds: string[] = []
Object.entries(blockRegistry).forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
if (blockConfig.hideFromToolbar) return
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) return
if (blockConfig.category === 'triggers') {
triggerBlockIds.push(blockType)

View File

@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
@@ -13,6 +14,7 @@ import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { EDGE, normalizeName } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
@@ -49,6 +51,8 @@ interface ValidationError {
type SkippedItemType =
| 'block_not_found'
| 'invalid_block_type'
| 'block_not_allowed'
| 'tool_not_allowed'
| 'invalid_edge_target'
| 'invalid_edge_source'
| 'invalid_source_handle'
@@ -558,7 +562,9 @@ function createBlockFromParams(
blockId: string,
params: any,
parentId?: string,
errorsCollector?: ValidationError[]
errorsCollector?: ValidationError[],
permissionConfig?: PermissionGroupConfig | null,
skippedItems?: SkippedItem[]
): any {
const blockConfig = getAllBlocks().find((b) => b.type === params.type)
@@ -626,9 +632,14 @@ function createBlockFromParams(
}
}
// Special handling for tools - normalize to restore sanitized fields
// Special handling for tools - normalize and filter disallowed
if (key === 'tools' && Array.isArray(value)) {
sanitizedValue = normalizeTools(value)
sanitizedValue = filterDisallowedTools(
normalizeTools(value),
permissionConfig ?? null,
blockId,
skippedItems ?? []
)
}
// Special handling for responseFormat - normalize to ensure consistent format
@@ -1093,12 +1104,69 @@ interface ApplyOperationsResult {
skippedItems: SkippedItem[]
}
/**
* Checks if a block type is allowed by the permission group config
*/
function isBlockTypeAllowed(
blockType: string,
permissionConfig: PermissionGroupConfig | null
): boolean {
if (!permissionConfig || permissionConfig.allowedIntegrations === null) {
return true
}
return permissionConfig.allowedIntegrations.includes(blockType)
}
/**
* Filters out tools that are not allowed by the permission group config
* Returns both the allowed tools and any skipped tool items for logging
*/
function filterDisallowedTools(
tools: any[],
permissionConfig: PermissionGroupConfig | null,
blockId: string,
skippedItems: SkippedItem[]
): any[] {
if (!permissionConfig) {
return tools
}
const allowedTools: any[] = []
for (const tool of tools) {
if (tool.type === 'custom-tool' && permissionConfig.disableCustomTools) {
logSkippedItem(skippedItems, {
type: 'tool_not_allowed',
operationType: 'add',
blockId,
reason: `Custom tool "${tool.title || tool.customToolId || 'unknown'}" is not allowed by permission group - tool not added`,
details: { toolType: 'custom-tool', toolId: tool.customToolId },
})
continue
}
if (tool.type === 'mcp' && permissionConfig.disableMcpTools) {
logSkippedItem(skippedItems, {
type: 'tool_not_allowed',
operationType: 'add',
blockId,
reason: `MCP tool "${tool.title || 'unknown'}" is not allowed by permission group - tool not added`,
details: { toolType: 'mcp', serverId: tool.params?.serverId },
})
continue
}
allowedTools.push(tool)
}
return allowedTools
}
/**
* Apply operations directly to the workflow JSON state
*/
function applyOperationsToWorkflowState(
workflowState: any,
operations: EditWorkflowOperation[]
operations: EditWorkflowOperation[],
permissionConfig: PermissionGroupConfig | null = null
): ApplyOperationsResult {
// Deep clone the workflow state to avoid mutations
const modifiedState = JSON.parse(JSON.stringify(workflowState))
@@ -1297,9 +1365,14 @@ function applyOperationsToWorkflowState(
}
}
// Special handling for tools - normalize to restore sanitized fields
// Special handling for tools - normalize and filter disallowed
if (key === 'tools' && Array.isArray(value)) {
sanitizedValue = normalizeTools(value)
sanitizedValue = filterDisallowedTools(
normalizeTools(value),
permissionConfig,
block_id,
skippedItems
)
}
// Special handling for responseFormat - normalize to ensure consistent format
@@ -1401,6 +1474,14 @@ function applyOperationsToWorkflowState(
reason: `Invalid block type "${params.type}" - type change skipped`,
details: { requestedType: params.type },
})
} else if (!isContainerType && !isBlockTypeAllowed(params.type, permissionConfig)) {
logSkippedItem(skippedItems, {
type: 'block_not_allowed',
operationType: 'edit',
blockId: block_id,
reason: `Block type "${params.type}" is not allowed by permission group - type change skipped`,
details: { requestedType: params.type },
})
} else {
block.type = params.type
}
@@ -1503,7 +1584,9 @@ function applyOperationsToWorkflowState(
childId,
childBlock,
block_id,
validationErrors
validationErrors,
permissionConfig,
skippedItems
)
modifiedState.blocks[childId] = childBlockState
@@ -1680,8 +1763,27 @@ function applyOperationsToWorkflowState(
break
}
// Check if block type is allowed by permission group
if (!isContainerType && !isBlockTypeAllowed(params.type, permissionConfig)) {
logSkippedItem(skippedItems, {
type: 'block_not_allowed',
operationType: 'add',
blockId: block_id,
reason: `Block type "${params.type}" is not allowed by permission group - block not added`,
details: { requestedType: params.type },
})
break
}
// Create new block with proper structure
const newBlock = createBlockFromParams(block_id, params, undefined, validationErrors)
const newBlock = createBlockFromParams(
block_id,
params,
undefined,
validationErrors,
permissionConfig,
skippedItems
)
// Set loop/parallel data on parent block BEFORE adding to blocks (strict validation)
if (params.nestedNodes) {
@@ -1760,7 +1862,9 @@ function applyOperationsToWorkflowState(
childId,
childBlock,
block_id,
validationErrors
validationErrors,
permissionConfig,
skippedItems
)
modifiedState.blocks[childId] = childBlockState
@@ -1882,9 +1986,14 @@ function applyOperationsToWorkflowState(
}
}
// Special handling for tools - normalize to restore sanitized fields
// Special handling for tools - normalize and filter disallowed
if (key === 'tools' && Array.isArray(value)) {
sanitizedValue = normalizeTools(value)
sanitizedValue = filterDisallowedTools(
normalizeTools(value),
permissionConfig,
block_id,
skippedItems
)
}
// Special handling for responseFormat - normalize to ensure consistent format
@@ -1920,8 +2029,27 @@ function applyOperationsToWorkflowState(
break
}
// Check if block type is allowed by permission group
if (!isContainerType && !isBlockTypeAllowed(params.type, permissionConfig)) {
logSkippedItem(skippedItems, {
type: 'block_not_allowed',
operationType: 'insert_into_subflow',
blockId: block_id,
reason: `Block type "${params.type}" is not allowed by permission group - block not inserted`,
details: { requestedType: params.type, subflowId },
})
break
}
// Create new block as child of subflow
const newBlock = createBlockFromParams(block_id, params, subflowId, validationErrors)
const newBlock = createBlockFromParams(
block_id,
params,
subflowId,
validationErrors,
permissionConfig,
skippedItems
)
modifiedState.blocks[block_id] = newBlock
}
@@ -2223,12 +2351,15 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = {
workflowState = fromDb.workflowState
}
// Get permission config for the user
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
// Apply operations directly to the workflow state
const {
state: modifiedWorkflowState,
validationErrors,
skippedItems,
} = applyOperationsToWorkflowState(workflowState, operations)
} = applyOperationsToWorkflowState(workflowState, operations, permissionConfig)
// Get workspaceId for selector validation
let workspaceId: string | undefined

View File

@@ -251,6 +251,12 @@ export const env = createEnv({
// Credential Sets (Email Polling) - for self-hosted deployments
CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements)
// Access Control (Permission Groups) - for self-hosted deployments
ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control on self-hosted (bypasses plan requirements)
// Organizations - for self-hosted deployments
ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
// SSO Configuration (for script-based registration)
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type
@@ -329,6 +335,8 @@ export const env = createEnv({
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: z.boolean().optional(), // Client-side gate for async executions UI
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
},
@@ -358,6 +366,8 @@ export const env = createEnv({
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: process.env.NEXT_PUBLIC_TRIGGER_DEV_ENABLED,
NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED,
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: process.env.NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED,
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED,
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED,
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,

View File

@@ -1,7 +1,7 @@
/**
* Environment utility functions for consistent environment detection across the application
*/
import { env, getEnv, isFalsy, isTruthy } from './env'
import { env, isFalsy, isTruthy } from './env'
/**
* Is the application running in production mode
@@ -21,9 +21,7 @@ export const isTest = env.NODE_ENV === 'test'
/**
* Is this the hosted version of the application
*/
export const isHosted =
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
export const isHosted = true
/**
* Is billing enforcement enabled
@@ -86,6 +84,20 @@ export const isSsoEnabled = isTruthy(env.SSO_ENABLED)
*/
export const isCredentialSetsEnabled = isTruthy(env.CREDENTIAL_SETS_ENABLED)
/**
* Is access control (permission groups) enabled via env var override
* This bypasses plan requirements for self-hosted deployments
*/
export const isAccessControlEnabled = isTruthy(env.ACCESS_CONTROL_ENABLED)
/**
* Is organizations enabled
* True if billing is enabled (orgs come with billing), OR explicitly enabled via env var,
* OR if access control is enabled (access control requires organizations)
*/
export const isOrganizationsEnabled =
isBillingEnabled || isTruthy(env.ORGANIZATIONS_ENABLED) || isAccessControlEnabled
/**
* Is E2B enabled for remote code execution
*/

View File

@@ -0,0 +1,51 @@
export interface PermissionGroupConfig {
allowedIntegrations: string[] | null
allowedModelProviders: string[] | null
// Platform Configuration
hideTraceSpans: boolean
hideKnowledgeBaseTab: boolean
hideCopilot: boolean
hideApiKeysTab: boolean
hideEnvironmentTab: boolean
hideFilesTab: boolean
disableMcpTools: boolean
disableCustomTools: boolean
hideTemplates: boolean
}
export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
allowedIntegrations: null,
allowedModelProviders: null,
hideTraceSpans: false,
hideKnowledgeBaseTab: false,
hideCopilot: false,
hideApiKeysTab: false,
hideEnvironmentTab: false,
hideFilesTab: false,
disableMcpTools: false,
disableCustomTools: false,
hideTemplates: false,
}
export function parsePermissionGroupConfig(config: unknown): PermissionGroupConfig {
if (!config || typeof config !== 'object') {
return DEFAULT_PERMISSION_GROUP_CONFIG
}
const c = config as Record<string, unknown>
return {
allowedIntegrations: Array.isArray(c.allowedIntegrations) ? c.allowedIntegrations : null,
allowedModelProviders: Array.isArray(c.allowedModelProviders) ? c.allowedModelProviders : null,
hideTraceSpans: typeof c.hideTraceSpans === 'boolean' ? c.hideTraceSpans : false,
hideKnowledgeBaseTab:
typeof c.hideKnowledgeBaseTab === 'boolean' ? c.hideKnowledgeBaseTab : false,
hideCopilot: typeof c.hideCopilot === 'boolean' ? c.hideCopilot : false,
hideApiKeysTab: typeof c.hideApiKeysTab === 'boolean' ? c.hideApiKeysTab : false,
hideEnvironmentTab: typeof c.hideEnvironmentTab === 'boolean' ? c.hideEnvironmentTab : false,
hideFilesTab: typeof c.hideFilesTab === 'boolean' ? c.hideFilesTab : false,
disableMcpTools: typeof c.disableMcpTools === 'boolean' ? c.disableMcpTools : false,
disableCustomTools: typeof c.disableCustomTools === 'boolean' ? c.disableCustomTools : false,
hideTemplates: typeof c.hideTemplates === 'boolean' ? c.hideTemplates : false,
}
}

View File

@@ -0,0 +1,29 @@
CREATE TABLE "permission_group" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"config" jsonb DEFAULT '{}' NOT NULL,
"created_by" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "permission_group_member" (
"id" text PRIMARY KEY NOT NULL,
"permission_group_id" text NOT NULL,
"user_id" text NOT NULL,
"assigned_by" text,
"assigned_at" timestamp DEFAULT now() NOT NULL
);
--> 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" ADD CONSTRAINT "permission_group_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "permission_group_member" ADD CONSTRAINT "permission_group_member_permission_group_id_permission_group_id_fk" FOREIGN KEY ("permission_group_id") REFERENCES "public"."permission_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "permission_group_member" ADD CONSTRAINT "permission_group_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "permission_group_member" ADD CONSTRAINT "permission_group_member_assigned_by_user_id_fk" FOREIGN KEY ("assigned_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "permission_group_organization_id_idx" ON "permission_group" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "permission_group_created_by_idx" ON "permission_group" USING btree ("created_by");--> statement-breakpoint
CREATE UNIQUE INDEX "permission_group_org_name_unique" ON "permission_group" USING btree ("organization_id","name");--> statement-breakpoint
CREATE INDEX "permission_group_member_group_id_idx" ON "permission_group_member" USING btree ("permission_group_id");--> statement-breakpoint
CREATE UNIQUE INDEX "permission_group_member_user_id_unique" ON "permission_group_member" USING btree ("user_id");

File diff suppressed because it is too large Load Diff

View File

@@ -953,6 +953,13 @@
"when": 1767905804764,
"tag": "0136_pretty_jack_flag",
"breakpoints": true
},
{
"idx": 137,
"version": "7",
"when": 1767924777319,
"tag": "0137_yellow_korath",
"breakpoints": true
}
]
}

View File

@@ -1877,3 +1877,48 @@ export const credentialSetInvitation = pgTable(
expiresAtIdx: index('credential_set_invitation_expires_at_idx').on(table.expiresAt),
})
)
export const permissionGroup = pgTable(
'permission_group',
{
id: text('id').primaryKey(),
organizationId: text('organization_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
config: jsonb('config').notNull().default('{}'),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
organizationIdIdx: index('permission_group_organization_id_idx').on(table.organizationId),
createdByIdx: index('permission_group_created_by_idx').on(table.createdBy),
orgNameUnique: uniqueIndex('permission_group_org_name_unique').on(
table.organizationId,
table.name
),
})
)
export const permissionGroupMember = pgTable(
'permission_group_member',
{
id: text('id').primaryKey(),
permissionGroupId: text('permission_group_id')
.notNull()
.references(() => permissionGroup.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
assignedBy: text('assigned_by').references(() => user.id, { onDelete: 'set null' }),
assignedAt: timestamp('assigned_at').notNull().defaultNow(),
},
(table) => ({
permissionGroupIdIdx: index('permission_group_member_group_id_idx').on(table.permissionGroupId),
userIdUnique: uniqueIndex('permission_group_member_user_id_unique').on(table.userId),
})
)