chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
Checkbox,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
ChipTag,
|
||||
Label,
|
||||
} from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { ArrowRight, Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { GroupDetail } from '@/ee/access-control/components/group-detail'
|
||||
import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select'
|
||||
import {
|
||||
useCreatePermissionGroup,
|
||||
useOrganizationWorkspaces,
|
||||
usePermissionGroups,
|
||||
useUserPermissionConfig,
|
||||
} from '@/ee/access-control/hooks/permission-groups'
|
||||
|
||||
const logger = createLogger('AccessControl')
|
||||
|
||||
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.
|
||||
*/
|
||||
const { data: userPermissionConfig, isPending: entitlementLoading } =
|
||||
useUserPermissionConfig(workspaceId)
|
||||
const organizationId = userPermissionConfig?.organizationId ?? undefined
|
||||
const currentUserIsOrgAdmin = userPermissionConfig?.isOrgAdmin ?? false
|
||||
|
||||
const { data: permissionGroups = [], isPending: groupsLoading } = usePermissionGroups(
|
||||
organizationId,
|
||||
!!organizationId && currentUserIsOrgAdmin
|
||||
)
|
||||
const { data: organizationWorkspaces = [], isPending: workspacesLoading } =
|
||||
useOrganizationWorkspaces(organizationId, !!organizationId && currentUserIsOrgAdmin)
|
||||
|
||||
const accessControlEnabledLocally = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED'))
|
||||
const isEntitled = accessControlEnabledLocally || !!userPermissionConfig?.entitled
|
||||
const canManage = isEntitled && currentUserIsOrgAdmin && !!organizationId
|
||||
|
||||
const isLoading =
|
||||
!workspaceId ||
|
||||
entitlementLoading ||
|
||||
(!!organizationId && currentUserIsOrgAdmin && groupsLoading)
|
||||
|
||||
const createPermissionGroup = useCreatePermissionGroup()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [newGroupName, setNewGroupName] = useState('')
|
||||
const [newGroupDescription, setNewGroupDescription] = useState('')
|
||||
const [newGroupIsDefault, setNewGroupIsDefault] = useState(false)
|
||||
const [newGroupWorkspaceIds, setNewGroupWorkspaceIds] = useState<string[]>([])
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
const workspaceOptions = useMemo(
|
||||
() => organizationWorkspaces.map((ws) => ({ value: ws.id, label: ws.name })),
|
||||
[organizationWorkspaces]
|
||||
)
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (!searchTerm.trim()) return permissionGroups
|
||||
const searchLower = searchTerm.toLowerCase()
|
||||
return permissionGroups.filter((g) => g.name.toLowerCase().includes(searchLower))
|
||||
}, [permissionGroups, searchTerm])
|
||||
|
||||
const selectedGroup = useMemo(
|
||||
() => (selectedGroupId ? permissionGroups.find((g) => g.id === selectedGroupId) : undefined),
|
||||
[permissionGroups, selectedGroupId]
|
||||
)
|
||||
|
||||
const handleCreatePermissionGroup = useCallback(async () => {
|
||||
if (!newGroupName.trim() || !organizationId) return
|
||||
setCreateError(null)
|
||||
try {
|
||||
await createPermissionGroup.mutateAsync({
|
||||
organizationId,
|
||||
name: newGroupName.trim(),
|
||||
description: newGroupDescription.trim() || undefined,
|
||||
isDefault: newGroupIsDefault,
|
||||
workspaceIds: newGroupIsDefault ? undefined : newGroupWorkspaceIds,
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
setNewGroupName('')
|
||||
setNewGroupDescription('')
|
||||
setNewGroupIsDefault(false)
|
||||
setNewGroupWorkspaceIds([])
|
||||
} catch (error) {
|
||||
logger.error('Failed to create permission group', error)
|
||||
setCreateError(getErrorMessage(error, 'Failed to create permission group'))
|
||||
}
|
||||
}, [
|
||||
newGroupName,
|
||||
newGroupDescription,
|
||||
newGroupIsDefault,
|
||||
newGroupWorkspaceIds,
|
||||
organizationId,
|
||||
createPermissionGroup,
|
||||
])
|
||||
|
||||
const handleCloseCreateModal = useCallback(() => {
|
||||
setShowCreateModal(false)
|
||||
setNewGroupName('')
|
||||
setNewGroupDescription('')
|
||||
setNewGroupIsDefault(false)
|
||||
setNewGroupWorkspaceIds([])
|
||||
setCreateError(null)
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<SettingsEmptyState>
|
||||
{!organizationId
|
||||
? "Access Control applies to organization workspaces. This workspace isn't part of an organization."
|
||||
: 'Only organization admins on Enterprise plans can manage Access Control settings.'}
|
||||
</SettingsEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedGroup && organizationId) {
|
||||
return (
|
||||
<GroupDetail
|
||||
group={selectedGroup}
|
||||
organizationId={organizationId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceOptions={workspaceOptions}
|
||||
organizationWorkspaces={organizationWorkspaces}
|
||||
workspacesLoading={workspacesLoading}
|
||||
onBack={() => setSelectedGroupId(null)}
|
||||
onDeleted={() => setSelectedGroupId(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPanel
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search permission groups...',
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
text: 'Create group',
|
||||
icon: Plus,
|
||||
variant: 'primary',
|
||||
onSelect: () => setShowCreateModal(true),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SettingsSection label={`Permission groups (${permissionGroups.length})`}>
|
||||
{permissionGroups.length === 0 ? (
|
||||
<SettingsEmptyState variant='inline'>
|
||||
No permission groups yet. Click "Create group" to get started.
|
||||
</SettingsEmptyState>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<SettingsEmptyState variant='inline'>
|
||||
No groups found matching "{searchTerm}"
|
||||
</SettingsEmptyState>
|
||||
) : (
|
||||
<div className='-mx-2 flex flex-col gap-y-0.5'>
|
||||
{filteredGroups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type='button'
|
||||
onClick={() => setSelectedGroupId(group.id)}
|
||||
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='truncate text-[var(--text-body)] text-sm'>{group.name}</span>
|
||||
{group.isDefault && (
|
||||
<ChipTag variant='gray' className='flex-shrink-0'>
|
||||
Default
|
||||
</ChipTag>
|
||||
)}
|
||||
</div>
|
||||
<span className='truncate text-[var(--text-muted)] text-caption'>
|
||||
{group.isDefault
|
||||
? 'Everyone in the organization'
|
||||
: `${
|
||||
group.memberCount === 0
|
||||
? 'All members'
|
||||
: `${group.memberCount} member${group.memberCount === 1 ? '' : 's'}`
|
||||
} · ${group.workspaces.length} workspace${
|
||||
group.workspaces.length === 1 ? '' : 's'
|
||||
}`}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</SettingsPanel>
|
||||
|
||||
<ChipModal
|
||||
open={showCreateModal}
|
||||
onOpenChange={handleCloseCreateModal}
|
||||
size='sm'
|
||||
srTitle='Create Permission Group'
|
||||
>
|
||||
<ChipModalHeader onClose={handleCloseCreateModal}>Create Permission Group</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Name'
|
||||
value={newGroupName}
|
||||
onChange={(value) => {
|
||||
setNewGroupName(value)
|
||||
if (createError) setCreateError(null)
|
||||
}}
|
||||
placeholder='e.g., Marketing Team'
|
||||
/>
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Description (optional)'
|
||||
value={newGroupDescription}
|
||||
onChange={(value) => setNewGroupDescription(value)}
|
||||
placeholder='e.g., Limited access for marketing users'
|
||||
/>
|
||||
<ChipModalField type='custom' title='Membership'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
id='default-group'
|
||||
checked={newGroupIsDefault}
|
||||
onCheckedChange={(checked) => {
|
||||
const isDefault = checked === true
|
||||
setNewGroupIsDefault(isDefault)
|
||||
if (isDefault) setNewGroupWorkspaceIds([])
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor='default-group' className='cursor-pointer font-normal'>
|
||||
Make this the organization default group
|
||||
</Label>
|
||||
</div>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' title='Workspaces'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<WorkspaceSelect
|
||||
workspaceIds={newGroupWorkspaceIds}
|
||||
onChange={setNewGroupWorkspaceIds}
|
||||
options={workspaceOptions}
|
||||
disabled={newGroupIsDefault}
|
||||
isLoading={workspacesLoading}
|
||||
allowAllWorkspaces={newGroupIsDefault}
|
||||
fullWidth
|
||||
/>
|
||||
{!newGroupIsDefault && (
|
||||
<p className='text-[var(--text-muted)] text-xs'>
|
||||
Applies to all members of the selected workspaces. Restrict to specific people
|
||||
later from the group's Members section.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ChipModalField>
|
||||
<ChipModalError>{createError}</ChipModalError>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={handleCloseCreateModal}
|
||||
primaryAction={{
|
||||
label: createPermissionGroup.isPending ? 'Creating...' : 'Create',
|
||||
onClick: handleCreatePermissionGroup,
|
||||
disabled:
|
||||
!newGroupName.trim() ||
|
||||
createPermissionGroup.isPending ||
|
||||
(!newGroupIsDefault && newGroupWorkspaceIds.length === 0),
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { ChipDropdown } from '@sim/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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
bulkAddPermissionGroupMembersContract,
|
||||
createPermissionGroupContract,
|
||||
deletePermissionGroupContract,
|
||||
getUserPermissionConfigContract,
|
||||
listOrganizationWorkspacesContract,
|
||||
listPermissionGroupMembersContract,
|
||||
listPermissionGroupsContract,
|
||||
type PermissionGroup,
|
||||
type PermissionGroupMember,
|
||||
type PermissionGroupWorkspaceRef,
|
||||
removePermissionGroupMemberContract,
|
||||
type UserPermissionConfig,
|
||||
updatePermissionGroupContract,
|
||||
} from '@/lib/api/contracts'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
|
||||
export type {
|
||||
PermissionGroup,
|
||||
PermissionGroupMember,
|
||||
PermissionGroupWorkspaceRef,
|
||||
UserPermissionConfig,
|
||||
}
|
||||
|
||||
export const permissionGroupKeys = {
|
||||
all: ['permissionGroups'] as const,
|
||||
lists: () => [...permissionGroupKeys.all, 'list'] as const,
|
||||
list: (organizationId?: string) =>
|
||||
[...permissionGroupKeys.lists(), organizationId ?? ''] as const,
|
||||
details: () => [...permissionGroupKeys.all, 'detail'] as const,
|
||||
detail: (organizationId?: string, id?: string) =>
|
||||
[...permissionGroupKeys.details(), organizationId ?? '', id ?? ''] as const,
|
||||
members: (organizationId?: string, id?: string) =>
|
||||
[...permissionGroupKeys.detail(organizationId, id), 'members'] as const,
|
||||
userConfig: (workspaceId?: string) =>
|
||||
[...permissionGroupKeys.all, 'userConfig', workspaceId ?? ''] as const,
|
||||
orgWorkspaces: (organizationId?: string) =>
|
||||
[...permissionGroupKeys.all, 'orgWorkspaces', organizationId ?? ''] as const,
|
||||
}
|
||||
|
||||
export function usePermissionGroups(organizationId?: string, enabled = true) {
|
||||
return useQuery<PermissionGroup[]>({
|
||||
queryKey: permissionGroupKeys.list(organizationId),
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!organizationId) return []
|
||||
const data = await requestJson(listPermissionGroupsContract, {
|
||||
params: { id: organizationId },
|
||||
signal,
|
||||
})
|
||||
return data.permissionGroups ?? []
|
||||
},
|
||||
enabled: Boolean(organizationId) && enabled,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePermissionGroupMembers(organizationId?: string, permissionGroupId?: string) {
|
||||
return useQuery<PermissionGroupMember[]>({
|
||||
queryKey: permissionGroupKeys.members(organizationId, permissionGroupId),
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!organizationId || !permissionGroupId) return []
|
||||
const data = await requestJson(listPermissionGroupMembersContract, {
|
||||
params: { id: organizationId, groupId: permissionGroupId },
|
||||
signal,
|
||||
})
|
||||
return data.members ?? []
|
||||
},
|
||||
enabled: Boolean(organizationId) && Boolean(permissionGroupId),
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useOrganizationWorkspaces(organizationId?: string, enabled = true) {
|
||||
return useQuery<PermissionGroupWorkspaceRef[]>({
|
||||
queryKey: permissionGroupKeys.orgWorkspaces(organizationId),
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!organizationId) return []
|
||||
const data = await requestJson(listOrganizationWorkspacesContract, {
|
||||
params: { id: organizationId },
|
||||
signal,
|
||||
})
|
||||
return data.workspaces
|
||||
},
|
||||
enabled: Boolean(organizationId) && enabled,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUserPermissionConfig(workspaceId?: string) {
|
||||
return useQuery<UserPermissionConfig>({
|
||||
queryKey: permissionGroupKeys.userConfig(workspaceId),
|
||||
queryFn: async ({ signal }) => {
|
||||
const data = await requestJson(getUserPermissionConfigContract, {
|
||||
query: { workspaceId: workspaceId ?? '' },
|
||||
signal,
|
||||
})
|
||||
return data
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export interface CreatePermissionGroupData {
|
||||
organizationId: string
|
||||
name: string
|
||||
description?: string
|
||||
config?: Partial<PermissionGroupConfig>
|
||||
isDefault?: boolean
|
||||
workspaceIds?: string[]
|
||||
}
|
||||
|
||||
export function useCreatePermissionGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ organizationId, ...data }: CreatePermissionGroupData) => {
|
||||
return requestJson(createPermissionGroupContract, {
|
||||
params: { id: organizationId },
|
||||
body: data,
|
||||
})
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.list(variables.organizationId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface UpdatePermissionGroupData {
|
||||
id: string
|
||||
organizationId: string
|
||||
name?: string
|
||||
description?: string | null
|
||||
config?: Partial<PermissionGroupConfig>
|
||||
isDefault?: boolean
|
||||
workspaceIds?: string[]
|
||||
}
|
||||
|
||||
export function useUpdatePermissionGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, organizationId, ...data }: UpdatePermissionGroupData) => {
|
||||
return requestJson(updatePermissionGroupContract, {
|
||||
params: { id: organizationId, groupId: id },
|
||||
body: data,
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
// `all` is the prefix of every key in the factory (list/detail/members/userConfig),
|
||||
// so a single invalidation covers them — including the workspace-keyed userConfig
|
||||
// entries a mutation that only knows organizationId cannot target directly.
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface DeletePermissionGroupParams {
|
||||
permissionGroupId: string
|
||||
organizationId: string
|
||||
}
|
||||
|
||||
export function useDeletePermissionGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ permissionGroupId, organizationId }: DeletePermissionGroupParams) => {
|
||||
return requestJson(deletePermissionGroupContract, {
|
||||
params: { id: organizationId, groupId: permissionGroupId },
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemovePermissionGroupMember() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: {
|
||||
organizationId: string
|
||||
permissionGroupId: string
|
||||
memberId: string
|
||||
}) => {
|
||||
return requestJson(removePermissionGroupMemberContract, {
|
||||
params: { id: data.organizationId, groupId: data.permissionGroupId },
|
||||
query: { memberId: data.memberId },
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface BulkAddMembersData {
|
||||
organizationId: string
|
||||
permissionGroupId: string
|
||||
userIds?: string[]
|
||||
addAllOrganizationMembers?: boolean
|
||||
}
|
||||
|
||||
export function useBulkAddPermissionGroupMembers() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ organizationId, permissionGroupId, ...data }: BulkAddMembersData) => {
|
||||
return requestJson(bulkAddPermissionGroupMembersContract, {
|
||||
params: { id: organizationId, groupId: permissionGroupId },
|
||||
body: data,
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
mockGetAllowedIntegrationsFromEnv,
|
||||
mockIsOrganizationOnEnterprisePlan,
|
||||
mockGetWorkspaceWithOwner,
|
||||
mockGetProviderFromModel,
|
||||
mockGetBlock,
|
||||
mockWorkspaceGroups,
|
||||
mockDefaultGroup,
|
||||
} = vi.hoisted(() => ({
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG: {
|
||||
allowedIntegrations: null,
|
||||
allowedModelProviders: null,
|
||||
deniedModels: [],
|
||||
deniedTools: [],
|
||||
hideTraceSpans: false,
|
||||
hideKnowledgeBaseTab: false,
|
||||
hideTablesTab: false,
|
||||
hideCopilot: false,
|
||||
hideIntegrationsTab: false,
|
||||
hideSecretsTab: false,
|
||||
hideApiKeysTab: false,
|
||||
hideInboxTab: false,
|
||||
hideFilesTab: false,
|
||||
disableMcpTools: false,
|
||||
disableCustomTools: false,
|
||||
disableSkills: false,
|
||||
disableInvitations: false,
|
||||
disablePublicApi: false,
|
||||
disablePublicFileSharing: false,
|
||||
allowedFileShareAuthTypes: null,
|
||||
hideDeployApi: false,
|
||||
hideDeployMcp: false,
|
||||
hideDeployChatbot: false,
|
||||
hideDeployTemplate: false,
|
||||
},
|
||||
mockGetAllowedIntegrationsFromEnv: vi.fn<() => string[] | null>(),
|
||||
mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise<boolean>>(),
|
||||
mockGetWorkspaceWithOwner: vi.fn<() => Promise<{ organizationId: string | null } | null>>(),
|
||||
mockGetProviderFromModel: vi.fn<(model: string) => string>(),
|
||||
mockGetBlock: vi.fn<(type: string) => { hideFromToolbar?: boolean } | undefined>(),
|
||||
// resolveWorkspaceGroup selects non-default groups targeting the workspace
|
||||
// (FROM permissionGroup INNER JOIN permissionGroupWorkspace), awaiting the
|
||||
// builder directly; each row carries `isMember`/`hasMembers` booleans. A row
|
||||
// with neither flag set reads as an all-members group (hasMembers falsy).
|
||||
// resolveDefaultGroup selects the org default directly with limit(1), no join.
|
||||
// The db mock branches on whether an inner join was used.
|
||||
mockWorkspaceGroups: {
|
||||
value: [] as Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
config: Record<string, unknown>
|
||||
isMember?: boolean
|
||||
hasMembers?: boolean
|
||||
}>,
|
||||
},
|
||||
mockDefaultGroup: { value: [] as Array<{ config: Record<string, unknown> }> },
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
let usedInnerJoin = false
|
||||
const resolveRows = () => (usedInnerJoin ? mockWorkspaceGroups.value : mockDefaultGroup.value)
|
||||
const chain: Record<string, unknown> = {}
|
||||
chain.from = vi.fn().mockReturnValue(chain)
|
||||
chain.innerJoin = vi.fn().mockImplementation(() => {
|
||||
usedInnerJoin = true
|
||||
return chain
|
||||
})
|
||||
chain.leftJoin = vi.fn().mockReturnValue(chain)
|
||||
chain.where = vi.fn().mockReturnValue(chain)
|
||||
chain.orderBy = vi.fn().mockReturnValue(chain)
|
||||
chain.limit = vi.fn().mockImplementation(() => Promise.resolve(resolveRows()))
|
||||
// resolveWorkspaceGroup awaits the builder directly after `orderBy` (no
|
||||
// limit), so the chain must be thenable.
|
||||
chain.then = (onFulfilled: (rows: unknown) => unknown) =>
|
||||
Promise.resolve(resolveRows()).then(onFulfilled)
|
||||
return chain
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
permissionGroup: {},
|
||||
permissionGroupMember: {},
|
||||
permissionGroupWorkspace: {},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
asc: vi.fn(),
|
||||
sql: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getWorkspaceWithOwner: mockGetWorkspaceWithOwner,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => ({
|
||||
getAllowedIntegrationsFromEnv: mockGetAllowedIntegrationsFromEnv,
|
||||
isAccessControlEnabled: true,
|
||||
isHosted: true,
|
||||
isInvitationsDisabled: false,
|
||||
isPublicApiDisabled: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/permission-groups/types', () => ({
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
parsePermissionGroupConfig: (config: unknown) => {
|
||||
if (!config || typeof config !== 'object') return DEFAULT_PERMISSION_GROUP_CONFIG
|
||||
return { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...config }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/providers/utils', () => ({
|
||||
getProviderFromModel: mockGetProviderFromModel,
|
||||
}))
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getBlock: mockGetBlock,
|
||||
getAllBlocks: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
import {
|
||||
assertPermissionsAllowed,
|
||||
CustomToolsNotAllowedError,
|
||||
getUserPermissionConfig,
|
||||
IntegrationNotAllowedError,
|
||||
McpToolsNotAllowedError,
|
||||
ModelNotAllowedError,
|
||||
ProviderNotAllowedError,
|
||||
PublicFileSharingNotAllowedError,
|
||||
SkillsNotAllowedError,
|
||||
ToolNotAllowedError,
|
||||
validateBlockType,
|
||||
validateMcpToolsAllowed,
|
||||
validateModelProvider,
|
||||
validatePublicFileSharing,
|
||||
} from './permission-check'
|
||||
|
||||
/** Default an org-backed, enterprise-entitled workspace so resolution reaches the group queries. */
|
||||
function setEnterpriseOrgWorkspace() {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: 'org-1' })
|
||||
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default every block to non-legacy. `vi.clearAllMocks()` (used by the
|
||||
* describe-level hooks) keeps implementations, so reset here to stop a legacy
|
||||
* `getBlock` implementation set in one test from leaking into later ones.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
mockGetBlock.mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
describe('IntegrationNotAllowedError', () => {
|
||||
it.concurrent('creates error with correct name and message', () => {
|
||||
const error = new IntegrationNotAllowedError('discord')
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.name).toBe('IntegrationNotAllowedError')
|
||||
expect(error.message).toContain('discord')
|
||||
})
|
||||
|
||||
it.concurrent('includes custom reason when provided', () => {
|
||||
const error = new IntegrationNotAllowedError('discord', 'blocked by server policy')
|
||||
|
||||
expect(error.message).toContain('blocked by server policy')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUserPermissionConfig (org + entitlement gating)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspaceGroups.value = []
|
||||
mockDefaultGroup.value = []
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('returns null when the workspace has no organization', async () => {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: null })
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config).toBeNull()
|
||||
expect(mockIsOrganizationOnEnterprisePlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still applies the env allowlist on a no-org workspace', async () => {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: null })
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack'])
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config?.allowedIntegrations).toEqual(['slack'])
|
||||
})
|
||||
|
||||
it('returns null when the organization is not on an enterprise plan', async () => {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: 'org-1' })
|
||||
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false)
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the org default group when no workspace group governs the user', async () => {
|
||||
setEnterpriseOrgWorkspace()
|
||||
mockWorkspaceGroups.value = []
|
||||
mockDefaultGroup.value = [{ config: { disableSkills: true } }]
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config?.disableSkills).toBe(true)
|
||||
})
|
||||
|
||||
it('governs an external member via the org default group', async () => {
|
||||
setEnterpriseOrgWorkspace()
|
||||
mockWorkspaceGroups.value = []
|
||||
mockDefaultGroup.value = [{ config: { disableCustomTools: true } }]
|
||||
|
||||
const config = await getUserPermissionConfig('external-user', 'workspace-1')
|
||||
|
||||
expect(config?.disableCustomTools).toBe(true)
|
||||
})
|
||||
|
||||
it('returns null when no workspace group and no default group apply', async () => {
|
||||
setEnterpriseOrgWorkspace()
|
||||
mockWorkspaceGroups.value = []
|
||||
mockDefaultGroup.value = []
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUserPermissionConfig (workspace-group precedence)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspaceGroups.value = []
|
||||
mockDefaultGroup.value = []
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
setEnterpriseOrgWorkspace()
|
||||
})
|
||||
|
||||
it('governs an explicit member via their workspace group', async () => {
|
||||
mockWorkspaceGroups.value = [
|
||||
{ id: 'g', config: { disableMcpTools: true }, isMember: true, hasMembers: true },
|
||||
]
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config?.disableMcpTools).toBe(true)
|
||||
})
|
||||
|
||||
it('governs all members (including non-listed) via an all-members group', async () => {
|
||||
mockWorkspaceGroups.value = [
|
||||
{ id: 'g', config: { disableSkills: true }, isMember: false, hasMembers: false },
|
||||
]
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config?.disableSkills).toBe(true)
|
||||
})
|
||||
|
||||
it('governs an external member via an all-members group', async () => {
|
||||
mockWorkspaceGroups.value = [
|
||||
{ id: 'g', config: { disableCustomTools: true }, isMember: false, hasMembers: false },
|
||||
]
|
||||
|
||||
const config = await getUserPermissionConfig('external-user', 'workspace-1')
|
||||
|
||||
expect(config?.disableCustomTools).toBe(true)
|
||||
})
|
||||
|
||||
it('prefers an explicit-member group over an all-members group on the same workspace', async () => {
|
||||
mockWorkspaceGroups.value = [
|
||||
{ id: 'all', config: { disableMcpTools: true }, isMember: false, hasMembers: false },
|
||||
{ id: 'explicit', config: { disableSkills: true }, isMember: true, hasMembers: true },
|
||||
]
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config?.disableSkills).toBe(true)
|
||||
expect(config?.disableMcpTools).toBe(false)
|
||||
})
|
||||
|
||||
it('a narrowed group (has members) does not govern a non-member; falls back to default', async () => {
|
||||
mockWorkspaceGroups.value = [
|
||||
{ id: 'narrowed', config: { disableSkills: true }, isMember: false, hasMembers: true },
|
||||
]
|
||||
mockDefaultGroup.value = [{ config: { disableCustomTools: true } }]
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config?.disableCustomTools).toBe(true)
|
||||
expect(config?.disableSkills).toBe(false)
|
||||
})
|
||||
|
||||
it('a narrowed group does not govern a non-member; unrestricted when no default', async () => {
|
||||
mockWorkspaceGroups.value = [
|
||||
{ id: 'narrowed', config: { disableSkills: true }, isMember: false, hasMembers: true },
|
||||
]
|
||||
mockDefaultGroup.value = []
|
||||
|
||||
const config = await getUserPermissionConfig('user-123', 'workspace-1')
|
||||
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateBlockType', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspaceGroups.value = []
|
||||
mockDefaultGroup.value = []
|
||||
})
|
||||
|
||||
describe('when no env allowlist is configured', () => {
|
||||
beforeEach(() => {
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('allows any block type', async () => {
|
||||
await validateBlockType(undefined, undefined, 'google_drive')
|
||||
})
|
||||
|
||||
it('allows multi-word block types', async () => {
|
||||
await validateBlockType(undefined, undefined, 'microsoft_excel')
|
||||
})
|
||||
|
||||
it('always allows start_trigger', async () => {
|
||||
await validateBlockType(undefined, undefined, 'start_trigger')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when env allowlist is configured', () => {
|
||||
beforeEach(() => {
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue([
|
||||
'slack',
|
||||
'google_drive',
|
||||
'microsoft_excel',
|
||||
])
|
||||
})
|
||||
|
||||
it('allows block types on the allowlist', async () => {
|
||||
await validateBlockType(undefined, undefined, 'slack')
|
||||
await validateBlockType(undefined, undefined, 'google_drive')
|
||||
await validateBlockType(undefined, undefined, 'microsoft_excel')
|
||||
})
|
||||
|
||||
it('rejects block types not on the allowlist', async () => {
|
||||
await expect(validateBlockType(undefined, undefined, 'discord')).rejects.toThrow(
|
||||
IntegrationNotAllowedError
|
||||
)
|
||||
})
|
||||
|
||||
it('always allows start_trigger regardless of allowlist', async () => {
|
||||
await validateBlockType(undefined, undefined, 'start_trigger')
|
||||
})
|
||||
|
||||
it('always allows legacy blocks hidden from the toolbar', async () => {
|
||||
mockGetBlock.mockImplementation((type) =>
|
||||
type === 'notion' ? { hideFromToolbar: true } : undefined
|
||||
)
|
||||
|
||||
await validateBlockType(undefined, undefined, 'notion')
|
||||
})
|
||||
|
||||
it('does NOT treat preview blocks as exempt — preview is not legacy', async () => {
|
||||
// A `preview: true` block has static hideFromToolbar unset, so it is a
|
||||
// normal access-controlled block: visibility gating (discovery) and
|
||||
// permission-group enforcement (execution) are deliberately independent.
|
||||
mockGetBlock.mockImplementation((type) =>
|
||||
type === 'gmail_v2' ? ({ preview: true } as { hideFromToolbar?: boolean }) : undefined
|
||||
)
|
||||
|
||||
await expect(validateBlockType(undefined, undefined, 'gmail_v2')).rejects.toThrow(
|
||||
IntegrationNotAllowedError
|
||||
)
|
||||
})
|
||||
|
||||
it('matches case-insensitively', async () => {
|
||||
await validateBlockType(undefined, undefined, 'Slack')
|
||||
await validateBlockType(undefined, undefined, 'GOOGLE_DRIVE')
|
||||
})
|
||||
|
||||
it('includes env reason in error when env allowlist is the source', async () => {
|
||||
await expect(validateBlockType(undefined, undefined, 'discord')).rejects.toThrow(
|
||||
/ALLOWED_INTEGRATIONS/
|
||||
)
|
||||
})
|
||||
|
||||
it('includes env reason even when a workspace is in context', async () => {
|
||||
await expect(validateBlockType('user-123', 'workspace-1', 'discord')).rejects.toThrow(
|
||||
/ALLOWED_INTEGRATIONS/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateModelProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspaceGroups.value = []
|
||||
mockDefaultGroup.value = []
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
setEnterpriseOrgWorkspace()
|
||||
})
|
||||
|
||||
it('no-ops when user or workspace is missing', async () => {
|
||||
await validateModelProvider(undefined, 'workspace-1', 'gpt-4')
|
||||
await validateModelProvider('user-123', undefined, 'gpt-4')
|
||||
})
|
||||
|
||||
it('throws ProviderNotAllowedError when provider is not in allowlist', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { allowedModelProviders: ['anthropic'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf(
|
||||
ProviderNotAllowedError
|
||||
)
|
||||
})
|
||||
|
||||
it('allows when provider is on the allowlist', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { allowedModelProviders: ['anthropic', 'openai'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await validateModelProvider('user-123', 'workspace-1', 'gpt-4')
|
||||
})
|
||||
|
||||
it('throws ModelNotAllowedError when the model is on the denylist', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { deniedModels: ['gpt-4'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf(
|
||||
ModelNotAllowedError
|
||||
)
|
||||
})
|
||||
|
||||
it('denylist match is case-insensitive', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { deniedModels: ['Ollama/Llama3'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('ollama')
|
||||
|
||||
await expect(
|
||||
validateModelProvider('user-123', 'workspace-1', 'ollama/llama3')
|
||||
).rejects.toBeInstanceOf(ModelNotAllowedError)
|
||||
})
|
||||
|
||||
it('enforces the denylist even when no provider allowlist is set', async () => {
|
||||
mockWorkspaceGroups.value = [
|
||||
{ config: { allowedModelProviders: null, deniedModels: ['gpt-4'] } },
|
||||
]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf(
|
||||
ModelNotAllowedError
|
||||
)
|
||||
})
|
||||
|
||||
it('allows a model that is not on the denylist', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { deniedModels: ['gpt-4'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await validateModelProvider('user-123', 'workspace-1', 'gpt-4o')
|
||||
})
|
||||
|
||||
it('applies the org default group when no workspace group governs the user', async () => {
|
||||
mockWorkspaceGroups.value = []
|
||||
mockDefaultGroup.value = [{ config: { allowedModelProviders: ['anthropic'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await expect(validateModelProvider('user-123', 'workspace-1', 'gpt-4')).rejects.toBeInstanceOf(
|
||||
ProviderNotAllowedError
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateMcpToolsAllowed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspaceGroups.value = []
|
||||
mockDefaultGroup.value = []
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
setEnterpriseOrgWorkspace()
|
||||
})
|
||||
|
||||
it('throws McpToolsNotAllowedError when disableMcpTools is set', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { disableMcpTools: true } }]
|
||||
|
||||
await expect(validateMcpToolsAllowed('user-123', 'workspace-1')).rejects.toBeInstanceOf(
|
||||
McpToolsNotAllowedError
|
||||
)
|
||||
})
|
||||
|
||||
it('no-ops when disableMcpTools is false', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: {} }]
|
||||
|
||||
await validateMcpToolsAllowed('user-123', 'workspace-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePublicFileSharing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspaceGroups.value = []
|
||||
mockDefaultGroup.value = []
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
setEnterpriseOrgWorkspace()
|
||||
})
|
||||
|
||||
it('throws when public file sharing is fully disabled', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { disablePublicFileSharing: true } }]
|
||||
await expect(
|
||||
validatePublicFileSharing('user-123', 'workspace-1', 'password')
|
||||
).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError)
|
||||
})
|
||||
|
||||
it('throws when the auth type is not in the allow-list', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }]
|
||||
await expect(
|
||||
validatePublicFileSharing('user-123', 'workspace-1', 'public')
|
||||
).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError)
|
||||
})
|
||||
|
||||
it('allows an auth type that is in the allow-list', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }]
|
||||
await validatePublicFileSharing('user-123', 'workspace-1', 'password')
|
||||
})
|
||||
|
||||
it('allows any auth type when the allow-list is null', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { allowedFileShareAuthTypes: null } }]
|
||||
await validatePublicFileSharing('user-123', 'workspace-1', 'email')
|
||||
})
|
||||
|
||||
it('no-ops when no auth type is provided (master switch only)', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { allowedFileShareAuthTypes: ['password'] } }]
|
||||
await validatePublicFileSharing('user-123', 'workspace-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertPermissionsAllowed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspaceGroups.value = []
|
||||
mockDefaultGroup.value = []
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
setEnterpriseOrgWorkspace()
|
||||
})
|
||||
|
||||
it('throws ProviderNotAllowedError when model provider is blocked', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { allowedModelProviders: ['anthropic'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await expect(
|
||||
assertPermissionsAllowed({
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-1',
|
||||
model: 'gpt-4',
|
||||
})
|
||||
).rejects.toBeInstanceOf(ProviderNotAllowedError)
|
||||
})
|
||||
|
||||
it('throws ModelNotAllowedError when the model is on the denylist', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { deniedModels: ['gpt-4'] } }]
|
||||
mockGetProviderFromModel.mockReturnValue('openai')
|
||||
|
||||
await expect(
|
||||
assertPermissionsAllowed({
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-1',
|
||||
model: 'gpt-4',
|
||||
})
|
||||
).rejects.toBeInstanceOf(ModelNotAllowedError)
|
||||
})
|
||||
|
||||
it('throws IntegrationNotAllowedError when block type is blocked', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { allowedIntegrations: ['slack'] } }]
|
||||
|
||||
await expect(
|
||||
assertPermissionsAllowed({
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-1',
|
||||
blockType: 'discord',
|
||||
})
|
||||
).rejects.toBeInstanceOf(IntegrationNotAllowedError)
|
||||
})
|
||||
|
||||
it('exempts legacy blocks from the integration allowlist', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { allowedIntegrations: ['slack'] } }]
|
||||
mockGetBlock.mockImplementation((type) =>
|
||||
type === 'notion' ? { hideFromToolbar: true } : undefined
|
||||
)
|
||||
|
||||
await assertPermissionsAllowed({
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-1',
|
||||
blockType: 'notion',
|
||||
})
|
||||
})
|
||||
|
||||
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 } }]
|
||||
|
||||
await expect(
|
||||
assertPermissionsAllowed({
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-1',
|
||||
toolKind: 'custom',
|
||||
})
|
||||
).rejects.toBeInstanceOf(CustomToolsNotAllowedError)
|
||||
})
|
||||
|
||||
it('throws SkillsNotAllowedError when skills are disabled', async () => {
|
||||
mockWorkspaceGroups.value = [{ config: { disableSkills: true } }]
|
||||
|
||||
await expect(
|
||||
assertPermissionsAllowed({
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-1',
|
||||
toolKind: 'skill',
|
||||
})
|
||||
).rejects.toBeInstanceOf(SkillsNotAllowedError)
|
||||
})
|
||||
|
||||
it('passes when the workspace has no blocking config', async () => {
|
||||
mockWorkspaceGroups.value = []
|
||||
mockDefaultGroup.value = []
|
||||
|
||||
await assertPermissionsAllowed({
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-1',
|
||||
model: 'gpt-4',
|
||||
blockType: 'slack',
|
||||
toolKind: 'mcp',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,669 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, asc, eq, sql } from 'drizzle-orm'
|
||||
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
|
||||
import {
|
||||
getAllowedIntegrationsFromEnv,
|
||||
isAccessControlEnabled,
|
||||
isHosted,
|
||||
isInvitationsDisabled,
|
||||
isPublicApiDisabled,
|
||||
} from '@/lib/core/config/env-flags'
|
||||
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
|
||||
import {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('PermissionCheck')
|
||||
|
||||
export class ProviderNotAllowedError extends Error {
|
||||
constructor(providerId: string, model: string) {
|
||||
super(
|
||||
`Provider "${providerId}" is not allowed for model "${model}" based on your permission group settings`
|
||||
)
|
||||
this.name = 'ProviderNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelNotAllowedError extends Error {
|
||||
constructor(model: string) {
|
||||
super(`Model "${model}" is not allowed based on your permission group settings`)
|
||||
this.name = 'ModelNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class IntegrationNotAllowedError extends Error {
|
||||
constructor(blockType: string, reason?: string) {
|
||||
super(
|
||||
reason
|
||||
? `Integration "${blockType}" is not allowed: ${reason}`
|
||||
: `Integration "${blockType}" is not allowed based on your permission group settings`
|
||||
)
|
||||
this.name = 'IntegrationNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
this.name = 'McpToolsNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class CustomToolsNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Custom tools are not allowed based on your permission group settings')
|
||||
this.name = 'CustomToolsNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class SkillsNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Skills are not allowed based on your permission group settings')
|
||||
this.name = 'SkillsNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class InvitationsNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Invitations are not allowed based on your permission group settings')
|
||||
this.name = 'InvitationsNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicApiNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Public API access is not allowed based on your permission group settings')
|
||||
this.name = 'PublicApiNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicFileSharingNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Public file sharing is not allowed based on your permission group settings')
|
||||
this.name = 'PublicFileSharingNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the env allowlist into a permission config.
|
||||
* If `config` is null and no env allowlist is set, returns null.
|
||||
* If `config` is null but env allowlist is set, returns a default config with only allowedIntegrations set.
|
||||
* If both are set, intersects the two allowlists.
|
||||
*/
|
||||
function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGroupConfig | null {
|
||||
const envAllowlist = getAllowedIntegrationsFromEnv()
|
||||
|
||||
if (envAllowlist === null) {
|
||||
return config
|
||||
}
|
||||
|
||||
if (config === null) {
|
||||
return { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: envAllowlist }
|
||||
}
|
||||
|
||||
const merged =
|
||||
config.allowedIntegrations === null
|
||||
? envAllowlist
|
||||
: config.allowedIntegrations
|
||||
.map((i) => i.toLowerCase())
|
||||
.filter((i) => envAllowlist.includes(i))
|
||||
|
||||
return { ...config, allowedIntegrations: merged }
|
||||
}
|
||||
|
||||
/**
|
||||
* The permission group that governs a user in a given context, with its parsed
|
||||
* config. Shared by the executor path and the `/api/permission-groups/user`
|
||||
* route so resolution never drifts between the two.
|
||||
*/
|
||||
export interface ResolvedPermissionGroup {
|
||||
permissionGroupId: string
|
||||
groupName: string
|
||||
config: PermissionGroupConfig
|
||||
}
|
||||
|
||||
/** The organization's single default group (`isDefault`), or `null`. */
|
||||
async function resolveDefaultGroup(
|
||||
organizationId: string
|
||||
): Promise<ResolvedPermissionGroup | null> {
|
||||
const [defaultGroup] = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
name: permissionGroup.name,
|
||||
config: permissionGroup.config,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.isDefault, true))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!defaultGroup) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
permissionGroupId: defaultGroup.id,
|
||||
groupName: defaultGroup.name,
|
||||
config: parsePermissionGroupConfig(defaultGroup.config),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the group governing `userId` in `workspaceId` (which belongs to
|
||||
* `organizationId`). One effective group per workspace, by precedence:
|
||||
* 1. a non-default group targeting this workspace that `userId` is an explicit
|
||||
* member of, else
|
||||
* 2. a non-default group targeting this workspace that has no explicit members
|
||||
* — governs all members of the workspace, including external members, else
|
||||
* 3. the organization's default group (also governs external members), else
|
||||
* 4. `null` (unrestricted).
|
||||
*
|
||||
* Assignment-time conflict checks keep this unambiguous: at most one all-members
|
||||
* group per workspace, and a user is an explicit member of at most one group per
|
||||
* workspace. If an overlap nonetheless exists, the oldest group wins — rows are
|
||||
* ordered by `created_at` (then `id`).
|
||||
*
|
||||
* Callers gate on enterprise entitlement before invoking this and merge the env
|
||||
* allowlist afterwards.
|
||||
*/
|
||||
export async function resolveWorkspaceGroup(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
workspaceId: string
|
||||
): Promise<ResolvedPermissionGroup | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
name: permissionGroup.name,
|
||||
config: permissionGroup.config,
|
||||
isMember: sql<boolean>`exists (
|
||||
select 1 from ${permissionGroupMember}
|
||||
where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id}
|
||||
and ${permissionGroupMember.userId} = ${userId}
|
||||
)`,
|
||||
hasMembers: sql<boolean>`exists (
|
||||
select 1 from ${permissionGroupMember}
|
||||
where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id}
|
||||
)`,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.innerJoin(
|
||||
permissionGroupWorkspace,
|
||||
and(
|
||||
eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id),
|
||||
eq(permissionGroupWorkspace.workspaceId, workspaceId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.isDefault, false))
|
||||
)
|
||||
.orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id))
|
||||
|
||||
const winner = rows.find((row) => row.isMember) ?? rows.find((row) => !row.hasMembers)
|
||||
|
||||
if (winner) {
|
||||
return {
|
||||
permissionGroupId: winner.id,
|
||||
groupName: winner.name,
|
||||
config: parsePermissionGroupConfig(winner.config),
|
||||
}
|
||||
}
|
||||
|
||||
return resolveDefaultGroup(organizationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective permission-group config for a user in the context of a
|
||||
* specific workspace. The workspace is mapped to its organization and the
|
||||
* governing group is resolved with specific-over-all precedence.
|
||||
*
|
||||
* Returns `null` (after env merge) when the workspace has no organization, the
|
||||
* organization isn't on an enterprise plan, or no group governs the user.
|
||||
*
|
||||
* The env-level integration allowlist is always merged last so self-hosted
|
||||
* deployments can constrain integrations without touching the DB.
|
||||
*/
|
||||
export async function getUserPermissionConfig(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<PermissionGroupConfig | null> {
|
||||
if (!isHosted && !isAccessControlEnabled) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
|
||||
if (!ws?.organizationId) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
const isEnterprise = await isOrganizationOnEnterprisePlan(ws.organizationId)
|
||||
if (!isEnterprise) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
const resolved = await resolveWorkspaceGroup(userId, ws.organizationId, workspaceId)
|
||||
return mergeEnvAllowlist(resolved?.config ?? null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws {@link PublicFileSharingNotAllowedError} if the user's effective permission
|
||||
* group for the workspace disables public file sharing, or — when `authType` is
|
||||
* given — if that auth mode isn't in the group's `allowedFileShareAuthTypes`
|
||||
* allow-list (`null` allows all). No-op when access control doesn't apply
|
||||
* (non-enterprise / disabled), so non-governed orgs are unaffected.
|
||||
*/
|
||||
export async function validatePublicFileSharing(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
authType?: ShareAuthType
|
||||
): Promise<void> {
|
||||
const config = await getUserPermissionConfig(userId, workspaceId)
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
if (config.disablePublicFileSharing) {
|
||||
throw new PublicFileSharingNotAllowedError()
|
||||
}
|
||||
if (
|
||||
authType &&
|
||||
config.allowedFileShareAuthTypes !== null &&
|
||||
!config.allowedFileShareAuthTypes.includes(authType)
|
||||
) {
|
||||
logger.warn('File share auth type blocked by permission group', {
|
||||
userId,
|
||||
workspaceId,
|
||||
authType,
|
||||
})
|
||||
throw new PublicFileSharingNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Org-addressed variant of {@link getUserPermissionConfig}. Use when only the
|
||||
* organization is known (e.g. organization-level invitations). Non-default
|
||||
* groups target specific workspaces and never gate organization-level actions,
|
||||
* so this resolves the organization's default group — which governs everyone not
|
||||
* covered by a workspace group.
|
||||
*/
|
||||
export async function getUserPermissionConfigForOrganization(
|
||||
organizationId: string
|
||||
): Promise<PermissionGroupConfig | null> {
|
||||
if (!isHosted && !isAccessControlEnabled) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
|
||||
if (!isEnterprise) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
const resolved = await resolveDefaultGroup(organizationId)
|
||||
return mergeEnvAllowlist(resolved?.config ?? null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-aware wrapper around `getUserPermissionConfig`. When an
|
||||
* `ExecutionContext` is provided, the resolved config is memoized on the
|
||||
* context so repeated checks during a single workflow run share one DB hit.
|
||||
*/
|
||||
async function getPermissionConfig(
|
||||
userId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<PermissionGroupConfig | null> {
|
||||
if (!userId || !workspaceId) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
if (ctx) {
|
||||
if (ctx.permissionConfigLoaded) {
|
||||
return ctx.permissionConfig ?? null
|
||||
}
|
||||
|
||||
const config = await getUserPermissionConfig(userId, workspaceId)
|
||||
ctx.permissionConfig = config
|
||||
ctx.permissionConfigLoaded = true
|
||||
return config
|
||||
}
|
||||
|
||||
return getUserPermissionConfig(userId, workspaceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `model` appears in the group's model denylist. Comparison is
|
||||
* case-insensitive to match the normalization applied by `getProviderFromModel`.
|
||||
*/
|
||||
function isModelDenied(config: PermissionGroupConfig, model: string): boolean {
|
||||
if (!config.deniedModels || config.deniedModels.length === 0) {
|
||||
return false
|
||||
}
|
||||
const normalized = model.toLowerCase()
|
||||
return config.deniedModels.some((denied) => denied.toLowerCase() === normalized)
|
||||
}
|
||||
|
||||
export async function validateModelProvider(
|
||||
userId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
model: string,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (!userId || !workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, workspaceId, ctx)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.allowedModelProviders !== null) {
|
||||
const providerId = getProviderFromModel(model)
|
||||
|
||||
if (!config.allowedModelProviders.includes(providerId)) {
|
||||
logger.warn('Model provider blocked by permission group', {
|
||||
userId,
|
||||
workspaceId,
|
||||
model,
|
||||
providerId,
|
||||
})
|
||||
throw new ProviderNotAllowedError(providerId, model)
|
||||
}
|
||||
}
|
||||
|
||||
if (isModelDenied(config, model)) {
|
||||
logger.warn('Model blocked by permission group', { userId, workspaceId, model })
|
||||
throw new ModelNotAllowedError(model)
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateBlockType(
|
||||
userId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
blockType: string,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (isBlockTypeAccessControlExempt(blockType)) {
|
||||
return
|
||||
}
|
||||
|
||||
const config =
|
||||
userId && workspaceId
|
||||
? await getPermissionConfig(userId, workspaceId, ctx)
|
||||
: mergeEnvAllowlist(null)
|
||||
|
||||
if (!config || config.allowedIntegrations === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!config.allowedIntegrations.includes(blockType.toLowerCase())) {
|
||||
const envAllowlist = getAllowedIntegrationsFromEnv()
|
||||
const blockedByEnv = envAllowlist !== null && !envAllowlist.includes(blockType.toLowerCase())
|
||||
logger.warn(
|
||||
blockedByEnv
|
||||
? 'Integration blocked by env allowlist'
|
||||
: 'Integration blocked by permission group',
|
||||
{ userId, workspaceId, blockType }
|
||||
)
|
||||
throw new IntegrationNotAllowedError(
|
||||
blockType,
|
||||
blockedByEnv ? 'blocked by server ALLOWED_INTEGRATIONS policy' : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateMcpToolsAllowed(
|
||||
userId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (!userId || !workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, workspaceId, ctx)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.disableMcpTools) {
|
||||
logger.warn('MCP tools blocked by permission group', { userId, workspaceId })
|
||||
throw new McpToolsNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateCustomToolsAllowed(
|
||||
userId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (!userId || !workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, workspaceId, ctx)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.disableCustomTools) {
|
||||
logger.warn('Custom tools blocked by permission group', { userId, workspaceId })
|
||||
throw new CustomToolsNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateSkillsAllowed(
|
||||
userId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (!userId || !workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, workspaceId, ctx)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.disableSkills) {
|
||||
logger.warn('Skills blocked by permission group', { userId, workspaceId })
|
||||
throw new SkillsNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the user is allowed to send invitations. Pass one of:
|
||||
* - `workspaceId` — workspace-scoped invite: block when the user's governing group (explicit or
|
||||
* org default) for the workspace's organization has `disableInvitations`.
|
||||
* - `organizationId` — organization-level invite (no specific workspace target): block when the
|
||||
* user's group in that organization (explicit or the org default) has `disableInvitations`.
|
||||
* - neither — only the global feature flag is checked.
|
||||
*/
|
||||
export async function validateInvitationsAllowed(
|
||||
userId: string | undefined,
|
||||
scope: string | { workspaceId?: string; organizationId?: string } = {}
|
||||
): Promise<void> {
|
||||
if (isInvitationsDisabled) {
|
||||
logger.warn('Invitations blocked by feature flag')
|
||||
throw new InvitationsNotAllowedError()
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
const { workspaceId, organizationId } =
|
||||
typeof scope === 'string' ? { workspaceId: scope, organizationId: undefined } : scope
|
||||
|
||||
if (workspaceId) {
|
||||
const config = await getUserPermissionConfig(userId, workspaceId)
|
||||
if (config?.disableInvitations) {
|
||||
logger.warn('Invitations blocked by permission group', { userId, workspaceId })
|
||||
throw new InvitationsNotAllowedError()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (organizationId) {
|
||||
const config = await getUserPermissionConfigForOrganization(organizationId)
|
||||
if (config?.disableInvitations) {
|
||||
logger.warn('Invitations blocked by permission group (organization-wide)', {
|
||||
userId,
|
||||
organizationId,
|
||||
})
|
||||
throw new InvitationsNotAllowedError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the user is allowed to enable public API access on the given
|
||||
* workspace. Also checks the global feature flag. When `workspaceId` is
|
||||
* omitted only the feature-flag check runs (no permission-group gate).
|
||||
*/
|
||||
export async function validatePublicApiAllowed(
|
||||
userId: string | undefined,
|
||||
workspaceId?: string
|
||||
): Promise<void> {
|
||||
if (isPublicApiDisabled) {
|
||||
logger.warn('Public API blocked by feature flag')
|
||||
throw new PublicApiNotAllowedError()
|
||||
}
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getUserPermissionConfig(userId, workspaceId)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.disablePublicApi) {
|
||||
logger.warn('Public API blocked by permission group', { userId, workspaceId })
|
||||
throw new PublicApiNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
export type ToolKind = 'mcp' | 'custom' | 'skill'
|
||||
|
||||
interface PermissionAssertion {
|
||||
userId: string | undefined
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified entry point for workspace-scoped access control. Loads the user's
|
||||
* permission config for `workspaceId` once and runs every applicable gate
|
||||
* (model provider, block type, tool kind) against it, throwing the existing
|
||||
* granular error classes on the first mismatch.
|
||||
*
|
||||
* Prefer this over calling the individual `validate*Allowed` helpers when
|
||||
* gating a shared entry point like `executeTool` or an HTTP proxy, so a single
|
||||
* callsite covers every future config field.
|
||||
*/
|
||||
export async function assertPermissionsAllowed(req: PermissionAssertion): Promise<void> {
|
||||
const { userId, workspaceId, model, blockType, toolId, toolKind, ctx } = req
|
||||
|
||||
const blockTypeExempt = blockType ? isBlockTypeAccessControlExempt(blockType) : false
|
||||
|
||||
if (blockTypeExempt && !model && !toolKind && !toolId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config =
|
||||
userId && workspaceId
|
||||
? await getPermissionConfig(userId, workspaceId, ctx)
|
||||
: mergeEnvAllowlist(null)
|
||||
|
||||
if (model && config) {
|
||||
if (config.allowedModelProviders !== null) {
|
||||
const providerId = getProviderFromModel(model)
|
||||
if (!config.allowedModelProviders.includes(providerId)) {
|
||||
logger.warn('Model provider blocked by permission group', {
|
||||
userId,
|
||||
workspaceId,
|
||||
model,
|
||||
providerId,
|
||||
})
|
||||
throw new ProviderNotAllowedError(providerId, model)
|
||||
}
|
||||
}
|
||||
|
||||
if (isModelDenied(config, model)) {
|
||||
logger.warn('Model blocked by permission group', { userId, workspaceId, model })
|
||||
throw new ModelNotAllowedError(model)
|
||||
}
|
||||
}
|
||||
|
||||
if (blockType && !blockTypeExempt) {
|
||||
if (config && config.allowedIntegrations !== null) {
|
||||
if (!config.allowedIntegrations.includes(blockType.toLowerCase())) {
|
||||
const envAllowlist = getAllowedIntegrationsFromEnv()
|
||||
const blockedByEnv =
|
||||
envAllowlist !== null && !envAllowlist.includes(blockType.toLowerCase())
|
||||
logger.warn(
|
||||
blockedByEnv
|
||||
? 'Integration blocked by env allowlist'
|
||||
: 'Integration blocked by permission group',
|
||||
{ userId, workspaceId, blockType }
|
||||
)
|
||||
throw new IntegrationNotAllowedError(
|
||||
blockType,
|
||||
blockedByEnv ? 'blocked by server ALLOWED_INTEGRATIONS policy' : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
throw new McpToolsNotAllowedError()
|
||||
}
|
||||
if (toolKind === 'custom' && config.disableCustomTools) {
|
||||
logger.warn('Custom tools blocked by permission group', { userId, workspaceId })
|
||||
throw new CustomToolsNotAllowedError()
|
||||
}
|
||||
if (toolKind === 'skill' && config.disableSkills) {
|
||||
logger.warn('Skills blocked by permission group', { userId, workspaceId })
|
||||
throw new SkillsNotAllowedError()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user