From 33ee7da6b912833f9b1919eb8f149d9184cbc317 Mon Sep 17 00:00:00 2001
From: waleed
Date: Thu, 25 Jun 2026 14:53:29 -0700
Subject: [PATCH 1/8] feat(access-control): page-based permission groups,
tool-level deny-list, settings row-action consistency
- Replace the cramped configure modal with a full-surface tabbed Access Control page (General/Model Providers/Blocks/Platform) with a sticky save bar
- Add deniedTools denylist to permission groups: deny individual tools within an allowed integration; enforced at the universal executeTool chokepoint via ToolNotAllowedError, and hidden from the operation dropdown for governed users
- Add per-section Select/Deselect All on the Blocks tab and expandable per-tool deny rows (mirrors Providers->Models)
- Standardize every settings list row on the canonical "..." DropdownMenu (custom-tools, mcp, workflow-mcp-servers, api-keys, secrets, credential-sets) and align badges (ChipTag), avatars (MemberAvatar), inputs (ChipInput), and the mothership env picker (ChipSelect)
---
.../settings/components/admin/admin.tsx | 6 +-
.../settings/components/api-keys/api-keys.tsx | 95 +-
.../credential-sets/credential-sets.tsx | 119 +-
.../components/custom-tools/custom-tools.tsx | 45 +-
.../settings/components/mcp/mcp.tsx | 31 +-
.../components/mothership/mothership.tsx | 53 +-
.../secrets-manager/secrets-manager.tsx | 134 +-
.../workflow-mcp-servers.tsx | 63 +-
.../components/dropdown/dropdown.tsx | 37 +-
.../components/access-control.tsx | 1701 +---------------
.../components/group-detail.tsx | 1738 +++++++++++++++++
.../components/workspace-select.tsx | 58 +
.../utils/permission-check.test.ts | 65 +
.../access-control/utils/permission-check.ts | 22 +-
.../components/data-retention-settings.tsx | 5 +-
apps/sim/hooks/use-permission-config.ts | 10 +
.../lib/api/contracts/permission-groups.ts | 1 +
apps/sim/lib/permission-groups/types.ts | 12 +
apps/sim/tools/index.ts | 7 +-
19 files changed, 2358 insertions(+), 1844 deletions(-)
create mode 100644 apps/sim/ee/access-control/components/group-detail.tsx
create mode 100644 apps/sim/ee/access-control/components/workspace-select.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
index 62393f2d479..444d6f573bf 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
@@ -196,7 +196,7 @@ export function Admin() {
)}
-
+
@@ -231,10 +231,10 @@ export function Admin() {
)}
-
+
-
User Management
+
User Management
void
+ /** When false, the Delete item is disabled (e.g. non-admins on workspace keys). */
+ canDelete?: boolean
+}
+
+/**
+ * Trailing `...` actions menu for an API key row. Mirrors the Secrets /
+ * Teammates row menu so the settings experience is consistent.
+ */
+function ApiKeyRowMenu({ keyName, onDelete, canDelete = true }: ApiKeyRowMenuProps) {
+ return (
+
- {viewingGroup.workspaces.length === 0
- ? 'Applies to no one yet — add workspaces below to choose who this group governs.'
- : members.length === 0
- ? 'Applies to all members of its workspaces.'
- : `Restricted to ${members.length} member${members.length === 1 ? '' : 's'}.`}
-
- )}
-
-
-
-
-
- Applies to everyone in the organization not assigned to another group, including
- external workspace members
-
- handleToggleDefault(checked)}
- disabled={updatePermissionGroup.isPending}
- />
-
-
-
-
- {viewingGroup.isDefault ? (
-
-
- Governs every workspace in the organization
-
-
Applies to all members of the selected workspaces. Restrict to specific people
- later from Configure → Members.
+ later from the group's Members section.
+
+ Applies to everyone in the organization not assigned to another group,
+ including external workspace members
+
+ handleToggleDefault(checked)}
+ disabled={updatePermissionGroup.isPending}
+ />
+
+
+
+
+ {viewingGroup.isDefault ? (
+
+
+ Governs every workspace in the organization
+
+
+
+ {members.length === 0
+ ? 'Applies to all members of its workspaces. Add members to restrict it to specific people.'
+ : `Restricted to ${members.length} member${members.length === 1 ? '' : 's'}`}
+
+
+ Add
+
+
+
+ Integrations and Triggers
+
+
+ Allow a whole integration with its checkbox, then expand it to deny
+ specific tools while keeping the rest available.
+
+
+
+ {
+ setShowAddMembersModal(open)
+ if (!open) setAddMembersError(null)
+ }}
+ availableMembers={availableMembersToAdd}
+ selectedMemberIds={selectedMemberIds}
+ setSelectedMemberIds={setSelectedMemberIds}
+ onAddMembers={handleAddSelectedMembers}
+ isAdding={bulkAddMembers.isPending}
+ errorMessage={addMembersError}
+ />
+
+ setShowDeleteConfirm(false)}
+ srTitle='Delete Permission Group'
+ title='Delete Permission Group'
+ text={[
+ 'Are you sure you want to delete ',
+ { text: viewingGroup.name, bold: true },
+ '? ',
+ { text: 'All members will be removed from this group.', error: true },
+ ' This action cannot be undone.',
+ ]}
+ confirm={{
+ label: 'Delete',
+ onClick: confirmDelete,
+ pending: deletePermissionGroup.isPending,
+ pendingLabel: 'Deleting...',
+ }}
+ />
+
+
+ setShowUnsavedChanges(false)}>
+ Unsaved Changes
+
+
+
+ You have unsaved changes. Do you want to save them before leaving?
+
+
+ setShowUnsavedChanges(false)}
+ secondaryActions={[
+ {
+ label: 'Discard Changes',
+ onClick: () => {
+ setShowUnsavedChanges(false)
+ onBack()
+ },
+ variant: 'destructive',
+ },
+ ]}
+ primaryAction={{
+ label: updatePermissionGroup.isPending ? 'Saving...' : 'Save Changes',
+ onClick: async () => {
+ await handleSaveConfig()
+ setShowUnsavedChanges(false)
+ onBack()
+ },
+ disabled: updatePermissionGroup.isPending,
+ }}
+ />
+
+ >
+ )
+}
diff --git a/apps/sim/ee/access-control/components/workspace-select.tsx b/apps/sim/ee/access-control/components/workspace-select.tsx
new file mode 100644
index 00000000000..f8f2496aa13
--- /dev/null
+++ b/apps/sim/ee/access-control/components/workspace-select.tsx
@@ -0,0 +1,58 @@
+'use client'
+
+import { ChipDropdown } from '@/components/emcn'
+
+interface WorkspaceSelectProps {
+ workspaceIds: string[]
+ onChange: (ids: string[]) => void
+ options: { value: string; label: string }[]
+ disabled?: boolean
+ isLoading?: boolean
+ fullWidth?: boolean
+ className?: string
+ /**
+ * When false, the "All workspaces" reset option is hidden and an empty
+ * selection reads as a prompt. Non-default groups must target ≥1 workspace.
+ */
+ allowAllWorkspaces?: boolean
+}
+
+/**
+ * Workspace scope multi-select. With `allowAllWorkspaces` an empty selection
+ * reads as "All workspaces" (the default group); otherwise it prompts for a
+ * selection, since non-default groups must target specific workspaces.
+ */
+export function WorkspaceSelect({
+ workspaceIds,
+ onChange,
+ options,
+ disabled = false,
+ isLoading = false,
+ fullWidth = false,
+ className,
+ allowAllWorkspaces = true,
+}: WorkspaceSelectProps) {
+ return (
+
+ )
+}
diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts
index d78ba101395..8c24c396706 100644
--- a/apps/sim/ee/access-control/utils/permission-check.test.ts
+++ b/apps/sim/ee/access-control/utils/permission-check.test.ts
@@ -17,6 +17,7 @@ const {
allowedIntegrations: null,
allowedModelProviders: null,
deniedModels: [],
+ deniedTools: [],
hideTraceSpans: false,
hideKnowledgeBaseTab: false,
hideTablesTab: false,
@@ -142,6 +143,7 @@ import {
ProviderNotAllowedError,
PublicFileSharingNotAllowedError,
SkillsNotAllowedError,
+ ToolNotAllowedError,
validateBlockType,
validateMcpToolsAllowed,
validateModelProvider,
@@ -597,6 +599,69 @@ describe('assertPermissionsAllowed', () => {
})
})
+ it('throws ToolNotAllowedError when the tool is on the denylist', async () => {
+ mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }]
+
+ await expect(
+ assertPermissionsAllowed({
+ userId: 'user-123',
+ workspaceId: 'workspace-1',
+ toolId: 'slack_canvas',
+ })
+ ).rejects.toBeInstanceOf(ToolNotAllowedError)
+ })
+
+ it('allows a tool that is not on the denylist', async () => {
+ mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }]
+
+ await assertPermissionsAllowed({
+ userId: 'user-123',
+ workspaceId: 'workspace-1',
+ toolId: 'slack_message',
+ })
+ })
+
+ it('allows every tool when the denylist is empty', async () => {
+ mockWorkspaceGroups.value = [{ config: { deniedTools: [] } }]
+
+ await assertPermissionsAllowed({
+ userId: 'user-123',
+ workspaceId: 'workspace-1',
+ toolId: 'slack_canvas',
+ })
+ })
+
+ it('denies a tool even when its block is allowed by the integration allowlist', async () => {
+ mockWorkspaceGroups.value = [
+ { config: { allowedIntegrations: ['slack'], deniedTools: ['slack_canvas'] } },
+ ]
+
+ await expect(
+ assertPermissionsAllowed({
+ userId: 'user-123',
+ workspaceId: 'workspace-1',
+ blockType: 'slack',
+ toolId: 'slack_canvas',
+ })
+ ).rejects.toBeInstanceOf(ToolNotAllowedError)
+ })
+
+ it('still enforces the tool denylist for an exempt block type', async () => {
+ mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }]
+ mockGetBlock.mockImplementation((type) =>
+ type === 'slack' ? { hideFromToolbar: true } : undefined
+ )
+
+ await expect(
+ assertPermissionsAllowed({
+ userId: 'user-123',
+ workspaceId: 'workspace-1',
+ blockType: 'slack',
+ toolId: 'slack_canvas',
+ })
+ ).rejects.toBeInstanceOf(ToolNotAllowedError)
+ })
+
it('throws CustomToolsNotAllowedError when custom tools are disabled', async () => {
mockWorkspaceGroups.value = [{ config: { disableCustomTools: true } }]
diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts
index 03c41369ecc..611ea077b45 100644
--- a/apps/sim/ee/access-control/utils/permission-check.ts
+++ b/apps/sim/ee/access-control/utils/permission-check.ts
@@ -50,6 +50,13 @@ export class IntegrationNotAllowedError extends Error {
}
}
+export class ToolNotAllowedError extends Error {
+ constructor(toolId: string) {
+ super(`Tool "${toolId}" is not allowed based on your permission group settings`)
+ this.name = 'ToolNotAllowedError'
+ }
+}
+
export class McpToolsNotAllowedError extends Error {
constructor() {
super('MCP tools are not allowed based on your permission group settings')
@@ -566,6 +573,12 @@ interface PermissionAssertion {
workspaceId: string | undefined
model?: string
blockType?: string
+ /**
+ * Concrete tool ID being executed (e.g. `slack_canvas`). Checked against the
+ * group's `deniedTools` denylist so an admin can allow an integration but deny
+ * specific operations within it. Pass the normalized tool id.
+ */
+ toolId?: string
toolKind?: ToolKind
ctx?: ExecutionContext
}
@@ -581,11 +594,11 @@ interface PermissionAssertion {
* callsite covers every future config field.
*/
export async function assertPermissionsAllowed(req: PermissionAssertion): Promise {
- const { userId, workspaceId, model, blockType, toolKind, ctx } = req
+ const { userId, workspaceId, model, blockType, toolId, toolKind, ctx } = req
const blockTypeExempt = blockType ? isBlockTypeAccessControlExempt(blockType) : false
- if (blockTypeExempt && !model && !toolKind) {
+ if (blockTypeExempt && !model && !toolKind && !toolId) {
return
}
@@ -634,6 +647,11 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis
}
}
+ if (toolId && config?.deniedTools?.includes(toolId)) {
+ logger.warn('Tool blocked by permission group', { userId, workspaceId, toolId })
+ throw new ToolNotAllowedError(toolId)
+ }
+
if (toolKind && config) {
if (toolKind === 'mcp' && config.disableMcpTools) {
logger.warn('MCP tools blocked by permission group', { userId, workspaceId })
diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx
index 7d48ae631ea..a72ca616a09 100644
--- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx
+++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx
@@ -18,6 +18,7 @@ import {
ChipModalHeader,
ChipSelect,
ChipSwitch,
+ ChipTag,
Search,
toast,
} from '@/components/emcn'
@@ -767,9 +768,9 @@ export function DataRetentionSettings() {
Organization
-
+
Default
-
+
{orgRowSummary()}
diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts
index 330e502c78a..3513c4c167d 100644
--- a/apps/sim/hooks/use-permission-config.ts
+++ b/apps/sim/hooks/use-permission-config.ts
@@ -23,6 +23,7 @@ export interface PermissionConfigResult {
isBlockAllowed: (blockType: string) => boolean
isProviderAllowed: (providerId: string) => boolean
isModelAllowed: (model: string) => boolean
+ isToolAllowed: (toolId: string) => boolean
isInvitationsDisabled: boolean
isPublicApiDisabled: boolean
}
@@ -113,6 +114,13 @@ export function usePermissionConfig(): PermissionConfigResult {
}
}, [config.deniedModels])
+ const isToolAllowed = useMemo(() => {
+ return (toolId: string) => {
+ if (config.deniedTools.length === 0) return true
+ return !config.deniedTools.includes(toolId)
+ }
+ }, [config.deniedTools])
+
const filterBlocks = useMemo(() => {
return (blocks: T[]): T[] => {
if (mergedAllowedIntegrations === null) return blocks
@@ -156,6 +164,7 @@ export function usePermissionConfig(): PermissionConfigResult {
isBlockAllowed,
isProviderAllowed,
isModelAllowed,
+ isToolAllowed,
isInvitationsDisabled,
isPublicApiDisabled,
}),
@@ -168,6 +177,7 @@ export function usePermissionConfig(): PermissionConfigResult {
isBlockAllowed,
isProviderAllowed,
isModelAllowed,
+ isToolAllowed,
isInvitationsDisabled,
isPublicApiDisabled,
]
diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts
index b2f2d7fa7fd..a1e55c3d9ea 100644
--- a/apps/sim/lib/api/contracts/permission-groups.ts
+++ b/apps/sim/lib/api/contracts/permission-groups.ts
@@ -8,6 +8,7 @@ export const permissionGroupFullConfigSchema = z.object({
allowedIntegrations: z.array(z.string()).nullable(),
allowedModelProviders: z.array(z.string()).nullable(),
deniedModels: z.array(z.string()).default([]),
+ deniedTools: z.array(z.string()).default([]),
hideTraceSpans: z.boolean(),
hideKnowledgeBaseTab: z.boolean(),
hideTablesTab: z.boolean(),
diff --git a/apps/sim/lib/permission-groups/types.ts b/apps/sim/lib/permission-groups/types.ts
index 3269ec69459..76c18c0b9b1 100644
--- a/apps/sim/lib/permission-groups/types.ts
+++ b/apps/sim/lib/permission-groups/types.ts
@@ -21,6 +21,7 @@ export const permissionGroupConfigSchema = z.object({
allowedIntegrations: z.array(z.string()).nullable().optional(),
allowedModelProviders: z.array(z.string()).nullable().optional(),
deniedModels: z.array(z.string()).optional(),
+ deniedTools: z.array(z.string()).optional(),
hideTraceSpans: z.boolean().optional(),
hideKnowledgeBaseTab: z.boolean().optional(),
hideTablesTab: z.boolean().optional(),
@@ -52,6 +53,13 @@ export interface PermissionGroupConfig {
* group, checked after `allowedModelProviders`. Empty means nothing is blocked.
*/
deniedModels: string[]
+ /**
+ * Snake_case tool IDs (e.g. `slack_canvas`) blocked for this group, checked
+ * after the block-level `allowedIntegrations` gate. Lets an admin allow an
+ * integration but deny specific operations within it. Empty means nothing is
+ * blocked.
+ */
+ deniedTools: string[]
hideTraceSpans: boolean
hideKnowledgeBaseTab: boolean
hideTablesTab: boolean
@@ -80,6 +88,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
allowedIntegrations: null,
allowedModelProviders: null,
deniedModels: [],
+ deniedTools: [],
hideTraceSpans: false,
hideKnowledgeBaseTab: false,
hideTablesTab: false,
@@ -116,6 +125,9 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf
deniedModels: Array.isArray(c.deniedModels)
? c.deniedModels.filter((m): m is string => typeof m === 'string')
: [],
+ deniedTools: Array.isArray(c.deniedTools)
+ ? c.deniedTools.filter((t): t is string => typeof t === 'string')
+ : [],
hideTraceSpans: typeof c.hideTraceSpans === 'boolean' ? c.hideTraceSpans : false,
hideKnowledgeBaseTab:
typeof c.hideKnowledgeBaseTab === 'boolean' ? c.hideKnowledgeBaseTab : false,
diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts
index 1f7e554c324..7f3cdc1b56e 100644
--- a/apps/sim/tools/index.ts
+++ b/apps/sim/tools/index.ts
@@ -936,10 +936,15 @@ export async function executeTool(
? 'mcp'
: undefined
- if (toolKind && scope.userId && scope.workspaceId) {
+ // Single chokepoint for every tool execution. Asserts both the tool-kind
+ // gates (mcp/custom/skill) and the per-tool denylist (`deniedTools`), so an
+ // admin can allow an integration block yet deny specific operations within
+ // it. Runs for all tools, not just kinded ones.
+ if (scope.userId && scope.workspaceId) {
await assertPermissionsAllowed({
userId: scope.userId,
workspaceId: scope.workspaceId,
+ toolId: normalizedToolId,
toolKind,
ctx: executionContext,
})
From 166e764be940cc834161d2455e3636a57f093e19 Mon Sep 17 00:00:00 2001
From: waleed
Date: Thu, 25 Jun 2026 15:04:24 -0700
Subject: [PATCH 2/8] fix(access-control): don't leave the detail view when an
unsaved-changes save fails
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The unsaved-changes dialog's Save action navigated back unconditionally after handleSaveConfig, but that helper swallows mutation errors — so a failed save still exited the view and silently dropped the edits. handleSaveConfig now returns success, and the dialog only closes + navigates back when the save actually succeeded.
---
.../ee/access-control/components/group-detail.tsx | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx
index 19ebea7f71c..1ccb732d278 100644
--- a/apps/sim/ee/access-control/components/group-detail.tsx
+++ b/apps/sim/ee/access-control/components/group-detail.tsx
@@ -1065,7 +1065,8 @@ export function GroupDetail({
})
}, [])
- const handleSaveConfig = useCallback(async () => {
+ /** Persists the editing buffer. Returns whether the save succeeded so callers can decide whether to navigate away. */
+ const handleSaveConfig = useCallback(async (): Promise => {
try {
await updatePermissionGroup.mutateAsync({
id: viewingGroup.id,
@@ -1073,11 +1074,13 @@ export function GroupDetail({
config: editingConfig,
})
setViewingGroup((prev) => ({ ...prev, config: editingConfig }))
+ return true
} catch (error) {
logger.error('Failed to update config', error)
toast.error("Couldn't save changes", {
description: getErrorMessage(error, 'Please try again in a moment.'),
})
+ return false
}
}, [viewingGroup.id, editingConfig, organizationId, updatePermissionGroup])
@@ -1725,9 +1728,13 @@ export function GroupDetail({
primaryAction={{
label: updatePermissionGroup.isPending ? 'Saving...' : 'Save Changes',
onClick: async () => {
- await handleSaveConfig()
- setShowUnsavedChanges(false)
- onBack()
+ // Only leave once the save actually succeeds; a failed save keeps
+ // the dialog open with the edits intact (error surfaced via toast).
+ const saved = await handleSaveConfig()
+ if (saved) {
+ setShowUnsavedChanges(false)
+ onBack()
+ }
},
disabled: updatePermissionGroup.isPending,
}}
From b161dd35bccb41c34462372869b003020f677b59 Mon Sep 17 00:00:00 2001
From: waleed
Date: Thu, 25 Jun 2026 15:08:24 -0700
Subject: [PATCH 3/8] fix(access-control): prune deniedTools for blocks that
get disabled
deniedTools only matters while a block is allowed, but toggleIntegration/setBlocksAllowed left a disabled block's denied tools in the config. Disabling then re-enabling an integration would silently re-apply the old per-tool denials. Both handlers now prune deniedTools to the set of allowed blocks, keeping the invariant that deniedTools only holds tools of currently-allowed integrations.
---
.../components/group-detail.tsx | 47 ++++++++++++++-----
1 file changed, 34 insertions(+), 13 deletions(-)
diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx
index 1ccb732d278..90134c748f7 100644
--- a/apps/sim/ee/access-control/components/group-detail.tsx
+++ b/apps/sim/ee/access-control/components/group-detail.tsx
@@ -875,29 +875,48 @@ export function GroupDetail({
[editingConfig.allowedIntegrations]
)
+ /**
+ * Drops denied tools whose integration is no longer allowed, keeping the
+ * invariant that `deniedTools` only holds tools of currently-allowed blocks.
+ * Without this, disabling then re-enabling an integration would silently
+ * re-apply stale per-tool denials. Tools we can't attribute to a known block
+ * are preserved.
+ */
+ const pruneDeniedTools = useCallback(
+ (allowedIntegrations: string[] | null, deniedTools: string[]) => {
+ if (allowedIntegrations === null) return deniedTools
+ const allowed = new Set(allowedIntegrations)
+ const pruned = deniedTools.filter((toolId) => {
+ const blockType = toolToBlockType[toolId]
+ return !blockType || allowed.has(blockType)
+ })
+ return pruned.length === deniedTools.length ? deniedTools : pruned
+ },
+ [toolToBlockType]
+ )
+
const toggleIntegration = useCallback(
(blockType: string) => {
setEditingConfig((prev) => {
const current = prev.allowedIntegrations
+ let nextAllowed: string[] | null
if (current === null) {
- const allExcept = allBlocks.map((b) => b.type).filter((t) => t !== blockType)
- return { ...prev, allowedIntegrations: allExcept }
- }
- if (current.includes(blockType)) {
+ nextAllowed = allBlocks.map((b) => b.type).filter((t) => t !== blockType)
+ } else if (current.includes(blockType)) {
const updated = current.filter((t) => t !== blockType)
- return {
- ...prev,
- allowedIntegrations: updated.length === allBlocks.length ? null : updated,
- }
+ nextAllowed = updated.length === allBlocks.length ? null : updated
+ } else {
+ const updated = [...current, blockType]
+ nextAllowed = updated.length === allBlocks.length ? null : updated
}
- const updated = [...current, blockType]
return {
...prev,
- allowedIntegrations: updated.length === allBlocks.length ? null : updated,
+ allowedIntegrations: nextAllowed,
+ deniedTools: pruneDeniedTools(nextAllowed, prev.deniedTools),
}
})
},
- [allBlocks]
+ [allBlocks, pruneDeniedTools]
)
/** Allow or deny a whole section's blocks at once, respecting the active filter. */
@@ -912,13 +931,15 @@ export function GroupDetail({
else current.delete(block.type)
}
const nextArr = allTypes.filter((t) => current.has(t))
+ const nextAllowed = nextArr.length === allTypes.length ? null : nextArr
return {
...prev,
- allowedIntegrations: nextArr.length === allTypes.length ? null : nextArr,
+ allowedIntegrations: nextAllowed,
+ deniedTools: pruneDeniedTools(nextAllowed, prev.deniedTools),
}
})
},
- [allBlocks]
+ [allBlocks, pruneDeniedTools]
)
const isToolAllowed = useCallback(
From ae1abfa308c6793df90cb331b2172244d3a52da6 Mon Sep 17 00:00:00 2001
From: waleed
Date: Thu, 25 Jun 2026 15:16:00 -0700
Subject: [PATCH 4/8] fix(access-control): attribute denied tools to all
exposing blocks when pruning
A tool id can appear in more than one block's tools.access. The single tool->block map meant pruneDeniedTools (and the per-block denied count) attributed a shared tool to only one block, so disabling that block could drop a denial while the tool was still exposed by another allowed block. Tools now map to all exposing block types; a denial is pruned only when no allowed block exposes the tool, and the per-block count is derived from each block's own tool list.
---
.../components/group-detail.tsx | 28 +++++++++++--------
1 file changed, 17 insertions(+), 11 deletions(-)
diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx
index 90134c748f7..d6b9c67b0fd 100644
--- a/apps/sim/ee/access-control/components/group-detail.tsx
+++ b/apps/sim/ee/access-control/components/group-detail.tsx
@@ -630,12 +630,12 @@ export function GroupDetail({
return allIds.filter((id) => !blacklist.includes(id.toLowerCase()))
}, [blacklistedProvidersData])
- /** Maps every tool id to the block type that exposes it (for denied-count grouping). */
- const toolToBlockType = useMemo(() => {
- const map: Record = {}
+ /** Maps every tool id to ALL block types that expose it (some tools are shared across blocks). */
+ const toolBlockTypes = useMemo(() => {
+ const map: Record = {}
for (const block of allBlocks) {
for (const toolId of block.tools?.access ?? []) {
- map[toolId] = block.type
+ ;(map[toolId] ??= []).push(block.type)
}
}
return map
@@ -887,12 +887,14 @@ export function GroupDetail({
if (allowedIntegrations === null) return deniedTools
const allowed = new Set(allowedIntegrations)
const pruned = deniedTools.filter((toolId) => {
- const blockType = toolToBlockType[toolId]
- return !blockType || allowed.has(blockType)
+ const blockTypes = toolBlockTypes[toolId]
+ // Keep the denial while ANY block exposing the tool is still allowed;
+ // preserve tools we can't attribute to a known block.
+ return !blockTypes || blockTypes.some((bt) => allowed.has(bt))
})
return pruned.length === deniedTools.length ? deniedTools : pruned
},
- [toolToBlockType]
+ [toolBlockTypes]
)
const toggleIntegration = useCallback(
@@ -973,13 +975,17 @@ export function GroupDetail({
}, [])
const deniedCountByBlock = useMemo(() => {
+ const denied = new Set(editingConfig.deniedTools)
const counts: Record = {}
- for (const toolId of editingConfig.deniedTools) {
- const blockType = toolToBlockType[toolId]
- if (blockType) counts[blockType] = (counts[blockType] ?? 0) + 1
+ for (const block of allBlocks) {
+ let count = 0
+ for (const toolId of block.tools?.access ?? []) {
+ if (denied.has(toolId)) count++
+ }
+ if (count > 0) counts[block.type] = count
}
return counts
- }, [editingConfig.deniedTools, toolToBlockType])
+ }, [editingConfig.deniedTools, allBlocks])
const isProviderAllowed = useCallback(
(providerId: string) =>
From a0fae05a63473283d2bd881ba3167e5862c7fe73 Mon Sep 17 00:00:00 2001
From: waleed
Date: Thu, 25 Jun 2026 15:24:49 -0700
Subject: [PATCH 5/8] fix(access-control): scope Platform Select/Deselect All
to the search filter
The Platform tab's bulk Select/Deselect All toggled every feature regardless of the active search, unlike the Blocks tab which scopes its per-section toggle to the filtered view. Both the all-visible check and the bulk update now operate on filteredPlatformFeatures for consistent behavior while searching.
---
apps/sim/ee/access-control/components/group-detail.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx
index d6b9c67b0fd..80dc73ef0d8 100644
--- a/apps/sim/ee/access-control/components/group-detail.tsx
+++ b/apps/sim/ee/access-control/components/group-detail.tsx
@@ -1255,7 +1255,7 @@ export function GroupDetail({
const coreBlocksAllAllowed = filteredCoreBlocks.every((b) => isIntegrationAllowed(b.type))
const toolBlocksAllAllowed = filteredToolBlocks.every((b) => isIntegrationAllowed(b.type))
- const platformAllVisible = platformFeatures.every((f) => !editingConfig[f.configKey])
+ const platformAllVisible = filteredPlatformFeatures.every((f) => !editingConfig[f.configKey])
return (
<>
@@ -1580,7 +1580,7 @@ export function GroupDetail({
setEditingConfig((prev) => ({
...prev,
...Object.fromEntries(
- platformFeatures.map((f) => [f.configKey, platformAllVisible])
+ filteredPlatformFeatures.map((f) => [f.configKey, platformAllVisible])
),
}))
}
From 58e8895d5ed3bed7629475adf61dbf682d4db781 Mon Sep 17 00:00:00 2001
From: waleed
Date: Thu, 25 Jun 2026 15:32:54 -0700
Subject: [PATCH 6/8] fix(access-control): scope Model Providers
Select/Deselect All to the search filter
Like the Platform fix, the Providers tab's bulk action toggled every provider via allProviderIds regardless of the active search. Added setProvidersAllowed (mirroring setBlocksAllowed) so the bulk toggle and its label operate on filteredProviders, keeping all three tabs (Blocks/Platform/Providers) consistent while searching.
---
.../components/group-detail.tsx | 42 ++++++++++++-------
1 file changed, 27 insertions(+), 15 deletions(-)
diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx
index 80dc73ef0d8..de05b670185 100644
--- a/apps/sim/ee/access-control/components/group-detail.tsx
+++ b/apps/sim/ee/access-control/components/group-detail.tsx
@@ -1019,6 +1019,28 @@ export function GroupDetail({
[allProviderIds]
)
+ /** Allow or deny a set of providers at once, respecting the active search filter. */
+ const setProvidersAllowed = useCallback(
+ (providerIds: string[], allowed: boolean) => {
+ setEditingConfig((prev) => {
+ const current =
+ prev.allowedModelProviders === null
+ ? new Set(allProviderIds)
+ : new Set(prev.allowedModelProviders)
+ for (const id of providerIds) {
+ if (allowed) current.add(id)
+ else current.delete(id)
+ }
+ const nextArr = allProviderIds.filter((id) => current.has(id))
+ return {
+ ...prev,
+ allowedModelProviders: nextArr.length === allProviderIds.length ? null : nextArr,
+ }
+ })
+ },
+ [allProviderIds]
+ )
+
const isModelAllowed = useCallback(
(model: string) => {
const normalized = model.toLowerCase()
@@ -1253,6 +1275,7 @@ export function GroupDetail({
[]
)
+ const filteredProvidersAllAllowed = filteredProviders.every((id) => isProviderAllowed(id))
const coreBlocksAllAllowed = filteredCoreBlocks.every((b) => isIntegrationAllowed(b.type))
const toolBlocksAllAllowed = filteredToolBlocks.every((b) => isIntegrationAllowed(b.type))
const platformAllVisible = filteredPlatformFeatures.every((f) => !editingConfig[f.configKey])
@@ -1431,22 +1454,11 @@ export function GroupDetail({
className='min-w-0 flex-1'
/>
{
- const allAllowed =
- editingConfig.allowedModelProviders === null ||
- allProviderIds.every((id) =>
- editingConfig.allowedModelProviders?.includes(id)
- )
- setEditingConfig((prev) => ({
- ...prev,
- allowedModelProviders: allAllowed ? [] : null,
- }))
- }}
+ onClick={() =>
+ setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed)
+ }
>
- {editingConfig.allowedModelProviders === null ||
- allProviderIds.every((id) => editingConfig.allowedModelProviders?.includes(id))
- ? 'Deselect All'
- : 'Select All'}
+ {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'}
From 887e0097227440580037b9945db3d9b64b935a42 Mon Sep 17 00:00:00 2001
From: waleed
Date: Thu, 25 Jun 2026 15:44:14 -0700
Subject: [PATCH 7/8] fix(access-control): don't seed a denied operation as a
block's default
The operation dropdown hides denied tools from the picker, but defaultOptionValue returned the block's defaultValue without checking deniedOperationIds, so a new block could start on an operation the user isn't allowed to run. It now falls back to the first allowed option when the configured default is denied. Existing stored operation values are intentionally left untouched (auto-rewriting a user's saved block would be destructive; the server remains the authoritative gate).
---
.../sub-block/components/dropdown/dropdown.tsx | 16 +++++++++-------
1 file changed, 9 insertions(+), 7 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx
index 1aad573c91c..0e212c45609 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx
@@ -260,17 +260,19 @@ export const Dropdown = memo(function Dropdown({
const defaultOptionValue = useMemo(() => {
if (multiSelect) return undefined
- if (defaultValue !== undefined) {
- return defaultValue
- }
const firstSelectable = comboboxOptions.find((opt) => !opt.hidden)
- if (firstSelectable) {
- return firstSelectable.value
+ if (defaultValue !== undefined) {
+ // Never seed a permission-denied operation as the default; fall back to the
+ // first allowed option so a new block doesn't start on a disallowed value.
+ if (deniedOperationIds.has(defaultValue)) {
+ return firstSelectable?.value
+ }
+ return defaultValue
}
- return undefined
- }, [defaultValue, comboboxOptions, multiSelect])
+ return firstSelectable?.value
+ }, [defaultValue, comboboxOptions, deniedOperationIds, multiSelect])
useEffect(() => {
if (multiSelect || defaultOptionValue === undefined) {
From f9627ede6a34937e44c3b4b4a1430aaefa9fd976 Mon Sep 17 00:00:00 2001
From: waleed
Date: Thu, 25 Jun 2026 15:54:26 -0700
Subject: [PATCH 8/8] chore(access-control): prefer TSDoc over inline comments
Convert declaration-level rationale comments to TSDoc (/** */) and trim redundant/verbose inline comments added during review, per the project's TSDoc convention.
---
.../components/dropdown/dropdown.tsx | 6 ++----
.../components/access-control.tsx | 10 ++++++----
.../components/group-detail.tsx | 20 +++++++++----------
apps/sim/tools/index.ts | 6 ++----
4 files changed, 20 insertions(+), 22 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx
index 0e212c45609..edf87395914 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx
@@ -233,8 +233,7 @@ export const Dropdown = memo(function Dropdown({
const toolId = selectTool({ operation: optionId })
if (toolId && !isToolAllowed(toolId)) denied.add(optionId)
} catch {
- // Selector couldn't resolve a tool from the operation alone — leave the
- // option visible; runtime enforcement still applies.
+ // Unresolvable from the operation alone — leave it visible; the server still enforces.
}
}
return denied
@@ -263,8 +262,7 @@ export const Dropdown = memo(function Dropdown({
const firstSelectable = comboboxOptions.find((opt) => !opt.hidden)
if (defaultValue !== undefined) {
- // Never seed a permission-denied operation as the default; fall back to the
- // first allowed option so a new block doesn't start on a disallowed value.
+ // Don't seed a denied operation as the default; use the first allowed option.
if (deniedOperationIds.has(defaultValue)) {
return firstSelectable?.value
}
diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx
index 91be65dfdb4..6ac886fec28 100644
--- a/apps/sim/ee/access-control/components/access-control.tsx
+++ b/apps/sim/ee/access-control/components/access-control.tsx
@@ -35,10 +35,12 @@ export function AccessControl() {
const params = useParams()
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
- // Access control is governed by the workspace's OWNING organization, which may
- // differ from the caller's active org (e.g. external members). Resolve the org
- // id and the caller's admin status server-side from the workspace so gating is
- // never keyed off the session's active org.
+ /**
+ * Access control is governed by the workspace's OWNING organization, which may
+ * differ from the caller's active org (e.g. external members). Resolve the org
+ * id and the caller's admin status server-side from the workspace so gating is
+ * never keyed off the session's active org.
+ */
const { data: userPermissionConfig, isPending: entitlementLoading } =
useUserPermissionConfig(workspaceId)
const organizationId = userPermissionConfig?.organizationId ?? undefined
diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx
index de05b670185..5477c7a0fa9 100644
--- a/apps/sim/ee/access-control/components/group-detail.tsx
+++ b/apps/sim/ee/access-control/components/group-detail.tsx
@@ -577,9 +577,11 @@ export function GroupDetail({
const removeMember = useRemovePermissionGroupMember()
const bulkAddMembers = useBulkAddPermissionGroupMembers()
- // Local, authoritative copy of the group while the detail view is open. Seeded
- // from the prop and re-seeded only when the selected group id changes, so
- // optimistic scope/default/config writes are not clobbered by list refetches.
+ /**
+ * Local, authoritative copy of the group while the detail view is open. Seeded
+ * from the prop and re-seeded only when the selected group id changes, so
+ * optimistic scope/default/config writes are not clobbered by list refetches.
+ */
const [viewingGroup, setViewingGroup] = useState(group)
const [editingConfig, setEditingConfig] = useState({ ...group.config })
const prevGroupIdRef = useRef(group.id)
@@ -589,9 +591,11 @@ export function GroupDetail({
setEditingConfig({ ...group.config })
}
- // Monotonic token for scope-affecting writes (workspace select + default
- // toggle). Only the most recent write may reconcile or revert the local
- // group, so rapid multi-select toggles can't settle on a stale response.
+ /**
+ * Monotonic token for scope-affecting writes (workspace select + default
+ * toggle). Only the most recent write may reconcile or revert the local
+ * group, so rapid multi-select toggles can't settle on a stale response.
+ */
const scopeWriteSeqRef = useRef(0)
const [configTab, setConfigTab] = useState('general')
@@ -888,8 +892,6 @@ export function GroupDetail({
const allowed = new Set(allowedIntegrations)
const pruned = deniedTools.filter((toolId) => {
const blockTypes = toolBlockTypes[toolId]
- // Keep the denial while ANY block exposing the tool is still allowed;
- // preserve tools we can't attribute to a known block.
return !blockTypes || blockTypes.some((bt) => allowed.has(bt))
})
return pruned.length === deniedTools.length ? deniedTools : pruned
@@ -1767,8 +1769,6 @@ export function GroupDetail({
primaryAction={{
label: updatePermissionGroup.isPending ? 'Saving...' : 'Save Changes',
onClick: async () => {
- // Only leave once the save actually succeeds; a failed save keeps
- // the dialog open with the edits intact (error surfaced via toast).
const saved = await handleSaveConfig()
if (saved) {
setShowUnsavedChanges(false)
diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts
index 7f3cdc1b56e..2ee548a4324 100644
--- a/apps/sim/tools/index.ts
+++ b/apps/sim/tools/index.ts
@@ -936,10 +936,8 @@ export async function executeTool(
? 'mcp'
: undefined
- // Single chokepoint for every tool execution. Asserts both the tool-kind
- // gates (mcp/custom/skill) and the per-tool denylist (`deniedTools`), so an
- // admin can allow an integration block yet deny specific operations within
- // it. Runs for all tools, not just kinded ones.
+ // Runs for ALL tools (not just kinded ones) so the per-tool `deniedTools`
+ // denylist is enforced alongside the existing mcp/custom/skill gates.
if (scope.userId && scope.workspaceId) {
await assertPermissionsAllowed({
userId: scope.userId,