Skip to content

Commit 3adc397

Browse files
committed
feat(copilot): expose effective enterprise context
1 parent cd4399a commit 3adc397

16 files changed

Lines changed: 940 additions & 163 deletions

File tree

apps/docs/content/docs/en/platform/permissions.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ Here's a detailed breakdown of what users can do with each permission level:
126126
**What they can do:**
127127
- Everything Read users can do, plus:
128128
- Create, edit, and delete workflows
129-
- Run and deploy workflows
129+
- Run workflows
130130
- Add, edit, and delete workspace environment variables
131131
- Use all available tools and integrations
132132
- Collaborate in real-time on workflow editing
@@ -140,6 +140,7 @@ Here's a detailed breakdown of what users can do with each permission level:
140140

141141
**What they can do:**
142142
- Everything Write users can do, plus:
143+
- Deploy workflows
143144
- Invite new users to the workspace with any permission level
144145
- Remove users from the workspace
145146
- Manage workspace settings and integrations
@@ -254,4 +255,4 @@ import { FAQ } from '@/components/ui/faq'
254255
{ 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." },
255256
{ 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." },
256257
{ 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." },
257-
]} />
258+
]} />

apps/sim/ee/access-control/components/group-detail.tsx

Lines changed: 2 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { useQueryState } from 'nuqs'
3030
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
3131
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
3232
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
33+
import { PLATFORM_CATEGORY_ORDER, PLATFORM_FEATURES } from '@/lib/permission-groups/features'
3334
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
3435
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
3536
import {
@@ -174,151 +175,6 @@ function AuthModeField({ label, value, onChange, options, disabled }: AuthModeFi
174175
)
175176
}
176177

