From ea246042c01c74798cf128ba9759b7e769813e73 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:31:48 -0700 Subject: [PATCH 1/5] feat(copilot): register blank platform subagent --- .../home/components/message-content/utils.ts | 1 + .../app/workspace/[workspaceId]/home/types.ts | 1 + .../lib/copilot/generated/tool-catalog-v1.ts | 23 +++++++++++++++++++ .../lib/copilot/generated/tool-schemas-v1.ts | 13 +++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 1 + 5 files changed, 39 insertions(+) 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/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index d4d2df7141d..0c9caa14c33 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -85,6 +85,7 @@ export interface ToolCatalogEntry { | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'platform' | 'promote_to_live' | 'query_logs' | 'query_user_table' @@ -205,6 +206,7 @@ export interface ToolCatalogEntry { | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'platform' | 'promote_to_live' | 'query_logs' | 'query_user_table' @@ -257,6 +259,7 @@ export interface ToolCatalogEntry { | 'file' | 'knowledge' | 'media' + | 'platform' | 'run' | 'search' | 'table' @@ -3867,6 +3870,25 @@ export const OpenResource: ToolCatalogEntry = { }, } +export const Platform: ToolCatalogEntry = { + id: 'platform', + name: 'platform', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: 'A task for the Platform agent.', + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'platform', + internal: true, +} + export const PromoteToLive: ToolCatalogEntry = { id: 'promote_to_live', name: 'promote_to_live', @@ -6574,6 +6596,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 6654dc277b5..63e0ca211c6 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3728,6 +3728,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + platform: { + parameters: { + properties: { + task: { + description: 'A task for the Platform agent.', + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, promote_to_live: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 8f77f4cd856..01520732c0b 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -536,6 +536,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', From 86fd659734720e73fa77c96b135074d0ddf09cee Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:33:01 -0700 Subject: [PATCH 2/5] feat(copilot): describe platform agent delegation --- apps/sim/lib/copilot/generated/tool-catalog-v1.ts | 3 ++- apps/sim/lib/copilot/generated/tool-schemas-v1.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 0c9caa14c33..a4312707e3d 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3878,7 +3878,8 @@ export const Platform: ToolCatalogEntry = { parameters: { properties: { task: { - description: 'A task for the Platform agent.', + 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', }, }, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 63e0ca211c6..b75d04a7f64 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3732,7 +3732,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { properties: { task: { - description: 'A task for the Platform agent.', + 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', }, }, From 952c7373f111d2254d22322a0db26241d1599c60 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:47:26 -0700 Subject: [PATCH 3/5] feat(copilot): answer account billing questions from a live snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the no-argument get_account_billing tool: the handler combines the org-aware billing lookups (usage data, credit balance, usage-limit info) into one snapshot — plan, current-period usage vs limit with remaining, and purchased credit balance — always scoped to the requesting user. Catalog and schema mirrors regenerated from the mothership definitions. Co-Authored-By: Claude Fable 5 --- .../lib/copilot/generated/tool-catalog-v1.ts | 11 ++ .../lib/copilot/generated/tool-schemas-v1.ts | 7 + .../tool-executor/register-handlers.ts | 3 + .../copilot/tools/handlers/account.test.ts | 121 ++++++++++++++++++ .../sim/lib/copilot/tools/handlers/account.ts | 43 +++++++ apps/sim/lib/copilot/tools/tool-display.ts | 1 + 6 files changed, 186 insertions(+) create mode 100644 apps/sim/lib/copilot/tools/handlers/account.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/account.ts diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index a4312707e3d..4068fa6d8e7 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -56,6 +56,7 @@ export interface ToolCatalogEntry { | 'generate_audio' | 'generate_image' | 'generate_video' + | 'get_account_billing' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' @@ -177,6 +178,7 @@ export interface ToolCatalogEntry { | 'generate_audio' | 'generate_image' | 'generate_video' + | 'get_account_billing' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' @@ -2923,6 +2925,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', @@ -6568,6 +6578,7 @@ 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, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index b75d04a7f64..205c8565129 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', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index b5b9768ac14..324931bc75a 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -12,6 +12,7 @@ import { DiffWorkflows, FunctionExecute, GenerateApiKey, + GetAccountBilling, GetBlockOutputs, GetBlockUpstreamReferences, GetDeployedWorkflowState, @@ -52,6 +53,7 @@ import { } from '@/lib/copilot/generated/tool-catalog-v1' 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 +136,7 @@ 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)), [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/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 01520732c0b..7759e17d676 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -474,6 +474,7 @@ 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', From f9f7fb83e44c356fdc44f305b11eb61710719cfd Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:51:08 -0700 Subject: [PATCH 4/5] feat(copilot): expose effective enterprise context --- .../content/docs/en/platform/permissions.mdx | 5 +- .../components/group-detail.tsx | 148 +-------- .../utils/permission-check.test.ts | 122 ++++++++ .../access-control/utils/permission-check.ts | 96 +++++- apps/sim/lib/api/contracts/workspaces.ts | 2 + .../lib/copilot/generated/tool-catalog-v1.ts | 11 + .../lib/copilot/generated/tool-schemas-v1.ts | 7 + .../tool-executor/register-handlers.ts | 3 + .../tools/handlers/enterprise-context.test.ts | 285 ++++++++++++++++++ .../tools/handlers/enterprise-context.ts | 94 ++++++ .../lib/copilot/tools/tool-display.test.ts | 1 + apps/sim/lib/copilot/tools/tool-display.ts | 1 + .../lib/permission-groups/features.test.ts | 94 ++++++ apps/sim/lib/permission-groups/features.ts | 228 ++++++++++++++ apps/sim/lib/workspaces/host-context.test.ts | 3 + apps/sim/lib/workspaces/host-context.ts | 3 +- 16 files changed, 940 insertions(+), 163 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/enterprise-context.ts create mode 100644 apps/sim/lib/permission-groups/features.test.ts create mode 100644 apps/sim/lib/permission-groups/features.ts 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/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 4068fa6d8e7..d753c42437d 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -61,6 +61,7 @@ export interface ToolCatalogEntry { | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_log' + | 'get_enterprise_context' | 'get_page_contents' | 'get_workflow_data' | 'get_workflow_run_options' @@ -183,6 +184,7 @@ export interface ToolCatalogEntry { | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_log' + | 'get_enterprise_context' | 'get_page_contents' | 'get_workflow_data' | 'get_workflow_run_options' @@ -3010,6 +3012,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', @@ -6583,6 +6593,7 @@ export const TOOL_CATALOG: Record = { [GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences, [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, [GetDeploymentLog.id]: GetDeploymentLog, + [GetEnterpriseContext.id]: GetEnterpriseContext, [GetPageContents.id]: GetPageContents, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 205c8565129..f4a8ce82175 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2897,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', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 324931bc75a..91c74be519b 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -17,6 +17,7 @@ import { GetBlockUpstreamReferences, GetDeployedWorkflowState, GetDeploymentLog, + GetEnterpriseContext, GetWorkflowData, GetWorkflowRunOptions, Glob as GlobTool, @@ -51,6 +52,7 @@ 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' @@ -137,6 +139,7 @@ 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/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 94cfedd34af..cff4b8ef479 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 7759e17d676..9b1efb92534 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -479,6 +479,7 @@ const TOOL_TITLES: Record = { 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', 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, }, } } From 51e8300b732ac301c380ab48e96f7a4195429d2a Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:53:59 -0700 Subject: [PATCH 5/5] fix(copilot): secure live platform context --- apps/sim/lib/api/contracts/organization.ts | 4 +- apps/sim/lib/api/contracts/primitives.test.ts | 11 + apps/sim/lib/api/contracts/primitives.ts | 6 + apps/sim/lib/api/contracts/workspaces.test.ts | 32 +++ apps/sim/lib/api/contracts/workspaces.ts | 8 +- .../core/account-billing-snapshot.test.ts | 100 ++++++++ .../billing/core/account-billing-snapshot.ts | 58 +++++ apps/sim/lib/billing/core/usage.ts | 48 ++-- .../execute-platform-context-use-case.ts | 40 ++++ .../auth/application-delegation.test.ts | 22 ++ .../copilot/auth/application-delegation.ts | 26 +++ .../request/lifecycle/headless.test.ts | 18 ++ .../lib/copilot/request/lifecycle/headless.ts | 1 + .../lib/copilot/request/lifecycle/run.test.ts | 42 ++++ apps/sim/lib/copilot/request/lifecycle/run.ts | 2 + .../tool-executor/register-handlers.ts | 2 +- apps/sim/lib/copilot/tool-executor/types.ts | 2 + .../copilot/tools/handlers/account.test.ts | 176 +++++++------- .../sim/lib/copilot/tools/handlers/account.ts | 36 +-- .../tools/handlers/enterprise-context.test.ts | 100 +++++++- .../tools/handlers/enterprise-context.ts | 80 +------ .../lib/organizations/settings-access.test.ts | 8 + apps/sim/lib/organizations/settings-access.ts | 5 +- .../lib/permission-groups/features.test.ts | 3 + .../application/authorization.ts | 12 + .../platform-context/application/context.ts | 14 ++ .../application/operations.ts | 24 ++ .../platform-context-use-cases.test.ts | 221 ++++++++++++++++++ .../application/read-account-billing.ts | 22 ++ .../application/read-enterprise-context.ts | 88 +++++++ 30 files changed, 989 insertions(+), 222 deletions(-) create mode 100644 apps/sim/lib/api/contracts/workspaces.test.ts create mode 100644 apps/sim/lib/billing/core/account-billing-snapshot.test.ts create mode 100644 apps/sim/lib/billing/core/account-billing-snapshot.ts create mode 100644 apps/sim/lib/copilot/application/execute-platform-context-use-case.ts create mode 100644 apps/sim/lib/platform-context/application/authorization.ts create mode 100644 apps/sim/lib/platform-context/application/context.ts create mode 100644 apps/sim/lib/platform-context/application/operations.ts create mode 100644 apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts create mode 100644 apps/sim/lib/platform-context/application/read-account-billing.ts create mode 100644 apps/sim/lib/platform-context/application/read-enterprise-context.ts diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index bbd1fcf69ce..bf3415aae92 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { + organizationRoleSchema, type PiiRedactionSettings, piiRedactionSettingsSchema, retentionOverridesSchema, @@ -15,9 +16,6 @@ const numericResponseSchema = z.preprocess((value) => { return Number.isFinite(parsed) ? parsed : value }, z.number()) -export const organizationRoleSchema = z.enum(['owner', 'admin', 'member'], { - error: 'Invalid role', -}) export const organizationParamsSchema = z.object({ id: z.string().min(1), }) diff --git a/apps/sim/lib/api/contracts/primitives.test.ts b/apps/sim/lib/api/contracts/primitives.test.ts index 4e8a605a98f..7670830de49 100644 --- a/apps/sim/lib/api/contracts/primitives.test.ts +++ b/apps/sim/lib/api/contracts/primitives.test.ts @@ -6,6 +6,7 @@ import { customPatternSchema, isCanonicalBase64, organizationIdSchema, + organizationRoleSchema, piiStagePolicySchema, piiStagesSchema, privateSecretProvenanceBundleSchema, @@ -16,6 +17,16 @@ import { workspaceIdSchema, } from '@/lib/api/contracts/primitives' +describe('organizationRoleSchema', () => { + it.each(['owner', 'admin', 'member'] as const)('accepts canonical role %s', (role) => { + expect(organizationRoleSchema.parse(role)).toBe(role) + }) + + it.each(['billing-owner', 'viewer', '', null, undefined])('rejects invalid role %j', (role) => { + expect(organizationRoleSchema.safeParse(role).success).toBe(false) + }) +}) + describe('workspaceFileNameSchema', () => { it('trims and accepts one bounded file name', () => { expect(workspaceFileNameSchema.parse(' report.pdf ')).toBe('report.pdf') diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index c05dc4679fa..7de554762ba 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -231,6 +231,12 @@ export const workspaceFileNameSchema = z /** Non-empty `organizationId` field with a stable, human-readable message. */ export const organizationIdSchema = requiredFieldSchema('Organization ID is required') +/** Canonical organization membership role shared across API resource families. */ +export const organizationRoleSchema = z.enum(['owner', 'admin', 'member'], { + error: 'Invalid role', +}) +export type OrganizationRole = z.output + /** Non-empty `workflowId` field with a stable, human-readable message. */ export const workflowIdSchema = requiredFieldSchema('Workflow ID is required') diff --git a/apps/sim/lib/api/contracts/workspaces.test.ts b/apps/sim/lib/api/contracts/workspaces.test.ts new file mode 100644 index 00000000000..8adfeed25e4 --- /dev/null +++ b/apps/sim/lib/api/contracts/workspaces.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { workspaceHostContextSchema } from '@/lib/api/contracts/workspaces' + +const viewerSchema = workspaceHostContextSchema.shape.viewer +const viewer = { + permission: 'read' as const, + isHostOrganizationMember: false, + isHostOrganizationAdmin: false, +} + +describe('workspaceHostContextSchema organizationRole', () => { + it.each(['owner', 'admin', 'member'] as const)( + 'accepts canonical role %s', + (organizationRole) => { + expect(viewerSchema.safeParse({ ...viewer, organizationRole }).success).toBe(true) + } + ) + + it('retains null and omission for rolling response compatibility', () => { + expect(viewerSchema.safeParse({ ...viewer, organizationRole: null }).success).toBe(true) + expect(viewerSchema.safeParse(viewer).success).toBe(true) + }) + + it('rejects non-canonical organization roles', () => { + expect(viewerSchema.safeParse({ ...viewer, organizationRole: 'billing-owner' }).success).toBe( + false + ) + }) +}) diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index aaa1b836caa..9c32dc4d23e 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { nonEmptyIdSchema, requiredFieldSchema } from '@/lib/api/contracts/primitives' +import { + nonEmptyIdSchema, + organizationRoleSchema, + requiredFieldSchema, +} from '@/lib/api/contracts/primitives' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' export const workspaceScopeSchema = z.enum(['active', 'archived', 'all']) @@ -264,7 +268,7 @@ export const workspaceHostContextSchema = z.object({ isHostOrganizationMember: z.boolean(), isHostOrganizationAdmin: z.boolean(), /** Optional for rolling compatibility with app versions that predate organization-role projection. */ - organizationRole: z.string().nullable().optional(), + organizationRole: organizationRoleSchema.nullable().optional(), }), }) diff --git a/apps/sim/lib/billing/core/account-billing-snapshot.test.ts b/apps/sim/lib/billing/core/account-billing-snapshot.test.ts new file mode 100644 index 00000000000..503d01834af --- /dev/null +++ b/apps/sim/lib/billing/core/account-billing-snapshot.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + events: [] as string[], + getResolvedUserUsageData: vi.fn(), + getCreditBalanceForEntity: vi.fn(), + isOrgScopedSubscription: vi.fn(), +})) + +vi.mock('@/lib/billing/core/usage', () => ({ + getResolvedUserUsageData: mocks.getResolvedUserUsageData, +})) + +vi.mock('@/lib/billing/credits/balance', () => ({ + getCreditBalanceForEntity: mocks.getCreditBalanceForEntity, +})) + +vi.mock('@/lib/billing/subscriptions/utils', () => ({ + isOrgScopedSubscription: mocks.isOrgScopedSubscription, +})) + +import { getAccountBillingSnapshot } from '@/lib/billing/core/account-billing-snapshot' + +const usage = { + currentUsage: 18.5, + limit: 40, + percentUsed: 46.25, + isWarning: false, + isExceeded: false, + billingPeriodStart: new Date('2026-08-01T00:00:00Z'), + billingPeriodEnd: new Date('2026-09-01T00:00:00Z'), + lastPeriodCost: 31, +} + +describe('getAccountBillingSnapshot', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.events.length = 0 + }) + + it('reuses one resolved subscription for org scope, usage, limits, and credits', async () => { + const subscription = { + plan: 'team', + referenceId: 'org-1', + } + mocks.getResolvedUserUsageData.mockImplementation(async () => { + mocks.events.push('usage-and-subscription') + return { usage, subscription, personalCreditBalance: 4 } + }) + mocks.isOrgScopedSubscription.mockReturnValue(true) + mocks.getCreditBalanceForEntity.mockImplementation(async () => { + mocks.events.push('credits') + return 25 + }) + + await expect(getAccountBillingSnapshot('user-1')).resolves.toEqual({ + plan: 'team', + billingScope: 'organization', + organizationId: 'org-1', + usage: { + currentPeriodCost: 18.5, + limit: 40, + remaining: 21.5, + percentUsed: 46.25, + isExceeded: false, + billingPeriodEnd: new Date('2026-09-01T00:00:00Z'), + }, + credits: { balance: 25, scope: 'organization' }, + }) + expect(mocks.getResolvedUserUsageData).toHaveBeenCalledOnce() + expect(mocks.getCreditBalanceForEntity).toHaveBeenCalledWith( + 'organization', + 'org-1', + expect.anything() + ) + expect(mocks.events).toEqual(['usage-and-subscription', 'credits']) + }) + + it('preserves personal scope and clamps negative remaining usage to zero', async () => { + mocks.getResolvedUserUsageData.mockResolvedValue({ + usage: { ...usage, currentUsage: 45, isExceeded: true }, + subscription: { plan: 'pro', referenceId: 'user-1' }, + personalCreditBalance: 0, + }) + mocks.isOrgScopedSubscription.mockReturnValue(false) + mocks.getCreditBalanceForEntity.mockResolvedValue(0) + + await expect(getAccountBillingSnapshot('user-1')).resolves.toMatchObject({ + plan: 'pro', + billingScope: 'user', + organizationId: null, + usage: { remaining: 0, isExceeded: true }, + credits: { balance: 0, scope: 'user' }, + }) + expect(mocks.getCreditBalanceForEntity).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/billing/core/account-billing-snapshot.ts b/apps/sim/lib/billing/core/account-billing-snapshot.ts new file mode 100644 index 00000000000..a7591357aca --- /dev/null +++ b/apps/sim/lib/billing/core/account-billing-snapshot.ts @@ -0,0 +1,58 @@ +import { db } from '@sim/db' +import { getResolvedUserUsageData } from '@/lib/billing/core/usage' +import { getCreditBalanceForEntity } from '@/lib/billing/credits/balance' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' +import type { DbClient } from '@/lib/db/types' + +export interface AccountBillingSnapshot { + plan: string + billingScope: 'user' | 'organization' + organizationId: string | null + usage: { + currentPeriodCost: number + limit: number + remaining: number + percentUsed: number + isExceeded: boolean + billingPeriodEnd: Date | null + } + credits: { + balance: number + scope: 'user' | 'organization' + } +} + +/** Resolves one coherent subscription, usage, limit, and credit snapshot for an account. */ +export async function getAccountBillingSnapshot( + userId: string, + executor: DbClient = db +): Promise { + const { usage, subscription, personalCreditBalance } = await getResolvedUserUsageData( + userId, + executor + ) + const organizationScoped = isOrgScopedSubscription(subscription, userId) && subscription !== null + const billingScope = organizationScoped ? 'organization' : 'user' + const billingEntityId = organizationScoped ? subscription.referenceId : userId + const creditBalance = organizationScoped + ? await getCreditBalanceForEntity('organization', billingEntityId, executor) + : personalCreditBalance + + return { + plan: subscription?.plan || 'free', + billingScope, + organizationId: organizationScoped ? subscription.referenceId : null, + 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: creditBalance, + scope: billingScope, + }, + } +} diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index b68d1982623..0fe0e91009d 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -14,7 +14,10 @@ import { } from '@/components/emails' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' -import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' +import { + getHighestPrioritySubscription, + type HighestPrioritySubscription, +} from '@/lib/billing/core/plan' import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' import { computeDailyRefreshConsumed, @@ -190,13 +193,18 @@ export async function ensureUserStatsExists(userId: string): Promise { .onConflictDoNothing({ target: userStats.userId }) } -/** - * Get comprehensive usage data for a user - */ -export async function getUserUsageData( +export interface ResolvedUserUsageData { + usage: UsageData + subscription: HighestPrioritySubscription + /** The personal balance from the same user-stats row used to calculate usage. */ + personalCreditBalance: number +} + +/** Resolves comprehensive usage and the subscription that determined its billing scope. */ +export async function getResolvedUserUsageData( userId: string, executor: DbClient = db -): Promise { +): Promise { try { // Write — always on the primary regardless of executor routing. await ensureUserStatsExists(userId) @@ -332,14 +340,18 @@ export async function getUserUsageData( const isExceeded = effectiveUsage >= limit return { - currentUsage: effectiveUsage, - limit, - percentUsed, - isWarning, - isExceeded, - billingPeriodStart, - billingPeriodEnd, - lastPeriodCost, + usage: { + currentUsage: effectiveUsage, + limit, + percentUsed, + isWarning, + isExceeded, + billingPeriodStart, + billingPeriodEnd, + lastPeriodCost, + }, + subscription, + personalCreditBalance: toNumber(toDecimal(stats.creditBalance)), } } catch (error) { logger.error('Failed to get user usage data', { userId, error }) @@ -347,6 +359,14 @@ export async function getUserUsageData( } } +/** Get comprehensive usage data for a user. */ +export async function getUserUsageData( + userId: string, + executor: DbClient = db +): Promise { + return (await getResolvedUserUsageData(userId, executor)).usage +} + /** * Get usage limit information for a user */ diff --git a/apps/sim/lib/copilot/application/execute-platform-context-use-case.ts b/apps/sim/lib/copilot/application/execute-platform-context-use-case.ts new file mode 100644 index 00000000000..b891ea294ce --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-platform-context-use-case.ts @@ -0,0 +1,40 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + type CopilotExecutionContext, + InteractiveCopilotExecutionRequiredError, + requireInteractiveCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' +import type { OperationUseCase } from '@/lib/core/application' +import { platformContextDelegationPolicy } from '@/lib/platform-context/application/authorization' +import { + type PlatformContextOperation, + platformContextOperations, +} from '@/lib/platform-context/application/operations' + +const executePlatformContextUseCase = createCopilotApplicationAdapter({ + domain: 'platform context', + delegation: { + audience: platformContextDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: platformContextOperations, +}) + +/** Enters a live platform-context operation only from a trusted interactive Copilot lifecycle. */ +export function executeCopilotPlatformContextUseCase( + context: CopilotExecutionContext | undefined, + useCase: OperationUseCase, + input: I +): Promise { + const trustedContext = requireInteractiveCopilotExecutionContext(context) + return executePlatformContextUseCase(trustedContext, useCase, input) +} + +/** Projects only actionable authorization failures into live platform-context tool output. */ +export function messageForCopilotPlatformContextError(error: unknown): string { + if (error instanceof InteractiveCopilotExecutionRequiredError) return error.message + return messageForCopilotApplicationError(error) +} diff --git a/apps/sim/lib/copilot/auth/application-delegation.test.ts b/apps/sim/lib/copilot/auth/application-delegation.test.ts index 1e0bdc5037c..655f685fc64 100644 --- a/apps/sim/lib/copilot/auth/application-delegation.test.ts +++ b/apps/sim/lib/copilot/auth/application-delegation.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createCopilotApplicationPrincipal, + requireInteractiveCopilotExecutionContext, requireTrustedCopilotExecutionContext, } from '@/lib/copilot/auth/application-delegation' @@ -61,4 +62,25 @@ describe('Copilot application delegation', () => { }, }) }) + + it.each([undefined, 'headless' as const])( + 'rejects non-interactive live platform context (%s)', + (copilotInteractionMode) => { + expect(() => + requireInteractiveCopilotExecutionContext({ + ...trustedContext, + copilotInteractionMode, + }) + ).toThrow('only in an interactive Copilot session') + } + ) + + it('accepts a server-classified interactive lifecycle', () => { + expect( + requireInteractiveCopilotExecutionContext({ + ...trustedContext, + copilotInteractionMode: 'interactive', + }) + ).toMatchObject({ copilotInteractionMode: 'interactive' }) + }) }) diff --git a/apps/sim/lib/copilot/auth/application-delegation.ts b/apps/sim/lib/copilot/auth/application-delegation.ts index 969bf37b325..7b095d8c7ba 100644 --- a/apps/sim/lib/copilot/auth/application-delegation.ts +++ b/apps/sim/lib/copilot/auth/application-delegation.ts @@ -9,6 +9,7 @@ export interface CopilotExecutionContext { executionId?: string toolCallId?: string copilotToolExecution?: boolean + copilotInteractionMode?: 'interactive' | 'headless' } export interface TrustedCopilotExecutionContext extends CopilotExecutionContext { @@ -18,6 +19,17 @@ export interface TrustedCopilotExecutionContext extends CopilotExecutionContext copilotToolExecution: true } +export interface TrustedInteractiveCopilotExecutionContext extends TrustedCopilotExecutionContext { + copilotInteractionMode: 'interactive' +} + +export class InteractiveCopilotExecutionRequiredError extends Error { + constructor() { + super('Live platform context is available only in an interactive Copilot session.') + this.name = 'InteractiveCopilotExecutionRequiredError' + } +} + export type CopilotResourceScope = Pick< NonNullable, 'fileId' | 'tableId' @@ -74,9 +86,23 @@ export function requireTrustedCopilotExecutionContext( ...(context.executionId ? { executionId: context.executionId } : {}), toolCallId: context.toolCallId, copilotToolExecution: true, + ...(context.copilotInteractionMode + ? { copilotInteractionMode: context.copilotInteractionMode } + : {}), }) } +/** Restricts sensitive live platform reads to a server-classified interactive lifecycle. */ +export function requireInteractiveCopilotExecutionContext( + context: CopilotExecutionContext | undefined +): TrustedInteractiveCopilotExecutionContext { + const trustedContext = requireTrustedCopilotExecutionContext(context) + if (trustedContext.copilotInteractionMode !== 'interactive') { + throw new InteractiveCopilotExecutionRequiredError() + } + return trustedContext as TrustedInteractiveCopilotExecutionContext +} + /** Creates a bounded Copilot principal from an explicitly trusted server lifecycle. */ export function createTrustedCopilotPrincipal( input: CreateTrustedCopilotPrincipalInput, diff --git a/apps/sim/lib/copilot/request/lifecycle/headless.test.ts b/apps/sim/lib/copilot/request/lifecycle/headless.test.ts index d31751c4ad2..42f2fbd9eb0 100644 --- a/apps/sim/lib/copilot/request/lifecycle/headless.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/headless.test.ts @@ -98,6 +98,24 @@ describe('runHeadlessCopilotLifecycle', () => { expect(result.success).toBe(false) }) + it('forces the server-owned headless classification', async () => { + runCopilotLifecycle.mockResolvedValueOnce(createLifecycleResult()) + + await runHeadlessCopilotLifecycle( + { message: 'hello', messageId: 'req-classification' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + interactive: true, + } + ) + + expect(runCopilotLifecycle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ interactive: false }) + ) + }) + it('prefers an explicit simRequestId over the payload messageId', async () => { runCopilotLifecycle.mockResolvedValueOnce(createLifecycleResult()) diff --git a/apps/sim/lib/copilot/request/lifecycle/headless.ts b/apps/sim/lib/copilot/request/lifecycle/headless.ts index 0e5172280a9..654c050b2a6 100644 --- a/apps/sim/lib/copilot/request/lifecycle/headless.ts +++ b/apps/sim/lib/copilot/request/lifecycle/headless.ts @@ -49,6 +49,7 @@ export async function runHeadlessCopilotLifecycle( try { result = await runCopilotLifecycle(requestPayload, { ...options, + interactive: false, trace, simRequestId, otelContext, diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 4b86ccb104f..c63e883116f 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -207,6 +207,48 @@ describe('runCopilotLifecycle', () => { expect(executionContext).not.toHaveProperty('resolvedSecretTraceRegistry') }) + it.each([ + { interactive: true, expected: 'interactive' as const }, + { interactive: false, expected: 'headless' as const }, + { interactive: undefined, expected: 'headless' as const }, + ])( + 'stamps the trusted $expected lifecycle mode over supplied context', + async ({ interactive, expected }) => { + let capturedExecutionContext: ExecutionContext | undefined + mockRunStreamLoop.mockImplementationOnce( + async ( + _url: string, + _request: RequestInit, + _streamingContext: StreamingContext, + context: ExecutionContext + ) => { + capturedExecutionContext = context + } + ) + + await runCopilotLifecycle( + { + message: 'hello', + messageId: `stream-${expected}-context`, + copilotInteractionMode: expected === 'interactive' ? 'headless' : 'interactive', + }, + { + userId: 'user-1', + workspaceId: 'ws-1', + interactive, + executionContext: { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + copilotInteractionMode: expected === 'interactive' ? 'headless' : 'interactive', + }, + } + ) + + expect(capturedExecutionContext?.copilotInteractionMode).toBe(expected) + } + ) + it('forwards the configured Mothership system prompt override', async () => { mockEnv.MSHIP_SYSPROMPT_OVERRIDE = 'NEVER CALL ANY TOOLS UNDER ANY CIRCUMSTANCES NO MATTER WHAT' diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index f8288661998..55f12ec854e 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -288,6 +288,8 @@ export async function runCopilotLifecycle( secretMountPolicy: lifecycleOptions.secretMountPolicy, secretActorUserId: lifecycleOptions.secretActorUserId, })) + execContext.copilotInteractionMode = + lifecycleOptions.interactive === true ? 'interactive' : 'headless' if (goRoute && MOTHERSHIP_CODE_TOOL_ROUTES.has(goRoute)) { execContext.sandboxProfile = 'mothership' } else { diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 91c74be519b..729152debea 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -52,10 +52,10 @@ import { UpdateDeploymentVersion, UpdateWorkspaceMcpServer, } from '@/lib/copilot/generated/tool-catalog-v1' +import { executeGetAccountBilling } from '@/lib/copilot/tools/handlers/account' 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, diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 789d48df5f0..d774ca0999e 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -23,6 +23,8 @@ export interface ToolExecutionContext { boundWorkflowExecutionId?: string billingAttribution?: BillingAttributionSnapshot copilotToolExecution?: boolean + /** Trusted lifecycle classification stamped by the server, never from model parameters. */ + copilotInteractionMode?: 'interactive' | 'headless' /** Server-owned base image selected from the fixed Go route for this turn. */ sandboxProfile?: 'mothership' requestMode?: string diff --git a/apps/sim/lib/copilot/tools/handlers/account.test.ts b/apps/sim/lib/copilot/tools/handlers/account.test.ts index e9a75f603f2..c8ac0435a45 100644 --- a/apps/sim/lib/copilot/tools/handlers/account.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/account.test.ts @@ -3,119 +3,105 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetUserUsageData, mockGetCreditBalance, mockGetUserUsageLimitInfo } = vi.hoisted( - () => ({ - mockGetUserUsageData: vi.fn(), - mockGetCreditBalance: vi.fn(), - mockGetUserUsageLimitInfo: vi.fn(), - }) -) +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getAccountBillingSnapshot: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) -vi.mock('@/lib/billing', () => ({ - getUserUsageData: mockGetUserUsageData, - getCreditBalance: mockGetCreditBalance, - getUserUsageLimitInfo: mockGetUserUsageLimitInfo, +vi.mock('@/lib/billing/core/account-billing-snapshot', () => ({ + getAccountBillingSnapshot: mocks.getAccountBillingSnapshot, })) import type { ExecutionContext } from '@/lib/copilot/request/types' import { executeGetAccountBilling } from '@/lib/copilot/tools/handlers/account' -const context = { userId: 'user-1' } as ExecutionContext +const context = { + userId: 'user-1', + workflowId: '', + workspaceId: 'workspace-1', + chatId: 'chat-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + copilotInteractionMode: 'interactive', +} as const satisfies ExecutionContext + +const snapshot = { + plan: 'team', + billingScope: 'organization' as const, + organizationId: 'org-1', + usage: { + currentPeriodCost: 18.5, + limit: 40, + remaining: 21.5, + percentUsed: 46.25, + isExceeded: false, + billingPeriodEnd: new Date('2026-09-01T00:00:00Z'), + }, + credits: { balance: 25, scope: 'organization' as const }, +} 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', + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', }) - mockGetUserUsageLimitInfo.mockResolvedValue({ - currentLimit: 40, - canEdit: false, - minimumLimit: 0, - plan: 'team', - updatedAt: null, - scope: 'organization', - organizationId: 'org-1', - }) - - const result = await executeGetAccountBilling(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getAccountBillingSnapshot.mockResolvedValue(snapshot) + }) - expect(mockGetUserUsageData).toHaveBeenCalledWith('user-1') - expect(mockGetCreditBalance).toHaveBeenCalledWith('user-1') - expect(mockGetUserUsageLimitInfo).toHaveBeenCalledWith('user-1') - expect(result).toEqual({ + it('returns the existing account billing tool result shape after authorization', async () => { + await expect(executeGetAccountBilling(context)).resolves.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' }, - }, + output: snapshot, }) + expect(mocks.getAccountBillingSnapshot).toHaveBeenCalledWith('user-1') }) - 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) + it.each(['headless' as const, undefined])( + 'fails closed for a non-interactive lifecycle (%s) before protected lookup', + async (copilotInteractionMode) => { + const result = await executeGetAccountBilling({ + ...context, + copilotInteractionMode, + }) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - plan: 'pro', - usage: { remaining: 0, isExceeded: true }, - }) - }) + expect(result).toEqual({ + success: false, + error: 'Live platform context is available only in an interactive Copilot session.', + }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getAccountBillingSnapshot).not.toHaveBeenCalled() + } + ) - 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({}) + it('does not expose an underlying billing failure', async () => { + mocks.getAccountBillingSnapshot.mockRejectedValue( + new Error('connection secret from billing database') + ) - const result = await executeGetAccountBilling(context) - - expect(result).toEqual({ success: false, error: 'stats row missing' }) + await expect(executeGetAccountBilling(context)).resolves.toEqual({ + success: false, + error: 'The operation failed due to a system error. Please retry.', + }) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/account.ts b/apps/sim/lib/copilot/tools/handlers/account.ts index ec3b3440f5a..051b46f931f 100644 --- a/apps/sim/lib/copilot/tools/handlers/account.ts +++ b/apps/sim/lib/copilot/tools/handlers/account.ts @@ -1,6 +1,9 @@ -import { toError } from '@sim/utils/errors' -import { getCreditBalance, getUserUsageData, getUserUsageLimitInfo } from '@/lib/billing' +import { + executeCopilotPlatformContextUseCase, + messageForCopilotPlatformContextError, +} from '@/lib/copilot/application/execute-platform-context-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { readAccountBilling } from '@/lib/platform-context/application/read-account-billing' /** * Live billing snapshot for the requesting user: plan, current-period usage @@ -11,33 +14,14 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ */ export async function executeGetAccountBilling(context: ExecutionContext): Promise { try { - const [usage, credits, limitInfo] = await Promise.all([ - getUserUsageData(context.userId), - getCreditBalance(context.userId), - getUserUsageLimitInfo(context.userId), - ]) - + const output = await executeCopilotPlatformContextUseCase(context, readAccountBilling, { + workspaceId: context.workspaceId ?? '', + }) 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, - }, - }, + output, } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotPlatformContextError(error) } } } diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts index a6d257e2326..231692c6a76 100644 --- a/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts @@ -3,11 +3,31 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetWorkspaceHostContextForViewer, mockResolveVerifiedUserAccessControlContext } = - vi.hoisted(() => ({ - mockGetWorkspaceHostContextForViewer: vi.fn(), - mockResolveVerifiedUserAccessControlContext: vi.fn(), - })) +const { + mockGetWorkspaceHostContextForViewer, + mockResolveVerifiedUserAccessControlContext, + mockLoadWorkspace, + mockResolvePermission, +} = vi.hoisted(() => ({ + mockGetWorkspaceHostContextForViewer: vi.fn(), + mockResolveVerifiedUserAccessControlContext: vi.fn(), + mockLoadWorkspace: vi.fn(), + mockResolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mockLoadWorkspace, +})) vi.mock('@/lib/workspaces/host-context', () => ({ getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer, @@ -23,8 +43,13 @@ import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' const context = { userId: 'user-1', + workflowId: '', workspaceId: 'workspace-1', -} as ExecutionContext + chatId: 'chat-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + copilotInteractionMode: 'interactive', +} as const satisfies ExecutionContext function enterpriseHost(permission: 'read' | 'write' | 'admin') { return { @@ -60,6 +85,13 @@ function enterpriseHost(permission: 'read' | 'write' | 'admin') { describe('executeGetEnterpriseContext', () => { beforeEach(() => { vi.clearAllMocks() + mockLoadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + }) + mockResolvePermission.mockResolvedValue('read') }) it('requires a current workspace', async () => { @@ -72,6 +104,21 @@ describe('executeGetEnterpriseContext', () => { expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled() }) + it('rejects headless execution before loading workspace or enterprise context', async () => { + const result = await executeGetEnterpriseContext({ + ...context, + copilotInteractionMode: 'headless', + }) + + expect(result).toEqual({ + success: false, + error: 'Live platform context is available only in an interactive Copilot session.', + }) + expect(mockLoadWorkspace).not.toHaveBeenCalled() + expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled() + expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() + }) + it('keeps external workspace administration separate from organization authority', async () => { mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('admin')) mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ @@ -201,7 +248,7 @@ describe('executeGetEnterpriseContext', () => { capabilities: { canRead: true, canEdit: false, - canRun: false, + canRun: true, canDeploy: false, canManageWorkspace: false, }, @@ -210,6 +257,35 @@ describe('executeGetEnterpriseContext', () => { }) }) + it('does not advertise deployment when every deployment surface is hidden', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('admin')) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: 'org-1', + entitled: true, + permissionGroup: null, + config: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployApi: true, + hideDeployMcp: true, + hideDeployChatbot: true, + }, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toMatchObject({ + success: true, + output: { + workspace: { + capabilities: { + canRun: true, + canDeploy: false, + }, + }, + }, + }) + }) + it('returns a personal-workspace context without looking up organization membership', async () => { mockGetWorkspaceHostContextForViewer.mockResolvedValue({ ...enterpriseHost('write'), @@ -268,7 +344,10 @@ describe('executeGetEnterpriseContext', () => { const result = await executeGetEnterpriseContext(context) - expect(result).toEqual({ success: false, error: 'workspace lookup failed' }) + expect(result).toEqual({ + success: false, + error: 'The operation failed due to a system error. Please retry.', + }) expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() }) @@ -280,6 +359,9 @@ describe('executeGetEnterpriseContext', () => { const result = await executeGetEnterpriseContext(context) - expect(result).toEqual({ success: false, error: 'access-control lookup failed' }) + expect(result).toEqual({ + success: false, + error: 'The operation failed due to a system error. Please retry.', + }) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts index ece309369d7..d72f7ae1db0 100644 --- a/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts +++ b/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts @@ -1,22 +1,9 @@ -import { permissionSatisfies } from '@sim/platform-authz/workspace' -import { toError } from '@sim/utils/errors' +import { + executeCopilotPlatformContextUseCase, + messageForCopilotPlatformContextError, +} from '@/lib/copilot/application/execute-platform-context-use-case' 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 +import { readEnterpriseContext } from '@/lib/platform-context/application/read-enterprise-context' /** * Resolves the authenticated user's effective Enterprise access in the current @@ -34,61 +21,14 @@ export async function executeGetEnterpriseContext( } 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') - + const output = await executeCopilotPlatformContextUseCase(context, readEnterpriseContext, { + workspaceId: context.workspaceId, + }) 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(), - }, + output, } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotPlatformContextError(error) } } } diff --git a/apps/sim/lib/organizations/settings-access.test.ts b/apps/sim/lib/organizations/settings-access.test.ts index 3ac741ff69d..f598c09fd85 100644 --- a/apps/sim/lib/organizations/settings-access.test.ts +++ b/apps/sim/lib/organizations/settings-access.test.ts @@ -46,6 +46,14 @@ describe('organization settings access', () => { }) }) + it('fails closed when a stored membership has a non-canonical role', async () => { + queueTableRows(member, [{ role: 'billing-owner' }]) + + await expect(getOrganizationSettingsAccess('organization-route', 'viewer')).rejects.toThrow( + 'Invalid role' + ) + }) + it('allows members to view the roster but reserves control-plane sections for admins', async () => { queueTableRows(member, [{ role: 'member' }]) await expect( diff --git a/apps/sim/lib/organizations/settings-access.ts b/apps/sim/lib/organizations/settings-access.ts index 3281a11a930..db374c716db 100644 --- a/apps/sim/lib/organizations/settings-access.ts +++ b/apps/sim/lib/organizations/settings-access.ts @@ -7,11 +7,12 @@ import { type OrganizationSettingsSection, resolveOrganizationSectionAccess, } from '@/components/settings/navigation' +import { type OrganizationRole, organizationRoleSchema } from '@/lib/api/contracts/primitives' interface OrganizationSettingsAccess { isAdmin: boolean isMember: boolean - role: string | null + role: OrganizationRole | null } /** @@ -28,7 +29,7 @@ async function resolveOrganizationSettingsAccess( .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) .limit(1) - const role = membership?.role ?? null + const role = membership ? organizationRoleSchema.parse(membership.role) : null return { role, isMember: role !== null, diff --git a/apps/sim/lib/permission-groups/features.test.ts b/apps/sim/lib/permission-groups/features.test.ts index a22b5474004..c7496830cd3 100644 --- a/apps/sim/lib/permission-groups/features.test.ts +++ b/apps/sim/lib/permission-groups/features.test.ts @@ -1,3 +1,6 @@ +/** + * @vitest-environment node + */ import { describe, expect, it } from 'vitest' import { getActivePermissionGroupRestrictions, diff --git a/apps/sim/lib/platform-context/application/authorization.ts b/apps/sim/lib/platform-context/application/authorization.ts new file mode 100644 index 00000000000..11fe1a00bf5 --- /dev/null +++ b/apps/sim/lib/platform-context/application/authorization.ts @@ -0,0 +1,12 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export const PLATFORM_CONTEXT_DELEGATION_AUDIENCE = 'sim:platform-context' + +export const platformContextDelegationPolicy = { + audience: PLATFORM_CONTEXT_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: DelegatedPrincipal, + context: ActiveWorkspaceApplicationContext + ): boolean => principal.workspaceId === context.workspaceId, +} as const diff --git a/apps/sim/lib/platform-context/application/context.ts b/apps/sim/lib/platform-context/application/context.ts new file mode 100644 index 00000000000..f25cc85b882 --- /dev/null +++ b/apps/sim/lib/platform-context/application/context.ts @@ -0,0 +1,14 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +/** Loads canonical active workspace state before authorizing a live platform-context read. */ +export async function resolvePlatformContextWorkspace( + workspaceId: string +): Promise { + const context = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} diff --git a/apps/sim/lib/platform-context/application/operations.ts b/apps/sim/lib/platform-context/application/operations.ts new file mode 100644 index 00000000000..36c650a064f --- /dev/null +++ b/apps/sim/lib/platform-context/application/operations.ts @@ -0,0 +1,24 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY = { + principalKinds: ['delegated'], + delegatedServices: ['copilot'], +} as const + +export const platformContextOperations = { + readAccountBilling: defineWorkspaceOperation({ + id: 'platform_context.account_billing.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY, + }), + readEnterpriseContext: defineWorkspaceOperation({ + id: 'platform_context.enterprise.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY, + }), +} as const + +export type PlatformContextOperation = + (typeof platformContextOperations)[keyof typeof platformContextOperations] diff --git a/apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts b/apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts new file mode 100644 index 00000000000..64319782d72 --- /dev/null +++ b/apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts @@ -0,0 +1,221 @@ +/** + * @vitest-environment node + */ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getAccountBillingSnapshot: vi.fn(), + getWorkspaceHostContextForViewer: vi.fn(), + resolveVerifiedUserAccessControlContext: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@/lib/billing/core/account-billing-snapshot', () => ({ + getAccountBillingSnapshot: mocks.getAccountBillingSnapshot, +})) + +vi.mock('@/lib/workspaces/host-context', () => ({ + getWorkspaceHostContextForViewer: mocks.getWorkspaceHostContextForViewer, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + resolveVerifiedUserAccessControlContext: mocks.resolveVerifiedUserAccessControlContext, +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' +import { readAccountBilling } from '@/lib/platform-context/application/read-account-billing' +import { readEnterpriseContext } from '@/lib/platform-context/application/read-enterprise-context' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', +} + +function copilotPrincipal(): DelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:platform-context', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + } +} + +describe('platform context application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('read') + }) + + it('authorizes a current Copilot subject before reading account billing', async () => { + const snapshot = { + plan: 'pro', + billingScope: 'user', + organizationId: null, + usage: {}, + credits: {}, + } + mocks.getAccountBillingSnapshot.mockResolvedValue(snapshot) + + await expect( + readAccountBilling.execute({ + principal: copilotPrincipal(), + input: { workspaceId: 'workspace-1' }, + }) + ).resolves.toBe(snapshot) + + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'org-1', + undefined, + { forUpdate: undefined } + ) + expect(mocks.getAccountBillingSnapshot).toHaveBeenCalledWith('user-1') + }) + + it.each([ + { + name: 'workspace API key', + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + }, + { + name: 'executor delegation', + principal: { ...copilotPrincipal(), serviceId: 'executor' as const }, + }, + ])('rejects a $name before loading protected account context', async ({ principal }) => { + await expect( + readAccountBilling.execute({ principal, input: { workspaceId: 'workspace-1' } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.getAccountBillingSnapshot).not.toHaveBeenCalled() + }) + + it('does not load enterprise context when current workspace access is absent', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + readEnterpriseContext.execute({ + principal: copilotPrincipal(), + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.getWorkspaceHostContextForViewer).not.toHaveBeenCalled() + expect(mocks.resolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() + }) + + it('projects enterprise context only after authorization', async () => { + mocks.getWorkspaceHostContextForViewer.mockResolvedValue({ + workspace: { + id: 'workspace-1', + name: 'Customer Support', + workspaceMode: 'collaborative', + }, + hostOrganizationId: 'org-1', + ownerBilling: { plan: 'enterprise', isEnterprise: true }, + viewer: { + permission: 'admin', + isHostOrganizationMember: false, + isHostOrganizationAdmin: false, + organizationRole: null, + }, + }) + mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ + entitled: true, + permissionGroup: null, + config: DEFAULT_PERMISSION_GROUP_CONFIG, + }) + + await expect( + readEnterpriseContext.execute({ + principal: copilotPrincipal(), + input: { workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ + workspace: { + id: 'workspace-1', + capabilities: { canRead: true, canEdit: true, canDeploy: true }, + }, + organization: { + id: 'org-1', + relationship: 'external', + canManageOrganization: false, + }, + accessControl: { entitled: true }, + }) + expect(mocks.getWorkspaceHostContextForViewer).toHaveBeenCalledWith('workspace-1', 'user-1') + }) + + it('allows read-role execution but hides deployment when every deploy surface is hidden', async () => { + mocks.getWorkspaceHostContextForViewer.mockResolvedValue({ + workspace: { + id: 'workspace-1', + name: 'Customer Support', + workspaceMode: 'collaborative', + }, + hostOrganizationId: 'org-1', + ownerBilling: { plan: 'enterprise', isEnterprise: true }, + viewer: { + permission: 'read', + isHostOrganizationMember: true, + isHostOrganizationAdmin: false, + organizationRole: 'member', + }, + }) + mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ + entitled: true, + permissionGroup: null, + config: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployApi: true, + hideDeployMcp: true, + hideDeployChatbot: true, + }, + }) + + await expect( + readEnterpriseContext.execute({ + principal: copilotPrincipal(), + input: { workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ + workspace: { + capabilities: { + canRead: true, + canEdit: false, + canRun: true, + canDeploy: false, + canManageWorkspace: false, + }, + }, + }) + }) +}) diff --git a/apps/sim/lib/platform-context/application/read-account-billing.ts b/apps/sim/lib/platform-context/application/read-account-billing.ts new file mode 100644 index 00000000000..ee3bd7ed698 --- /dev/null +++ b/apps/sim/lib/platform-context/application/read-account-billing.ts @@ -0,0 +1,22 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + type AccountBillingSnapshot, + getAccountBillingSnapshot, +} from '@/lib/billing/core/account-billing-snapshot' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { platformContextDelegationPolicy } from '@/lib/platform-context/application/authorization' +import { resolvePlatformContextWorkspace } from '@/lib/platform-context/application/context' +import { platformContextOperations } from '@/lib/platform-context/application/operations' + +export interface ReadAccountBillingInput { + workspaceId: string +} + +export const readAccountBilling = defineAuthorizedWorkspaceUseCase({ + operation: platformContextOperations.readAccountBilling, + resolveContext: ({ input }: { input: ReadAccountBillingInput }) => + resolvePlatformContextWorkspace(input.workspaceId), + authorizationOptions: { delegation: platformContextDelegationPolicy }, + execute: async ({ principal }): Promise => + getAccountBillingSnapshot(requirePrincipalSubjectUserId(principal)), +}) diff --git a/apps/sim/lib/platform-context/application/read-enterprise-context.ts b/apps/sim/lib/platform-context/application/read-enterprise-context.ts new file mode 100644 index 00000000000..dab02b17e5d --- /dev/null +++ b/apps/sim/lib/platform-context/application/read-enterprise-context.ts @@ -0,0 +1,88 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' +import { platformContextDelegationPolicy } from '@/lib/platform-context/application/authorization' +import { resolvePlatformContextWorkspace } from '@/lib/platform-context/application/context' +import { platformContextOperations } from '@/lib/platform-context/application/operations' +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 + +export interface ReadEnterpriseContextInput { + workspaceId: string +} + +export const readEnterpriseContext = defineAuthorizedWorkspaceUseCase({ + operation: platformContextOperations.readEnterpriseContext, + resolveContext: ({ input }: { input: ReadEnterpriseContextInput }) => + resolvePlatformContextWorkspace(input.workspaceId), + authorizationOptions: { delegation: platformContextDelegationPolicy }, + async execute({ principal, context }) { + const userId = requirePrincipalSubjectUserId(principal) + const hostContext = await getWorkspaceHostContextForViewer(context.workspaceId, userId) + if (!hostContext) { + throw new OrchestrationError('not_found', 'Workspace not found or you do not have access.') + } + + const accessControl = await resolveVerifiedUserAccessControlContext( + userId, + context.workspaceId, + hostContext.hostOrganizationId + ) + const canWrite = permissionSatisfies(hostContext.viewer.permission, 'write') + const canAdmin = permissionSatisfies(hostContext.viewer.permission, 'admin') + const allDeploymentSurfacesHidden = + accessControl.config?.hideDeployApi === true && + accessControl.config.hideDeployMcp === true && + accessControl.config.hideDeployChatbot === true + + return { + workspace: { + id: hostContext.workspace.id, + name: hostContext.workspace.name, + mode: hostContext.workspace.workspaceMode, + permission: hostContext.viewer.permission, + capabilities: { + canRead: true, + canEdit: canWrite, + canRun: true, + canDeploy: canAdmin && !allDeploymentSurfacesHidden, + 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(), + } + }, +})