chore: import upstream snapshot with attribution
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) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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()
}
}
}