177-
/** Render order for the platform-feature category sections; unlisted ones follow. */
178-
const PLATFORM_CATEGORY_ORDER = [
179-
'Sidebar',
180-
'Deploy Tabs',
181-
'Chat',
182-
'Collaboration',
183-
'Workflow Panel',
184-
'Tools',
185-
'Features',
186-
'Settings Tabs',
187-
'Logs',
188-
'Files',
189-
]
190-
191-
const PLATFORM_FEATURES = [
192-
{
193-
id: 'hide-knowledge-base',
194-
label: 'Knowledge Base',
195-
category: 'Sidebar',
196-
configKey: 'hideKnowledgeBaseTab' as const,
197-
hint: 'Hide the Knowledge Base module from the sidebar.',
198-
},
199-
{
200-
id: 'hide-tables',
201-
label: 'Tables',
202-
category: 'Sidebar',
203-
configKey: 'hideTablesTab' as const,
204-
hint: 'Hide the Tables module from the sidebar.',
205-
},
206-
{
207-
id: 'hide-copilot',
208-
label: 'Chat',
209-
category: 'Workflow Panel',
210-
configKey: 'hideCopilot' as const,
211-
hint: 'Hide the Chat panel so users cannot build or edit with natural language.',
212-
},
213-
{
214-
id: 'hide-integrations',
215-
label: 'Integrations',
216-
category: 'Settings Tabs',
217-
configKey: 'hideIntegrationsTab' as const,
218-
hint: 'Hide the Integrations settings tab (OAuth connections).',
219-
},
220-
{
221-
id: 'hide-secrets',
222-
label: 'Secrets',
223-
category: 'Settings Tabs',
224-
configKey: 'hideSecretsTab' as const,
225-
hint: 'Hide the Secrets (environment variables) settings tab.',
226-
},
227-
{
228-
id: 'hide-api-keys',
229-
label: 'API Keys',
230-
category: 'Settings Tabs',
231-
configKey: 'hideApiKeysTab' as const,
232-
hint: 'Hide the API Keys settings tab.',
233-
},
234-
{
235-
id: 'hide-files',
236-
label: 'Files',
237-
category: 'Settings Tabs',
238-
configKey: 'hideFilesTab' as const,
239-
hint: 'Hide the Files settings tab.',
240-
},
241-
{
242-
id: 'hide-deploy-api',
243-
label: 'API',
244-
category: 'Deploy Tabs',
245-
configKey: 'hideDeployApi' as const,
246-
hint: 'Hide the API deployment option.',
247-
},
248-
{
249-
id: 'hide-deploy-mcp',
250-
label: 'MCP',
251-
category: 'Deploy Tabs',
252-
configKey: 'hideDeployMcp' as const,
253-
hint: 'Hide the MCP server deployment option.',
254-
},
255-
{
256-
id: 'disable-mcp',
257-
label: 'MCP Tools',
258-
category: 'Tools',
259-
configKey: 'disableMcpTools' as const,
260-
hint: 'Block agents from calling MCP tools.',
261-
},
262-
{
263-
id: 'disable-custom-tools',
264-
label: 'Custom Tools',
265-
category: 'Tools',
266-
configKey: 'disableCustomTools' as const,
267-
hint: 'Block agents from calling user-defined custom tools.',
268-
},
269-
{
270-
id: 'disable-skills',
271-
label: 'Skills',
272-
category: 'Tools',
273-
configKey: 'disableSkills' as const,
274-
hint: 'Block agents from loading skills.',
275-
},
276-
{
277-
id: 'hide-trace-spans',
278-
label: 'Trace Spans',
279-
category: 'Logs',
280-
configKey: 'hideTraceSpans' as const,
281-
hint: 'Hide per-block trace spans in logs.',
282-
},
283-
{
284-
id: 'disable-invitations',
285-
label: 'Invitations',
286-
category: 'Collaboration',
287-
configKey: 'disableInvitations' as const,
288-
hint: 'Prevent users from inviting others to workspaces.',
289-
},
290-
{
291-
id: 'hide-inbox',
292-
label: 'Sim Mailer',
293-
category: 'Features',
294-
configKey: 'hideInboxTab' as const,
295-
hint: 'Hide the Sim Mailer inbox.',
296-
},
297-
{
298-
id: 'disable-public-api',
299-
label: 'Public API',
300-
category: 'Features',
301-
configKey: 'disablePublicApi' as const,
302-
hint: 'Disable public API access to deployed workflows.',
303-
},
304-
// Chat and Files get a category of their own so their nested auth-mode
305-
// dropdown (see `featureExtras`) reads as part of the toggle it qualifies.
306-
{
307-
id: 'hide-deploy-chatbot',
308-
label: 'Deployment',
309-
category: 'Chat',
310-
configKey: 'hideDeployChatbot' as const,
311-
hint: 'Hide the chat deployment option.',
312-
},
313-
{
314-
id: 'disable-public-file-sharing',
315-
label: 'Public Sharing',
316-
category: 'Files',
317-
configKey: 'disablePublicFileSharing' as const,
318-
hint: 'Disable public file-share links.',
319-
},
320-
]
321-
322178
interface OrganizationMemberOption {
323179
userId: string
324180
user: {
@@ -954,7 +810,7 @@ export function GroupDetail({
954810
}, [searchedPlatformFeatures, statusFilter, editingConfig])
955811

956812
const platformCategories = useMemo(() => {
957-
const categories: Record<string, typeof PLATFORM_FEATURES> = {}
813+
const categories: Record<string, (typeof PLATFORM_FEATURES)[number][]> = {}
958814
for (const feature of filteredPlatformFeatures) {
959815
if (!categories[feature.category]) {
960816
categories[feature.category] = []

apps/sim/ee/access-control/utils/permission-check.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ import {
8484
ModelNotAllowedError,
8585
ProviderNotAllowedError,
8686
PublicFileSharingNotAllowedError,
87+
resolveUserAccessControlContext,
88+
resolveVerifiedUserAccessControlContext,
8789
SkillsNotAllowedError,
8890
ToolNotAllowedError,
8991
validateBlockType,
@@ -229,6 +231,126 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => {
229231
})
230232
})
231233

234+
describe('resolveUserAccessControlContext', () => {
235+
beforeEach(() => {
236+
vi.clearAllMocks()
237+
resetDbChainMock()
238+
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
239+
})
240+
241+
it('describes a personal workspace without changing the config-only result', async () => {
242+
mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: null })
243+
244+
await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({
245+
organizationId: null,
246+
entitled: false,
247+
permissionGroup: null,
248+
config: null,
249+
})
250+
await expect(getUserPermissionConfig('user-123', 'workspace-1')).resolves.toBeNull()
251+
})
252+
253+
it('returns the explicit governing group and its effective config', async () => {
254+
setEnterpriseOrgWorkspace()
255+
queueGroupResolution([
256+
{
257+
id: 'group-explicit',
258+
name: 'Engineering',
259+
config: { disableMcpTools: true },
260+
isMember: true,
261+
hasMembers: true,
262+
},
263+
])
264+
265+
await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({
266+
organizationId: 'org-1',
267+
entitled: true,
268+
permissionGroup: {
269+
id: 'group-explicit',
270+
name: 'Engineering',
271+
resolution: 'explicit-member',
272+
},
273+
config: expect.objectContaining({ disableMcpTools: true }),
274+
})
275+
})
276+
277+
it('identifies an all-members governing group', async () => {
278+
setEnterpriseOrgWorkspace()
279+
queueGroupResolution([
280+
{
281+
id: 'group-all-members',
282+
name: 'All workspace members',
283+
config: { disableCustomTools: true },
284+
isMember: false,
285+
hasMembers: false,
286+
},
287+
])
288+
289+
const context = await resolveUserAccessControlContext('user-123', 'workspace-1')
290+
291+
expect(context.permissionGroup).toEqual({
292+
id: 'group-all-members',
293+
name: 'All workspace members',
294+
resolution: 'all-members',
295+
})
296+
})
297+
298+
it('uses a verified workspace organization without loading the workspace again', async () => {
299+
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
300+
queueGroupResolution([
301+
{
302+
id: 'group-verified',
303+
name: 'Verified group',
304+
config: { disableSkills: true },
305+
isMember: true,
306+
hasMembers: true,
307+
},
308+
])
309+
310+
const context = await resolveVerifiedUserAccessControlContext(
311+
'user-123',
312+
'workspace-1',
313+
'org-verified'
314+
)
315+
316+
expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled()
317+
expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-verified')
318+
expect(context).toMatchObject({
319+
organizationId: 'org-verified',
320+
entitled: true,
321+
permissionGroup: {
322+
id: 'group-verified',
323+
resolution: 'explicit-member',
324+
},
325+
config: { disableSkills: true },
326+
})
327+
})
328+
329+
it('identifies the default group and preserves the environment allowlist', async () => {
330+
setEnterpriseOrgWorkspace()
331+
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack'])
332+
queueGroupResolution(
333+
[],
334+
[
335+
{
336+
id: 'group-default',
337+
name: 'Organization default',
338+
config: { allowedIntegrations: ['slack', 'github'] },
339+
},
340+
]
341+
)
342+
343+
const context = await resolveUserAccessControlContext('user-123', 'workspace-1')
344+
345+
expect(context.permissionGroup).toEqual({
346+
id: 'group-default',
347+
name: 'Organization default',
348+
resolution: 'default',
349+
})
350+
expect(context.config?.allowedIntegrations).toEqual(['slack'])
351+
})
352+
})
353+
232354
describe('getUserPermissionConfig (workspace-group precedence)', () => {
233355
beforeEach(() => {
234356
vi.clearAllMocks()

0 commit comments

Comments
 (0)