diff --git a/apps/docs/content/docs/en/platform/permissions.mdx b/apps/docs/content/docs/en/platform/permissions.mdx index b5f1969c240..2f45f5c1a42 100644 --- a/apps/docs/content/docs/en/platform/permissions.mdx +++ b/apps/docs/content/docs/en/platform/permissions.mdx @@ -126,7 +126,7 @@ Here's a detailed breakdown of what users can do with each permission level: **What they can do:** - Everything Read users can do, plus: - Create, edit, and delete workflows -- Run and deploy workflows +- Run workflows - Add, edit, and delete workspace environment variables - Use all available tools and integrations - Collaborate in real-time on workflow editing @@ -140,6 +140,7 @@ Here's a detailed breakdown of what users can do with each permission level: **What they can do:** - Everything Write users can do, plus: +- Deploy workflows - Invite new users to the workspace with any permission level - Remove users from the workspace - Manage workspace settings and integrations @@ -254,4 +255,4 @@ import { FAQ } from '@/components/ui/faq' { question: "Who can manage a workspace's credentials and secrets?", answer: "Workspace Admins are automatically Credential Admins of the workspace's shared credentials — OAuth connections, service accounts, and workspace environment variables — so they can use, edit, delete, and share them, and run workflows that rely on them. Organization Owners and Admins get this too because they are workspace Admins everywhere. Read and Write members get use-only access to shared credentials unless they are explicitly made a Credential Admin. Personal environment variables are never shared; they stay private to their owner." }, { question: "What are permission groups and how do they work?", answer: "Permission groups are an Enterprise access control feature that lets organization owners and admins define granular restrictions beyond the standard Read/Write/Admin roles. The organization's default group is org-wide; every other group targets specific workspaces and, by default, governs all members of those workspaces (including external members) — add members to restrict it to specific people. A user is governed by one group per workspace: a group they're an explicit member of takes precedence over an all-members group (one with no members) on that workspace, which takes precedence over the organization's default group. A permission group can hide UI sections (like trace spans, knowledge base, API keys, or deployment options), disable features (MCP tools, custom tools, skills, invitations), and restrict which integrations and model providers its members can access. Only one group per organization can be the default; it ignores members and governs everyone not covered by a workspace group, including external members. Restrictions are enforced based on the organization that owns the workflow's workspace, not on which workspace you're currently viewing." }, { question: "How should I set up permissions for a new team member?", answer: "Start with the lowest permission level they need. Invite them with Read workspace access if they only need visibility, Write if they need to create and run workflows, or Admin if they need to manage the workspace and its users, and leave Membership on Member. For clients, partners, and contractors, choose External so they collaborate without joining your organization or using a seat — this requires them to already be on a paid Sim plan, either their own Pro or Max subscription or another organization that seats them." }, -]} /> \ No newline at end of file +]} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 8079fc40de6..4ccbf00f5a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -64,6 +64,7 @@ const TOOL_ICONS: Record = { research: Search, scout: Search, search: Search, + platform: Library, context_compaction: Asterisk, open_resource: Eye, file: File, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index e6d21c27765..cf2a46b1b7c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -189,6 +189,7 @@ export const SUBAGENT_LABELS: Record = { custom_tool: 'Custom Tool Agent', scout: 'Scout Agent', search: 'Search Agent', + platform: 'Platform Agent', superagent: 'Superagent', run: 'Run Agent', agent: 'Tools Agent', diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index f51c843ff60..a7961ba8949 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -30,6 +30,7 @@ import { useQueryState } from 'nuqs' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { ShareAuthType } from '@/lib/api/contracts/public-shares' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { PLATFORM_CATEGORY_ORDER, PLATFORM_FEATURES } from '@/lib/permission-groups/features' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { @@ -174,151 +175,6 @@ function AuthModeField({ label, value, onChange, options, disabled }: AuthModeFi ) } -/** Render order for the platform-feature category sections; unlisted ones follow. */ -const PLATFORM_CATEGORY_ORDER = [ - 'Sidebar', - 'Deploy Tabs', - 'Chat', - 'Collaboration', - 'Workflow Panel', - 'Tools', - 'Features', - 'Settings Tabs', - 'Logs', - 'Files', -] - -const PLATFORM_FEATURES = [ - { - id: 'hide-knowledge-base', - label: 'Knowledge Base', - category: 'Sidebar', - configKey: 'hideKnowledgeBaseTab' as const, - hint: 'Hide the Knowledge Base module from the sidebar.', - }, - { - id: 'hide-tables', - label: 'Tables', - category: 'Sidebar', - configKey: 'hideTablesTab' as const, - hint: 'Hide the Tables module from the sidebar.', - }, - { - id: 'hide-copilot', - label: 'Chat', - category: 'Workflow Panel', - configKey: 'hideCopilot' as const, - hint: 'Hide the Chat panel so users cannot build or edit with natural language.', - }, - { - id: 'hide-integrations', - label: 'Integrations', - category: 'Settings Tabs', - configKey: 'hideIntegrationsTab' as const, - hint: 'Hide the Integrations settings tab (OAuth connections).', - }, - { - id: 'hide-secrets', - label: 'Secrets', - category: 'Settings Tabs', - configKey: 'hideSecretsTab' as const, - hint: 'Hide the Secrets (environment variables) settings tab.', - }, - { - id: 'hide-api-keys', - label: 'API Keys', - category: 'Settings Tabs', - configKey: 'hideApiKeysTab' as const, - hint: 'Hide the API Keys settings tab.', - }, - { - id: 'hide-files', - label: 'Files', - category: 'Settings Tabs', - configKey: 'hideFilesTab' as const, - hint: 'Hide the Files settings tab.', - }, - { - id: 'hide-deploy-api', - label: 'API', - category: 'Deploy Tabs', - configKey: 'hideDeployApi' as const, - hint: 'Hide the API deployment option.', - }, - { - id: 'hide-deploy-mcp', - label: 'MCP', - category: 'Deploy Tabs', - configKey: 'hideDeployMcp' as const, - hint: 'Hide the MCP server deployment option.', - }, - { - id: 'disable-mcp', - label: 'MCP Tools', - category: 'Tools', - configKey: 'disableMcpTools' as const, - hint: 'Block agents from calling MCP tools.', - }, - { - id: 'disable-custom-tools', - label: 'Custom Tools', - category: 'Tools', - configKey: 'disableCustomTools' as const, - hint: 'Block agents from calling user-defined custom tools.', - }, - { - id: 'disable-skills', - label: 'Skills', - category: 'Tools', - configKey: 'disableSkills' as const, - hint: 'Block agents from loading skills.', - }, - { - id: 'hide-trace-spans', - label: 'Trace Spans', - category: 'Logs', - configKey: 'hideTraceSpans' as const, - hint: 'Hide per-block trace spans in logs.', - }, - { - id: 'disable-invitations', - label: 'Invitations', - category: 'Collaboration', - configKey: 'disableInvitations' as const, - hint: 'Prevent users from inviting others to workspaces.', - }, - { - id: 'hide-inbox', - label: 'Sim Mailer', - category: 'Features', - configKey: 'hideInboxTab' as const, - hint: 'Hide the Sim Mailer inbox.', - }, - { - id: 'disable-public-api', - label: 'Public API', - category: 'Features', - configKey: 'disablePublicApi' as const, - hint: 'Disable public API access to deployed workflows.', - }, - // Chat and Files get a category of their own so their nested auth-mode - // dropdown (see `featureExtras`) reads as part of the toggle it qualifies. - { - id: 'hide-deploy-chatbot', - label: 'Deployment', - category: 'Chat', - configKey: 'hideDeployChatbot' as const, - hint: 'Hide the chat deployment option.', - }, - { - id: 'disable-public-file-sharing', - label: 'Public Sharing', - category: 'Files', - configKey: 'disablePublicFileSharing' as const, - hint: 'Disable public file-share links.', - }, -] - interface OrganizationMemberOption { userId: string user: { @@ -954,7 +810,7 @@ export function GroupDetail({ }, [searchedPlatformFeatures, statusFilter, editingConfig]) const platformCategories = useMemo(() => { - const categories: Record = {} + const categories: Record = {} for (const feature of filteredPlatformFeatures) { if (!categories[feature.category]) { categories[feature.category] = [] diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index 8a9d502c80c..b5eaa610496 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -84,6 +84,8 @@ import { ModelNotAllowedError, ProviderNotAllowedError, PublicFileSharingNotAllowedError, + resolveUserAccessControlContext, + resolveVerifiedUserAccessControlContext, SkillsNotAllowedError, ToolNotAllowedError, validateBlockType, @@ -229,6 +231,126 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { }) }) +describe('resolveUserAccessControlContext', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) + }) + + it('describes a personal workspace without changing the config-only result', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: null }) + + await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({ + organizationId: null, + entitled: false, + permissionGroup: null, + config: null, + }) + await expect(getUserPermissionConfig('user-123', 'workspace-1')).resolves.toBeNull() + }) + + it('returns the explicit governing group and its effective config', async () => { + setEnterpriseOrgWorkspace() + queueGroupResolution([ + { + id: 'group-explicit', + name: 'Engineering', + config: { disableMcpTools: true }, + isMember: true, + hasMembers: true, + }, + ]) + + await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({ + organizationId: 'org-1', + entitled: true, + permissionGroup: { + id: 'group-explicit', + name: 'Engineering', + resolution: 'explicit-member', + }, + config: expect.objectContaining({ disableMcpTools: true }), + }) + }) + + it('identifies an all-members governing group', async () => { + setEnterpriseOrgWorkspace() + queueGroupResolution([ + { + id: 'group-all-members', + name: 'All workspace members', + config: { disableCustomTools: true }, + isMember: false, + hasMembers: false, + }, + ]) + + const context = await resolveUserAccessControlContext('user-123', 'workspace-1') + + expect(context.permissionGroup).toEqual({ + id: 'group-all-members', + name: 'All workspace members', + resolution: 'all-members', + }) + }) + + it('uses a verified workspace organization without loading the workspace again', async () => { + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) + queueGroupResolution([ + { + id: 'group-verified', + name: 'Verified group', + config: { disableSkills: true }, + isMember: true, + hasMembers: true, + }, + ]) + + const context = await resolveVerifiedUserAccessControlContext( + 'user-123', + 'workspace-1', + 'org-verified' + ) + + expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled() + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-verified') + expect(context).toMatchObject({ + organizationId: 'org-verified', + entitled: true, + permissionGroup: { + id: 'group-verified', + resolution: 'explicit-member', + }, + config: { disableSkills: true }, + }) + }) + + it('identifies the default group and preserves the environment allowlist', async () => { + setEnterpriseOrgWorkspace() + mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack']) + queueGroupResolution( + [], + [ + { + id: 'group-default', + name: 'Organization default', + config: { allowedIntegrations: ['slack', 'github'] }, + }, + ] + ) + + const context = await resolveUserAccessControlContext('user-123', 'workspace-1') + + expect(context.permissionGroup).toEqual({ + id: 'group-default', + name: 'Organization default', + resolution: 'default', + }) + expect(context.config?.allowedIntegrations).toEqual(['slack']) + }) +}) + describe('getUserPermissionConfig (workspace-group precedence)', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index d83b16e8f62..0afd24ed462 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -141,9 +141,30 @@ function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGrou export interface ResolvedPermissionGroup { permissionGroupId: string groupName: string + resolution: 'explicit-member' | 'all-members' | 'default' config: PermissionGroupConfig } +export interface UserAccessControlContext { + organizationId: string | null + entitled: boolean + permissionGroup: { + id: string + name: string + resolution: ResolvedPermissionGroup['resolution'] + } | null + config: PermissionGroupConfig | null +} + +function inactiveUserAccessControlContext(organizationId: string | null): UserAccessControlContext { + return { + organizationId, + entitled: false, + permissionGroup: null, + config: mergeEnvAllowlist(null), + } +} + /** The organization's single default group (`isDefault`), or `null`. */ async function resolveDefaultGroup( organizationId: string @@ -167,6 +188,7 @@ async function resolveDefaultGroup( return { permissionGroupId: defaultGroup.id, groupName: defaultGroup.name, + resolution: 'default', config: parsePermissionGroupConfig(defaultGroup.config), } } @@ -222,12 +244,14 @@ export async function resolveWorkspaceGroup( ) .orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id)) - const winner = rows.find((row) => row.isMember) ?? rows.find((row) => !row.hasMembers) + const explicitMemberGroup = rows.find((row) => row.isMember) + const winner = explicitMemberGroup ?? rows.find((row) => !row.hasMembers) if (winner) { return { permissionGroupId: winner.id, groupName: winner.name, + resolution: explicitMemberGroup ? 'explicit-member' : 'all-members', config: parsePermissionGroupConfig(winner.config), } } @@ -246,26 +270,70 @@ export async function resolveWorkspaceGroup( * The env-level integration allowlist is always merged last so self-hosted * deployments can constrain integrations without touching the DB. */ -export async function getUserPermissionConfig( +async function resolveUserAccessControlContextForOrganization( userId: string, - workspaceId: string -): Promise { - if (!isHosted && !isAccessControlEnabled) { - return mergeEnvAllowlist(null) + workspaceId: string, + organizationId: string | null +): Promise { + if (!organizationId) return inactiveUserAccessControlContext(null) + + const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId) + if (!isEnterprise) { + return inactiveUserAccessControlContext(organizationId) } - const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) - if (!ws?.organizationId) { - return mergeEnvAllowlist(null) + const resolved = await resolveWorkspaceGroup(userId, organizationId, workspaceId) + return { + organizationId, + entitled: true, + permissionGroup: resolved + ? { + id: resolved.permissionGroupId, + name: resolved.groupName, + resolution: resolved.resolution, + } + : null, + config: mergeEnvAllowlist(resolved?.config ?? null), } +} - const isEnterprise = await isOrganizationOnEnterprisePlan(ws.organizationId) - if (!isEnterprise) { - return mergeEnvAllowlist(null) +/** + * Resolves Access Control from an organization ID obtained from an already + * access-checked workspace. This function does not independently authorize the + * user for the workspace; callers must establish that boundary first. + */ +export async function resolveVerifiedUserAccessControlContext( + userId: string, + workspaceId: string, + organizationId: string | null +): Promise { + if (!isHosted && !isAccessControlEnabled) { + return inactiveUserAccessControlContext(null) } + return resolveUserAccessControlContextForOrganization(userId, workspaceId, organizationId) +} - const resolved = await resolveWorkspaceGroup(userId, ws.organizationId, workspaceId) - return mergeEnvAllowlist(resolved?.config ?? null) +export async function resolveUserAccessControlContext( + userId: string, + workspaceId: string +): Promise { + if (!isHosted && !isAccessControlEnabled) { + return inactiveUserAccessControlContext(null) + } + + const workspace = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) + return resolveUserAccessControlContextForOrganization( + userId, + workspaceId, + workspace?.organizationId ?? null + ) +} + +export async function getUserPermissionConfig( + userId: string, + workspaceId: string +): Promise { + return (await resolveUserAccessControlContext(userId, workspaceId)).config } /** diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 5210e774c75..aaa1b836caa 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -263,6 +263,8 @@ export const workspaceHostContextSchema = z.object({ permission: workspacePermissionSchema, isHostOrganizationMember: z.boolean(), isHostOrganizationAdmin: z.boolean(), + /** Optional for rolling compatibility with app versions that predate organization-role projection. */ + organizationRole: z.string().nullable().optional(), }), }) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 92f71cc1edb..f2add9d35cb 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -56,10 +56,12 @@ export interface ToolCatalogEntry { | 'generate_audio' | 'generate_image' | 'generate_video' + | 'get_account_billing' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_log' + | 'get_enterprise_context' | 'get_page_contents' | 'get_workflow_data' | 'get_workflow_run_options' @@ -85,6 +87,7 @@ export interface ToolCatalogEntry { | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'platform' | 'promote_to_live' | 'query_logs' | 'query_user_table' @@ -171,10 +174,12 @@ export interface ToolCatalogEntry { | 'generate_audio' | 'generate_image' | 'generate_video' + | 'get_account_billing' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_log' + | 'get_enterprise_context' | 'get_page_contents' | 'get_workflow_data' | 'get_workflow_run_options' @@ -200,6 +205,7 @@ export interface ToolCatalogEntry { | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'platform' | 'promote_to_live' | 'query_logs' | 'query_user_table' @@ -247,6 +253,7 @@ export interface ToolCatalogEntry { | 'file' | 'knowledge' | 'media' + | 'platform' | 'run' | 'search' | 'table' @@ -2910,6 +2917,14 @@ export const GenerateVideo: ToolCatalogEntry = { capabilities: ['file_input', 'file_output', 'generated_media'], } +export const GetAccountBilling: ToolCatalogEntry = { + id: 'get_account_billing', + name: 'get_account_billing', + route: 'sim', + mode: 'async', + parameters: { type: 'object', properties: {} }, +} + export const GetBlockOutputs: ToolCatalogEntry = { id: 'get_block_outputs', name: 'get_block_outputs', @@ -2987,6 +3002,14 @@ export const GetDeploymentLog: ToolCatalogEntry = { }, } +export const GetEnterpriseContext: ToolCatalogEntry = { + id: 'get_enterprise_context', + name: 'get_enterprise_context', + route: 'sim', + mode: 'async', + parameters: { type: 'object', properties: {} }, +} + export const GetPageContents: ToolCatalogEntry = { id: 'get_page_contents', name: 'get_page_contents', @@ -3837,6 +3860,26 @@ export const OpenResource: ToolCatalogEntry = { }, } +export const Platform: ToolCatalogEntry = { + id: 'platform', + name: 'platform', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: + "A fully self-contained question about Sim — the platform agent sees none of this conversation, so include every name, id, constraint, and prior finding it needs. Example: 'what is the minimum schedule-trigger interval, and does it differ by plan?' or 'does the agent block persist memory across runs?'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'platform', + internal: true, +} + export const PromoteToLive: ToolCatalogEntry = { id: 'promote_to_live', name: 'promote_to_live', @@ -5920,10 +5963,12 @@ export const TOOL_CATALOG: Record = { [GenerateAudio.id]: GenerateAudio, [GenerateImage.id]: GenerateImage, [GenerateVideo.id]: GenerateVideo, + [GetAccountBilling.id]: GetAccountBilling, [GetBlockOutputs.id]: GetBlockOutputs, [GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences, [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, [GetDeploymentLog.id]: GetDeploymentLog, + [GetEnterpriseContext.id]: GetEnterpriseContext, [GetPageContents.id]: GetPageContents, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, @@ -5949,6 +5994,7 @@ export const TOOL_CATALOG: Record = { [OauthGetAuthLink.id]: OauthGetAuthLink, [OauthRequestAccess.id]: OauthRequestAccess, [OpenResource.id]: OpenResource, + [Platform.id]: Platform, [PromoteToLive.id]: PromoteToLive, [QueryLogs.id]: QueryLogs, [QueryUserTable.id]: QueryUserTable, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 187d5751dd5..96c999e00cf 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2821,6 +2821,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + get_account_billing: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, get_block_outputs: { parameters: { type: 'object', @@ -2890,6 +2897,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + get_enterprise_context: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, get_page_contents: { parameters: { type: 'object', @@ -3708,6 +3722,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + platform: { + parameters: { + properties: { + task: { + description: + "A fully self-contained question about Sim — the platform agent sees none of this conversation, so include every name, id, constraint, and prior finding it needs. Example: 'what is the minimum schedule-trigger interval, and does it differ by plan?' or 'does the agent block persist memory across runs?'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, promote_to_live: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index b5b9768ac14..91c74be519b 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -12,10 +12,12 @@ import { DiffWorkflows, FunctionExecute, GenerateApiKey, + GetAccountBilling, GetBlockOutputs, GetBlockUpstreamReferences, GetDeployedWorkflowState, GetDeploymentLog, + GetEnterpriseContext, GetWorkflowData, GetWorkflowRunOptions, Glob as GlobTool, @@ -50,8 +52,10 @@ import { UpdateDeploymentVersion, UpdateWorkspaceMcpServer, } from '@/lib/copilot/generated/tool-catalog-v1' +import { executeGetEnterpriseContext } from '@/lib/copilot/tools/handlers/enterprise-context' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' +import { executeGetAccountBilling } from '../tools/handlers/account' import { executeDeployCustomBlock } from '../tools/handlers/deployment/custom-block' import { executeDeployApi, @@ -134,6 +138,8 @@ function h(fn: (params: any, context: any) => Promise): ToolHandler { function buildHandlerMap(): Record { return { [ListUserWorkspaces.id]: h((_p, c) => executeListUserWorkspaces(c)), + [GetAccountBilling.id]: h((_p, c) => executeGetAccountBilling(c)), + [GetEnterpriseContext.id]: h((_p, c) => executeGetEnterpriseContext(c)), [GetWorkflowData.id]: h(executeGetWorkflowData), [GetWorkflowRunOptions.id]: h(executeGetWorkflowRunOptions), [GetBlockOutputs.id]: h(executeGetBlockOutputs), diff --git a/apps/sim/lib/copilot/tools/handlers/account.test.ts b/apps/sim/lib/copilot/tools/handlers/account.test.ts new file mode 100644 index 00000000000..e9a75f603f2 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/account.test.ts @@ -0,0 +1,121 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserUsageData, mockGetCreditBalance, mockGetUserUsageLimitInfo } = vi.hoisted( + () => ({ + mockGetUserUsageData: vi.fn(), + mockGetCreditBalance: vi.fn(), + mockGetUserUsageLimitInfo: vi.fn(), + }) +) + +vi.mock('@/lib/billing', () => ({ + getUserUsageData: mockGetUserUsageData, + getCreditBalance: mockGetCreditBalance, + getUserUsageLimitInfo: mockGetUserUsageLimitInfo, +})) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeGetAccountBilling } from '@/lib/copilot/tools/handlers/account' + +const context = { userId: 'user-1' } as ExecutionContext + +describe('executeGetAccountBilling', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the org-aware plan, usage, and credit snapshot', async () => { + const periodEnd = new Date('2026-09-01T00:00:00Z') + mockGetUserUsageData.mockResolvedValue({ + currentUsage: 18.5, + limit: 40, + percentUsed: 46.25, + isWarning: false, + isExceeded: false, + billingPeriodStart: new Date('2026-08-01T00:00:00Z'), + billingPeriodEnd: periodEnd, + lastPeriodCost: 31, + }) + mockGetCreditBalance.mockResolvedValue({ + balance: 25, + entityType: 'organization', + entityId: 'org-1', + }) + mockGetUserUsageLimitInfo.mockResolvedValue({ + currentLimit: 40, + canEdit: false, + minimumLimit: 0, + plan: 'team', + updatedAt: null, + scope: 'organization', + organizationId: 'org-1', + }) + + const result = await executeGetAccountBilling(context) + + expect(mockGetUserUsageData).toHaveBeenCalledWith('user-1') + expect(mockGetCreditBalance).toHaveBeenCalledWith('user-1') + expect(mockGetUserUsageLimitInfo).toHaveBeenCalledWith('user-1') + expect(result).toEqual({ + success: true, + output: { + plan: 'team', + billingScope: 'organization', + organizationId: 'org-1', + usage: { + currentPeriodCost: 18.5, + limit: 40, + remaining: 21.5, + percentUsed: 46.25, + isExceeded: false, + billingPeriodEnd: periodEnd, + }, + credits: { balance: 25, scope: 'organization' }, + }, + }) + }) + + it('clamps remaining to zero when usage exceeds the limit', async () => { + mockGetUserUsageData.mockResolvedValue({ + currentUsage: 45, + limit: 40, + percentUsed: 112.5, + isWarning: false, + isExceeded: true, + billingPeriodStart: null, + billingPeriodEnd: null, + lastPeriodCost: 0, + }) + mockGetCreditBalance.mockResolvedValue({ balance: 0, entityType: 'user', entityId: 'user-1' }) + mockGetUserUsageLimitInfo.mockResolvedValue({ + currentLimit: 40, + canEdit: true, + minimumLimit: 0, + plan: 'pro', + updatedAt: null, + scope: 'user', + organizationId: null, + }) + + const result = await executeGetAccountBilling(context) + + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + plan: 'pro', + usage: { remaining: 0, isExceeded: true }, + }) + }) + + it('surfaces a billing lookup failure as a tool error', async () => { + mockGetUserUsageData.mockRejectedValue(new Error('stats row missing')) + mockGetCreditBalance.mockResolvedValue({ balance: 0, entityType: 'user', entityId: 'user-1' }) + mockGetUserUsageLimitInfo.mockResolvedValue({}) + + const result = await executeGetAccountBilling(context) + + expect(result).toEqual({ success: false, error: 'stats row missing' }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/account.ts b/apps/sim/lib/copilot/tools/handlers/account.ts new file mode 100644 index 00000000000..ec3b3440f5a --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/account.ts @@ -0,0 +1,43 @@ +import { toError } from '@sim/utils/errors' +import { getCreditBalance, getUserUsageData, getUserUsageLimitInfo } from '@/lib/billing' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' + +/** + * Live billing snapshot for the requesting user: plan, current-period usage + * against its limit, and purchased credit balance. All three sources are + * org-aware — a member whose subscription lives on an organization gets the + * org's plan, limit, and credit pool, with `billingScope`/`organizationId` + * saying which applied. + */ +export async function executeGetAccountBilling(context: ExecutionContext): Promise { + try { + const [usage, credits, limitInfo] = await Promise.all([ + getUserUsageData(context.userId), + getCreditBalance(context.userId), + getUserUsageLimitInfo(context.userId), + ]) + + return { + success: true, + output: { + plan: limitInfo.plan, + billingScope: limitInfo.scope, + organizationId: limitInfo.organizationId, + usage: { + currentPeriodCost: usage.currentUsage, + limit: usage.limit, + remaining: Math.max(0, usage.limit - usage.currentUsage), + percentUsed: usage.percentUsed, + isExceeded: usage.isExceeded, + billingPeriodEnd: usage.billingPeriodEnd, + }, + credits: { + balance: credits.balance, + scope: credits.entityType, + }, + }, + } + } catch (error) { + return { success: false, error: toError(error).message } + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts new file mode 100644 index 00000000000..a6d257e2326 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts @@ -0,0 +1,285 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetWorkspaceHostContextForViewer, mockResolveVerifiedUserAccessControlContext } = + vi.hoisted(() => ({ + mockGetWorkspaceHostContextForViewer: vi.fn(), + mockResolveVerifiedUserAccessControlContext: vi.fn(), + })) + +vi.mock('@/lib/workspaces/host-context', () => ({ + getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + resolveVerifiedUserAccessControlContext: mockResolveVerifiedUserAccessControlContext, +})) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeGetEnterpriseContext } from '@/lib/copilot/tools/handlers/enterprise-context' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' + +const context = { + userId: 'user-1', + workspaceId: 'workspace-1', +} as ExecutionContext + +function enterpriseHost(permission: 'read' | 'write' | 'admin') { + return { + workspace: { + id: 'workspace-1', + name: 'Customer Support', + workspaceMode: 'collaborative', + billedAccountUserId: 'owner-1', + }, + hostOrganizationId: 'org-1', + ownerBilling: { + plan: 'enterprise', + status: 'active', + isPaid: true, + isPro: true, + isTeam: true, + isEnterprise: true, + isOrgScoped: true, + organizationId: 'org-1', + billingInterval: 'year', + billingBlocked: false, + billingBlockedReason: null, + }, + viewer: { + permission, + isHostOrganizationMember: false, + isHostOrganizationAdmin: false, + organizationRole: null, + }, + } +} + +describe('executeGetEnterpriseContext', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('requires a current workspace', async () => { + const result = await executeGetEnterpriseContext({ userId: 'user-1' } as ExecutionContext) + + expect(result).toEqual({ + success: false, + error: 'A current workspace is required to resolve enterprise access.', + }) + expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled() + }) + + it('keeps external workspace administration separate from organization authority', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('admin')) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: 'org-1', + entitled: true, + permissionGroup: { + id: 'group-1', + name: 'Contractors', + resolution: 'all-members', + }, + config: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['slack'], + deniedTools: ['slack_delete_message'], + disableMcpTools: true, + disableInvitations: true, + }, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(mockResolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'org-1' + ) + expect(result).toMatchObject({ + success: true, + output: { + workspace: { + id: 'workspace-1', + permission: 'admin', + capabilities: { + canRead: true, + canEdit: true, + canRun: true, + canDeploy: true, + canManageWorkspace: true, + }, + }, + organization: { + id: 'org-1', + relationship: 'external', + role: null, + canManageOrganization: false, + canManageBilling: false, + plan: 'enterprise', + isEnterprise: true, + }, + accessControl: { + entitled: true, + governingPermissionGroup: { + id: 'group-1', + name: 'Contractors', + resolution: 'all-members', + }, + effectiveConfig: expect.objectContaining({ disableMcpTools: true }), + activeRestrictions: expect.arrayContaining([ + expect.objectContaining({ key: 'allowedIntegrations' }), + expect.objectContaining({ key: 'deniedTools' }), + expect.objectContaining({ key: 'disableMcpTools' }), + expect.objectContaining({ key: 'disableInvitations' }), + ]), + }, + }, + }) + }) + + it('reports an internal member role without granting organization administration', async () => { + const host = enterpriseHost('write') + mockGetWorkspaceHostContextForViewer.mockResolvedValue({ + ...host, + viewer: { + ...host.viewer, + isHostOrganizationMember: true, + organizationRole: 'member', + }, + }) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: 'org-1', + entitled: true, + permissionGroup: null, + config: null, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toMatchObject({ + success: true, + output: { + workspace: { + permission: 'write', + capabilities: { + canRead: true, + canEdit: true, + canRun: true, + canDeploy: false, + canManageWorkspace: false, + }, + }, + organization: { + relationship: 'internal', + role: 'member', + canManageOrganization: false, + canManageBilling: false, + }, + }, + }) + }) + + it('reports read access without write, run, deployment, or administration capabilities', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('read')) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: 'org-1', + entitled: true, + permissionGroup: null, + config: null, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toMatchObject({ + success: true, + output: { + workspace: { + permission: 'read', + capabilities: { + canRead: true, + canEdit: false, + canRun: false, + canDeploy: false, + canManageWorkspace: false, + }, + }, + }, + }) + }) + + it('returns a personal-workspace context without looking up organization membership', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue({ + ...enterpriseHost('write'), + hostOrganizationId: null, + ownerBilling: { + ...enterpriseHost('write').ownerBilling, + plan: 'pro', + isEnterprise: false, + isOrgScoped: false, + organizationId: null, + }, + }) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: null, + entitled: false, + permissionGroup: null, + config: null, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toMatchObject({ + success: true, + output: { + workspace: { permission: 'write' }, + organization: null, + accessControl: { + entitled: false, + governingPermissionGroup: null, + effectiveConfig: null, + activeRestrictions: [], + }, + }, + }) + expect(mockResolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + null + ) + }) + + it('does not expose enterprise context when workspace access cannot be resolved', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(null) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toEqual({ + success: false, + error: 'Workspace not found or you do not have access.', + }) + expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() + }) + + it('returns a failure when workspace context resolution fails', async () => { + mockGetWorkspaceHostContextForViewer.mockRejectedValue(new Error('workspace lookup failed')) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toEqual({ success: false, error: 'workspace lookup failed' }) + expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() + }) + + it('returns a failure when access-control resolution fails', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('write')) + mockResolveVerifiedUserAccessControlContext.mockRejectedValue( + new Error('access-control lookup failed') + ) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toEqual({ success: false, error: 'access-control lookup failed' }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts new file mode 100644 index 00000000000..ece309369d7 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts @@ -0,0 +1,94 @@ +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { toError } from '@sim/utils/errors' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' +import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check' + +const ENTERPRISE_PERMISSION_DOCUMENTATION = [ + { + title: 'Roles and permissions', + path: 'docs/platform/permissions.mdx', + url: 'https://docs.sim.ai/platform/permissions', + }, + { + title: 'Enterprise Access Control', + path: 'docs/platform/enterprise/access-control.mdx', + url: 'https://docs.sim.ai/platform/enterprise/access-control', + }, +] as const + +/** + * Resolves the authenticated user's effective Enterprise access in the current + * workspace. This is an explanatory snapshot; every later mutation must still + * perform its normal server-side authorization at execution time. + */ +export async function executeGetEnterpriseContext( + context: ExecutionContext +): Promise { + if (!context.workspaceId) { + return { + success: false, + error: 'A current workspace is required to resolve enterprise access.', + } + } + + try { + const hostContext = await getWorkspaceHostContextForViewer(context.workspaceId, context.userId) + if (!hostContext) { + return { + success: false, + error: 'Workspace not found or you do not have access.', + } + } + + const accessControl = await resolveVerifiedUserAccessControlContext( + context.userId, + context.workspaceId, + hostContext.hostOrganizationId + ) + + const canWrite = permissionSatisfies(hostContext.viewer.permission, 'write') + const canAdmin = permissionSatisfies(hostContext.viewer.permission, 'admin') + + return { + success: true, + output: { + workspace: { + id: hostContext.workspace.id, + name: hostContext.workspace.name, + mode: hostContext.workspace.workspaceMode, + permission: hostContext.viewer.permission, + capabilities: { + canRead: true, + canEdit: canWrite, + canRun: canWrite, + canDeploy: canAdmin, + canManageWorkspace: canAdmin, + }, + }, + organization: hostContext.hostOrganizationId + ? { + id: hostContext.hostOrganizationId, + relationship: hostContext.viewer.isHostOrganizationMember ? 'internal' : 'external', + role: hostContext.viewer.organizationRole ?? null, + canManageOrganization: hostContext.viewer.isHostOrganizationAdmin, + canManageBilling: hostContext.viewer.isHostOrganizationAdmin, + plan: hostContext.ownerBilling.plan, + isEnterprise: hostContext.ownerBilling.isEnterprise, + } + : null, + accessControl: { + entitled: accessControl.entitled, + governingPermissionGroup: accessControl.permissionGroup, + effectiveConfig: accessControl.config, + activeRestrictions: getActivePermissionGroupRestrictions(accessControl.config), + }, + documentation: ENTERPRISE_PERMISSION_DOCUMENTATION, + resolvedAt: new Date().toISOString(), + }, + } + } catch (error) { + return { success: false, error: toError(error).message } + } +} diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 681e555f92d..4ccd0ee4bc9 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -76,6 +76,7 @@ describe('getToolDisplayTitle natural-language coverage', () => { expect(getToolDisplayTitle('list_workspace_mcp_servers')).toBe('Listing MCP servers') expect(getToolDisplayTitle('oauth_get_auth_link')).toBe('Getting authorization link') expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows') + expect(getToolDisplayTitle('get_enterprise_context')).toBe('Checking enterprise access') }) it('includes the query in search_docs titles', () => { diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 93637695838..460d3d7bf4f 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -469,10 +469,12 @@ const TOOL_TITLES: Record = { function_execute: 'Running code', complete_scheduled_task: 'Completing scheduled task', generate_api_key: 'Generating API key', + get_account_billing: 'Checking plan and usage', get_block_outputs: 'Getting block outputs', get_block_upstream_references: 'Getting block references', get_deployed_workflow_state: 'Getting deployed workflow', get_deployment_log: 'Getting deployment logs', + get_enterprise_context: 'Checking enterprise access', get_platform_actions: 'Getting platform actions', get_scheduled_task_logs: 'Reading scheduled task logs', get_workflow_data: 'Getting workflow data', @@ -531,6 +533,7 @@ const TOOL_TITLES: Record = { research: 'Research Agent', scout: 'Scout Agent', search: 'Search Agent', + platform: 'Platform Agent', file: 'File Agent', media: 'Media Agent', browser: 'Browser Agent', diff --git a/apps/sim/lib/permission-groups/features.test.ts b/apps/sim/lib/permission-groups/features.test.ts new file mode 100644 index 00000000000..a22b5474004 --- /dev/null +++ b/apps/sim/lib/permission-groups/features.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { + getActivePermissionGroupRestrictions, + PLATFORM_FEATURES, +} from '@/lib/permission-groups/features' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + type PermissionGroupConfig, +} from '@/lib/permission-groups/types' + +describe('getActivePermissionGroupRestrictions', () => { + it('returns no restrictions for an absent or unrestricted config', () => { + expect(getActivePermissionGroupRestrictions(null)).toEqual([]) + expect(getActivePermissionGroupRestrictions(DEFAULT_PERMISSION_GROUP_CONFIG)).toEqual([]) + }) + + it.each([ + { + key: 'allowedIntegrations', + emptyValue: [], + limitedValue: ['slack'], + emptyDescription: 'No non-exempt integrations or blocks are allowed.', + limitedDescription: + 'Integrations and blocks are limited to effectiveConfig.allowedIntegrations.', + }, + { + key: 'allowedModelProviders', + emptyValue: [], + limitedValue: ['openai'], + emptyDescription: 'No model providers are allowed.', + limitedDescription: 'Model providers are limited to effectiveConfig.allowedModelProviders.', + }, + { + key: 'allowedFileShareAuthTypes', + emptyValue: [], + limitedValue: ['password'], + emptyDescription: 'No public file-share authentication modes are allowed.', + limitedDescription: + 'Public file-share authentication is limited to effectiveConfig.allowedFileShareAuthTypes.', + }, + { + key: 'allowedChatDeployAuthTypes', + emptyValue: [], + limitedValue: ['sso'], + emptyDescription: 'No chat deployment authentication modes are allowed.', + limitedDescription: + 'Chat deployment authentication is limited to effectiveConfig.allowedChatDeployAuthTypes.', + }, + ] as const)( + 'describes empty and limited $key allowlists', + ({ key, emptyValue, limitedValue, emptyDescription, limitedDescription }) => { + const emptyConfig = { ...DEFAULT_PERMISSION_GROUP_CONFIG, [key]: emptyValue } + const limitedConfig = { ...DEFAULT_PERMISSION_GROUP_CONFIG, [key]: limitedValue } + + expect(getActivePermissionGroupRestrictions(emptyConfig)).toEqual([ + { key, description: emptyDescription }, + ]) + expect(getActivePermissionGroupRestrictions(limitedConfig)).toEqual([ + { key, description: limitedDescription }, + ]) + } + ) + + it.each([ + { + key: 'deniedModels', + value: ['gpt-4o'], + description: 'Models listed in effectiveConfig.deniedModels are blocked.', + }, + { + key: 'deniedTools', + value: ['slack_delete_message'], + description: 'Integration tools listed in effectiveConfig.deniedTools are blocked.', + }, + ] as const)('describes a populated $key denylist', ({ key, value, description }) => { + const config = { ...DEFAULT_PERMISSION_GROUP_CONFIG, [key]: value } + + expect(getActivePermissionGroupRestrictions(config)).toEqual([{ key, description }]) + }) + + it.each(PLATFORM_FEATURES)( + 'uses the shared prose for $configKey when enabled', + ({ configKey, hint }) => { + const config: PermissionGroupConfig = { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + [configKey]: true, + } + + expect(getActivePermissionGroupRestrictions(config)).toEqual([ + { key: configKey, description: hint }, + ]) + } + ) +}) diff --git a/apps/sim/lib/permission-groups/features.ts b/apps/sim/lib/permission-groups/features.ts new file mode 100644 index 00000000000..78aa4f79545 --- /dev/null +++ b/apps/sim/lib/permission-groups/features.ts @@ -0,0 +1,228 @@ +import type { PermissionGroupConfig } from '@/lib/permission-groups/types' + +type BooleanPermissionGroupConfigKey = { + [Key in keyof PermissionGroupConfig]: PermissionGroupConfig[Key] extends boolean ? Key : never +}[keyof PermissionGroupConfig] + +export interface PermissionGroupPlatformFeature { + id: string + label: string + category: string + configKey: BooleanPermissionGroupConfigKey + hint: string +} + +export interface ActivePermissionGroupRestriction { + key: keyof PermissionGroupConfig + description: string +} + +/** Render order for the platform-feature category sections; unlisted ones follow. */ +export const PLATFORM_CATEGORY_ORDER: readonly string[] = [ + 'Sidebar', + 'Deploy Tabs', + 'Chat', + 'Collaboration', + 'Workflow Panel', + 'Tools', + 'Features', + 'Settings Tabs', + 'Logs', + 'Files', +] as const + +/** User-facing descriptions shared by the Access Control editor and live permission context. */ +export const PLATFORM_FEATURES = [ + { + id: 'hide-knowledge-base', + label: 'Knowledge Base', + category: 'Sidebar', + configKey: 'hideKnowledgeBaseTab', + hint: 'Hide the Knowledge Base module from the sidebar.', + }, + { + id: 'hide-tables', + label: 'Tables', + category: 'Sidebar', + configKey: 'hideTablesTab', + hint: 'Hide the Tables module from the sidebar.', + }, + { + id: 'hide-copilot', + label: 'Chat', + category: 'Workflow Panel', + configKey: 'hideCopilot', + hint: 'Hide the Chat panel so users cannot build or edit with natural language.', + }, + { + id: 'hide-integrations', + label: 'Integrations', + category: 'Settings Tabs', + configKey: 'hideIntegrationsTab', + hint: 'Hide the Integrations settings tab (OAuth connections).', + }, + { + id: 'hide-secrets', + label: 'Secrets', + category: 'Settings Tabs', + configKey: 'hideSecretsTab', + hint: 'Hide the Secrets (environment variables) settings tab.', + }, + { + id: 'hide-api-keys', + label: 'API Keys', + category: 'Settings Tabs', + configKey: 'hideApiKeysTab', + hint: 'Hide the API Keys settings tab.', + }, + { + id: 'hide-files', + label: 'Files', + category: 'Settings Tabs', + configKey: 'hideFilesTab', + hint: 'Hide the Files settings tab.', + }, + { + id: 'hide-deploy-api', + label: 'API', + category: 'Deploy Tabs', + configKey: 'hideDeployApi', + hint: 'Hide the API deployment option.', + }, + { + id: 'hide-deploy-mcp', + label: 'MCP', + category: 'Deploy Tabs', + configKey: 'hideDeployMcp', + hint: 'Hide the MCP server deployment option.', + }, + { + id: 'disable-mcp', + label: 'MCP Tools', + category: 'Tools', + configKey: 'disableMcpTools', + hint: 'Block agents from calling MCP tools.', + }, + { + id: 'disable-custom-tools', + label: 'Custom Tools', + category: 'Tools', + configKey: 'disableCustomTools', + hint: 'Block agents from calling user-defined custom tools.', + }, + { + id: 'disable-skills', + label: 'Skills', + category: 'Tools', + configKey: 'disableSkills', + hint: 'Block agents from loading skills.', + }, + { + id: 'hide-trace-spans', + label: 'Trace Spans', + category: 'Logs', + configKey: 'hideTraceSpans', + hint: 'Hide per-block trace spans in logs.', + }, + { + id: 'disable-invitations', + label: 'Invitations', + category: 'Collaboration', + configKey: 'disableInvitations', + hint: 'Prevent users from inviting others to workspaces.', + }, + { + id: 'hide-inbox', + label: 'Sim Mailer', + category: 'Features', + configKey: 'hideInboxTab', + hint: 'Hide the Sim Mailer inbox.', + }, + { + id: 'disable-public-api', + label: 'Public API', + category: 'Features', + configKey: 'disablePublicApi', + hint: 'Disable public API access to deployed workflows.', + }, + { + id: 'hide-deploy-chatbot', + label: 'Deployment', + category: 'Chat', + configKey: 'hideDeployChatbot', + hint: 'Hide the chat deployment option.', + }, + { + id: 'disable-public-file-sharing', + label: 'Public Sharing', + category: 'Files', + configKey: 'disablePublicFileSharing', + hint: 'Disable public file-share links.', + }, +] as const satisfies readonly PermissionGroupPlatformFeature[] + +/** Returns only restrictions that actively constrain the current user. */ +export function getActivePermissionGroupRestrictions( + config: PermissionGroupConfig | null +): ActivePermissionGroupRestriction[] { + if (!config) return [] + + const restrictions: ActivePermissionGroupRestriction[] = [] + + if (config.allowedIntegrations !== null) { + restrictions.push({ + key: 'allowedIntegrations', + description: + config.allowedIntegrations.length > 0 + ? 'Integrations and blocks are limited to effectiveConfig.allowedIntegrations.' + : 'No non-exempt integrations or blocks are allowed.', + }) + } + if (config.allowedModelProviders !== null) { + restrictions.push({ + key: 'allowedModelProviders', + description: + config.allowedModelProviders.length > 0 + ? 'Model providers are limited to effectiveConfig.allowedModelProviders.' + : 'No model providers are allowed.', + }) + } + if (config.deniedModels.length > 0) { + restrictions.push({ + key: 'deniedModels', + description: 'Models listed in effectiveConfig.deniedModels are blocked.', + }) + } + if (config.deniedTools.length > 0) { + restrictions.push({ + key: 'deniedTools', + description: 'Integration tools listed in effectiveConfig.deniedTools are blocked.', + }) + } + if (config.allowedFileShareAuthTypes !== null) { + restrictions.push({ + key: 'allowedFileShareAuthTypes', + description: + config.allowedFileShareAuthTypes.length > 0 + ? 'Public file-share authentication is limited to effectiveConfig.allowedFileShareAuthTypes.' + : 'No public file-share authentication modes are allowed.', + }) + } + if (config.allowedChatDeployAuthTypes !== null) { + restrictions.push({ + key: 'allowedChatDeployAuthTypes', + description: + config.allowedChatDeployAuthTypes.length > 0 + ? 'Chat deployment authentication is limited to effectiveConfig.allowedChatDeployAuthTypes.' + : 'No chat deployment authentication modes are allowed.', + }) + } + + for (const feature of PLATFORM_FEATURES) { + if (config[feature.configKey]) { + restrictions.push({ key: feature.configKey, description: feature.hint }) + } + } + + return restrictions +} diff --git a/apps/sim/lib/workspaces/host-context.test.ts b/apps/sim/lib/workspaces/host-context.test.ts index c08bb7d7d39..6c83a34867d 100644 --- a/apps/sim/lib/workspaces/host-context.test.ts +++ b/apps/sim/lib/workspaces/host-context.test.ts @@ -85,6 +85,7 @@ describe('getWorkspaceHostContextForViewer', () => { permission: 'write', isHostOrganizationMember: true, isHostOrganizationAdmin: false, + organizationRole: 'member', }, }) ) @@ -104,6 +105,7 @@ describe('getWorkspaceHostContextForViewer', () => { permission: 'read', isHostOrganizationMember: false, isHostOrganizationAdmin: false, + organizationRole: null, }) expect(context?.hostOrganizationId).toBe('org-host') }) @@ -125,6 +127,7 @@ describe('getWorkspaceHostContextForViewer', () => { permission: 'admin', isHostOrganizationMember: false, isHostOrganizationAdmin: false, + organizationRole: null, }, }) ) diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index d350c5a1763..5df7df76461 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -25,7 +25,7 @@ async function resolveWorkspaceHostContextForViewer( getWorkspaceOwnerSubscriptionAccess(workspaceId), hostOrganizationId ? getOrganizationSettingsAccess(hostOrganizationId, userId) - : Promise.resolve({ isMember: false, isAdmin: false }), + : Promise.resolve({ role: null, isMember: false, isAdmin: false }), ]) return { @@ -41,6 +41,7 @@ async function resolveWorkspaceHostContextForViewer( permission: access.permission, isHostOrganizationMember: hostOrganizationAccess.isMember, isHostOrganizationAdmin: hostOrganizationAccess.isAdmin, + organizationRole: hostOrganizationAccess.role, }, } }