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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/docs/content/docs/en/platform/permissions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -126,7 +126,7 @@ Here's a detailed breakdown of what users can do with each permission level:
**What they can do:**
- Everything Read users can do, plus:
- Create, edit, and delete workflows
- Run and deploy workflows
- Run workflows
- Add, edit, and delete workspace environment variables
- Use all available tools and integrations
- Collaborate in real-time on workflow editing
Expand All@@ -140,6 +140,7 @@ Here's a detailed breakdown of what users can do with each permission level:

**What they can do:**
- Everything Write users can do, plus:
- Deploy workflows
- Invite new users to the workspace with any permission level
- Remove users from the workspace
- Manage workspace settings and integrations
Expand DownExpand Up@@ -254,4 +255,4 @@ import { FAQ } from '@/components/ui/faq'
{ question: "Who can manage a workspace's credentials and secrets?", answer: "Workspace Admins are automatically Credential Admins of the workspace's shared credentials — OAuth connections, service accounts, and workspace environment variables — so they can use, edit, delete, and share them, and run workflows that rely on them. Organization Owners and Admins get this too because they are workspace Admins everywhere. Read and Write members get use-only access to shared credentials unless they are explicitly made a Credential Admin. Personal environment variables are never shared; they stay private to their owner." },
{ question: "What are permission groups and how do they work?", answer: "Permission groups are an Enterprise access control feature that lets organization owners and admins define granular restrictions beyond the standard Read/Write/Admin roles. The organization's default group is org-wide; every other group targets specific workspaces and, by default, governs all members of those workspaces (including external members) — add members to restrict it to specific people. A user is governed by one group per workspace: a group they're an explicit member of takes precedence over an all-members group (one with no members) on that workspace, which takes precedence over the organization's default group. A permission group can hide UI sections (like trace spans, knowledge base, API keys, or deployment options), disable features (MCP tools, custom tools, skills, invitations), and restrict which integrations and model providers its members can access. Only one group per organization can be the default; it ignores members and governs everyone not covered by a workspace group, including external members. Restrictions are enforced based on the organization that owns the workflow's workspace, not on which workspace you're currently viewing." },
{ question: "How should I set up permissions for a new team member?", answer: "Start with the lowest permission level they need. Invite them with Read workspace access if they only need visibility, Write if they need to create and run workflows, or Admin if they need to manage the workspace and its users, and leave Membership on Member. For clients, partners, and contractors, choose External so they collaborate without joining your organization or using a seat — this requires them to already be on a paid Sim plan, either their own Pro or Max subscription or another organization that seats them." },
]} />
]} />
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,6 +64,7 @@ const TOOL_ICONS: Record<string, IconComponent> = {
research: Search,
scout: Search,
search: Search,
platform: Library,
context_compaction: Asterisk,
open_resource: Eye,
file: File,
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/workspace/[workspaceId]/home/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,6 +189,7 @@ export const SUBAGENT_LABELS: Record<string, string> = {
custom_tool: 'Custom Tool Agent',
scout: 'Scout Agent',
search: 'Search Agent',
platform: 'Platform Agent',
superagent: 'Superagent',
run: 'Run Agent',
agent: 'Tools Agent',
Expand Down
148 changes: 2 additions & 146 deletions apps/sim/ee/access-control/components/group-detail.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ import { useQueryState } from 'nuqs'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
import { PLATFORM_CATEGORY_ORDER, PLATFORM_FEATURES } from '@/lib/permission-groups/features'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import {
Expand DownExpand Up@@ -174,151 +175,6 @@ function AuthModeField({ label, value, onChange, options, disabled }: AuthModeFi
)
}

/** Render order for the platform-feature category sections; unlisted ones follow. */
const PLATFORM_CATEGORY_ORDER = [
'Sidebar',
'Deploy Tabs',
'Chat',
'Collaboration',
'Workflow Panel',
'Tools',
'Features',
'Settings Tabs',
'Logs',
'Files',
]

const PLATFORM_FEATURES = [
{
id: 'hide-knowledge-base',
label: 'Knowledge Base',
category: 'Sidebar',
configKey: 'hideKnowledgeBaseTab' as const,
hint: 'Hide the Knowledge Base module from the sidebar.',
},
{
id: 'hide-tables',
label: 'Tables',
category: 'Sidebar',
configKey: 'hideTablesTab' as const,
hint: 'Hide the Tables module from the sidebar.',
},
{
id: 'hide-copilot',
label: 'Chat',
category: 'Workflow Panel',
configKey: 'hideCopilot' as const,
hint: 'Hide the Chat panel so users cannot build or edit with natural language.',
},
{
id: 'hide-integrations',
label: 'Integrations',
category: 'Settings Tabs',
configKey: 'hideIntegrationsTab' as const,
hint: 'Hide the Integrations settings tab (OAuth connections).',
},
{
id: 'hide-secrets',
label: 'Secrets',
category: 'Settings Tabs',
configKey: 'hideSecretsTab' as const,
hint: 'Hide the Secrets (environment variables) settings tab.',
},
{
id: 'hide-api-keys',
label: 'API Keys',
category: 'Settings Tabs',
configKey: 'hideApiKeysTab' as const,
hint: 'Hide the API Keys settings tab.',
},
{
id: 'hide-files',
label: 'Files',
category: 'Settings Tabs',
configKey: 'hideFilesTab' as const,
hint: 'Hide the Files settings tab.',
},
{
id: 'hide-deploy-api',
label: 'API',
category: 'Deploy Tabs',
configKey: 'hideDeployApi' as const,
hint: 'Hide the API deployment option.',
},
{
id: 'hide-deploy-mcp',
label: 'MCP',
category: 'Deploy Tabs',
configKey: 'hideDeployMcp' as const,
hint: 'Hide the MCP server deployment option.',
},
{
id: 'disable-mcp',
label: 'MCP Tools',
category: 'Tools',
configKey: 'disableMcpTools' as const,
hint: 'Block agents from calling MCP tools.',
},
{
id: 'disable-custom-tools',
label: 'Custom Tools',
category: 'Tools',
configKey: 'disableCustomTools' as const,
hint: 'Block agents from calling user-defined custom tools.',
},
{
id: 'disable-skills',
label: 'Skills',
category: 'Tools',
configKey: 'disableSkills' as const,
hint: 'Block agents from loading skills.',
},
{
id: 'hide-trace-spans',
label: 'Trace Spans',
category: 'Logs',
configKey: 'hideTraceSpans' as const,
hint: 'Hide per-block trace spans in logs.',
},
{
id: 'disable-invitations',
label: 'Invitations',
category: 'Collaboration',
configKey: 'disableInvitations' as const,
hint: 'Prevent users from inviting others to workspaces.',
},
{
id: 'hide-inbox',
label: 'Sim Mailer',
category: 'Features',
configKey: 'hideInboxTab' as const,
hint: 'Hide the Sim Mailer inbox.',
},
{
id: 'disable-public-api',
label: 'Public API',
category: 'Features',
configKey: 'disablePublicApi' as const,
hint: 'Disable public API access to deployed workflows.',
},
// Chat and Files get a category of their own so their nested auth-mode
// dropdown (see `featureExtras`) reads as part of the toggle it qualifies.
{
id: 'hide-deploy-chatbot',
label: 'Deployment',
category: 'Chat',
configKey: 'hideDeployChatbot' as const,
hint: 'Hide the chat deployment option.',
},
{
id: 'disable-public-file-sharing',
label: 'Public Sharing',
category: 'Files',
configKey: 'disablePublicFileSharing' as const,
hint: 'Disable public file-share links.',
},
]

interface OrganizationMemberOption {
userId: string
user: {
Expand DownExpand Up@@ -954,7 +810,7 @@ export function GroupDetail({
}, [searchedPlatformFeatures, statusFilter, editingConfig])

const platformCategories = useMemo(() => {
const categories: Record<string, typeof PLATFORM_FEATURES> = {}
const categories: Record<string, (typeof PLATFORM_FEATURES)[number][]> = {}
for (const feature of filteredPlatformFeatures) {
if (!categories[feature.category]) {
categories[feature.category] = []
Expand Down
122 changes: 122 additions & 0 deletions apps/sim/ee/access-control/utils/permission-check.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,8 @@ import {
ModelNotAllowedError,
ProviderNotAllowedError,
PublicFileSharingNotAllowedError,
resolveUserAccessControlContext,
resolveVerifiedUserAccessControlContext,
SkillsNotAllowedError,
ToolNotAllowedError,
validateBlockType,
Expand DownExpand Up@@ -229,6 +231,126 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => {
})
})

describe('resolveUserAccessControlContext', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
})

it('describes a personal workspace without changing the config-only result', async () => {
mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: null })

await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({
organizationId: null,
entitled: false,
permissionGroup: null,
config: null,
})
await expect(getUserPermissionConfig('user-123', 'workspace-1')).resolves.toBeNull()
})

it('returns the explicit governing group and its effective config', async () => {
setEnterpriseOrgWorkspace()
queueGroupResolution([
{
id: 'group-explicit',
name: 'Engineering',
config: { disableMcpTools: true },
isMember: true,
hasMembers: true,
},
])

await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({
organizationId: 'org-1',
entitled: true,
permissionGroup: {
id: 'group-explicit',
name: 'Engineering',
resolution: 'explicit-member',
},
config: expect.objectContaining({ disableMcpTools: true }),
})
})

it('identifies an all-members governing group', async () => {
setEnterpriseOrgWorkspace()
queueGroupResolution([
{
id: 'group-all-members',
name: 'All workspace members',
config: { disableCustomTools: true },
isMember: false,
hasMembers: false,
},
])

const context = await resolveUserAccessControlContext('user-123', 'workspace-1')

expect(context.permissionGroup).toEqual({
id: 'group-all-members',
name: 'All workspace members',
resolution: 'all-members',
})
})

it('uses a verified workspace organization without loading the workspace again', async () => {
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
queueGroupResolution([
{
id: 'group-verified',
name: 'Verified group',
config: { disableSkills: true },
isMember: true,
hasMembers: true,
},
])

const context = await resolveVerifiedUserAccessControlContext(
'user-123',
'workspace-1',
'org-verified'
)

expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled()
expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-verified')
expect(context).toMatchObject({
organizationId: 'org-verified',
entitled: true,
permissionGroup: {
id: 'group-verified',
resolution: 'explicit-member',
},
config: { disableSkills: true },
})
})

it('identifies the default group and preserves the environment allowlist', async () => {
setEnterpriseOrgWorkspace()
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack'])
queueGroupResolution(
[],
[
{
id: 'group-default',
name: 'Organization default',
config: { allowedIntegrations: ['slack', 'github'] },
},
]
)

const context = await resolveUserAccessControlContext('user-123', 'workspace-1')

expect(context.permissionGroup).toEqual({
id: 'group-default',
name: 'Organization default',
resolution: 'default',
})
expect(context.config?.allowedIntegrations).toEqual(['slack'])
})
})

describe('getUserPermissionConfig (workspace-group precedence)', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down
Loading