mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(access-control): page-based permission groups, tool-level deny-list, settings row-action consistency (#5216)
* 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) * fix(access-control): don't leave the detail view when an unsaved-changes save fails 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. * 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. * 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. * 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. * 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. * 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). * 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.
This commit is contained in:
@@ -196,7 +196,7 @@ export function Admin() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='h-px bg-[var(--border-secondary)]' />
|
||||
<div className='h-px bg-[var(--border)]' />
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='text-[var(--text-secondary)] text-sm'>
|
||||
@@ -231,10 +231,10 @@ export function Admin() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='h-px bg-[var(--border-secondary)]' />
|
||||
<div className='h-px bg-[var(--border)]' />
|
||||
|
||||
<div className='flex flex-col gap-3'>
|
||||
<p className='font-medium text-[var(--text-primary)] text-sm'>User Management</p>
|
||||
<p className='font-medium text-[var(--text-muted)] text-small'>User Management</p>
|
||||
<div className='flex gap-2'>
|
||||
<ChipInput
|
||||
icon={Search}
|
||||
|
||||
@@ -5,7 +5,21 @@ import { createLogger } from '@sim/logger'
|
||||
import { formatDate } from '@sim/utils/formatting'
|
||||
import { Info, Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Chip, ChipConfirmModal, ChipInput, Search, Switch, Tooltip } from '@/components/emcn'
|
||||
import {
|
||||
Chip,
|
||||
ChipConfirmModal,
|
||||
ChipInput,
|
||||
chipVariants,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
Switch,
|
||||
Tooltip,
|
||||
toast,
|
||||
} from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
@@ -20,6 +34,51 @@ import { CreateApiKeyModal } from './components'
|
||||
|
||||
const logger = createLogger('ApiKeys')
|
||||
|
||||
/** Copies an API key's name and confirms with a toast. */
|
||||
function copyKeyName(name: string) {
|
||||
void navigator.clipboard.writeText(name)
|
||||
toast.success('Copied name to clipboard')
|
||||
}
|
||||
|
||||
interface ApiKeyRowMenuProps {
|
||||
keyName: string
|
||||
onDelete: () => 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 (
|
||||
<div className='flex-shrink-0'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='API key actions'
|
||||
className={chipVariants({ flush: true })}
|
||||
>
|
||||
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onSelect={() => copyKeyName(keyName)}>Copy name</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className='text-[var(--text-error)]'
|
||||
onSelect={onDelete}
|
||||
disabled={!canDelete}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApiKeys() {
|
||||
const { data: session } = useSession()
|
||||
const userId = session?.user?.id
|
||||
@@ -164,16 +223,14 @@ export function ApiKeys() {
|
||||
{key.displayKey || key.key}
|
||||
</p>
|
||||
</div>
|
||||
<Chip
|
||||
className='flex-shrink-0'
|
||||
onClick={() => {
|
||||
<ApiKeyRowMenu
|
||||
keyName={key.name}
|
||||
onDelete={() => {
|
||||
setDeleteKey(key)
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
disabled={!canManageWorkspaceKeys}
|
||||
>
|
||||
Delete
|
||||
</Chip>
|
||||
canDelete={canManageWorkspaceKeys}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -197,16 +254,14 @@ export function ApiKeys() {
|
||||
{key.displayKey || key.key}
|
||||
</p>
|
||||
</div>
|
||||
<Chip
|
||||
className='flex-shrink-0'
|
||||
onClick={() => {
|
||||
<ApiKeyRowMenu
|
||||
keyName={key.name}
|
||||
onDelete={() => {
|
||||
setDeleteKey(key)
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
disabled={!canManageWorkspaceKeys}
|
||||
>
|
||||
Delete
|
||||
</Chip>
|
||||
canDelete={canManageWorkspaceKeys}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -235,15 +290,13 @@ export function ApiKeys() {
|
||||
{key.displayKey || key.key}
|
||||
</p>
|
||||
</div>
|
||||
<Chip
|
||||
className='flex-shrink-0'
|
||||
onClick={() => {
|
||||
<ApiKeyRowMenu
|
||||
keyName={key.name}
|
||||
onDelete={() => {
|
||||
setDeleteKey(key)
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Chip>
|
||||
/>
|
||||
</div>
|
||||
{isConflict && (
|
||||
<div className='text-[var(--text-error)] text-small leading-tight'>
|
||||
|
||||
+85
-34
@@ -20,7 +20,13 @@ import {
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
chipVariants,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
type FileInputOptions,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
@@ -516,13 +522,26 @@ export function CredentialSets() {
|
||||
</div>
|
||||
|
||||
<div className='ml-4 flex items-center gap-1'>
|
||||
<Chip
|
||||
variant='destructive'
|
||||
onClick={() => handleRemoveMember(member.id)}
|
||||
disabled={removeMember.isPending}
|
||||
>
|
||||
Remove
|
||||
</Chip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Member actions'
|
||||
className={chipVariants({ flush: true })}
|
||||
>
|
||||
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
className='text-[var(--text-error)]'
|
||||
onSelect={() => handleRemoveMember(member.id)}
|
||||
disabled={removeMember.isPending}
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -561,25 +580,41 @@ export function CredentialSets() {
|
||||
</div>
|
||||
|
||||
<div className='ml-4 flex items-center gap-1'>
|
||||
<Chip
|
||||
onClick={() => handleResendInvitation(invitation.id, email)}
|
||||
disabled={
|
||||
resendingInvitations.has(invitation.id) ||
|
||||
(resendCooldowns[invitation.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
{resendingInvitations.has(invitation.id)
|
||||
? 'Sending...'
|
||||
: resendCooldowns[invitation.id]
|
||||
? `Resend (${resendCooldowns[invitation.id]}s)`
|
||||
: 'Resend'}
|
||||
</Chip>
|
||||
<Chip
|
||||
onClick={() => handleCancelInvitation(invitation.id)}
|
||||
disabled={cancellingInvitations.has(invitation.id)}
|
||||
>
|
||||
{cancellingInvitations.has(invitation.id) ? 'Cancelling...' : 'Cancel'}
|
||||
</Chip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Invitation actions'
|
||||
className={chipVariants({ flush: true })}
|
||||
>
|
||||
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleResendInvitation(invitation.id, email)}
|
||||
disabled={
|
||||
resendingInvitations.has(invitation.id) ||
|
||||
(resendCooldowns[invitation.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
{resendingInvitations.has(invitation.id)
|
||||
? 'Sending...'
|
||||
: resendCooldowns[invitation.id]
|
||||
? `Resend (${resendCooldowns[invitation.id]}s)`
|
||||
: 'Resend'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className='text-[var(--text-error)]'
|
||||
onSelect={() => handleCancelInvitation(invitation.id)}
|
||||
disabled={cancellingInvitations.has(invitation.id)}
|
||||
>
|
||||
{cancellingInvitations.has(invitation.id)
|
||||
? 'Cancelling...'
|
||||
: 'Cancel'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -729,14 +764,30 @@ export function CredentialSets() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Chip onClick={() => setViewingSet(set)}>Details</Chip>
|
||||
<Chip
|
||||
onClick={() => handleDeleteClick(set)}
|
||||
disabled={deletingSetIds.has(set.id)}
|
||||
>
|
||||
{deletingSetIds.has(set.id) ? 'Deleting...' : 'Delete'}
|
||||
</Chip>
|
||||
<div className='flex items-center gap-1'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Group actions'
|
||||
className={chipVariants({ flush: true })}
|
||||
>
|
||||
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onSelect={() => setViewingSet(set)}>
|
||||
Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className='text-[var(--text-error)]'
|
||||
onSelect={() => handleDeleteClick(set)}
|
||||
disabled={deletingSetIds.has(set.id)}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+36
-9
@@ -5,7 +5,18 @@ import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Chip, ChipConfirmModal, ChipInput, Search } from '@/components/emcn'
|
||||
import {
|
||||
Chip,
|
||||
ChipConfirmModal,
|
||||
ChipInput,
|
||||
chipVariants,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
} from '@/components/emcn'
|
||||
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
|
||||
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
|
||||
|
||||
@@ -134,14 +145,30 @@ export function CustomTools() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-2'>
|
||||
<Chip onClick={() => setEditingTool(tool.id)}>Edit</Chip>
|
||||
<Chip
|
||||
onClick={() => handleDeleteClick(tool.id)}
|
||||
disabled={deletingTools.has(tool.id)}
|
||||
>
|
||||
{deletingTools.has(tool.id) ? 'Deleting...' : 'Delete'}
|
||||
</Chip>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Tool actions'
|
||||
className={chipVariants({ flush: true })}
|
||||
>
|
||||
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onSelect={() => setEditingTool(tool.id)}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className='text-[var(--text-error)]'
|
||||
onSelect={() => handleDeleteClick(tool.id)}
|
||||
disabled={deletingTools.has(tool.id)}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
Chip,
|
||||
ChipConfirmModal,
|
||||
ChipInput,
|
||||
chipVariants,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
@@ -120,10 +126,27 @@ function ServerListItem({
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
<Chip onClick={onViewDetails}>Details</Chip>
|
||||
<Chip onClick={onRemove} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Chip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Server actions'
|
||||
className={chipVariants({ flush: true })}
|
||||
>
|
||||
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onSelect={onViewDetails}>Details</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className='text-[var(--text-error)]'
|
||||
onSelect={onRemove}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+21
-32
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import { Badge, Button, Input as EmcnInput, Label, Skeleton } from '@/components/emcn'
|
||||
import { Badge, Button, ChipInput, ChipSelect, Label, Skeleton } from '@/components/emcn'
|
||||
import { AnthropicIcon, OpenAIIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
@@ -50,10 +50,10 @@ const TABS: { id: MothershipTab; label: string }[] = [
|
||||
{ id: 'byok', label: 'BYOK' },
|
||||
]
|
||||
|
||||
const ENV_OPTIONS: { id: MothershipEnv; label: string }[] = [
|
||||
{ id: 'dev', label: 'Dev' },
|
||||
{ id: 'staging', label: 'Staging' },
|
||||
{ id: 'prod', label: 'Prod' },
|
||||
const ENV_OPTIONS: { value: MothershipEnv; label: string }[] = [
|
||||
{ value: 'dev', label: 'Dev' },
|
||||
{ value: 'staging', label: 'Staging' },
|
||||
{ value: 'prod', label: 'Prod' },
|
||||
]
|
||||
|
||||
function defaultTimeRange() {
|
||||
@@ -81,11 +81,11 @@ function formatDate(d: string | null | undefined) {
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div className='h-px bg-[var(--border-secondary)]' />
|
||||
return <div className='h-px bg-[var(--border)]' />
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <p className='font-medium text-[var(--text-primary)] text-sm'>{children}</p>
|
||||
return <p className='font-medium text-[var(--text-muted)] text-small'>{children}</p>
|
||||
}
|
||||
|
||||
export function Mothership() {
|
||||
@@ -104,23 +104,14 @@ export function Mothership() {
|
||||
{/* Environment selector */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label className='text-[var(--text-secondary)] text-sm'>Environment</Label>
|
||||
<div className='flex gap-1'>
|
||||
{ENV_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type='button'
|
||||
onClick={() => setMothershipParams({ env: opt.id })}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1 font-medium text-sm transition-colors',
|
||||
environment === opt.id
|
||||
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] hover-hover:hover:text-[var(--text-secondary)]'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ChipSelect
|
||||
align='start'
|
||||
dropdownWidth={160}
|
||||
value={environment}
|
||||
onChange={(value) => setMothershipParams({ env: value as MothershipEnv })}
|
||||
placeholder='Select environment'
|
||||
options={ENV_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
@@ -149,20 +140,18 @@ export function Mothership() {
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label className='text-[var(--text-secondary)] text-caption'>From</Label>
|
||||
<EmcnInput
|
||||
<ChipInput
|
||||
type='datetime-local'
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
className='h-[30px] text-caption'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label className='text-[var(--text-secondary)] text-caption'>To</Label>
|
||||
<EmcnInput
|
||||
<ChipInput
|
||||
type='datetime-local'
|
||||
value={end}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
className='h-[30px] text-caption'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -431,23 +420,23 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
|
||||
<div className='flex items-end gap-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Label className='text-[var(--text-secondary)] text-caption'>Enterprise Name</Label>
|
||||
<EmcnInput
|
||||
<ChipInput
|
||||
value={newName}
|
||||
onChange={(e) => {
|
||||
setNewName(e.target.value)
|
||||
setGeneratedKey(null)
|
||||
}}
|
||||
placeholder='e.g. Acme Corp'
|
||||
className='h-[32px] w-[200px]'
|
||||
className='w-[200px]'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Label className='text-[var(--text-secondary)] text-caption'>Expiration (optional)</Label>
|
||||
<EmcnInput
|
||||
<ChipInput
|
||||
type='date'
|
||||
value={newExpiry}
|
||||
onChange={(e) => setNewExpiry(e.target.value)}
|
||||
className='h-[32px] w-[160px]'
|
||||
className='w-[160px]'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
+87
-47
@@ -5,7 +5,19 @@ import { createLogger } from '@sim/logger'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Chip, ChipInput, Search, Tooltip, Trash, toast } from '@/components/emcn'
|
||||
import {
|
||||
Chip,
|
||||
ChipInput,
|
||||
chipVariants,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
Tooltip,
|
||||
toast,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
clearPendingCredentialCreateRequest,
|
||||
@@ -31,8 +43,54 @@ import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
|
||||
|
||||
const logger = createLogger('SecretsManager')
|
||||
|
||||
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto_auto] items-center'
|
||||
const COL_SPAN_ALL = 'col-span-5'
|
||||
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto] items-center'
|
||||
const COL_SPAN_ALL = 'col-span-4'
|
||||
|
||||
/** Copies a secret's name and confirms with a toast. */
|
||||
function copyName(key: string) {
|
||||
void navigator.clipboard.writeText(key)
|
||||
toast.success('Copied name to clipboard')
|
||||
}
|
||||
|
||||
interface SecretRowMenuProps {
|
||||
/** Copies the secret's name. */
|
||||
onCopyName: () => void
|
||||
/** Opens credential details; omit when the row has no backing credential. */
|
||||
onViewDetails?: () => void
|
||||
/** Deletes the secret (or clears the draft row); omit when the caller can't delete. */
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Trailing `...` actions menu for a secret row. Mirrors the Teammates /
|
||||
* Organization member menu so the settings experience is consistent.
|
||||
*/
|
||||
function SecretRowMenu({ onCopyName, onViewDetails, onDelete }: SecretRowMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Secret actions'
|
||||
className={cn(chipVariants({ flush: true }), 'ml-2')}
|
||||
>
|
||||
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
{onViewDetails && (
|
||||
<DropdownMenuItem onSelect={onViewDetails}>View details</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={onCopyName}>Copy name</DropdownMenuItem>
|
||||
{onDelete && (
|
||||
<DropdownMenuItem className='text-[var(--text-error)]' onSelect={onDelete}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const generateRowId = (() => {
|
||||
let counter = 0
|
||||
@@ -201,23 +259,11 @@ function WorkspaceVariableRow({
|
||||
canEdit={canEdit}
|
||||
name={`workspace_env_value_${envKey}_${generateShortId()}`}
|
||||
/>
|
||||
<Chip
|
||||
onClick={() => onViewDetails(envKey)}
|
||||
disabled={!hasCredential}
|
||||
className={cn('ml-2', !hasCredential && 'opacity-40')}
|
||||
>
|
||||
Details
|
||||
</Chip>
|
||||
{canEdit ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Chip leftIcon={Trash} onClick={() => onDelete(envKey)} aria-label='Delete secret' />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Delete secret</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<SecretRowMenu
|
||||
onCopyName={() => copyName(envKey)}
|
||||
onViewDetails={hasCredential ? () => onViewDetails(envKey) : undefined}
|
||||
onDelete={canEdit ? () => onDelete(envKey) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -262,22 +308,19 @@ function NewWorkspaceVariableRow({
|
||||
onPaste={onPaste ? (e) => onPaste(e, index) : undefined}
|
||||
placeholder='Enter value'
|
||||
name={`new_workspace_value_${envVar.id || index}_${generateShortId()}`}
|
||||
className='col-span-2 ml-0'
|
||||
className='ml-0'
|
||||
/>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Chip
|
||||
leftIcon={Trash}
|
||||
onClick={() => {
|
||||
onUpdate(index, 'key', '')
|
||||
onUpdate(index, 'value', '')
|
||||
}}
|
||||
disabled={!hasContent}
|
||||
aria-label='Delete secret'
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
{hasContent && <Tooltip.Content>Delete secret</Tooltip.Content>}
|
||||
</Tooltip.Root>
|
||||
{hasContent ? (
|
||||
<SecretRowMenu
|
||||
onCopyName={() => copyName(envVar.key)}
|
||||
onDelete={() => {
|
||||
onUpdate(index, 'key', '')
|
||||
onUpdate(index, 'value', '')
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{keyError && (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -862,19 +905,16 @@ export function SecretsManager() {
|
||||
readOnly={isConflicted}
|
||||
placeholder={isConflicted ? 'Workspace override active' : 'Enter value'}
|
||||
name={`env_variable_value_${envVar.id || originalIndex}_${generateShortId()}`}
|
||||
className={cn('col-span-2', isConflicted && 'cursor-not-allowed opacity-50')}
|
||||
className={cn(isConflicted && 'cursor-not-allowed opacity-50')}
|
||||
/>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Chip
|
||||
leftIcon={Trash}
|
||||
onClick={() => removeEnvVar(originalIndex)}
|
||||
disabled={!hasContent}
|
||||
aria-label='Delete secret'
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
{hasContent && <Tooltip.Content>Delete secret</Tooltip.Content>}
|
||||
</Tooltip.Root>
|
||||
{hasContent ? (
|
||||
<SecretRowMenu
|
||||
onCopyName={() => copyName(envVar.key)}
|
||||
onDelete={() => removeEnvVar(originalIndex)}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{keyError && (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
+52
-11
@@ -23,7 +23,13 @@ import {
|
||||
ChipSelect,
|
||||
Code,
|
||||
type ComboboxOption,
|
||||
chipVariants,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Label,
|
||||
MoreHorizontal,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { ArrowLeft, Search } from '@/components/emcn/icons'
|
||||
@@ -459,13 +465,29 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
<Chip onClick={() => setToolToView(tool)}>Edit</Chip>
|
||||
<Chip
|
||||
onClick={() => setToolToDelete(tool)}
|
||||
disabled={deleteToolMutation.isPending}
|
||||
>
|
||||
Remove
|
||||
</Chip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Tool actions'
|
||||
className={chipVariants({ flush: true })}
|
||||
>
|
||||
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onSelect={() => setToolToView(tool)}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className='text-[var(--text-error)]'
|
||||
onSelect={() => setToolToDelete(tool)}
|
||||
disabled={deleteToolMutation.isPending}
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1021,10 +1043,29 @@ export function WorkflowMcpServers() {
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
<Chip onClick={() => setSelectedServerId(server.id)}>Details</Chip>
|
||||
<Chip onClick={() => setServerToDelete(server)} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Chip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Server actions'
|
||||
className={chipVariants({ flush: true })}
|
||||
>
|
||||
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onSelect={() => setSelectedServerId(server.id)}>
|
||||
Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className='text-[var(--text-error)]'
|
||||
onSelect={() => setServerToDelete(server)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+36
-9
@@ -14,6 +14,7 @@ import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -98,6 +99,7 @@ export const Dropdown = memo(function Dropdown({
|
||||
searchable = false,
|
||||
}: DropdownProps) {
|
||||
const activeSearchTarget = useActiveSearchTarget()
|
||||
const { isToolAllowed } = usePermissionConfig()
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string | string[]>(blockId, subBlockId) as [
|
||||
string | string[] | null | undefined,
|
||||
(value: string | string[]) => void,
|
||||
@@ -214,19 +216,42 @@ export const Dropdown = memo(function Dropdown({
|
||||
return opts
|
||||
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions, hydratedOption])
|
||||
|
||||
/**
|
||||
* Operation IDs whose resolved tool is denied by the caller's permission
|
||||
* group. Only the `operation` selector of a block with a tool selector is
|
||||
* gated. Denied operations are hidden from the picker (still resolvable for
|
||||
* label display); the server is the authoritative gate regardless.
|
||||
*/
|
||||
const deniedOperationIds = useMemo(() => {
|
||||
const denied = new Set<string>()
|
||||
if (subBlockId !== 'operation') return denied
|
||||
const selectTool = blockConfig?.tools?.config?.tool
|
||||
if (!selectTool) return denied
|
||||
for (const opt of allOptions) {
|
||||
const optionId = typeof opt === 'string' ? opt : opt.id
|
||||
try {
|
||||
const toolId = selectTool({ operation: optionId })
|
||||
if (toolId && !isToolAllowed(toolId)) denied.add(optionId)
|
||||
} catch {
|
||||
// Unresolvable from the operation alone — leave it visible; the server still enforces.
|
||||
}
|
||||
}
|
||||
return denied
|
||||
}, [subBlockId, blockConfig, allOptions, isToolAllowed])
|
||||
|
||||
const comboboxOptions = useMemo((): ComboboxOption[] => {
|
||||
return allOptions.map((opt) => {
|
||||
if (typeof opt === 'string') {
|
||||
return { label: opt.toLowerCase(), value: opt }
|
||||
return { label: opt.toLowerCase(), value: opt, hidden: deniedOperationIds.has(opt) }
|
||||
}
|
||||
return {
|
||||
label: opt.label.toLowerCase(),
|
||||
value: opt.id,
|
||||
icon: 'icon' in opt ? opt.icon : undefined,
|
||||
hidden: opt.hidden,
|
||||
hidden: opt.hidden || deniedOperationIds.has(opt.id),
|
||||
}
|
||||
})
|
||||
}, [allOptions])
|
||||
}, [allOptions, deniedOperationIds])
|
||||
|
||||
const optionMap = useMemo(() => {
|
||||
return new Map(comboboxOptions.map((opt) => [opt.value, opt.label]))
|
||||
@@ -234,16 +259,18 @@ export const Dropdown = memo(function Dropdown({
|
||||
|
||||
const defaultOptionValue = useMemo(() => {
|
||||
if (multiSelect) return undefined
|
||||
|
||||
const firstSelectable = comboboxOptions.find((opt) => !opt.hidden)
|
||||
if (defaultValue !== undefined) {
|
||||
// Don't seed a denied operation as the default; use the first allowed option.
|
||||
if (deniedOperationIds.has(defaultValue)) {
|
||||
return firstSelectable?.value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
if (comboboxOptions.length > 0) {
|
||||
return comboboxOptions[0].value
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [defaultValue, comboboxOptions, multiSelect])
|
||||
return firstSelectable?.value
|
||||
}, [defaultValue, comboboxOptions, deniedOperationIds, multiSelect])
|
||||
|
||||
useEffect(() => {
|
||||
if (multiSelect || defaultOptionValue === undefined) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<ChipDropdown
|
||||
multiple
|
||||
searchable
|
||||
align={fullWidth ? 'start' : 'end'}
|
||||
matchTriggerWidth={fullWidth}
|
||||
options={options}
|
||||
value={workspaceIds}
|
||||
onChange={onChange}
|
||||
disabled={disabled || isLoading}
|
||||
showAllOption={allowAllWorkspaces}
|
||||
allLabel={
|
||||
isLoading
|
||||
? 'Loading workspaces…'
|
||||
: allowAllWorkspaces
|
||||
? 'All workspaces'
|
||||
: 'Select workspaces…'
|
||||
}
|
||||
searchPlaceholder='Search workspaces…'
|
||||
fullWidth={fullWidth}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 } }]
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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 })
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ChipModalHeader,
|
||||
ChipSelect,
|
||||
ChipSwitch,
|
||||
ChipTag,
|
||||
Search,
|
||||
toast,
|
||||
} from '@/components/emcn'
|
||||
@@ -767,9 +768,9 @@ export function DataRetentionSettings() {
|
||||
<span className='truncate text-[14px] text-[var(--text-body)]'>
|
||||
Organization
|
||||
</span>
|
||||
<span className='flex-shrink-0 rounded-sm bg-[var(--surface-3)] px-1.5 py-0.5 text-[var(--text-muted)] text-micro'>
|
||||
<ChipTag variant='gray' className='flex-shrink-0'>
|
||||
Default
|
||||
</span>
|
||||
</ChipTag>
|
||||
</div>
|
||||
<span className='truncate text-[12px] text-[var(--text-muted)]'>
|
||||
{orgRowSummary()}
|
||||
|
||||
@@ -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 <T extends { type: string }>(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,
|
||||
]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -936,10 +936,13 @@ export async function executeTool(
|
||||
? 'mcp'
|
||||
: undefined
|
||||
|
||||
if (toolKind && scope.userId && scope.workspaceId) {
|
||||
// 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,
|
||||
workspaceId: scope.workspaceId,
|
||||
toolId: normalizedToolId,
|
||||
toolKind,
|
||||
ctx: executionContext,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user