From f5597218f409280f3a96874e78c93bfa719d5c9b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 19:59:20 -0700 Subject: [PATCH 001/179] test(permission-groups): pin config coercion before deriving it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a golden corpus and a seeded fuzz loop over `parsePermissionGroupConfig`, written against the current hand-written implementation so a derived one has something to prove itself against. Every row states what a stored `jsonb` value coerces to today; a row that changes in a later diff is a decision someone has to defend rather than a silent regression. Two properties the corpus pins are easy to break by accident: - `typeof [] === 'object'`, so an array-valued column coerces to defaults rather than throwing. A parser built on `z.object()` throws here unless it guards `Array.isArray`. - An emptied allowlist denies everything while `null` allows everything, so the two must never collapse into one another. It also pins a live defect. `allowedIntegrations` and `allowedModelProviders` are the only keys that skip element validation, so a corrupted row coerces to a value `permissionGroupFullConfigSchema` then refuses — the route reading it fails response validation instead of returning a usable allowlist. Filtering non-strings on the way in is fail-closed and removes the class; the test inverts when that lands. Adds the two coverage guards that were missing: the write schema, the defaults and the read schema declare the same keys, and every boolean config key is registered as a platform feature. Neither was asserted, so a key omitted from the write schema would have made an admin checkbox silently no-op on save. Drops the `@/lib/permission-groups/types` mock in the permission-check suite and its hand-copied `DEFAULT_PERMISSION_GROUP_CONFIG`. That module imports only zod and a type, so there was nothing to mock, and the mock's permissive merge was strictly looser than the real parser — those 63 tests were asserting against a fake that could not reproduce a coercion bug. The factory also returned two exports, leaving `FILE_SHARE_AUTH_TYPES` and `PERMISSION_GROUP_CONSTRAINTS` undefined for that module graph. --- .../utils/permission-check.test.ts | 50 +-- apps/sim/lib/permission-groups/types.test.ts | 322 ++++++++++++++++++ 2 files changed, 328 insertions(+), 44 deletions(-) create mode 100644 apps/sim/lib/permission-groups/types.test.ts 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 3a47110594f..c8251378acf 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -12,42 +12,12 @@ import { import { afterAll, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { getBlock } from '@/blocks/registry' -const { - DEFAULT_PERMISSION_GROUP_CONFIG, - mockIsOrganizationOnEnterprisePlan, - mockGetWorkspaceWithOwner, - mockGetProviderFromModel, -} = vi.hoisted(() => ({ - DEFAULT_PERMISSION_GROUP_CONFIG: { - allowedIntegrations: null, - allowedModelProviders: null, - deniedModels: [], - deniedTools: [], - hideTraceSpans: false, - hideKnowledgeBaseTab: false, - hideTablesTab: false, - hideCopilot: false, - hideIntegrationsTab: false, - hideSecretsTab: false, - hideApiKeysTab: false, - hideInboxTab: false, - hideFilesTab: false, - disableMcpTools: false, - disableCustomTools: false, - disableSkills: false, - disableInvitations: false, - disablePublicApi: false, - disablePublicFileSharing: false, - allowedFileShareAuthTypes: null, - hideDeployApi: false, - hideDeployMcp: false, - hideDeployChatbot: false, - allowedChatDeployAuthTypes: null, - }, - mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), - mockGetWorkspaceWithOwner: vi.fn<() => Promise<{ organizationId: string | null } | null>>(), - mockGetProviderFromModel: vi.fn<(model: string) => string>(), -})) +const { mockIsOrganizationOnEnterprisePlan, mockGetWorkspaceWithOwner, mockGetProviderFromModel } = + vi.hoisted(() => ({ + mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), + mockGetWorkspaceWithOwner: vi.fn<() => Promise<{ organizationId: string | null } | null>>(), + mockGetProviderFromModel: vi.fn<(model: string) => string>(), + })) vi.mock('@/lib/billing', () => ({ isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, @@ -57,14 +27,6 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) -vi.mock('@/lib/permission-groups/types', () => ({ - DEFAULT_PERMISSION_GROUP_CONFIG, - parsePermissionGroupConfig: (config: unknown) => { - if (!config || typeof config !== 'object') return DEFAULT_PERMISSION_GROUP_CONFIG - return { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...config } - }, -})) - vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => typeof toolCall === 'object' && diff --git a/apps/sim/lib/permission-groups/types.test.ts b/apps/sim/lib/permission-groups/types.test.ts new file mode 100644 index 00000000000..799505382a1 --- /dev/null +++ b/apps/sim/lib/permission-groups/types.test.ts @@ -0,0 +1,322 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { permissionGroupFullConfigSchema } from '@/lib/api/contracts/permission-groups' +import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + type PermissionGroupConfig, + parsePermissionGroupConfig, + permissionGroupConfigSchema, +} from '@/lib/permission-groups/types' + +/** + * The coercion corpus, pinned against the hand-written parser before it is + * replaced by a derived one. + * + * Every row states what a stored `jsonb` value coerces to today. A derived + * implementation has to reproduce this table exactly, so any row that changes + * in a later diff is a deliberate semantic decision someone has to defend + * rather than a silent regression. + */ +interface CoercionFixture { + name: string + input: unknown + expected: PermissionGroupConfig + /** + * Whether the coerced config is one the read schema can serialize. Only the + * unfiltered-allowlist row is `false`, and that is a defect rather than a + * property worth keeping — see the test that pins it. + */ + readSchemaAccepts?: false +} + +const fixtures: readonly CoercionFixture[] = [ + { name: 'null', input: null, expected: DEFAULT_PERMISSION_GROUP_CONFIG }, + { name: 'undefined', input: undefined, expected: DEFAULT_PERMISSION_GROUP_CONFIG }, + /** + * `typeof [] === 'object'`, so an array-valued column falls through the + * object guard and coerces to defaults rather than throwing. A derived + * parser built on `z.object()` throws here unless it guards `Array.isArray`. + */ + { name: 'an array (jsonb [])', input: [], expected: DEFAULT_PERMISSION_GROUP_CONFIG }, + { name: 'a string', input: 'nope', expected: DEFAULT_PERMISSION_GROUP_CONFIG }, + { name: 'a number', input: 7, expected: DEFAULT_PERMISSION_GROUP_CONFIG }, + { name: 'an empty object', input: {}, expected: DEFAULT_PERMISSION_GROUP_CONFIG }, + { + name: 'unknown keys, which are dropped', + input: { bogus: 1, hideCopilot: true }, + expected: { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCopilot: true }, + }, + { + name: 'a boolean given a string', + input: { hideCopilot: 'yes' }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'a boolean given null', + input: { hideTablesTab: null }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'a boolean given false explicitly', + input: { hideFilesTab: false }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'a denylist with mixed members, keeping the strings', + input: { deniedTools: ['slack_canvas', 42, null, { a: 1 }] }, + expected: { ...DEFAULT_PERMISSION_GROUP_CONFIG, deniedTools: ['slack_canvas'] }, + }, + { + name: 'a denylist given an object', + input: { deniedModels: {} }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'a denylist given a string', + input: { deniedModels: 'gpt-4o' }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'auth types with an invalid member, keeping the valid ones', + input: { allowedFileShareAuthTypes: ['sso', 'bogus', 'password'] }, + expected: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedFileShareAuthTypes: ['sso', 'password'], + }, + }, + { + name: 'auth types given a bare string', + input: { allowedChatDeployAuthTypes: 'sso' }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'auth types emptied, which denies every mode', + input: { allowedChatDeployAuthTypes: [] }, + expected: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedChatDeployAuthTypes: [] }, + }, + /** + * An emptied allowlist denies everything while `null` allows everything, so + * the two must never collapse into one another. + */ + { + name: 'an emptied allowlist, which denies every integration', + input: { allowedIntegrations: [] }, + expected: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: [] }, + }, + { + name: 'an explicitly null allowlist', + input: { allowedModelProviders: null }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'an allowlist given a string', + input: { allowedIntegrations: 'slack' }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + /** + * Today the two allowlists are the only keys that skip element validation, so + * a `string[]`-typed field can hold a number. Filtering them would be a + * fail-closed change; this row exists so making it shows up in the diff. + */ + { + name: 'an allowlist with a non-string member, which is passed through unfiltered', + input: { allowedIntegrations: ['slack', 42] }, + expected: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['slack', 42] as unknown as string[], + }, + readSchemaAccepts: false, + }, + { + name: 'a fully populated config', + input: { + allowedIntegrations: ['slack_v2'], + allowedModelProviders: ['anthropic'], + deniedModels: ['gpt-4o'], + deniedTools: ['slack_canvas'], + hideTraceSpans: true, + hideKnowledgeBaseTab: true, + hideTablesTab: true, + hideCopilot: true, + hideIntegrationsTab: true, + hideSecretsTab: true, + hideApiKeysTab: true, + hideInboxTab: true, + hideFilesTab: true, + disableMcpTools: true, + disableCustomTools: true, + disableSkills: true, + disableInvitations: true, + disablePublicApi: true, + disablePublicFileSharing: true, + allowedFileShareAuthTypes: ['sso'], + hideDeployApi: true, + hideDeployMcp: true, + hideDeployChatbot: true, + allowedChatDeployAuthTypes: ['password'], + }, + expected: { + allowedIntegrations: ['slack_v2'], + allowedModelProviders: ['anthropic'], + deniedModels: ['gpt-4o'], + deniedTools: ['slack_canvas'], + hideTraceSpans: true, + hideKnowledgeBaseTab: true, + hideTablesTab: true, + hideCopilot: true, + hideIntegrationsTab: true, + hideSecretsTab: true, + hideApiKeysTab: true, + hideInboxTab: true, + hideFilesTab: true, + disableMcpTools: true, + disableCustomTools: true, + disableSkills: true, + disableInvitations: true, + disablePublicApi: true, + disablePublicFileSharing: true, + allowedFileShareAuthTypes: ['sso'], + hideDeployApi: true, + hideDeployMcp: true, + hideDeployChatbot: true, + allowedChatDeployAuthTypes: ['password'], + }, + }, +] + +describe('parsePermissionGroupConfig', () => { + it.each(fixtures)('coerces $name', ({ input, expected }) => { + expect(parsePermissionGroupConfig(input)).toEqual(expected) + }) + + it.each(fixtures)('emits every key in wire order for $name', ({ input }) => { + expect(Object.keys(parsePermissionGroupConfig(input))).toEqual( + Object.keys(DEFAULT_PERMISSION_GROUP_CONFIG) + ) + }) + + it.each(fixtures)( + 'produces a config the read schema accepts for $name', + ({ input, readSchemaAccepts }) => { + const parsed = structuredClone(parsePermissionGroupConfig(input)) + expect(permissionGroupFullConfigSchema.safeParse(parsed).success).toBe( + readSchemaAccepts !== false + ) + } + ) + + /** + * Pins a live defect so the fix is visible as a diff rather than a silent + * behavior change. `allowedIntegrations` and `allowedModelProviders` are the + * only keys that skip element validation, so a corrupted row coerces to a + * value `permissionGroupFullConfigSchema` then refuses — meaning the route + * that reads it fails response validation instead of returning a usable + * allowlist. Filtering non-strings on the way in is fail-closed and removes + * the whole class; when that lands, this test inverts. + */ + it('coerces a corrupted allowlist into a config the read schema refuses', () => { + const parsed = parsePermissionGroupConfig({ allowedIntegrations: ['slack', 42] }) + expect(parsed.allowedIntegrations).toEqual(['slack', 42]) + expect(permissionGroupFullConfigSchema.safeParse(structuredClone(parsed)).success).toBe(false) + }) + + it('is idempotent', () => { + for (const { input } of fixtures) { + const once = parsePermissionGroupConfig(input) + expect(parsePermissionGroupConfig(structuredClone(once))).toEqual(once) + } + }) +}) + +/** + * A deterministic generator, seeded so a failure reproduces from the printed + * seed alone. A fixed corpus pins the cases we thought of; this covers the + * shapes we did not, and asserts only invariants so it stays meaningful after + * the parser is reimplemented. + */ +function createRandom(seed: number): () => number { + let state = seed + return () => { + state = (state * 1664525 + 1013904223) % 0x100000000 + return state / 0x100000000 + } +} + +const MALFORMED_VALUES: readonly unknown[] = [ + undefined, + null, + true, + false, + 0, + 1, + 'sso', + '', + [], + ['slack'], + ['slack', 42], + ['sso', 'bogus'], + [null], + [{}], + {}, + { nested: true }, + Number.NaN, +] + +describe('parsePermissionGroupConfig invariants', () => { + const configKeys = Object.keys(DEFAULT_PERMISSION_GROUP_CONFIG) + + it('holds over randomly malformed configs', () => { + const seed = 0x5eed + const random = createRandom(seed) + + for (let iteration = 0; iteration < 2000; iteration++) { + const input: Record = {} + for (const key of configKeys) { + if (random() < 0.35) continue + input[key] = MALFORMED_VALUES[Math.floor(random() * MALFORMED_VALUES.length)] + } + + const parsed = parsePermissionGroupConfig(input) + const context = `seed ${seed}, iteration ${iteration}, input ${JSON.stringify(input)}` + + expect(Object.keys(parsed), context).toEqual(configKeys) + expect(parsePermissionGroupConfig(structuredClone(parsed)), context).toEqual(parsed) + } + }) + + /** + * Deliberately not asserted above: a config the read schema accepts. The + * unfiltered allowlists break it for any input carrying a non-string member, + * so this invariant only becomes true once element filtering lands — add it + * here in the same change that inverts the defect test above. + */ +}) + +describe('permission group config key coverage', () => { + it('declares the same keys in the write schema, the defaults, and the read schema', () => { + expect(Object.keys(permissionGroupConfigSchema.shape)).toEqual( + Object.keys(DEFAULT_PERMISSION_GROUP_CONFIG) + ) + expect(Object.keys(permissionGroupFullConfigSchema.shape)).toEqual( + Object.keys(DEFAULT_PERMISSION_GROUP_CONFIG) + ) + }) + + it('registers every boolean config key as a platform feature', () => { + const booleanKeys = Object.entries(DEFAULT_PERMISSION_GROUP_CONFIG) + .filter(([, value]) => typeof value === 'boolean') + .map(([key]) => key) + + expect([...PLATFORM_FEATURES.map((feature) => feature.configKey)].sort()).toEqual( + [...booleanKeys].sort() + ) + }) + + it('gives every platform feature a unique id', () => { + const ids = PLATFORM_FEATURES.map((feature) => feature.id) + expect(new Set(ids).size).toBe(ids.length) + }) +}) From 1230c2185e2b1052bfb125cd1c3ac1966f0d1e9b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 20:07:20 -0700 Subject: [PATCH 002/179] refactor(permission-groups): derive the config from one field registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config shape was maintained by hand in five parallel places — the write schema, the `PermissionGroupConfig` interface, the defaults, the tolerant parser, and the contract's read schema — plus the platform-feature list. Key order was load-bearing across all of them, because the group editor's dirty check compares stringified configs, so a key added at a different index read as an unsaved change forever. Nothing checked that the write schema had the same keys as the rest, and a key missing there made an admin checkbox silently no-op on save. `PERMISSION_GROUP_FIELDS` now declares each key once, carrying its schema, its default, whether it is server-enforced, and (for a boolean) its editor descriptor. Everything else is projected from it, so declaration order is the wire order by construction rather than by agreement. Two things this could have broken quietly, both pinned by the corpus added in the previous commit: - `z.object().parse([])` throws, but `typeof [] === 'object'`, so an array-valued jsonb column used to coerce to defaults. The guard keeps `Array.isArray` for exactly that row. - `.catch(default)` is whole-value tolerant while the old parser was element-wise. On an allowlist that would have been fail-open: one bad member would yield `null`, and `null` means unrestricted. `tolerantArray` filters instead, so a corrupt member narrows the allowlist. One deliberate behavior change. `allowedIntegrations` and `allowedModelProviders` were the only keys that skipped element validation, so a corrupted row produced a config the read schema then refused — the route reading it failed response validation instead of returning a usable allowlist. They now filter like every other array, which is fail-closed. Type-level assertions live in the source rather than a test because type-check excludes test files; a zod generic degrading to `unknown` would otherwise be invisible, since the runtime values would still be correct while every call site lost its narrowing. --- .../lib/api/contracts/permission-groups.ts | 37 +- apps/sim/lib/permission-groups/features.ts | 219 ++------- apps/sim/lib/permission-groups/fields.ts | 415 ++++++++++++++++++ apps/sim/lib/permission-groups/types.test.ts | 63 +-- apps/sim/lib/permission-groups/types.ts | 171 +------- 5 files changed, 510 insertions(+), 395 deletions(-) create mode 100644 apps/sim/lib/permission-groups/fields.ts diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index e3a531d8b43..fc1dc0c49ab 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -1,35 +1,18 @@ import { z } from 'zod' import { organizationIdSchema } from '@/lib/api/contracts/primitives' -import { shareAuthTypeSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' +import { permissionGroupReadShape } from '@/lib/permission-groups/fields' import { permissionGroupConfigSchema } from '@/lib/permission-groups/types' -export const permissionGroupFullConfigSchema = z.object({ - allowedIntegrations: z.array(z.string()).nullable(), - allowedModelProviders: z.array(z.string()).nullable(), - deniedModels: z.array(z.string()).default([]), - deniedTools: z.array(z.string()).default([]), - hideTraceSpans: z.boolean(), - hideKnowledgeBaseTab: z.boolean(), - hideTablesTab: z.boolean(), - hideCopilot: z.boolean(), - hideIntegrationsTab: z.boolean(), - hideSecretsTab: z.boolean(), - hideApiKeysTab: z.boolean(), - hideInboxTab: z.boolean(), - hideFilesTab: z.boolean(), - disableMcpTools: z.boolean(), - disableCustomTools: z.boolean(), - disableSkills: z.boolean(), - disableInvitations: z.boolean(), - disablePublicApi: z.boolean(), - disablePublicFileSharing: z.boolean(), - allowedFileShareAuthTypes: z.array(shareAuthTypeSchema).nullable(), - hideDeployApi: z.boolean(), - hideDeployMcp: z.boolean(), - hideDeployChatbot: z.boolean(), - allowedChatDeployAuthTypes: z.array(shareAuthTypeSchema).nullable(), -}) +/** + * The wire shape of a resolved config: every key present, in registry order. + * + * Built from the same field registry as the write schema and the tolerant + * parser, because the group editor's dirty check compares stringified configs — + * a key this schema omitted, or ordered differently, would read as an unsaved + * change forever. + */ +export const permissionGroupFullConfigSchema = z.object(permissionGroupReadShape) export const addPermissionGroupMemberBodySchema = z.object({ userId: z.string().min(1), diff --git a/apps/sim/lib/permission-groups/features.ts b/apps/sim/lib/permission-groups/features.ts index 78aa4f79545..d2cbe3c152b 100644 --- a/apps/sim/lib/permission-groups/features.ts +++ b/apps/sim/lib/permission-groups/features.ts @@ -1,6 +1,10 @@ -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import { + PERMISSION_GROUP_FIELDS, + type PermissionGroupConfig, + type PermissionGroupConfigKey, +} from '@/lib/permission-groups/fields' -type BooleanPermissionGroupConfigKey = { +export type BooleanPermissionGroupConfigKey = { [Key in keyof PermissionGroupConfig]: PermissionGroupConfig[Key] extends boolean ? Key : never }[keyof PermissionGroupConfig] @@ -31,137 +35,31 @@ export const PLATFORM_CATEGORY_ORDER: readonly string[] = [ '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[] +const FIELD_ENTRIES = Object.entries(PERMISSION_GROUP_FIELDS) as Array< + [PermissionGroupConfigKey, (typeof PERMISSION_GROUP_FIELDS)[PermissionGroupConfigKey]] +> -/** Returns only restrictions that actively constrain the current user. */ +/** + * The boolean toggles the Access Control editor renders, in registry order. + * + * Derived rather than listed, so a boolean key cannot reach the config without + * reaching the editor — an unrendered key is one an admin can neither set nor + * see, which is how a restriction ends up applying with nothing to explain it. + */ +export const PLATFORM_FEATURES: readonly PermissionGroupPlatformFeature[] = FIELD_ENTRIES.flatMap( + ([key, field]) => + field.kind === 'boolean-restriction' + ? [{ ...field.feature, configKey: key as BooleanPermissionGroupConfigKey }] + : [] +) + +/** + * Returns only restrictions that actively constrain the current user. + * + * Two passes, allowlists and denylists before booleans, because the resulting + * prose is what the Copilot context and the group roster read: reordering it + * would rewrite text that surfaces to users for no reason. + */ export function getActivePermissionGroupRestrictions( config: PermissionGroupConfig | null ): ActivePermissionGroupRestriction[] { @@ -169,53 +67,16 @@ export function getActivePermissionGroupRestrictions( 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 [key, field] of FIELD_ENTRIES) { + const value = config[key] + if (field.kind === 'allowlist' && Array.isArray(value)) { + restrictions.push({ + key, + description: value.length > 0 ? field.phrasing.limited : field.phrasing.empty, + }) + } else if (field.kind === 'denylist' && Array.isArray(value) && value.length > 0) { + restrictions.push({ key, description: field.phrasing }) + } } for (const feature of PLATFORM_FEATURES) { diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts new file mode 100644 index 00000000000..9ecbedd9ae2 --- /dev/null +++ b/apps/sim/lib/permission-groups/fields.ts @@ -0,0 +1,415 @@ +import { z } from 'zod' + +/** + * Auth modes a public file share or a chat deployment can use; admins may + * restrict the allowed subset. The two surfaces share the same four modes. + */ +export const FILE_SHARE_AUTH_TYPES = ['public', 'password', 'email', 'sso'] as const + +const shareAuthType = z.enum(FILE_SHARE_AUTH_TYPES) + +/** + * Whether a key is refused by a server-side gate or is only a client rendering + * hint. A `ui-only` key hides a surface without withholding it, so a caller + * that skips the UI still reaches the API — that is a property to state, not to + * discover. + */ +export type PermissionGroupEnforcement = 'server' | 'ui-only' + +/** The admin-editor descriptor for a boolean key, rendered from the registry. */ +interface PlatformFeatureMeta { + readonly id: string + readonly label: string + readonly category: string + readonly hint: string +} + +/** Prose for an allowlist, which reads differently narrowed than emptied. */ +interface AllowlistPhrasing { + readonly limited: string + readonly empty: string +} + +/** + * Coerces an untrusted value into an array of `item`, element by element. + * + * Deliberately not `z.array(item).catch(fallback)`: `.catch` is whole-value + * tolerant, so one bad member would discard every good one. For an allowlist + * that is also a fail-open change, because the fallback is `null` and `null` + * means unrestricted — a partially corrupt allowlist would stop restricting + * anything. Filtering keeps the surviving members and fails closed. + */ +function tolerantArray[] | null>( + item: TItem, + fallback: TFallback +): z.ZodType[] | TFallback> { + return z.unknown().transform((raw) => { + if (!Array.isArray(raw)) return fallback + return raw.flatMap((entry) => { + const parsed = item.safeParse(entry) + return parsed.success ? [parsed.data as z.infer] : [] + }) + }) +} + +interface BooleanRestrictionField { + readonly kind: 'boolean-restriction' + readonly schema: z.ZodBoolean + readonly writeSchema: z.ZodOptional + readonly readSchema: z.ZodBoolean + readonly tolerantSchema: z.ZodType + readonly default: boolean + readonly enforcement: PermissionGroupEnforcement + readonly feature: PlatformFeatureMeta +} + +interface AllowlistField { + readonly kind: 'allowlist' + readonly schema: z.ZodNullable> + readonly writeSchema: z.ZodOptional>> + readonly readSchema: z.ZodNullable> + readonly tolerantSchema: z.ZodType[] | null> + readonly default: null + readonly enforcement: PermissionGroupEnforcement + readonly phrasing: AllowlistPhrasing +} + +interface DenylistField { + readonly kind: 'denylist' + readonly schema: z.ZodArray + readonly writeSchema: z.ZodOptional> + readonly readSchema: z.ZodDefault> + readonly tolerantSchema: z.ZodType[]> + readonly default: never[] + readonly enforcement: PermissionGroupEnforcement + readonly phrasing: string +} + +/** + * The structural shape every entry satisfies. Deliberately loose in its schema + * members: a concrete `AllowlistField` is not reliably assignable to + * `AllowlistField` through zod's internals, and widening the registry + * to this type would erase the per-key output types the config depends on. It + * exists for `satisfies`, never as an annotation. + */ +type PermissionGroupField = { + readonly kind: 'boolean-restriction' | 'allowlist' | 'denylist' + readonly schema: z.ZodType + readonly writeSchema: z.ZodType + readonly readSchema: z.ZodType + readonly tolerantSchema: z.ZodType + readonly default: unknown + readonly enforcement: PermissionGroupEnforcement +} + +function booleanRestriction( + enforcement: PermissionGroupEnforcement, + feature: PlatformFeatureMeta +): BooleanRestrictionField { + const schema = z.boolean() + return { + kind: 'boolean-restriction', + schema, + writeSchema: schema.optional(), + readSchema: schema, + tolerantSchema: schema.catch(false), + default: false, + enforcement, + feature, + } +} + +function allowlist( + item: TItem, + enforcement: PermissionGroupEnforcement, + phrasing: AllowlistPhrasing +): AllowlistField { + const schema = z.array(item).nullable() + return { + kind: 'allowlist', + schema, + writeSchema: schema.optional(), + readSchema: schema, + tolerantSchema: tolerantArray(item, null), + default: null, + enforcement, + phrasing, + } +} + +function denylist( + item: TItem, + enforcement: PermissionGroupEnforcement, + phrasing: string +): DenylistField { + const schema = z.array(item) + return { + kind: 'denylist', + schema, + writeSchema: schema.optional(), + readSchema: schema.default([]), + tolerantSchema: tolerantArray(item, [] as never[]), + default: [], + enforcement, + phrasing, + } +} + +/** + * Every permission-group config key, in wire order. + * + * Declaration order here is the key order of `PermissionGroupConfig`, of both + * zod schemas, and of every config JSON that crosses the API boundary. The + * group editor's dirty check compares stringified configs, so reordering + * entries is a breaking change. + * + * Adding a key here adds it to the write schema, the read schema, the type, the + * defaults, the tolerant parser and — for a boolean — the admin editor. It does + * not add enforcement: `enforcement` records whether a server gate exists, and + * a key declared `server` without one is what `check:permission-group-enforcement` + * refuses. + */ +export const PERMISSION_GROUP_FIELDS = { + allowedIntegrations: allowlist(z.string(), 'server', { + limited: 'Integrations and blocks are limited to effectiveConfig.allowedIntegrations.', + empty: 'No non-exempt integrations or blocks are allowed.', + }), + allowedModelProviders: allowlist(z.string(), 'server', { + limited: 'Model providers are limited to effectiveConfig.allowedModelProviders.', + empty: 'No model providers are allowed.', + }), + deniedModels: denylist( + z.string(), + 'server', + 'Models listed in effectiveConfig.deniedModels are blocked.' + ), + deniedTools: denylist( + z.string(), + 'server', + 'Integration tools listed in effectiveConfig.deniedTools are blocked.' + ), + hideTraceSpans: booleanRestriction('ui-only', { + id: 'hide-trace-spans', + label: 'Trace Spans', + category: 'Logs', + hint: 'Hide per-block trace spans in logs.', + }), + hideKnowledgeBaseTab: booleanRestriction('ui-only', { + id: 'hide-knowledge-base', + label: 'Knowledge Base', + category: 'Sidebar', + hint: 'Hide the Knowledge Base module from the sidebar.', + }), + hideTablesTab: booleanRestriction('ui-only', { + id: 'hide-tables', + label: 'Tables', + category: 'Sidebar', + hint: 'Hide the Tables module from the sidebar.', + }), + hideCopilot: booleanRestriction('ui-only', { + id: 'hide-copilot', + label: 'Chat', + category: 'Workflow Panel', + hint: 'Hide the Chat panel so users cannot build or edit with natural language.', + }), + hideIntegrationsTab: booleanRestriction('ui-only', { + id: 'hide-integrations', + label: 'Integrations', + category: 'Settings Tabs', + hint: 'Hide the Integrations settings tab (OAuth connections).', + }), + hideSecretsTab: booleanRestriction('ui-only', { + id: 'hide-secrets', + label: 'Secrets', + category: 'Settings Tabs', + hint: 'Hide the Secrets (environment variables) settings tab.', + }), + hideApiKeysTab: booleanRestriction('ui-only', { + id: 'hide-api-keys', + label: 'API Keys', + category: 'Settings Tabs', + hint: 'Hide the API Keys settings tab.', + }), + hideInboxTab: booleanRestriction('ui-only', { + id: 'hide-inbox', + label: 'Sim Mailer', + category: 'Features', + hint: 'Hide the Sim Mailer inbox.', + }), + hideFilesTab: booleanRestriction('ui-only', { + id: 'hide-files', + label: 'Files', + category: 'Settings Tabs', + hint: 'Hide the Files settings tab.', + }), + disableMcpTools: booleanRestriction('server', { + id: 'disable-mcp', + label: 'MCP Tools', + category: 'Tools', + hint: 'Block agents from calling MCP tools.', + }), + disableCustomTools: booleanRestriction('server', { + id: 'disable-custom-tools', + label: 'Custom Tools', + category: 'Tools', + hint: 'Block agents from calling user-defined custom tools.', + }), + disableSkills: booleanRestriction('server', { + id: 'disable-skills', + label: 'Skills', + category: 'Tools', + hint: 'Block agents from loading skills.', + }), + disableInvitations: booleanRestriction('server', { + id: 'disable-invitations', + label: 'Invitations', + category: 'Collaboration', + hint: 'Prevent users from inviting others to workspaces.', + }), + disablePublicApi: booleanRestriction('server', { + id: 'disable-public-api', + label: 'Public API', + category: 'Features', + hint: 'Disable public API access to deployed workflows.', + }), + disablePublicFileSharing: booleanRestriction('server', { + id: 'disable-public-file-sharing', + label: 'Public Sharing', + category: 'Files', + hint: 'Disable public file-share links.', + }), + allowedFileShareAuthTypes: allowlist(shareAuthType, 'server', { + limited: + 'Public file-share authentication is limited to effectiveConfig.allowedFileShareAuthTypes.', + empty: 'No public file-share authentication modes are allowed.', + }), + hideDeployApi: booleanRestriction('ui-only', { + id: 'hide-deploy-api', + label: 'API', + category: 'Deploy Tabs', + hint: 'Hide the API deployment option.', + }), + hideDeployMcp: booleanRestriction('ui-only', { + id: 'hide-deploy-mcp', + label: 'MCP', + category: 'Deploy Tabs', + hint: 'Hide the MCP server deployment option.', + }), + hideDeployChatbot: booleanRestriction('ui-only', { + id: 'hide-deploy-chatbot', + label: 'Deployment', + category: 'Chat', + hint: 'Hide the chat deployment option.', + }), + allowedChatDeployAuthTypes: allowlist(shareAuthType, 'server', { + limited: + 'Chat deployment authentication is limited to effectiveConfig.allowedChatDeployAuthTypes.', + empty: 'No chat deployment authentication modes are allowed.', + }), +} satisfies Record + +export type PermissionGroupFields = typeof PERMISSION_GROUP_FIELDS +export type PermissionGroupConfigKey = keyof PermissionGroupFields + +type DerivedPermissionGroupConfig = { + [K in PermissionGroupConfigKey]: z.infer +} + +/** + * The effective permission-group configuration. + * + * Declared as an interface over the derived shape so it keeps a stable name in + * errors and hovers rather than expanding to a mapped type at every use site. + */ +export interface PermissionGroupConfig extends DerivedPermissionGroupConfig {} + +/** The per-field properties that each project into one whole-config shape. */ +type FieldProjection = 'writeSchema' | 'readSchema' | 'tolerantSchema' | 'default' + +/** + * Collects one property from every field, preserving declaration order. + * + * `Object.fromEntries` widens to an index signature, so the result is asserted + * back to the mapped type. The assertion is safe by construction — the value at + * each key is that key's own entry read at a fixed property — but TypeScript + * cannot follow that correspondence through a union of field kinds, so it is + * stated once here rather than at each of the four shapes. + */ +function collectFieldProperty

( + property: P +): { [K in PermissionGroupConfigKey]: PermissionGroupFields[K][P] } { + const entries = Object.entries(PERMISSION_GROUP_FIELDS).map(([key, field]) => [ + key, + field[property], + ]) + return Object.fromEntries(entries) as { + [K in PermissionGroupConfigKey]: PermissionGroupFields[K][P] + } +} + +/** The PATCH shape: every key optional, so a partial config is a legal write. */ +export const permissionGroupWriteShape = collectFieldProperty('writeSchema') + +/** The wire shape: every key present, denylists defaulted. */ +export const permissionGroupReadShape = collectFieldProperty('readSchema') + +const tolerantConfigSchema = z.object(collectFieldProperty('tolerantSchema')) + +/** + * The unrestricted config: allowlists `null` (everything permitted), denylists + * empty, restrictions off. Assigned rather than asserted, so each field's + * declared default has to actually satisfy that key's config type. + */ +export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = + collectFieldProperty('default') + +/** + * Coerces an untrusted stored config into a complete, well-typed one. + * + * Never throws: every field is total, and the guard covers the shapes a `jsonb` + * column can hold that `z.object()` refuses. `Array.isArray` is load-bearing — + * `typeof [] === 'object'`, so an array-valued column would otherwise reach + * `.parse` and throw where it used to coerce to defaults. + */ +export function parsePermissionGroupConfig(config: unknown): PermissionGroupConfig { + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return DEFAULT_PERMISSION_GROUP_CONFIG + } + return tolerantConfigSchema.parse(config) +} + +/** + * Compile-time proof that deriving the config from the registry did not widen + * it. Every consumer reads these keys expecting a precise type, and a zod + * generic that degraded to `unknown` would be invisible at runtime — the values + * would still be right, so no test would fail, while every call site quietly + * lost its narrowing. Declared here rather than in a `.test.ts` because + * type-check excludes test files. + */ +type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false + +/** Fails to compile unless `T` is exactly `true`, which is what makes the aliases below load-bearing. */ +type Assert = T + +type AssertsAllowlistStaysPrecise = Assert< + Exact +> +type AssertsDenylistStaysPrecise = Assert> +type AssertsRestrictionStaysPrecise = Assert> +type AssertsAuthTypesStayPrecise = Assert< + Exact< + PermissionGroupConfig['allowedFileShareAuthTypes'], + (typeof FILE_SHARE_AUTH_TYPES)[number][] | null + > +> +type AssertsParserReturnsTheConfig = Assert< + Exact, PermissionGroupConfig> +> + +export type { + AssertsAllowlistStaysPrecise, + AssertsAuthTypesStayPrecise, + AssertsDenylistStaysPrecise, + AssertsParserReturnsTheConfig, + AssertsRestrictionStaysPrecise, +} diff --git a/apps/sim/lib/permission-groups/types.test.ts b/apps/sim/lib/permission-groups/types.test.ts index 799505382a1..b94d7b4a5f2 100644 --- a/apps/sim/lib/permission-groups/types.test.ts +++ b/apps/sim/lib/permission-groups/types.test.ts @@ -24,12 +24,6 @@ interface CoercionFixture { name: string input: unknown expected: PermissionGroupConfig - /** - * Whether the coerced config is one the read schema can serialize. Only the - * unfiltered-allowlist row is `false`, and that is a defect rather than a - * property worth keeping — see the test that pins it. - */ - readSchemaAccepts?: false } const fixtures: readonly CoercionFixture[] = [ @@ -117,18 +111,15 @@ const fixtures: readonly CoercionFixture[] = [ expected: DEFAULT_PERMISSION_GROUP_CONFIG, }, /** - * Today the two allowlists are the only keys that skip element validation, so - * a `string[]`-typed field can hold a number. Filtering them would be a - * fail-closed change; this row exists so making it shows up in the diff. + * The allowlists used to be the only keys that skipped element validation, so + * a `string[]`-typed field could hold a number and the read schema then + * refused the config it produced. Filtering keeps the members that parse and + * fails closed. */ { - name: 'an allowlist with a non-string member, which is passed through unfiltered', + name: 'an allowlist with a non-string member, keeping the strings', input: { allowedIntegrations: ['slack', 42] }, - expected: { - ...DEFAULT_PERMISSION_GROUP_CONFIG, - allowedIntegrations: ['slack', 42] as unknown as string[], - }, - readSchemaAccepts: false, + expected: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: ['slack'] }, }, { name: 'a fully populated config', @@ -198,29 +189,22 @@ describe('parsePermissionGroupConfig', () => { ) }) - it.each(fixtures)( - 'produces a config the read schema accepts for $name', - ({ input, readSchemaAccepts }) => { - const parsed = structuredClone(parsePermissionGroupConfig(input)) - expect(permissionGroupFullConfigSchema.safeParse(parsed).success).toBe( - readSchemaAccepts !== false - ) - } - ) + it.each(fixtures)('produces a config the read schema accepts for $name', ({ input }) => { + const parsed = structuredClone(parsePermissionGroupConfig(input)) + expect(permissionGroupFullConfigSchema.safeParse(parsed).success).toBe(true) + }) /** - * Pins a live defect so the fix is visible as a diff rather than a silent - * behavior change. `allowedIntegrations` and `allowedModelProviders` are the - * only keys that skip element validation, so a corrupted row coerces to a - * value `permissionGroupFullConfigSchema` then refuses — meaning the route - * that reads it fails response validation instead of returning a usable - * allowlist. Filtering non-strings on the way in is fail-closed and removes - * the whole class; when that lands, this test inverts. + * The allowlists used to skip element validation, so a corrupted row coerced + * to a value `permissionGroupFullConfigSchema` then refused — the route + * reading it failed response validation instead of returning a usable + * allowlist. Filtering is fail-closed: the members that parse survive, and a + * corrupt one narrows the allowlist rather than voiding it. */ - it('coerces a corrupted allowlist into a config the read schema refuses', () => { + it('narrows a corrupted allowlist instead of voiding it', () => { const parsed = parsePermissionGroupConfig({ allowedIntegrations: ['slack', 42] }) - expect(parsed.allowedIntegrations).toEqual(['slack', 42]) - expect(permissionGroupFullConfigSchema.safeParse(structuredClone(parsed)).success).toBe(false) + expect(parsed.allowedIntegrations).toEqual(['slack']) + expect(permissionGroupFullConfigSchema.safeParse(structuredClone(parsed)).success).toBe(true) }) it('is idempotent', () => { @@ -284,15 +268,12 @@ describe('parsePermissionGroupConfig invariants', () => { expect(Object.keys(parsed), context).toEqual(configKeys) expect(parsePermissionGroupConfig(structuredClone(parsed)), context).toEqual(parsed) + expect( + permissionGroupFullConfigSchema.safeParse(structuredClone(parsed)).success, + context + ).toBe(true) } }) - - /** - * Deliberately not asserted above: a config the read schema accepts. The - * unfiltered allowlists break it for any input carrying a non-string member, - * so this invariant only becomes true once element filtering lands — add it - * here in the same change that inverts the defect test above. - */ }) describe('permission group config key coverage', () => { diff --git a/apps/sim/lib/permission-groups/types.ts b/apps/sim/lib/permission-groups/types.ts index 15bdb9773f9..939dd624da2 100644 --- a/apps/sim/lib/permission-groups/types.ts +++ b/apps/sim/lib/permission-groups/types.ts @@ -1,11 +1,20 @@ import { z } from 'zod' -import type { ShareAuthType } from '@/lib/api/contracts/public-shares' - -/** - * Auth modes a public file share or a chat deployment can use; admins may - * restrict the allowed subset. The two surfaces share the same four modes. - */ -export const FILE_SHARE_AUTH_TYPES = ['public', 'password', 'email', 'sso'] as const +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + FILE_SHARE_AUTH_TYPES, + type PermissionGroupConfig, + type PermissionGroupConfigKey, + parsePermissionGroupConfig, + permissionGroupWriteShape, +} from '@/lib/permission-groups/fields' + +export { + DEFAULT_PERMISSION_GROUP_CONFIG, + FILE_SHARE_AUTH_TYPES, + type PermissionGroupConfig, + type PermissionGroupConfigKey, + parsePermissionGroupConfig, +} export const PERMISSION_GROUP_CONSTRAINTS = { organizationName: 'permission_group_organization_name_unique', @@ -16,144 +25,10 @@ export const PERMISSION_GROUP_MEMBER_CONSTRAINTS = { groupUser: 'permission_group_member_group_user_unique', } as const -export const permissionGroupConfigSchema = z.object({ - allowedIntegrations: z.array(z.string()).nullable().optional(), - allowedModelProviders: z.array(z.string()).nullable().optional(), - deniedModels: z.array(z.string()).optional(), - deniedTools: z.array(z.string()).optional(), - hideTraceSpans: z.boolean().optional(), - hideKnowledgeBaseTab: z.boolean().optional(), - hideTablesTab: z.boolean().optional(), - hideCopilot: z.boolean().optional(), - hideIntegrationsTab: z.boolean().optional(), - hideSecretsTab: z.boolean().optional(), - hideApiKeysTab: z.boolean().optional(), - hideInboxTab: z.boolean().optional(), - hideFilesTab: z.boolean().optional(), - disableMcpTools: z.boolean().optional(), - disableCustomTools: z.boolean().optional(), - disableSkills: z.boolean().optional(), - disableInvitations: z.boolean().optional(), - disablePublicApi: z.boolean().optional(), - disablePublicFileSharing: z.boolean().optional(), - allowedFileShareAuthTypes: z.array(z.enum(FILE_SHARE_AUTH_TYPES)).nullable().optional(), - hideDeployApi: z.boolean().optional(), - hideDeployMcp: z.boolean().optional(), - hideDeployChatbot: z.boolean().optional(), - allowedChatDeployAuthTypes: z.array(z.enum(FILE_SHARE_AUTH_TYPES)).nullable().optional(), -}) - -export interface PermissionGroupConfig { - allowedIntegrations: string[] | null - allowedModelProviders: string[] | null - /** - * Fully-qualified model IDs (e.g. `ollama/llama3`, `gpt-4o`) blocked for this - * group, checked after `allowedModelProviders`. Empty means nothing is blocked. - */ - deniedModels: string[] - /** - * Snake_case tool IDs (e.g. `slack_canvas`) blocked for this group, checked - * after the block-level `allowedIntegrations` gate. Lets an admin allow an - * integration but deny specific operations within it. Empty means nothing is - * blocked. - */ - deniedTools: string[] - hideTraceSpans: boolean - hideKnowledgeBaseTab: boolean - hideTablesTab: boolean - hideCopilot: boolean - hideIntegrationsTab: boolean - hideSecretsTab: boolean - hideApiKeysTab: boolean - hideInboxTab: boolean - hideFilesTab: boolean - disableMcpTools: boolean - disableCustomTools: boolean - disableSkills: boolean - disableInvitations: boolean - disablePublicApi: boolean - disablePublicFileSharing: boolean - /** Allowed public-file-share auth modes; `null` means all are allowed. */ - allowedFileShareAuthTypes: ShareAuthType[] | null - hideDeployApi: boolean - hideDeployMcp: boolean - hideDeployChatbot: boolean - /** Allowed chat-deployment auth modes; `null` means all are allowed. */ - allowedChatDeployAuthTypes: ShareAuthType[] | null -} - -export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = { - allowedIntegrations: null, - allowedModelProviders: null, - deniedModels: [], - deniedTools: [], - hideTraceSpans: false, - hideKnowledgeBaseTab: false, - hideTablesTab: false, - hideCopilot: false, - hideIntegrationsTab: false, - hideSecretsTab: false, - hideApiKeysTab: false, - hideInboxTab: false, - hideFilesTab: false, - disableMcpTools: false, - disableCustomTools: false, - disableSkills: false, - disableInvitations: false, - disablePublicApi: false, - disablePublicFileSharing: false, - allowedFileShareAuthTypes: null, - hideDeployApi: false, - hideDeployMcp: false, - hideDeployChatbot: false, - allowedChatDeployAuthTypes: null, -} - -export function parsePermissionGroupConfig(config: unknown): PermissionGroupConfig { - if (!config || typeof config !== 'object') { - return DEFAULT_PERMISSION_GROUP_CONFIG - } - - const c = config as Record - - return { - allowedIntegrations: Array.isArray(c.allowedIntegrations) ? c.allowedIntegrations : null, - allowedModelProviders: Array.isArray(c.allowedModelProviders) ? c.allowedModelProviders : null, - deniedModels: Array.isArray(c.deniedModels) - ? c.deniedModels.filter((m): m is string => typeof m === 'string') - : [], - deniedTools: Array.isArray(c.deniedTools) - ? c.deniedTools.filter((t): t is string => typeof t === 'string') - : [], - hideTraceSpans: typeof c.hideTraceSpans === 'boolean' ? c.hideTraceSpans : false, - hideKnowledgeBaseTab: - typeof c.hideKnowledgeBaseTab === 'boolean' ? c.hideKnowledgeBaseTab : false, - hideTablesTab: typeof c.hideTablesTab === 'boolean' ? c.hideTablesTab : false, - hideCopilot: typeof c.hideCopilot === 'boolean' ? c.hideCopilot : false, - hideIntegrationsTab: typeof c.hideIntegrationsTab === 'boolean' ? c.hideIntegrationsTab : false, - hideSecretsTab: typeof c.hideSecretsTab === 'boolean' ? c.hideSecretsTab : false, - hideApiKeysTab: typeof c.hideApiKeysTab === 'boolean' ? c.hideApiKeysTab : false, - hideInboxTab: typeof c.hideInboxTab === 'boolean' ? c.hideInboxTab : false, - hideFilesTab: typeof c.hideFilesTab === 'boolean' ? c.hideFilesTab : false, - disableMcpTools: typeof c.disableMcpTools === 'boolean' ? c.disableMcpTools : false, - disableCustomTools: typeof c.disableCustomTools === 'boolean' ? c.disableCustomTools : false, - disableSkills: typeof c.disableSkills === 'boolean' ? c.disableSkills : false, - disableInvitations: typeof c.disableInvitations === 'boolean' ? c.disableInvitations : false, - disablePublicApi: typeof c.disablePublicApi === 'boolean' ? c.disablePublicApi : false, - disablePublicFileSharing: - typeof c.disablePublicFileSharing === 'boolean' ? c.disablePublicFileSharing : false, - allowedFileShareAuthTypes: Array.isArray(c.allowedFileShareAuthTypes) - ? c.allowedFileShareAuthTypes.filter((t): t is ShareAuthType => - (FILE_SHARE_AUTH_TYPES as readonly string[]).includes(t as string) - ) - : null, - hideDeployApi: typeof c.hideDeployApi === 'boolean' ? c.hideDeployApi : false, - hideDeployMcp: typeof c.hideDeployMcp === 'boolean' ? c.hideDeployMcp : false, - hideDeployChatbot: typeof c.hideDeployChatbot === 'boolean' ? c.hideDeployChatbot : false, - allowedChatDeployAuthTypes: Array.isArray(c.allowedChatDeployAuthTypes) - ? c.allowedChatDeployAuthTypes.filter((t): t is ShareAuthType => - (FILE_SHARE_AUTH_TYPES as readonly string[]).includes(t as string) - ) - : null, - } -} +/** + * The config a create or update body may carry: every key optional, so a caller + * patches only what it means to change. The route merges the result over the + * group's stored config, which is what heals a row written before a key + * existed. + */ +export const permissionGroupConfigSchema = z.object(permissionGroupWriteShape) From d47f5d67f014c9fbebeb4bc6b4714bb9766fb63c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 20:18:50 -0700 Subject: [PATCH 003/179] feat(permission-groups): add the capability gate to the authorization funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A permission-group key only means something if a server refuses when it is set. Twelve did not: `hideCopilot`, `hideSecretsTab`, `hideDeployChatbot` and the rest were read in a sidebar filter and nowhere else, so an organization that set one had hidden a nav item, not withheld a capability. The gap was structural — nothing connected "this key is offered to admins" to "something refuses when it is set" — so this adds the connection rather than one more check. Operations already declare their policy as frozen data, so capability joins it: `defineWorkspaceOperation` takes a capability id, and `authorizeWorkspaceOperation` refuses when the caller's group withholds it. One insert covers every surface — internal routes, v2 routes, the Copilot adapter, trusted tools — because all of them funnel through it, including the HEAD probe, which has to answer the same question its GET would or it becomes an existence oracle. Capability is checked after the role check. `requirePermission` throws the refusal the v2 surface conceals as a 404, so asking about capability first would tell a complete outsider which capabilities the organization withholds. It is also the cheaper check, and it names the remedy the caller can actually act on. Two principals pass through, as policy rather than oversight. A workspace API key authorizes as the workspace and has no user, so no group resolves; substituting the key's creator would apply a bystander's group to every caller and break the key when that person left. A deployment run has no subject either, and denying there would 403 every schedule and webhook in the organization the moment a group withheld anything — a deployed workflow runs with the workspace's authority, like any service account, and what it *does* is still gated by the executor. Capabilities are ids with a rule registry, not predicates on the operation: a closure cannot be logged, compared, or read by an audit, and fifty table operations naming one capability should be fifty strings rather than fifty identical functions. Rules split on whether the decision needs a request value; a parameterized one is refused at definition time, because declared on an operation it would silently never fire. `check:permission-group-enforcement` is what stops this recurring. It asserts every capability is reachable, every key claiming capability enforcement is read by a rule, and no key claiming something weaker is — so a key cannot reach the admin editor while still documented as cosmetic. It runs in count-down mode (0/233) until the operations are annotated, and refuses to report success if its own parsers come back empty, since an audit that goes quiet when it breaks is worse than none. Config resolution is memoized per request, keyed on user and workspace and caching the promise so concurrent callers share one query. The gate returns before touching the database when the operation declares nothing or the workspace has no organization, so a personal workspace pays nothing. --- apps/sim/lib/core/application/forbidden.ts | 4 + apps/sim/lib/core/application/index.ts | 1 + .../workspace-authorization.test.ts | 185 ++++++++++ .../application/workspace-authorization.ts | 96 ++++- .../core/application/workspace-operation.ts | 32 ++ apps/sim/lib/core/utils/with-route-handler.ts | 8 +- .../sim/lib/permission-groups/capabilities.ts | 273 ++++++++++++++ .../permission-groups/config-scope.server.ts | 78 ++++ apps/sim/lib/permission-groups/fields.ts | 72 ++-- package.json | 1 + scripts/check-permission-group-enforcement.ts | 340 ++++++++++++++++++ 11 files changed, 1050 insertions(+), 40 deletions(-) create mode 100644 apps/sim/lib/permission-groups/capabilities.ts create mode 100644 apps/sim/lib/permission-groups/config-scope.server.ts create mode 100644 scripts/check-permission-group-enforcement.ts diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index bc8da9e36f1..07ab19a680f 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -60,6 +60,8 @@ export const FORBIDDEN_DETAIL_CODES = [ 'CHAT_AUTH_MODE_NOT_PERMITTED', /** The resource is owned by a knowledge base connector and cannot be edited directly. */ 'CONNECTOR_MANAGED_RESOURCE_READ_ONLY', + /** The caller's permission group withholds a capability this operation needs. */ + 'PERMISSION_GROUP_CAPABILITY_BLOCKED', ] as const export type ForbiddenDetailCode = (typeof FORBIDDEN_DETAIL_CODES)[number] @@ -105,6 +107,8 @@ export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record ({ resolvePermission: vi.fn(), + resolvePermissionGroupConfig: vi.fn(), })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -20,15 +22,21 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => ({ + resolvePermissionGroupConfig: mocks.resolvePermissionGroupConfig, +})) + import { authorizeWorkspaceOperation, defineWorkspaceOperation, InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, + PermissionGroupCapabilityError, PrincipalKindAuthorizationError, WorkspaceApiKeyAuthorizationError, WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' const writeOperation = defineWorkspaceOperation({ id: 'test.write', @@ -264,3 +272,180 @@ describe('authorizeWorkspaceOperation', () => { ) }) }) + +const capabilityOperation = defineWorkspaceOperation({ + id: 'test.capability-read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['executor'], + capability: 'tables.use', +}) + +const personalKeyPrincipal: PersonalApiKeyPrincipal = { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-personal-1', +} + +const scopedWorkspaceKeyPrincipal: WorkspaceApiKeyPrincipal = { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', +} + +/** A config that withholds the capability the operation above declares. */ +function withholdingConfig() { + return { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideTablesTab: true } +} + +describe('authorizeWorkspaceOperation permission-group capability', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + mocks.resolvePermissionGroupConfig.mockResolvedValue(null) + }) + + it('refuses a session whose group withholds the capability', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation(principal, capabilityOperation, context) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + }) + + it('names the capability and a code a caller can branch on', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + + const error = await authorizeWorkspaceOperation(principal, capabilityOperation, context).catch( + (thrown: unknown) => thrown + ) + + expect(error).toBeInstanceOf(PermissionGroupCapabilityError) + expect((error as PermissionGroupCapabilityError).capability).toBe('tables.use') + expect((error as PermissionGroupCapabilityError).detailCode).toBe( + 'PERMISSION_GROUP_CAPABILITY_BLOCKED' + ) + }) + + it('refuses a personal API key the same way', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation(personalKeyPrincipal, capabilityOperation, context) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + }) + + it('refuses a delegated principal that carries a user subject', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation( + { + ...executorPrincipal( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { workflowId: 'current-workflow-1', mode: 'draft' } + ), + subjectUserId: 'user-1', + }, + capabilityOperation, + context, + executorAuthorization + ) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + }) + + /** + * A workspace API key authorizes as the workspace, so no group resolves for + * it. Documented policy rather than an oversight — the escape is closed by + * capability-gating key creation, not by guessing a user here. + */ + it('does not apply to a workspace API key, which has no user', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation(scopedWorkspaceKeyPrincipal, capabilityOperation, context) + ).resolves.toBeUndefined() + expect(mocks.resolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + /** + * A deployment run has no subject, so denying here would 403 every schedule + * and webhook in the organization. What the run does is still gated by the + * executor. + */ + it('does not apply to an actorless deployment run', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation( + executorPrincipal(undefined, { workflowId: 'current-workflow-1', mode: 'deployment' }), + capabilityOperation, + context, + executorAuthorization + ) + ).resolves.toBeUndefined() + expect(mocks.resolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('allows the operation when the group permits the capability', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + await expect( + authorizeWorkspaceOperation(principal, capabilityOperation, context) + ).resolves.toBeUndefined() + }) + + it('allows the operation when no group governs the user', async () => { + await expect( + authorizeWorkspaceOperation(principal, capabilityOperation, context) + ).resolves.toBeUndefined() + }) + + it('skips the lookup entirely for a workspace with no organization', async () => { + await expect( + authorizeWorkspaceOperation(principal, capabilityOperation, { + ...context, + workspaceOrganizationId: null, + }) + ).resolves.toBeUndefined() + expect(mocks.resolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('refuses on role before capability, so a non-member learns nothing about the group', async () => { + mocks.resolvePermission.mockResolvedValue(null) + mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation(principal, capabilityOperation, context) + ).rejects.toBeInstanceOf(NoWorkspaceAccessError) + expect(mocks.resolvePermissionGroupConfig).not.toHaveBeenCalled() + }) +}) + +describe('defineWorkspaceOperation capability policy', () => { + it('refuses a parameterized capability, which the funnel could never apply', () => { + expect(() => + defineWorkspaceOperation({ + id: 'test.parameterized', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + // @ts-expect-error a parameterized capability is not assignable to the field + capability: 'deploy.chat.auth_mode', + }) + ).toThrow(/parameterized capability/) + }) + + it('accepts an explicit opt-out', () => { + expect(() => + defineWorkspaceOperation({ + id: 'test.ungoverned', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + capability: 'none', + }) + ).not.toThrow() + }) +}) diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index afbd6c5342f..c4339024fa7 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -9,12 +9,17 @@ import { permissionSatisfies, resolveEffectiveWorkspacePermission, } from '@sim/platform-authz/workspace' -import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { type ForbiddenDetailCode, ForbiddenOperationError } from '@/lib/core/application/forbidden' import type { PrincipalForOperation, WorkspaceOperation, } from '@/lib/core/application/workspace-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + CAPABILITY_RULES, + type PermissionGroupCapability, +} from '@/lib/permission-groups/capabilities' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' export interface WorkspaceAuthorizationContext { workspaceId: string @@ -53,6 +58,25 @@ export class NoWorkspaceAccessError extends OrchestrationError { } } +/** + * The caller's permission group withholds a capability the operation needs. + * + * Carries the capability so a log line and an audit entry can name it; the + * message names it for the caller. One detail code covers every capability + * because the remedy is the same for all of them — the closed code set is + * closed over remedies, not over causes. + */ +export class PermissionGroupCapabilityError extends ForbiddenOperationError { + constructor( + readonly capability: PermissionGroupCapability, + detailCode: ForbiddenDetailCode, + describe: string + ) { + super(detailCode, `${describe} is not available under your organization's permission group`) + this.name = 'PermissionGroupCapabilityError' + } +} + export class PersonalApiKeysDisabledError extends ForbiddenOperationError { constructor() { super('PERSONAL_API_KEYS_DISABLED', 'Personal API keys are not allowed for this workspace') @@ -149,10 +173,50 @@ function requirePermission(permission: PermissionType | null, required: Permissi } } -async function requireCurrentHumanPermission( +/** + * Refuses an operation whose capability the caller's permission group withholds. + * + * Runs only for a principal that stands for a person, because a permission + * group is a membership of users — see {@link authorizeWorkspaceOperation} for + * why an actorless caller passes through rather than being denied. + */ +async function requireCapability( + userId: string, + context: WorkspaceAuthorizationContext, + operation: WorkspaceOperation +): Promise { + const capability = operation.capability + if (capability === undefined || capability === 'none') return + if (context.workspaceOrganizationId === null) return + + const config = await resolvePermissionGroupConfig( + userId, + context.workspaceId, + context.workspaceOrganizationId + ) + if (!config) return + + const rule = CAPABILITY_RULES[capability] + if (rule.kind !== 'static' || !rule.deniedBy(config)) return + + throw new PermissionGroupCapabilityError(capability, rule.detailCode, rule.describe) +} + +/** + * The workspace role check, then the permission-group capability check. + * + * Capability comes second on purpose. `requirePermission` throws + * {@link NoWorkspaceAccessError}, which the v2 surface conceals as a `404` so a + * non-member cannot learn the resource exists; refusing on capability first + * would tell a complete outsider which capabilities the organization withholds. + * It is also the cheaper check, and it names the remedy a caller can act on — + * raising a role, rather than chasing an admin about a group setting that is + * not why they were refused. + */ +async function requireCurrentHumanAccess( userId: string, context: C, - required: PermissionType, + operation: WorkspaceOperation, options?: WorkspaceAuthorizationOptions ): Promise { const permission = await resolveEffectiveWorkspacePermission( @@ -162,7 +226,8 @@ async function requireCurrentHumanPermission( @@ -175,14 +240,22 @@ export async function authorizeWorkspaceOperation readonly principalKinds: PrincipalKinds readonly delegatedServices?: DelegatedServices + /** + * The capability a permission group must not have withheld, or `'none'` when + * no group governs this operation. + * + * `'none'` is spelled out rather than left as an omission, because an absent + * field cannot be told apart from an unreviewed one — and unreviewed omission + * is exactly how twelve config keys shipped with an admin checkbox and no + * server gate. Optional only while the operations are being annotated; + * `check:permission-group-enforcement` reports what is still unfilled. + */ + readonly capability?: StaticPermissionGroupCapability | 'none' } type WorkspaceApiKeyPrincipalConsistency< @@ -108,6 +123,23 @@ export function defineWorkspaceOperation< if (operation.resourcePolicy) requireResourcePolicyBinding(operation.resourcePolicy) + if (operation.capability !== undefined && operation.capability !== 'none') { + const rule = CAPABILITY_RULES[operation.capability] + if (!rule) { + throw new Error(`Operation ${operation.id} names unknown capability ${operation.capability}`) + } + /** + * A parameterized rule reads a value only the request carries, which the + * authorization funnel never sees. Declared on an operation it would be + * silently skipped, so refuse it here rather than let it read as enforced. + */ + if (rule.kind !== 'static') { + throw new Error( + `Operation ${operation.id} declares parameterized capability ${operation.capability}; assert it from the use case instead` + ) + } + } + Object.freeze(operation.principalKinds) if (operation.delegatedServices) Object.freeze(operation.delegatedServices) if (operation.resourcePolicy) Object.freeze(operation.resourcePolicy) diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index 86e41967ada..c3d90ae26f5 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -5,6 +5,7 @@ import { NextResponse } from 'next/server' import { getRateLimitHeaders } from '@/lib/api/server/rate-limit-context' import { HttpError } from '@/lib/core/utils/http-error' import { generateRequestId } from '@/lib/core/utils/request' +import { withPermissionGroupScope } from '@/lib/permission-groups/config-scope.server' const logger = createLogger('RouteHandler') @@ -114,7 +115,12 @@ export function withRouteHandler( return runWithRequestContext({ requestId, method, path, traceId }, async () => { let response: NextResponse | Response try { - response = await handler(request, context) + /** + * One permission-group memo per request. A handler that authorizes + * several operations — a bulk mutation, or a route running two use + * cases — would otherwise resolve the same group once per operation. + */ + response = await withPermissionGroupScope(() => handler(request, context)) } catch (error) { const duration = Date.now() - startTime const message = getErrorMessage(error, 'Unknown error') diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts new file mode 100644 index 00000000000..f6f515d0c1c --- /dev/null +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -0,0 +1,273 @@ +import type { ForbiddenDetailCode } from '@/lib/core/application/forbidden' +import type { + FILE_SHARE_AUTH_TYPES, + PermissionGroupConfig, + PermissionGroupConfigKey, +} from '@/lib/permission-groups/fields' + +type ShareAuthMode = (typeof FILE_SHARE_AUTH_TYPES)[number] + +/** + * Every capability a permission group can withhold. + * + * Domain-shaped, because the declaration site is an operation + * (`tables.rows.create`) while the config is surface-shaped (`hideTablesTab`). + * {@link CAPABILITY_RULES} is the only place the two vocabularies meet — without + * it every domain's operations module would restate a config key, and the + * mapping would drift the first time one was renamed. + * + * A closed union rather than a predicate on the operation: operations are + * frozen policy data, and a closure cannot be logged, compared, or read by + * `check:permission-group-enforcement`. Fifty table operations naming one + * capability is fifty identical strings, not fifty identical functions. + */ +export const CAPABILITY_IDS = [ + 'knowledge.use', + 'tables.use', + 'files.use', + 'inbox.use', + 'copilot.use', + 'secrets.manage', + 'api_keys.manage', + 'integrations.manage', + 'deploy.api', + 'deploy.mcp', + 'deploy.chat', + 'deploy.chat.auth_mode', + 'file_share.publish', + 'file_share.auth_mode', + 'public_api.use', + 'invitations.send', + 'mcp_tools.use', + 'custom_tools.use', + 'skills.use', + 'logs.trace_spans', +] as const + +export type PermissionGroupCapability = (typeof CAPABILITY_IDS)[number] + +interface CapabilityRuleBase { + /** The config keys this rule reads, so the audit can prove a key is enforced. */ + readonly configKeys: readonly PermissionGroupConfigKey[] + readonly detailCode: ForbiddenDetailCode + /** Named in the refusal message; the remedy is always an organization admin. */ + readonly describe: string +} + +/** + * Decidable from the config alone, so the authorization funnel can apply it + * knowing only the principal, the workspace, and the operation. + */ +export interface StaticCapabilityRule extends CapabilityRuleBase { + readonly kind: 'static' + deniedBy(config: PermissionGroupConfig): boolean +} + +/** + * Needs a value only the request carries. The funnel never sees request input, + * and widening the authorization context to carry it would reach every use case + * for the sake of two keys, so these are asserted from inside `execute` and held + * to account by the audit's annotation rule instead. They are not valid as an + * operation's declared capability. + */ +export interface ParameterizedCapabilityRule extends CapabilityRuleBase { + readonly kind: 'parameterized' + deniedBy(config: PermissionGroupConfig, parameter: ShareAuthMode): boolean +} + +export type CapabilityRule = StaticCapabilityRule | ParameterizedCapabilityRule + +function authModeDeniedBy(allowed: ShareAuthMode[] | null, mode: ShareAuthMode): boolean { + return allowed !== null && !allowed.includes(mode) +} + +/** + * What each capability means in terms of the stored config. + * + * `satisfies` rather than an annotation, so adding a capability still fails to + * compile until it is given a rule — the same completeness gate + * `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS` uses — while each entry keeps its own + * `kind`. Annotating would widen every entry to `CapabilityRule`, and + * {@link StaticPermissionGroupCapability} would then resolve to `never`, + * silently rejecting every capability an operation tried to declare. + */ +export const CAPABILITY_RULES = { + 'knowledge.use': { + kind: 'static', + configKeys: ['hideKnowledgeBaseTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Knowledge bases', + deniedBy: (config) => config.hideKnowledgeBaseTab, + }, + 'tables.use': { + kind: 'static', + configKeys: ['hideTablesTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Tables', + deniedBy: (config) => config.hideTablesTab, + }, + 'files.use': { + kind: 'static', + configKeys: ['hideFilesTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Files', + deniedBy: (config) => config.hideFilesTab, + }, + 'inbox.use': { + kind: 'static', + configKeys: ['hideInboxTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'The inbox', + deniedBy: (config) => config.hideInboxTab, + }, + 'copilot.use': { + kind: 'static', + configKeys: ['hideCopilot'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Chat', + deniedBy: (config) => config.hideCopilot, + }, + 'secrets.manage': { + kind: 'static', + configKeys: ['hideSecretsTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Secrets', + deniedBy: (config) => config.hideSecretsTab, + }, + 'api_keys.manage': { + kind: 'static', + configKeys: ['hideApiKeysTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'API keys', + deniedBy: (config) => config.hideApiKeysTab, + }, + 'integrations.manage': { + kind: 'static', + configKeys: ['hideIntegrationsTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Integrations', + deniedBy: (config) => config.hideIntegrationsTab, + }, + 'deploy.api': { + kind: 'static', + configKeys: ['hideDeployApi'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'API deployment', + deniedBy: (config) => config.hideDeployApi, + }, + 'deploy.mcp': { + kind: 'static', + configKeys: ['hideDeployMcp'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'MCP server deployment', + deniedBy: (config) => config.hideDeployMcp, + }, + 'deploy.chat': { + kind: 'static', + configKeys: ['hideDeployChatbot'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Chat deployment', + deniedBy: (config) => config.hideDeployChatbot, + }, + 'deploy.chat.auth_mode': { + kind: 'parameterized', + configKeys: ['allowedChatDeployAuthTypes'], + detailCode: 'CHAT_AUTH_MODE_NOT_PERMITTED', + describe: 'This chat authentication mode', + deniedBy: (config, mode) => authModeDeniedBy(config.allowedChatDeployAuthTypes, mode), + }, + 'file_share.publish': { + kind: 'static', + configKeys: ['disablePublicFileSharing'], + detailCode: 'PUBLIC_SHARING_NOT_ALLOWED', + describe: 'Public file sharing', + deniedBy: (config) => config.disablePublicFileSharing, + }, + 'file_share.auth_mode': { + kind: 'parameterized', + configKeys: ['allowedFileShareAuthTypes'], + detailCode: 'PUBLIC_SHARING_NOT_ALLOWED', + describe: 'This file-share authentication mode', + deniedBy: (config, mode) => authModeDeniedBy(config.allowedFileShareAuthTypes, mode), + }, + 'public_api.use': { + kind: 'static', + configKeys: ['disablePublicApi'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Public API access', + deniedBy: (config) => config.disablePublicApi, + }, + 'invitations.send': { + kind: 'static', + configKeys: ['disableInvitations'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Invitations', + deniedBy: (config) => config.disableInvitations, + }, + 'mcp_tools.use': { + kind: 'static', + configKeys: ['disableMcpTools'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'MCP tools', + deniedBy: (config) => config.disableMcpTools, + }, + 'custom_tools.use': { + kind: 'static', + configKeys: ['disableCustomTools'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Custom tools', + deniedBy: (config) => config.disableCustomTools, + }, + 'skills.use': { + kind: 'static', + configKeys: ['disableSkills'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Skills', + deniedBy: (config) => config.disableSkills, + }, + 'logs.trace_spans': { + kind: 'static', + configKeys: ['hideTraceSpans'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Execution trace spans', + deniedBy: (config) => config.hideTraceSpans, + }, +} satisfies { readonly [K in PermissionGroupCapability]: CapabilityRule } + +/** + * The capabilities an operation may declare — the static ones. A parameterized + * rule needs a request value the funnel cannot see, so naming one on an + * operation would silently never fire. + */ +export type StaticPermissionGroupCapability = { + [K in PermissionGroupCapability]: (typeof CAPABILITY_RULES)[K] extends StaticCapabilityRule + ? K + : never +}[PermissionGroupCapability] + +/** Whether a capability id is one an operation may declare. */ +export function isStaticCapability( + capability: PermissionGroupCapability +): capability is StaticPermissionGroupCapability { + return CAPABILITY_RULES[capability].kind === 'static' +} + +/** + * Proof that the static/parameterized split resolves. + * + * `StaticPermissionGroupCapability` reads each rule's own `kind`, so annotating + * {@link CAPABILITY_RULES} instead of using `satisfies` would widen every entry + * and collapse this type to `never` — at which point no operation could declare + * any capability and every gate would silently never fire. Nothing at runtime + * would look wrong, so it is asserted here. + */ +type Assert = T + +type AssertsStaticCapabilityResolves = Assert< + 'tables.use' extends StaticPermissionGroupCapability ? true : false +> +type AssertsParameterizedCapabilityIsExcluded = Assert< + 'deploy.chat.auth_mode' extends StaticPermissionGroupCapability ? false : true +> + +export type { AssertsParameterizedCapabilityIsExcluded, AssertsStaticCapabilityResolves } diff --git a/apps/sim/lib/permission-groups/config-scope.server.ts b/apps/sim/lib/permission-groups/config-scope.server.ts new file mode 100644 index 00000000000..8e3735838a6 --- /dev/null +++ b/apps/sim/lib/permission-groups/config-scope.server.ts @@ -0,0 +1,78 @@ +import { cache } from 'react' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' +import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check' + +type ConfigKey = `${string}:${string}` +type ConfigStore = Map> + +interface Storage { + getStore(): T | undefined + run(store: T, fn: () => R): R +} + +let storage: Storage + +if (typeof globalThis.process !== 'undefined' && globalThis.process.versions?.node) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { AsyncLocalStorage } = require('node:async_hooks') as typeof import('node:async_hooks') + storage = new AsyncLocalStorage() +} else { + storage = { + getStore: () => undefined, + run: (_store: ConfigStore, fn: () => R) => fn(), + } +} + +/** + * Establishes one permission-group memo for everything a request or job does. + * + * A request that authorizes several operations — a bulk mutation, a route that + * runs two use cases — would otherwise resolve the same group once per + * operation. Nesting this inside the request context means every route handler + * gets it without threading a parameter through 266 operations. + */ +export function withPermissionGroupScope(run: () => R): R { + return storage.run(new Map(), run) +} + +/** + * Memoized for a React server request, so an RSC render that resolves the same + * viewer twice still makes one query. Both paths delegate here, so the scope and + * the React cache can never disagree. + */ +const resolveCached = cache( + async ( + userId: string, + workspaceId: string, + organizationId: string | null + ): Promise => + (await resolveVerifiedUserAccessControlContext(userId, workspaceId, organizationId)).config +) + +/** + * The permission-group config governing `userId` in `workspaceId`, resolved at + * most once per scope. + * + * Caches the promise rather than the value, so concurrent callers share one + * query instead of racing to start several. Caches `null` too — "no group + * governs this user" is the common answer and the one least worth re-asking. + * + * Outside a scope this degrades to the React memo, and outside a request to a + * direct call: slower, never wrong. + */ +export function resolvePermissionGroupConfig( + userId: string, + workspaceId: string, + organizationId: string | null +): Promise { + const store = storage.getStore() + if (!store) return resolveCached(userId, workspaceId, organizationId) + + const key: ConfigKey = `${userId}:${workspaceId}` + const existing = store.get(key) + if (existing) return existing + + const pending = resolveCached(userId, workspaceId, organizationId) + store.set(key, pending) + return pending +} diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index 9ecbedd9ae2..61676d8ca57 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -9,12 +9,20 @@ export const FILE_SHARE_AUTH_TYPES = ['public', 'password', 'email', 'sso'] as c const shareAuthType = z.enum(FILE_SHARE_AUTH_TYPES) /** - * Whether a key is refused by a server-side gate or is only a client rendering - * hint. A `ui-only` key hides a surface without withholding it, so a caller - * that skips the UI still reaches the API — that is a property to state, not to - * discover. + * Which mechanism refuses a request when this key is set. + * + * - `capability`: an operation declares a capability whose rule reads the key, + * so the authorization funnel refuses before the use case runs. + * - `executor`: the key is read per block, tool or model at execution time by + * `assertPermissionsAllowed`. It governs what a run may *do*, which no + * operation-level gate can express. + * - `ui-only`: the key hides a surface without withholding it, so a caller that + * skips the UI still reaches the API. + * + * Declared rather than inferred, because `ui-only` is the value an admin is + * most likely to mistake for a control — twelve keys shipped that way. */ -export type PermissionGroupEnforcement = 'server' | 'ui-only' +export type PermissionGroupEnforcement = 'capability' | 'executor' | 'ui-only' /** The admin-editor descriptor for a boolean key, rendered from the registry. */ interface PlatformFeatureMeta { @@ -165,143 +173,143 @@ function denylist( * * Adding a key here adds it to the write schema, the read schema, the type, the * defaults, the tolerant parser and — for a boolean — the admin editor. It does - * not add enforcement: `enforcement` records whether a server gate exists, and - * a key declared `server` without one is what `check:permission-group-enforcement` - * refuses. + * not add enforcement: `enforcement` names the mechanism that refuses, and + * `check:permission-group-enforcement` refuses a key that claims one it does + * not have. */ export const PERMISSION_GROUP_FIELDS = { - allowedIntegrations: allowlist(z.string(), 'server', { + allowedIntegrations: allowlist(z.string(), 'executor', { limited: 'Integrations and blocks are limited to effectiveConfig.allowedIntegrations.', empty: 'No non-exempt integrations or blocks are allowed.', }), - allowedModelProviders: allowlist(z.string(), 'server', { + allowedModelProviders: allowlist(z.string(), 'executor', { limited: 'Model providers are limited to effectiveConfig.allowedModelProviders.', empty: 'No model providers are allowed.', }), deniedModels: denylist( z.string(), - 'server', + 'executor', 'Models listed in effectiveConfig.deniedModels are blocked.' ), deniedTools: denylist( z.string(), - 'server', + 'executor', 'Integration tools listed in effectiveConfig.deniedTools are blocked.' ), - hideTraceSpans: booleanRestriction('ui-only', { + hideTraceSpans: booleanRestriction('capability', { id: 'hide-trace-spans', label: 'Trace Spans', category: 'Logs', hint: 'Hide per-block trace spans in logs.', }), - hideKnowledgeBaseTab: booleanRestriction('ui-only', { + hideKnowledgeBaseTab: booleanRestriction('capability', { id: 'hide-knowledge-base', label: 'Knowledge Base', category: 'Sidebar', hint: 'Hide the Knowledge Base module from the sidebar.', }), - hideTablesTab: booleanRestriction('ui-only', { + hideTablesTab: booleanRestriction('capability', { id: 'hide-tables', label: 'Tables', category: 'Sidebar', hint: 'Hide the Tables module from the sidebar.', }), - hideCopilot: booleanRestriction('ui-only', { + hideCopilot: booleanRestriction('capability', { id: 'hide-copilot', label: 'Chat', category: 'Workflow Panel', hint: 'Hide the Chat panel so users cannot build or edit with natural language.', }), - hideIntegrationsTab: booleanRestriction('ui-only', { + hideIntegrationsTab: booleanRestriction('capability', { id: 'hide-integrations', label: 'Integrations', category: 'Settings Tabs', hint: 'Hide the Integrations settings tab (OAuth connections).', }), - hideSecretsTab: booleanRestriction('ui-only', { + hideSecretsTab: booleanRestriction('capability', { id: 'hide-secrets', label: 'Secrets', category: 'Settings Tabs', hint: 'Hide the Secrets (environment variables) settings tab.', }), - hideApiKeysTab: booleanRestriction('ui-only', { + hideApiKeysTab: booleanRestriction('capability', { id: 'hide-api-keys', label: 'API Keys', category: 'Settings Tabs', hint: 'Hide the API Keys settings tab.', }), - hideInboxTab: booleanRestriction('ui-only', { + hideInboxTab: booleanRestriction('capability', { id: 'hide-inbox', label: 'Sim Mailer', category: 'Features', hint: 'Hide the Sim Mailer inbox.', }), - hideFilesTab: booleanRestriction('ui-only', { + hideFilesTab: booleanRestriction('capability', { id: 'hide-files', label: 'Files', category: 'Settings Tabs', hint: 'Hide the Files settings tab.', }), - disableMcpTools: booleanRestriction('server', { + disableMcpTools: booleanRestriction('capability', { id: 'disable-mcp', label: 'MCP Tools', category: 'Tools', hint: 'Block agents from calling MCP tools.', }), - disableCustomTools: booleanRestriction('server', { + disableCustomTools: booleanRestriction('capability', { id: 'disable-custom-tools', label: 'Custom Tools', category: 'Tools', hint: 'Block agents from calling user-defined custom tools.', }), - disableSkills: booleanRestriction('server', { + disableSkills: booleanRestriction('capability', { id: 'disable-skills', label: 'Skills', category: 'Tools', hint: 'Block agents from loading skills.', }), - disableInvitations: booleanRestriction('server', { + disableInvitations: booleanRestriction('capability', { id: 'disable-invitations', label: 'Invitations', category: 'Collaboration', hint: 'Prevent users from inviting others to workspaces.', }), - disablePublicApi: booleanRestriction('server', { + disablePublicApi: booleanRestriction('capability', { id: 'disable-public-api', label: 'Public API', category: 'Features', hint: 'Disable public API access to deployed workflows.', }), - disablePublicFileSharing: booleanRestriction('server', { + disablePublicFileSharing: booleanRestriction('capability', { id: 'disable-public-file-sharing', label: 'Public Sharing', category: 'Files', hint: 'Disable public file-share links.', }), - allowedFileShareAuthTypes: allowlist(shareAuthType, 'server', { + allowedFileShareAuthTypes: allowlist(shareAuthType, 'capability', { limited: 'Public file-share authentication is limited to effectiveConfig.allowedFileShareAuthTypes.', empty: 'No public file-share authentication modes are allowed.', }), - hideDeployApi: booleanRestriction('ui-only', { + hideDeployApi: booleanRestriction('capability', { id: 'hide-deploy-api', label: 'API', category: 'Deploy Tabs', hint: 'Hide the API deployment option.', }), - hideDeployMcp: booleanRestriction('ui-only', { + hideDeployMcp: booleanRestriction('capability', { id: 'hide-deploy-mcp', label: 'MCP', category: 'Deploy Tabs', hint: 'Hide the MCP server deployment option.', }), - hideDeployChatbot: booleanRestriction('ui-only', { + hideDeployChatbot: booleanRestriction('capability', { id: 'hide-deploy-chatbot', label: 'Deployment', category: 'Chat', hint: 'Hide the chat deployment option.', }), - allowedChatDeployAuthTypes: allowlist(shareAuthType, 'server', { + allowedChatDeployAuthTypes: allowlist(shareAuthType, 'capability', { limited: 'Chat deployment authentication is limited to effectiveConfig.allowedChatDeployAuthTypes.', empty: 'No chat deployment authentication modes are allowed.', diff --git a/package.json b/package.json index 115b8854347..24e04da883a 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", "check:tool-request-boundary": "bun run scripts/check-tool-request-boundary.ts", "check:actorless-executor-operations": "bun run scripts/check-actorless-executor-operations.ts", + "check:permission-group-enforcement": "bun run scripts/check-permission-group-enforcement.ts", "check:tool-registry-boundary": "bun run scripts/check-tool-registry-boundary.ts --check", "check:trigger-block-cycle": "bun run scripts/check-trigger-block-cycle.ts", "check:import-specifiers": "bun run scripts/check-import-specifiers.ts", diff --git a/scripts/check-permission-group-enforcement.ts b/scripts/check-permission-group-enforcement.ts new file mode 100644 index 00000000000..99b58234246 --- /dev/null +++ b/scripts/check-permission-group-enforcement.ts @@ -0,0 +1,340 @@ +#!/usr/bin/env bun +/** + * Connects a permission-group config key to the server gate that enforces it. + * + * Twelve keys shipped with an admin checkbox, a hint describing what they + * restrict, and no server check at all — an organization that set + * `hideCopilot` or `hideDeployChatbot` believed it had withheld a capability + * while every API route still answered. Nothing connected "this key is offered + * to admins" to "something refuses when it is set", because the two live in + * different files and neither knows about the other. This audit connects them. + * + * It asserts, in order of what actually goes wrong: + * + * A every workspace operation declares a capability, or `'none'` with a + * reason — an omission cannot be told apart from an unreviewed operation + * B every declared capability exists + * C every capability in the registry is reachable: named by an operation, or + * by an annotated call site for the ones the funnel cannot apply + * D every key claiming `enforcement: 'capability'` is read by some rule + * E no key claiming a weaker mechanism is read by one, so a key cannot gain + * enforcement while still documented as cosmetic or execution-scoped + * + * A capability the funnel cannot apply — one needing a request value, like an + * auth mode — is declared at its call site instead: + * + * // permission-group-enforced: deploy.chat.auth_mode — asserted from the use case + * + * An operation no group governs says so explicitly: + * + * // permission-group-exempt: + * + * While the operations are still being annotated the audit runs in count-down + * mode: it reports how many are unfilled and exits 0. Assertions B–E fail the + * build today. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const SCAN_ROOTS = ['apps/sim/lib', 'apps/sim/app', 'apps/sim/ee', 'apps/sim/executor'] +const CAPABILITIES_FILE = 'apps/sim/lib/permission-groups/capabilities.ts' +const FIELDS_FILE = 'apps/sim/lib/permission-groups/fields.ts' +const ENFORCED_ANNOTATION = 'permission-group-enforced:' +const EXEMPT_ANNOTATION = 'permission-group-exempt:' +const MAX_ANNOTATION_LOOKBACK = 3 + +interface Finding { + file: string + line?: number + message: string +} + +const CLOSING: Record = { '(': ')', '{': '}', '[': ']' } + +/** Text of the balanced `(...)`, `{...}` or `[...]` group that starts at `openIndex`. */ +function balancedGroup(source: string, openIndex: number): string { + const open = source[openIndex] + const close = CLOSING[open] + let depth = 0 + for (let index = openIndex; index < source.length; index++) { + const char = source[index] + if (char === open) depth++ + else if (char === close) { + depth-- + if (depth === 0) return source.slice(openIndex, index + 1) + } + } + return source.slice(openIndex) +} + +function walk(directory: string, into: string[]): string[] { + for (const entry of readdirSync(directory)) { + if (entry === 'node_modules' || entry === '.next') continue + const full = join(directory, entry) + if (statSync(full).isDirectory()) walk(full, into) + else if (full.endsWith('.ts') && !full.endsWith('.test.ts')) into.push(full) + } + return into +} + +/** The capability ids the registry declares, in declaration order. */ +export function parseCapabilityIds(source: string): string[] { + const start = source.indexOf('CAPABILITY_IDS = [') + if (start === -1) return [] + const group = balancedGroup(source, source.indexOf('[', start)) + return [...group.matchAll(/'([a-z0-9_.]+)'/g)].map((match) => match[1]) +} + +/** Each capability's rule kind and the config keys it reads. */ +export function parseCapabilityRules( + source: string +): Map { + const rules = new Map() + const start = source.indexOf('CAPABILITY_RULES = {') + if (start === -1) return rules + + const body = balancedGroup(source, source.indexOf('{', start)) + const entryPattern = /'([a-z0-9_.]+)'\s*:\s*\{/g + for (let match = entryPattern.exec(body); match; match = entryPattern.exec(body)) { + const entry = balancedGroup(body, body.indexOf('{', match.index + match[0].length - 1)) + const kind = /kind\s*:\s*'([a-z]+)'/.exec(entry)?.[1] ?? '' + const keysGroup = /configKeys\s*:\s*\[([^\]]*)\]/.exec(entry)?.[1] ?? '' + const configKeys = [...keysGroup.matchAll(/'([A-Za-z0-9_]+)'/g)].map((key) => key[1]) + rules.set(match[1], { kind, configKeys }) + } + return rules +} + +/** Each config key's declared enforcement, from the field registry. */ +export function parseFieldEnforcement(source: string): Map { + const enforcement = new Map() + const start = source.indexOf('PERMISSION_GROUP_FIELDS = {') + if (start === -1) return enforcement + + const body = balancedGroup(source, source.indexOf('{', start)) + const entryPattern = + /(?:^|\n)\s{2}([A-Za-z0-9_]+)\s*:\s*(allowlist|denylist|booleanRestriction)\(/g + for (let match = entryPattern.exec(body); match; match = entryPattern.exec(body)) { + const call = balancedGroup(body, body.indexOf('(', match.index + match[0].length - 1)) + const declared = /'(capability|executor|ui-only)'/.exec(call)?.[1] + if (declared) enforcement.set(match[1], declared) + } + return enforcement +} + +interface OperationDeclaration { + id: string + line: number + capability: string | undefined +} + +/** + * Every `defineWorkspaceOperation` in a module and the capability it declares, + * resolved through a same-file factory when a domain wraps the builder (the + * table operations take only an id and a capability). + */ +export function parseOperationCapabilities(source: string): OperationDeclaration[] { + const declarations: OperationDeclaration[] = [] + const lineAt = (index: number) => source.slice(0, index).split('\n').length + + const factories = new Set() + const factoryPattern = /(?:^|\n)\s*(?:export\s+)?function\s+([A-Za-z0-9_$]+)\s*[<(]/g + for (let match = factoryPattern.exec(source); match; match = factoryPattern.exec(source)) { + const bodyIndex = source.indexOf('{', match.index + match[0].length - 1) + if (bodyIndex === -1) continue + const body = balancedGroup(source, bodyIndex) + if (body.includes('defineWorkspaceOperation') && body.includes('capability')) { + factories.add(match[1]) + } + } + + const directPattern = /defineWorkspaceOperation\s*\(/g + for (let match = directPattern.exec(source); match; match = directPattern.exec(source)) { + const call = balancedGroup(source, source.indexOf('(', match.index)) + const id = /id\s*:\s*'([^']+)'/.exec(call)?.[1] + if (!id) continue + declarations.push({ + id, + line: lineAt(match.index), + capability: /capability\s*:\s*'([a-z0-9_.]+)'/.exec(call)?.[1], + }) + } + + for (const factory of factories) { + const callPattern = new RegExp(`\\b${factory}\\s*\\(\\s*'([^']+)'\\s*,\\s*'([a-z0-9_.]+)'`, 'g') + for (let match = callPattern.exec(source); match; match = callPattern.exec(source)) { + declarations.push({ id: match[1], line: lineAt(match.index), capability: match[2] }) + } + } + + return declarations +} + +/** Capabilities declared enforced at a call site the funnel cannot reach. */ +export function parseEnforcedAnnotations(source: string): string[] { + return [...source.matchAll(new RegExp(`${ENFORCED_ANNOTATION}\\s*([a-z0-9_.]+)`, 'g'))].map( + (match) => match[1] + ) +} + +/** Whether an operation's `capability: 'none'` carries a reason. */ +export function hasExemptAnnotation(source: string, line: number): boolean { + const lines = source.split('\n') + for (let back = line - 2; back >= 0 && back >= line - 2 - MAX_ANNOTATION_LOOKBACK; back--) { + const candidate = lines[back]?.trim() ?? '' + if (candidate === '') continue + if (!candidate.startsWith('//') && !candidate.startsWith('*')) break + if (candidate.includes(EXEMPT_ANNOTATION)) { + return ( + candidate.slice(candidate.indexOf(EXEMPT_ANNOTATION) + EXEMPT_ANNOTATION.length).trim() !== + '' + ) + } + } + return false +} + +function main(): void { + const capabilitiesSource = readFileSync(join(ROOT, CAPABILITIES_FILE), 'utf8') + const fieldsSource = readFileSync(join(ROOT, FIELDS_FILE), 'utf8') + + const capabilityIds = new Set(parseCapabilityIds(capabilitiesSource)) + const rules = parseCapabilityRules(capabilitiesSource) + const enforcement = parseFieldEnforcement(fieldsSource) + + /** + * This audit reads source text, so a rename it does not know about makes its + * parsers return nothing — and every assertion below would then pass over an + * empty set. An audit that goes quiet when it breaks is worse than no audit, + * so refuse to report success on an obviously empty parse. + */ + if (capabilityIds.size === 0 || rules.size === 0 || enforcement.size === 0) { + console.error( + 'Permission-group enforcement audit could not read its own inputs:\n' + + ` capabilities parsed: ${capabilityIds.size}, rules: ${rules.size}, config keys: ${enforcement.size}\n\n` + + 'One of CAPABILITY_IDS, CAPABILITY_RULES or PERMISSION_GROUP_FIELDS was renamed or\n' + + 'reshaped. Update the parsers in this script rather than leaving it passing vacuously.\n' + ) + process.exit(1) + } + if (rules.size !== capabilityIds.size) { + console.error( + `Permission-group enforcement audit parsed ${capabilityIds.size} capabilities but ${rules.size} rules; the registry and its rules disagree.\n` + ) + process.exit(1) + } + + const sourceFiles = SCAN_ROOTS.flatMap((root) => walk(join(ROOT, root), [])) + + const findings: Finding[] = [] + const usedCapabilities = new Set() + let declaredOperations = 0 + let unfilledOperations = 0 + + for (const file of sourceFiles) { + const relativePath = relative(ROOT, file) + const source = readFileSync(file, 'utf8') + + for (const capability of parseEnforcedAnnotations(source)) { + usedCapabilities.add(capability) + if (!capabilityIds.has(capability)) { + findings.push({ + file: relativePath, + message: `declares enforcement for unknown capability '${capability}'`, + }) + } + } + + if (!source.includes('defineWorkspaceOperation')) continue + + for (const declaration of parseOperationCapabilities(source)) { + declaredOperations++ + if (declaration.capability === undefined) { + unfilledOperations++ + continue + } + if (declaration.capability === 'none') { + if (!hasExemptAnnotation(source, declaration.line)) { + unfilledOperations++ + } + continue + } + usedCapabilities.add(declaration.capability) + if (!capabilityIds.has(declaration.capability)) { + findings.push({ + file: relativePath, + line: declaration.line, + message: `operation '${declaration.id}' names unknown capability '${declaration.capability}'`, + }) + } + } + } + + /** + * A capability nothing names is a key an admin can set to no effect. While + * the operations are still being annotated that is expected rather than + * broken, so it counts down with them instead of failing the build twice for + * the same unfinished migration. + */ + const unreachedCapabilities = [...capabilityIds].filter( + (capability) => !usedCapabilities.has(capability) + ) + if (unfilledOperations === 0) { + for (const capability of unreachedCapabilities) { + findings.push({ + file: CAPABILITIES_FILE, + message: `capability '${capability}' is declared but nothing enforces it — name it on an operation, or annotate its call site with '${ENFORCED_ANNOTATION} ${capability} — '`, + }) + } + } + + const enforcedByRule = new Set([...rules.values()].flatMap((rule) => rule.configKeys)) + for (const [key, declared] of enforcement) { + if (declared === 'capability' && !enforcedByRule.has(key)) { + findings.push({ + file: FIELDS_FILE, + message: `config key '${key}' claims capability enforcement but no rule reads it — give it a rule, or declare it 'executor' or 'ui-only'`, + }) + } + if (declared !== 'capability' && enforcedByRule.has(key)) { + findings.push({ + file: FIELDS_FILE, + message: `config key '${key}' is declared '${declared}' but a capability rule reads it — set enforcement to 'capability' so the key stops being documented as something weaker`, + }) + } + } + + if (findings.length > 0) { + console.error('Permission-group enforcement audit failed:\n') + for (const finding of findings) { + const where = finding.line ? `${finding.file}:${finding.line}` : finding.file + console.error(` ${where}\n ${finding.message}\n`) + } + console.error( + 'A permission-group key that reaches the admin editor without a server gate is a\n' + + 'restriction an organization believes it applied. Wire the gate, or declare the\n' + + "key 'ui-only' so it is documented as a rendering hint rather than a control.\n" + ) + process.exit(1) + } + + if (unfilledOperations > 0) { + console.log( + `✓ permission-group enforcement: ${declaredOperations - unfilledOperations}/${declaredOperations} operations declare a capability ` + + `(${unfilledOperations} to go; the field becomes required once they all do)` + ) + if (unreachedCapabilities.length > 0) { + console.log(` pending enforcement: ${unreachedCapabilities.join(', ')}`) + } + return + } + + console.log( + `✓ permission-group enforcement: ${declaredOperations} operations declare a capability, ${capabilityIds.size} capabilities all enforced` + ) +} + +if (import.meta.main) main() From 66cb9c042bac2fe3f65684b3fb5bde58780407fa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 20:32:58 -0700 Subject: [PATCH 004/179] feat(permission-groups): enforce the twelve UI-only keys on the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every capability in the registry now has something that refuses when the key behind it is set. An organization that hides Tables, Knowledge Bases, Files, Secrets, Integrations, API keys, the inbox, Chat, or any of the three deploy surfaces gets a 403 from the API rather than a hidden nav item. Most of it is declaration. 163 operations across tables, knowledge, files, secrets, credentials, workflows, MCP and API keys name their capability, and the funnel does the rest — the gate added in the previous commit already covered every surface those operations reach. The table domain fixes its capability in the factories rather than repeating it at ten call sites, since every operation in that module is one capability; the audit reads both that form and the positional one. Four sites are not workspace operations and are gated where they actually run: - Chat is a raw handler, checked after the request parses and before the send is claimed. That also settles the resume stream: with no run created there is nothing for it to replay. - `hideTraceSpans` becomes a projection rather than a refusal — the log stays readable, its trace spans, block I/O and final output do not. Applied before child traces hydrate, so a withheld view does not pay for a cross-workspace join it discards, and by deleting rather than omitting, because the execution-data schema is a passthrough and would otherwise let the fields through. - The inbox routes are raw handlers with inline queries; a shared guard keeps the six of them from drifting. - The auth-mode and tool-kind capabilities are annotated at the validators that already enforce them, which is what lets the audit prove every capability is reachable rather than assuming it. The audit now reports no pending enforcement: nothing can reach the admin editor claiming a restriction it does not apply. 120 operations remain unannotated and are counted, not enforced — they are the domains whose capabilities land with the creation-versus-invocation work. Three test files fail to load on this branch (`create-credential-connection`, `add-workspace-files`, `upload-sessions`); they fail identically on the base commit, and are unrelated to this change. --- .../app/api/workspaces/[id]/inbox/route.ts | 7 +++ .../workspaces/[id]/inbox/senders/route.ts | 10 ++++ .../api/workspaces/[id]/inbox/tasks/route.ts | 4 ++ .../access-control/utils/permission-check.ts | 8 +++ .../sim/lib/api-key/application/operations.ts | 1 + apps/sim/lib/copilot/chat/post.ts | 24 ++++++++- .../lib/credentials/application/operations.ts | 17 ++++++ .../lib/knowledge/application/operations.ts | 53 +++++++++++++++++++ .../lib/logs/application/read-log-detail.ts | 15 +++++- apps/sim/lib/logs/fetch-log-detail.ts | 35 +++++++++++- apps/sim/lib/mcp/application/operations.ts | 2 + apps/sim/lib/mothership/inbox/access.ts | 23 ++++++++ .../sim/lib/secrets/application/operations.ts | 5 ++ apps/sim/lib/table/application/operations.ts | 10 ++++ .../lib/workflows/application/operations.ts | 4 ++ .../workspace-files/application/operations.ts | 28 ++++++++++ scripts/check-permission-group-enforcement.ts | 28 +++++++--- 17 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 apps/sim/lib/mothership/inbox/access.ts diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index 0bcc27b959d..cb25a9125ec 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { inboxWithheldResponse } from '@/lib/mothership/inbox/access' import { disableInbox, enableInbox, updateInboxAddress } from '@/lib/mothership/inbox/lifecycle' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -27,6 +28,9 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } + const withheld = await inboxWithheldResponse(session.user.id, workspaceId) + if (withheld) return withheld + const [wsResult, statsResult, entitled] = await Promise.all([ db .select({ @@ -94,6 +98,9 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } + const withheld = await inboxWithheldResponse(session.user.id, workspaceId) + if (withheld) return withheld + const parsed = await parseRequest(updateInboxConfigContract, req, context) if (!parsed.success) return parsed.response const body = parsed.data.body diff --git a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts index eba711ed188..098858c9556 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts @@ -8,6 +8,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { inboxWithheldResponse } from '@/lib/mothership/inbox/access' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InboxSendersAPI') @@ -31,6 +32,9 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } + const withheld = await inboxWithheldResponse(session.user.id, workspaceId) + if (withheld) return withheld + const [senders, members] = await Promise.all([ db .select({ @@ -87,6 +91,9 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } + const withheld = await inboxWithheldResponse(session.user.id, workspaceId) + if (withheld) return withheld + try { const parsed = await parseRequest(addInboxSenderContract, req, context) if (!parsed.success) return parsed.response @@ -146,6 +153,9 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } + const withheld = await inboxWithheldResponse(session.user.id, workspaceId) + if (withheld) return withheld + try { const parsed = await parseRequest(removeInboxSenderContract, req, context) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts index 82a9e12e383..b9900c5bdc9 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts @@ -6,6 +6,7 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { inboxWithheldResponse } from '@/lib/mothership/inbox/access' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const GET = withRouteHandler( @@ -34,6 +35,9 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } + const withheld = await inboxWithheldResponse(session.user.id, workspaceId) + if (withheld) return withheld + const queryResult = inboxTasksQuerySchema.safeParse( Object.fromEntries(req.nextUrl.searchParams.entries()) ) diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index fdb7c5cfb9e..e20d8ede1b8 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -338,6 +338,8 @@ export async function getUserPermissionConfig( * allow-list (`null` allows all). No-op when access control doesn't apply * (non-enterprise / disabled), so non-governed orgs are unaffected. */ +/** permission-group-enforced: file_share.publish — asserted where a share is created, not per operation */ +/** permission-group-enforced: file_share.auth_mode — needs the request auth mode, which the funnel never sees */ export async function validatePublicFileSharing( userId: string, workspaceId: string, @@ -371,6 +373,7 @@ export async function validatePublicFileSharing( * No-op when access control doesn't apply (non-enterprise / disabled), so * non-governed orgs are unaffected. */ +/** permission-group-enforced: deploy.chat.auth_mode — needs the request auth mode, which the funnel never sees */ export async function validateChatDeployAuth( userId: string, workspaceId: string, @@ -527,6 +530,7 @@ export async function validateBlockType( } } +/** permission-group-enforced: mcp_tools.use — gates tool invocation during a run, not an operation */ export async function validateMcpToolsAllowed( userId: string | undefined, workspaceId: string | undefined, @@ -548,6 +552,7 @@ export async function validateMcpToolsAllowed( } } +/** permission-group-enforced: custom_tools.use — gates tool invocation during a run, not an operation */ export async function validateCustomToolsAllowed( userId: string | undefined, workspaceId: string | undefined, @@ -569,6 +574,7 @@ export async function validateCustomToolsAllowed( } } +/** permission-group-enforced: skills.use — gates skill loading during a run, not an operation */ export async function validateSkillsAllowed( userId: string | undefined, workspaceId: string | undefined, @@ -598,6 +604,7 @@ export async function validateSkillsAllowed( * user's group in that organization (explicit or the org default) has `disableInvitations`. * - neither — only the global feature flag is checked. */ +/** permission-group-enforced: invitations.send — organization-scoped, so it resolves the default group rather than a workspace one */ export async function validateInvitationsAllowed( userId: string | undefined, scope: string | { workspaceId?: string; organizationId?: string } = {} @@ -640,6 +647,7 @@ export async function validateInvitationsAllowed( * workspace. Also checks the global feature flag. When `workspaceId` is * omitted only the feature-flag check runs (no permission-group gate). */ +/** permission-group-enforced: public_api.use — gates the public execution surface, which has no workspace operation */ export async function validatePublicApiAllowed( userId: string | undefined, workspaceId?: string diff --git a/apps/sim/lib/api-key/application/operations.ts b/apps/sim/lib/api-key/application/operations.ts index 9534d44ec59..f252c98fea6 100644 --- a/apps/sim/lib/api-key/application/operations.ts +++ b/apps/sim/lib/api-key/application/operations.ts @@ -26,6 +26,7 @@ export const apiKeyOperations = { id: 'api_keys.copilot.create', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'api_keys.manage', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 90b4b3b283c..bbb4a02548d 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -46,7 +46,11 @@ import { import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1' -import { createBadRequestResponse, createUnauthorizedResponse } from '@/lib/copilot/request/http' +import { + createBadRequestResponse, + createForbiddenResponse, + createUnauthorizedResponse, +} from '@/lib/copilot/request/http' import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/lifecycle/start' import { startCopilotOtelRoot, withCopilotSpan } from '@/lib/copilot/request/otel' import { @@ -71,6 +75,7 @@ import { isWorkspaceAccessDeniedError, type PermissionType, } from '@/lib/workspaces/permissions/utils' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import type { ChatContext } from '@/stores/panel' export const maxDuration = 3600 @@ -1051,6 +1056,23 @@ export async function handleUnifiedChatPost(req: NextRequest) { typeof session.user.name === 'string' ? session.user.name : undefined const body = ChatMessageSchema.parse(await req.json()) + + /** + * permission-group-enforced: copilot.use — Chat is a raw handler rather + * than a workspace operation, so the authorization funnel never sees it. + * Checked before the send is claimed or a run is created, which also + * settles the resume stream: with no run there is nothing to replay. A + * request naming no workspace is governed by no group. + */ + if (body.workspaceId) { + const permissionConfig = await getUserPermissionConfig(authenticatedUserId, body.workspaceId) + if (permissionConfig?.hideCopilot) { + return createForbiddenResponse( + "Chat is not available under your organization's permission group" + ) + } + } + const userMetadata = { ...(authenticatedUserName ? { name: authenticatedUserName } : {}), ...(authenticatedUserEmail ? { email: authenticatedUserEmail } : {}), diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 4753c3136a8..da5b26a319c 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -34,30 +34,35 @@ export const credentialOperations = { id: 'credentials.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), listProviders: defineWorkspaceOperation({ id: 'credentials.providers.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'integrations.manage', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), listConnections: defineWorkspaceOperation({ id: 'credentials.connections.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'integrations.manage', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), createConnection: defineWorkspaceOperation({ id: 'credentials.connections.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session', 'personal_api_key'], }), prepareConnection: defineWorkspaceOperation({ id: 'credentials.connections.prepare', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), @@ -65,6 +70,7 @@ export const credentialOperations = { id: 'credentials.service_accounts.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session', 'personal_api_key'], }), read: defineCredentialOperation( @@ -72,6 +78,7 @@ export const credentialOperations = { id: 'credentials.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), 'member' @@ -80,6 +87,7 @@ export const credentialOperations = { id: 'credentials.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), update: defineCredentialOperation( @@ -87,6 +95,7 @@ export const credentialOperations = { id: 'credentials.update', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', ...HUMAN_AND_COPILOT_PRINCIPALS, }), 'admin' @@ -96,6 +105,7 @@ export const credentialOperations = { id: 'credentials.delete', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', ...HUMAN_AND_COPILOT_PRINCIPALS, }), 'admin' @@ -104,6 +114,7 @@ export const credentialOperations = { id: 'credentials.delete_many', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), @@ -111,12 +122,14 @@ export const credentialOperations = { id: 'credentials.drafts.save', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), listMembers: defineWorkspaceOperation({ id: 'credentials.members.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), upsertMember: defineCredentialOperation( @@ -124,6 +137,7 @@ export const credentialOperations = { id: 'credentials.members.upsert', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), 'admin' @@ -133,6 +147,7 @@ export const credentialOperations = { id: 'credentials.members.remove', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), 'admin' @@ -141,12 +156,14 @@ export const credentialOperations = { id: 'credentials.connections.launch', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), useManagedOAuth: defineWorkspaceOperation({ id: 'credentials.managed_oauth.use', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['delegated'], delegatedServices: ['executor'], resourcePolicy: { diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index a4ffeb7567b..5abcde41d4c 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -42,30 +42,35 @@ export const knowledgeOperations = { id: 'knowledge.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'knowledge.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'knowledge.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'knowledge.update', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'knowledge.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), /** @@ -79,162 +84,189 @@ export const knowledgeOperations = { id: 'knowledge.restore', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), bulkMoveItems: defineWorkspaceOperation({ id: 'knowledge.bulk_move_items', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), bulkDeleteItems: defineWorkspaceOperation({ id: 'knowledge.bulk_delete_items', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), bulkDelete: defineWorkspaceOperation({ id: 'knowledge.bulk_delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), renameByVfsPath: defineWorkspaceOperation({ id: 'knowledge.vfs.rename', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...COPILOT_PRINCIPAL_POLICY, }), moveByVfsPath: defineWorkspaceOperation({ id: 'knowledge.vfs.move', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...COPILOT_PRINCIPAL_POLICY, }), manageVfsFolders: defineWorkspaceOperation({ id: 'knowledge.vfs.folders.manage', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...COPILOT_PRINCIPAL_POLICY, }), deleteByVfsPath: defineWorkspaceOperation({ id: 'knowledge.vfs.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...COPILOT_PRINCIPAL_POLICY, }), search: defineWorkspaceOperation({ id: 'knowledge.search', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), listFolders: defineWorkspaceOperation({ id: 'knowledge.folders.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: HTTP_PRINCIPAL_KINDS, }), createFolder: defineWorkspaceOperation({ id: 'knowledge.folders.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: HTTP_PRINCIPAL_KINDS, }), relocateFolder: defineWorkspaceOperation({ id: 'knowledge.folders.relocate', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: HTTP_PRINCIPAL_KINDS, }), deleteFolder: defineWorkspaceOperation({ id: 'knowledge.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: HTTP_PRINCIPAL_KINDS, }), listDocuments: defineWorkspaceOperation({ id: 'knowledge.documents.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), readDocument: defineWorkspaceOperation({ id: 'knowledge.documents.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), uploadDocument: defineWorkspaceOperation({ id: 'knowledge.documents.upload', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), addWorkspaceFiles: defineWorkspaceOperation({ id: 'knowledge.documents.add_workspace_files', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), deleteDocument: defineWorkspaceOperation({ id: 'knowledge.documents.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), bulkDeleteDocuments: defineWorkspaceOperation({ id: 'knowledge.documents.bulk_delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), updateDocument: defineWorkspaceOperation({ id: 'knowledge.documents.update', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), bulkDocuments: defineWorkspaceOperation({ id: 'knowledge.documents.bulk', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), listChunks: defineWorkspaceOperation({ id: 'knowledge.chunks.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), readChunk: defineWorkspaceOperation({ id: 'knowledge.chunks.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), createChunk: defineWorkspaceOperation({ id: 'knowledge.chunks.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), updateChunk: defineWorkspaceOperation({ id: 'knowledge.chunks.update', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), deleteChunk: defineWorkspaceOperation({ id: 'knowledge.chunks.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), bulkChunks: defineWorkspaceOperation({ id: 'knowledge.chunks.bulk', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), /** @@ -247,42 +279,49 @@ export const knowledgeOperations = { id: 'knowledge.tags.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), createTag: defineWorkspaceOperation({ id: 'knowledge.tags.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), updateTag: defineWorkspaceOperation({ id: 'knowledge.tags.update', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), deleteTag: defineWorkspaceOperation({ id: 'knowledge.tags.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), readTagUsage: defineWorkspaceOperation({ id: 'knowledge.tags.read_usage', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), readDetailedTagUsage: defineWorkspaceOperation({ id: 'knowledge.tags.read_detailed_usage', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), readNextTagSlot: defineWorkspaceOperation({ id: 'knowledge.tags.read_next_slot', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), /** @@ -296,6 +335,7 @@ export const knowledgeOperations = { id: 'knowledge.tags.bulk_save', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), /** Removal over that same vocabulary — unused definitions, or all of them. */ @@ -303,78 +343,91 @@ export const knowledgeOperations = { id: 'knowledge.tags.cleanup', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), listConnectors: defineWorkspaceOperation({ id: 'knowledge.connectors.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), readConnector: defineWorkspaceOperation({ id: 'knowledge.connectors.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), createConnector: defineWorkspaceOperation({ id: 'knowledge.connectors.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), updateConnector: defineWorkspaceOperation({ id: 'knowledge.connectors.update', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), deleteConnector: defineWorkspaceOperation({ id: 'knowledge.connectors.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), syncConnector: defineWorkspaceOperation({ id: 'knowledge.connectors.sync', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), listConnectorDocuments: defineWorkspaceOperation({ id: 'knowledge.connectors.documents.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), updateConnectorDocuments: defineWorkspaceOperation({ id: 'knowledge.connectors.documents.update', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), uploadCreate: defineWorkspaceOperation({ id: 'knowledge.documents.upload.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: HTTP_PRINCIPAL_KINDS, }), uploadParts: defineWorkspaceOperation({ id: 'knowledge.documents.upload.parts', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: HTTP_PRINCIPAL_KINDS, }), uploadComplete: defineWorkspaceOperation({ id: 'knowledge.documents.upload.complete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: HTTP_PRINCIPAL_KINDS, }), uploadCancel: defineWorkspaceOperation({ id: 'knowledge.documents.upload.cancel', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: HTTP_PRINCIPAL_KINDS, }), } as const diff --git a/apps/sim/lib/logs/application/read-log-detail.ts b/apps/sim/lib/logs/application/read-log-detail.ts index 326a3047ee1..405e9cbfadb 100644 --- a/apps/sim/lib/logs/application/read-log-detail.ts +++ b/apps/sim/lib/logs/application/read-log-detail.ts @@ -15,6 +15,7 @@ import { type ActiveWorkspaceApplicationContext, resolveActiveWorkspaceApplicationContext, } from '@/lib/workspaces/application/workspace-context' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' export interface ReadLogDetailInput { workspaceId: string @@ -79,12 +80,24 @@ const authorizedReadLogDetailUseCase = defineAuthorizedWorkspaceUseCase({ input.signal?.throwIfAborted() // Attribution, not authorization: an actorless run (a schedule, or a webhook // with no external subject) reads its own workspace's logs with no user to name. + const viewerUserId = resolvePrincipalSubjectUserId(principal) + + /** + * permission-group-enforced: logs.trace_spans — a projection rather than a + * refusal: the log stays readable, its execution payloads do not. An + * actorless run has no group and reads its own workspace's logs whole. + */ + const permissionConfig = viewerUserId + ? await getUserPermissionConfig(viewerUserId, context.workspaceId) + : null + const detail = await readLogDetail({ - viewerUserId: resolvePrincipalSubjectUserId(principal), + viewerUserId, workspaceId: context.workspaceId, lookupColumn: input.lookupColumn, lookupValue: input.lookupValue, signal: input.signal, + hideTraceSpans: permissionConfig?.hideTraceSpans === true, }) input.signal?.throwIfAborted() if (!detail) throw new OrchestrationError('not_found', 'Not found') diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts index 41a89227a12..3e2bc896429 100644 --- a/apps/sim/lib/logs/fetch-log-detail.ts +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -40,6 +40,34 @@ interface FetchLogDetailArgs { lookupColumn: LookupColumn lookupValue: string signal?: AbortSignal + /** + * Whether the viewer's permission group withholds execution detail. Applied + * here rather than in the client, because the payloads it covers — trace + * spans, block inputs and outputs, the final output — are the customer data + * the restriction exists to withhold, and a hidden tab withholds nothing from + * a caller reading the route directly. + */ + hideTraceSpans?: boolean +} + +/** + * Strips the execution payloads a permission group withholds. + * + * Deletes rather than relies on the schema: `executionDataDetailSchema` is a + * passthrough, so a field left in place would survive response validation. + * Applied before child traces are hydrated, so a withheld view does not pay for + * a cross-workspace join whose result it discards. + */ +function withheldExecutionData(executionData: Record): Record { + const { + traceSpans: _traceSpans, + blockExecutions: _blockExecutions, + finalOutput: _finalOutput, + workflowInput: _workflowInput, + blockInput: _blockInput, + ...retained + } = executionData + return retained } /** @@ -56,6 +84,7 @@ export async function readLogDetail({ lookupColumn, lookupValue, signal, + hideTraceSpans = false, }: FetchLogDetailArgs): Promise { signal?.throwIfAborted() const workflowMatch: SQL = @@ -134,7 +163,7 @@ export async function readLogDetail({ // Trace spans / heavy execution data may live in object storage; resolve the // pointer here (no-op for inline / pre-externalization rows). - const executionData = await materializeExecutionDataForDisplay( + const materialized = await materializeExecutionDataForDisplay( log.executionData as Record | null, { workspaceId, @@ -143,6 +172,7 @@ export async function readLogDetail({ userId: viewerUserId, } ) + const executionData = hideTraceSpans ? withheldExecutionData(materialized) : materialized signal?.throwIfAborted() // A custom block's child ran in another workspace and kept its spans on its @@ -228,7 +258,7 @@ export async function readLogDetail({ const jobLog = jobRows[0] if (!jobLog) return null - const execData = await materializeExecutionDataForDisplay( + const materializedJobData = await materializeExecutionDataForDisplay( jobLog.executionData as Record | null, { workspaceId, @@ -237,6 +267,7 @@ export async function readLogDetail({ userId: viewerUserId, } ) + const execData = hideTraceSpans ? withheldExecutionData(materializedJobData) : materializedJobData signal?.throwIfAborted() return workflowLogDetailSchema.parse({ id: jobLog.id, diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index dc4ed4d2335..7b5bd098fbc 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -82,6 +82,7 @@ export const mcpServerOperations = { id: 'mcp_servers.workflow_deployments.create_server', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), /** @@ -101,6 +102,7 @@ export const mcpServerOperations = { id: 'mcp_servers.workflow_deployments.update_server', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), deleteWorkflowDeploymentServer: defineWorkspaceOperation({ diff --git a/apps/sim/lib/mothership/inbox/access.ts b/apps/sim/lib/mothership/inbox/access.ts new file mode 100644 index 00000000000..01489caea5e --- /dev/null +++ b/apps/sim/lib/mothership/inbox/access.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' + +/** + * Refuses the inbox when the caller's permission group withholds it. + * + * permission-group-enforced: inbox.use — the inbox routes are raw handlers with + * inline queries rather than workspace operations, so the authorization funnel + * never sees them. Returns a response instead of throwing to match how those + * handlers already report refusals, and returns `null` when nothing withholds + * the inbox so a caller can read it as a guard. + */ +export async function inboxWithheldResponse( + userId: string, + workspaceId: string +): Promise { + const permissionConfig = await getUserPermissionConfig(userId, workspaceId) + if (!permissionConfig?.hideInboxTab) return null + return NextResponse.json( + { error: "The inbox is not available under your organization's permission group" }, + { status: 403 } + ) +} diff --git a/apps/sim/lib/secrets/application/operations.ts b/apps/sim/lib/secrets/application/operations.ts index 00f408fd84e..b629a1014dd 100644 --- a/apps/sim/lib/secrets/application/operations.ts +++ b/apps/sim/lib/secrets/application/operations.ts @@ -7,18 +7,21 @@ export const secretOperations = { id: 'secrets.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'secrets.manage', principalKinds: HUMAN_API_PRINCIPAL_KINDS, }), set: defineWorkspaceOperation({ id: 'secrets.set', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'secrets.manage', principalKinds: HUMAN_API_PRINCIPAL_KINDS, }), delete: defineWorkspaceOperation({ id: 'secrets.delete', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'secrets.manage', principalKinds: HUMAN_API_PRINCIPAL_KINDS, }), /** @@ -29,6 +32,7 @@ export const secretOperations = { id: 'secrets.usage', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'secrets.manage', principalKinds: HUMAN_API_PRINCIPAL_KINDS, }), /** @@ -40,6 +44,7 @@ export const secretOperations = { id: 'secrets.references', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'secrets.manage', principalKinds: HUMAN_API_PRINCIPAL_KINDS, }), } as const diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index 209d11979a7..5077e56c9ff 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -24,6 +24,7 @@ function readOperation(id: Id) { id, minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'tables.use', ...ALL_PRINCIPAL_POLICY, }) } @@ -33,6 +34,7 @@ function writeOperation(id: Id) { id, minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'tables.use', ...ALL_PRINCIPAL_POLICY, }) } @@ -42,6 +44,7 @@ function toolWriteOperation(id: Id) { id, minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'tables.use', ...ALL_TABLE_TOOL_PRINCIPAL_POLICY, }) } @@ -51,6 +54,7 @@ function toolReadOperation(id: Id) { id, minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'tables.use', ...ALL_TABLE_TOOL_PRINCIPAL_POLICY, }) } @@ -60,6 +64,7 @@ function internalExecutorReadOperation(id: Id) { id, minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'tables.use', ...INTERNAL_EXECUTOR_PRINCIPAL_POLICY, }) } @@ -69,6 +74,7 @@ function internalExecutorWriteOperation(id: Id) { id, minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'tables.use', ...INTERNAL_EXECUTOR_PRINCIPAL_POLICY, }) } @@ -78,6 +84,7 @@ function delegatedWriteOperation(id: Id) { id, minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'tables.use', principalKinds: ['delegated'], delegatedServices: ['copilot'], }) @@ -96,18 +103,21 @@ export const tableOperations = { id: 'tables.vfs.rename', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'tables.use', ...COPILOT_PRINCIPAL_POLICY, }), moveByVfsPath: defineWorkspaceOperation({ id: 'tables.vfs.move', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'tables.use', ...COPILOT_PRINCIPAL_POLICY, }), deleteByVfsPath: defineWorkspaceOperation({ id: 'tables.vfs.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'tables.use', ...COPILOT_PRINCIPAL_POLICY, }), listFolders: readOperation('tables.folders.list'), diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 87591f517bc..84dff231f8d 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -236,24 +236,28 @@ export const workflowOperations = { id: 'workflows.deploy', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.api', ...WORKFLOW_DEPLOYMENT_PRINCIPAL_POLICY, }), undeploy: defineWorkspaceOperation({ id: 'workflows.undeploy', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.api', ...WORKFLOW_DEPLOYMENT_PRINCIPAL_POLICY, }), deployChat: defineWorkspaceOperation({ id: 'workflows.chat.deploy', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.chat', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), undeployChat: defineWorkspaceOperation({ id: 'workflows.chat.undeploy', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.chat', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), /** diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index 2752a576521..21d38c4590d 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -21,42 +21,49 @@ export const fileOperations = { id: 'files.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), readMetadata: defineWorkspaceOperation({ id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), readContent: defineWorkspaceOperation({ id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), download: defineWorkspaceOperation({ id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), compiledCheck: defineWorkspaceOperation({ id: 'files.compiled_check', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'files.use', principalKinds: ['session'], }), create: defineWorkspaceOperation({ id: 'files.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), rename: defineWorkspaceOperation({ id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), /** @@ -75,30 +82,35 @@ export const fileOperations = { id: 'files.extract_archive', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), updateContent: defineWorkspaceOperation({ id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), updateMetadata: defineWorkspaceOperation({ id: 'files.update_metadata', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), move: defineWorkspaceOperation({ id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), createVfsFolders: defineWorkspaceOperation({ id: 'files.vfs.folders.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'files.use', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), @@ -106,6 +118,7 @@ export const fileOperations = { id: 'files.vfs.relocate', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'files.use', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), @@ -113,6 +126,7 @@ export const fileOperations = { id: 'files.vfs.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'files.use', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), @@ -120,60 +134,70 @@ export const fileOperations = { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), restore: defineWorkspaceOperation({ id: 'files.restore', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), readShare: defineWorkspaceOperation({ id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), updateShare: defineWorkspaceOperation({ id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'files.use', ...HUMAN_FILE_TOOL_PRINCIPAL_POLICY, }), listFolders: defineWorkspaceOperation({ id: 'files.folders.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), createFolder: defineWorkspaceOperation({ id: 'files.folders.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), updateFolder: defineWorkspaceOperation({ id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), deleteFolder: defineWorkspaceOperation({ id: 'files.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), restoreFolder: defineWorkspaceOperation({ id: 'files.folders.restore', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), uploadCreate: defineWorkspaceOperation({ id: 'files.upload.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...UPLOAD_PRINCIPAL_POLICY, }), /** @@ -186,24 +210,28 @@ export const fileOperations = { id: 'files.upload.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...UPLOAD_PRINCIPAL_POLICY, }), uploadParts: defineWorkspaceOperation({ id: 'files.upload.parts', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...UPLOAD_PRINCIPAL_POLICY, }), uploadComplete: defineWorkspaceOperation({ id: 'files.upload.complete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...UPLOAD_PRINCIPAL_POLICY, }), uploadCancel: defineWorkspaceOperation({ id: 'files.upload.cancel', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...UPLOAD_PRINCIPAL_POLICY, }), } as const diff --git a/scripts/check-permission-group-enforcement.ts b/scripts/check-permission-group-enforcement.ts index 99b58234246..1966c1c1e78 100644 --- a/scripts/check-permission-group-enforcement.ts +++ b/scripts/check-permission-group-enforcement.ts @@ -140,15 +140,22 @@ export function parseOperationCapabilities(source: string): OperationDeclaration const declarations: OperationDeclaration[] = [] const lineAt = (index: number) => source.slice(0, index).split('\n').length - const factories = new Set() + /** + * Domains that wrap the builder in a same-file factory declare the capability + * one of two ways: fixed in the factory body, when every operation it makes + * belongs to one capability, or taken as a second argument when they differ. + * Both are legible at the call site, so both are read here. + */ + const factoryCapabilities = new Map() const factoryPattern = /(?:^|\n)\s*(?:export\s+)?function\s+([A-Za-z0-9_$]+)\s*[<(]/g for (let match = factoryPattern.exec(source); match; match = factoryPattern.exec(source)) { const bodyIndex = source.indexOf('{', match.index + match[0].length - 1) if (bodyIndex === -1) continue const body = balancedGroup(source, bodyIndex) - if (body.includes('defineWorkspaceOperation') && body.includes('capability')) { - factories.add(match[1]) - } + if (!body.includes('defineWorkspaceOperation')) continue + const fixed = /capability\s*:\s*'([a-z0-9_.]+)'/.exec(body)?.[1] + if (fixed) factoryCapabilities.set(match[1], fixed) + else if (/capability\s*[,:}]/.test(body)) factoryCapabilities.set(match[1], 'positional') } const directPattern = /defineWorkspaceOperation\s*\(/g @@ -163,10 +170,17 @@ export function parseOperationCapabilities(source: string): OperationDeclaration }) } - for (const factory of factories) { - const callPattern = new RegExp(`\\b${factory}\\s*\\(\\s*'([^']+)'\\s*,\\s*'([a-z0-9_.]+)'`, 'g') + for (const [factory, capability] of factoryCapabilities) { + const callPattern = + capability === 'positional' + ? new RegExp(`\\b${factory}\\s*\\(\\s*'([^']+)'\\s*,\\s*'([a-z0-9_.]+)'`, 'g') + : new RegExp(`\\b${factory}\\s*\\(\\s*'([^']+)'`, 'g') for (let match = callPattern.exec(source); match; match = callPattern.exec(source)) { - declarations.push({ id: match[1], line: lineAt(match.index), capability: match[2] }) + declarations.push({ + id: match[1], + line: lineAt(match.index), + capability: capability === 'positional' ? match[2] : capability, + }) } } From d0ea1151f45b992163c81b88e92479f862f4b684 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 20:36:45 -0700 Subject: [PATCH 005/179] fix(permission-groups): close the legacy-block and enrichment bypasses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two controls were configured and applied to nothing. **Legacy blocks defeated the integration allowlist.** Any block marked `hideFromToolbar` was exempt from access control, which covered 44 blocks — including fully functional superseded versions of Slack, GitHub, Notion, SharePoint and Google Sheets. Legacy `slack` talks to Slack exactly as `slack_v2` does, so an allowlist naming `slack_v2` was satisfied by `slack`, reachable through workflow import, the API, or a Copilot-built workflow. The admin editor filtered out exactly those blocks, so the hole was invisible to the person configuring the allowlist. A superseded block is now judged as the successor its `sunset.replacedBy` names, transitively, so allowing or denying an integration covers every version of it and the editor's single row means what it appears to. The exemption narrows to what it was for: the universal entry point, and a retired block with no successor — that one has no row to be permitted on and nothing to be permitted *as*, so denying it would break older workflows an admin could not rescue. **Enrichments sent row data with the tool denylist not applied.** The per-tool gate keys off the acting user and skips entirely when a call carries none; enrichment runs passed only a workspace. So `deniedTools` blocked a provider when a workflow called it and not when a table enrichment did — the same tool, the same row data, one path governed. The user is now threaded from all three callers, each of which already had one: the table run resolves it for billing attribution, the internal tool surface validated it and then dropped it, and the Copilot tool context carries it. `EnrichmentRunContext.userId` documents why it is load-bearing rather than attribution, since an omission fails open and silently. --- .../background/workflow-column-execution.ts | 1 + .../access-control/utils/permission-check.ts | 23 +++- apps/sim/enrichments/run.test.ts | 33 ++++++ apps/sim/enrichments/run.ts | 2 +- apps/sim/enrichments/types.ts | 7 ++ .../tools/server/enrichment/enrichment-run.ts | 1 + .../lib/internal/enrichment/execute-tool.ts | 1 + .../sim/lib/internal/enrichment/operations.ts | 3 + .../permission-groups/block-access.test.ts | 108 ++++++++++++++++++ .../sim/lib/permission-groups/block-access.ts | 53 +++++++-- apps/sim/lib/workflows/editing/validation.ts | 9 +- 11 files changed, 220 insertions(+), 21 deletions(-) create mode 100644 apps/sim/lib/permission-groups/block-access.test.ts diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index 28264abd776..a57e7277d39 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -586,6 +586,7 @@ async function runWorkflowAndWriteTerminal( tableId, rowId, workspaceId, + userId: enrichmentBillingAttribution.actorUserId, signal: attemptSignal, resolvedSecretTraceRegistry: enrichmentRegistry, }) diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index e20d8ede1b8..e543f80ced4 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -11,7 +11,10 @@ import { isInvitationsDisabled, isPublicApiDisabled, } from '@/lib/core/config/env-flags' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { + isBlockTypeAccessControlExempt, + resolveAccessControlBlockType, +} from '@/lib/permission-groups/block-access' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { @@ -514,9 +517,17 @@ export async function validateBlockType( return } - if (!config.allowedIntegrations.includes(blockType.toLowerCase())) { + /** + * A superseded version is judged as its successor, so an allowlist naming the + * current block covers every retired version of the same integration. The + * editor only offers current ids, so without this an admin could not deny a + * legacy block even knowing it existed. + */ + const allowlistType = resolveAccessControlBlockType(blockType).toLowerCase() + + if (!config.allowedIntegrations.includes(allowlistType)) { const envAllowlist = getAllowedIntegrationsFromEnv() - const blockedByEnv = envAllowlist !== null && !envAllowlist.includes(blockType.toLowerCase()) + const blockedByEnv = envAllowlist !== null && !envAllowlist.includes(allowlistType) logger.warn( blockedByEnv ? 'Integration blocked by env allowlist' @@ -736,10 +747,10 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis if (blockType && !blockTypeExempt) { if (config && config.allowedIntegrations !== null) { - if (!config.allowedIntegrations.includes(blockType.toLowerCase())) { + const allowlistType = resolveAccessControlBlockType(blockType).toLowerCase() + if (!config.allowedIntegrations.includes(allowlistType)) { const envAllowlist = getAllowedIntegrationsFromEnv() - const blockedByEnv = - envAllowlist !== null && !envAllowlist.includes(blockType.toLowerCase()) + const blockedByEnv = envAllowlist !== null && !envAllowlist.includes(allowlistType) logger.warn( blockedByEnv ? 'Integration blocked by env allowlist' diff --git a/apps/sim/enrichments/run.test.ts b/apps/sim/enrichments/run.test.ts index fd2b912307d..8b562c7ffb7 100644 --- a/apps/sim/enrichments/run.test.ts +++ b/apps/sim/enrichments/run.test.ts @@ -253,3 +253,36 @@ describe('runEnrichment cascade detail', () => { expect(outcome.detail.providers.map((p) => p.status)).toEqual(['error', 'no_match']) }) }) + +/** + * The per-tool permission gate keys off the acting user, and skips entirely + * when a tool call carries none. An enrichment that omitted the user therefore + * sent row data — names, emails, company domains — to its provider with the + * workspace's `deniedTools` denylist silently not applied. + */ +describe('runEnrichment tool attribution', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('names the acting user on the provider call', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { email: 'a@b.c' } }) + + await runEnrichment( + config([prov('p1')]), + {}, + { + workspaceId: 'workspace-1', + userId: 'user-1', + } + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'tool_p1', + expect.objectContaining({ + _context: { workspaceId: 'workspace-1', userId: 'user-1' }, + }), + expect.anything() + ) + }) +}) diff --git a/apps/sim/enrichments/run.ts b/apps/sim/enrichments/run.ts index 97f19f69d51..af318838e74 100644 --- a/apps/sim/enrichments/run.ts +++ b/apps/sim/enrichments/run.ts @@ -112,7 +112,7 @@ export async function runEnrichment( try { const response = await executeTool( provider.toolId, - { ...params, _context: { workspaceId: ctx.workspaceId } }, + { ...params, _context: { workspaceId: ctx.workspaceId, userId: ctx.userId } }, { signal: ctx.signal, resolvedSecretTraceRegistry: ctx.resolvedSecretTraceRegistry, diff --git a/apps/sim/enrichments/types.ts b/apps/sim/enrichments/types.ts index 64ad738b7eb..7974976e82a 100644 --- a/apps/sim/enrichments/types.ts +++ b/apps/sim/enrichments/types.ts @@ -30,6 +30,13 @@ export interface EnrichmentRunContext { tableId?: string rowId?: string workspaceId: string + /** + * The user the run is attributed to. Load-bearing, not decorative: the + * per-tool permission gate is skipped entirely when a tool call carries no + * user, so an enrichment that omits this sends row data to its provider with + * the workspace's `deniedTools` denylist silently not applied. + */ + userId?: string signal?: AbortSignal /** Isolated provenance for the exact mapped row inputs used by this run. */ resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry diff --git a/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts b/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts index 8b7a2a3d7ef..10479b04ed9 100644 --- a/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts +++ b/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts @@ -44,6 +44,7 @@ export const enrichmentRunServerTool: BaseServerTool + +interface FakeBlock { + hideFromToolbar?: boolean + sunset?: { status: 'legacy' | 'deprecated'; replacedBy?: string } +} + +function registry(blocks: Record) { + mockGetBlock.mockImplementation((type: string) => blocks[type]) +} + +describe('resolveAccessControlBlockType', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('judges a superseded block as its successor', () => { + registry({ + slack: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } }, + slack_v2: {}, + }) + + expect(resolveAccessControlBlockType('slack')).toBe('slack_v2') + }) + + it('follows a chain of successors to the current version', () => { + registry({ + a: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'b' } }, + b: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'c' } }, + c: {}, + }) + + expect(resolveAccessControlBlockType('a')).toBe('c') + }) + + it('stops rather than looping when successors point at each other', () => { + registry({ + a: { sunset: { status: 'legacy', replacedBy: 'b' } }, + b: { sunset: { status: 'legacy', replacedBy: 'a' } }, + }) + + expect(resolveAccessControlBlockType('a')).toBe('b') + }) + + it('keeps its own identity when the named successor is not registered', () => { + registry({ a: { sunset: { status: 'legacy', replacedBy: 'gone' } } }) + + expect(resolveAccessControlBlockType('a')).toBe('a') + }) + + it('leaves a current block alone', () => { + registry({ slack_v2: {} }) + + expect(resolveAccessControlBlockType('slack_v2')).toBe('slack_v2') + }) +}) + +describe('isBlockTypeAccessControlExempt', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('exempts the universal entry point', () => { + registry({}) + + expect(isBlockTypeAccessControlExempt('start_trigger')).toBe(true) + }) + + /** + * The bypass this closes: a legacy block is fully functional, so an allowlist + * naming only the current version used to be satisfied by the retired one. + */ + it('does not exempt a superseded block, which is judged as its successor', () => { + registry({ + slack: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } }, + slack_v2: {}, + }) + + expect(isBlockTypeAccessControlExempt('slack')).toBe(false) + }) + + /** + * A retired block with no successor has no row in the editor and nothing to + * be permitted as, so denying it would break older workflows an admin could + * not have rescued. + */ + it('exempts a retired block with no successor', () => { + registry({ thinking: { hideFromToolbar: true } }) + + expect(isBlockTypeAccessControlExempt('thinking')).toBe(true) + }) + + it('does not exempt a current block', () => { + registry({ slack_v2: {} }) + + expect(isBlockTypeAccessControlExempt('slack_v2')).toBe(false) + }) +}) diff --git a/apps/sim/lib/permission-groups/block-access.ts b/apps/sim/lib/permission-groups/block-access.ts index 884f952665a..88fdf1948d0 100644 --- a/apps/sim/lib/permission-groups/block-access.ts +++ b/apps/sim/lib/permission-groups/block-access.ts @@ -3,20 +3,49 @@ import { getBlock } from '@/blocks/registry' /** * Block types that bypass permission-group access control entirely. * - * Two kinds of blocks are exempt: - * - `start_trigger`: the universal workflow entry point. A workflow must always - * be startable regardless of the configured integration allowlist. - * - Legacy blocks (`hideFromToolbar: true`): superseded integration versions and - * deprecated blocks. They never appear in the toolbar or the Access Control - * admin list, so admins cannot allowlist them — yet they may still live inside - * older workflows. Exempting them keeps those workflows runnable instead of - * silently blocking blocks the admin had no way to permit. + * Two kinds are exempt: + * - `start_trigger`: the universal workflow entry point. A workflow must be + * startable whatever the integration allowlist says. + * - A retired block with no successor. It is hidden from the toolbar and from + * the Access Control editor, so an admin has no row to permit it on and + * nothing to permit it *as*; denying it would silently break the older + * workflows still carrying it. * - * This is the single source of truth shared by both the runtime enforcement - * paths and the Access Control admin UI so the "hidden from the list" set and - * the "skipped by enforcement" set never drift apart. + * A *superseded* block is deliberately not exempt. Legacy `slack` talks to + * Slack exactly as `slack_v2` does, so exempting it let an allowlist naming + * `slack_v2` be satisfied by `slack` — reachable through workflow import, the + * API, or a Copilot-built workflow, and invisible to the admin who configured + * the allowlist. It is judged as its successor instead; see + * {@link resolveAccessControlBlockType}. + * + * Shared by the runtime enforcement paths and the Access Control editor, so the + * set that is hidden and the set that is skipped cannot drift apart. */ export function isBlockTypeAccessControlExempt(blockType: string): boolean { if (blockType === 'start_trigger') return true - return getBlock(blockType)?.hideFromToolbar === true + const block = getBlock(blockType) + return block?.hideFromToolbar === true && resolveAccessControlBlockType(blockType) === blockType +} + +/** + * The block type an allowlist decision should be made against. + * + * A superseded version resolves to the successor its `sunset.replacedBy` names, + * transitively, so allowing or denying an integration covers every version of + * it. Without this an admin would have to know each retired id and deny it + * individually — and could not, since the editor only offers the current ones. + * + * A retired block with no successor keeps its own identity and appears in the + * editor under it, which is the only way an admin can decide about it at all. + */ +export function resolveAccessControlBlockType(blockType: string): string { + const seen = new Set([blockType]) + let current = blockType + + while (true) { + const successor = getBlock(current)?.sunset?.replacedBy + if (!successor || seen.has(successor) || !getBlock(successor)) return current + seen.add(successor) + current = successor + } } diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index 1925cf3174a..f96f6f55df1 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -3,7 +3,10 @@ import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import { isHosted as isHostedDeployment } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { + isBlockTypeAccessControlExempt, + resolveAccessControlBlockType, +} from '@/lib/permission-groups/block-access' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { validateSelectorIds } from '@/lib/workflows/editing/selector-validator' @@ -1013,7 +1016,9 @@ export function isBlockTypeAllowed( if (!permissionConfig || permissionConfig.allowedIntegrations === null) { return true } - return permissionConfig.allowedIntegrations.includes(blockType.toLowerCase()) + return permissionConfig.allowedIntegrations.includes( + resolveAccessControlBlockType(blockType).toLowerCase() + ) } /** From 060fc79cf01628aaa447fe08cc9e8ceb70996f1e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 20:40:26 -0700 Subject: [PATCH 006/179] feat(permission-groups): govern personal API keys per group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `allowPersonalApiKeys` was a workspace column and nothing else, so the policy was all-or-nothing for the whole workspace: an organization could not let one team hold personal keys while another could not. `disablePersonalApiKeys` adds that, and the two combine with AND rather than one overriding the other. The column stays the coarse switch every workspace has, including the ones no group governs; the group key narrows it for one cohort inside an enterprise organization. Either saying no is a no, which is also why the column is checked first — it costs nothing and skips the group lookup entirely. It is not declarable on an operation, and the capability registry says so. Every other capability withholds something about a resource, so an operation opts into it; this one refuses a *principal kind*, and applies to every operation a personal key could reach. It is asserted in the funnel's personal-key branch instead, annotated so the audit can still prove the key is enforced. v1 authorizes in its own middleware rather than through the funnel, so the check is repeated there. Without it the same key v2 refused would keep working against v1 — which is the shape of the coverage gap this whole change exists to remove, so leaving it would have been the same mistake in a smaller place. The settings toggle now reads both layers, so the UI never offers a key type the server will refuse. The key is appended last in the field registry: declaration order is the wire order, and moving an existing key would read as an unsaved change in every open group editor. --- apps/sim/app/api/v1/middleware.ts | 18 ++++++ .../settings/components/api-keys/api-keys.tsx | 12 +++- .../workspace-authorization.test.ts | 62 +++++++++++++++++++ .../application/workspace-authorization.ts | 32 ++++++++++ .../sim/lib/permission-groups/capabilities.ts | 13 ++++ apps/sim/lib/permission-groups/fields.ts | 11 ++++ apps/sim/lib/permission-groups/types.test.ts | 2 + 7 files changed, 149 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 3f8d4878119..bfd3f0f0490 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -16,6 +16,7 @@ import { getWorkspaceBillingSettings, } from '@/lib/workspaces/utils' import { authenticateV1Request } from '@/app/api/v1/auth' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('V1Middleware') const rateLimiter = new RateLimiter() @@ -260,6 +261,23 @@ export async function resolveWorkspaceScope( message: PERSONAL_KEY_DENIED, } } + + /** + * permission-group-enforced: personal_api_key.use — v1 authorizes in this + * middleware rather than through the application funnel, so the group check + * the funnel applies has to be repeated here or the same key that v2 + * refuses would still work against v1. + */ + if (rateLimit.userId) { + const permissionConfig = await getUserPermissionConfig(rateLimit.userId, requestedWorkspaceId) + if (permissionConfig?.disablePersonalApiKeys) { + return { + status: 403, + code: 'FORBIDDEN', + message: PERSONAL_KEY_DENIED, + } + } + } } return null diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index 656f1d225d5..c6586b29ff9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -27,6 +27,7 @@ import { useUpdateWorkspaceApiKeySettings, } from '@/hooks/queries/api-keys' import { useWorkspaceSettings } from '@/hooks/queries/workspace' +import { usePermissionConfig } from '@/hooks/use-permission-config' import { CreateApiKeyModal } from './components' const logger = createLogger('ApiKeys') @@ -105,8 +106,17 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { const conflictNames = useMemo(() => new Set(conflicts), [conflicts]) const isLoading = isLoadingKeys || (showsWorkspaceKeys && isLoadingSettings) + const { config: permissionConfig } = usePermissionConfig() + + /** + * Both layers have to agree. The workspace column is the coarse switch every + * workspace has; the permission group narrows it for one cohort inside an + * enterprise organization. The server combines them the same way, so offering + * a key type here that it would refuse is the only failure worth avoiding. + */ const allowPersonalApiKeys = - workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true + (workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true) && + !permissionConfig.disablePersonalApiKeys const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const [deleteKey, setDeleteKey] = useState(null) diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index 25e4c2c0cc7..e01529a5077 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -32,6 +32,7 @@ import { InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, PermissionGroupCapabilityError, + PersonalApiKeysDisabledError, PrincipalKindAuthorizationError, WorkspaceApiKeyAuthorizationError, WorkspaceApiKeyScopeAuthorizationError, @@ -449,3 +450,64 @@ describe('defineWorkspaceOperation capability policy', () => { ).not.toThrow() }) }) + +const personalKeyOperation = defineWorkspaceOperation({ + id: 'test.personal-key-read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + capability: 'none', +}) + +/** + * The workspace column and the group key combine with AND. The column is the + * coarse switch every workspace has; the group narrows it for one cohort inside + * an enterprise organization. + */ +describe('authorizeWorkspaceOperation personal API key policy', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + mocks.resolvePermissionGroupConfig.mockResolvedValue(null) + }) + + it('refuses when the permission group withholds personal keys', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + + await expect( + authorizeWorkspaceOperation(personalKeyPrincipal, personalKeyOperation, context) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + }) + + it('refuses when the workspace withholds them, without consulting the group', async () => { + await expect( + authorizeWorkspaceOperation(personalKeyPrincipal, personalKeyOperation, { + ...context, + allowPersonalApiKeys: false, + }) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + expect(mocks.resolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('allows when both layers permit', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + await expect( + authorizeWorkspaceOperation(personalKeyPrincipal, personalKeyOperation, context) + ).resolves.toBeUndefined() + }) + + it('leaves a session principal alone', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + + await expect( + authorizeWorkspaceOperation(principal, personalKeyOperation, context) + ).resolves.toBeUndefined() + }) +}) diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index c4339024fa7..d3e086555a8 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -202,6 +202,27 @@ async function requireCapability( throw new PermissionGroupCapabilityError(capability, rule.detailCode, rule.describe) } +/** + * Refuses a personal API key the caller's permission group withholds. + * + * Separate from {@link requireCapability} because it is not a property of the + * operation: no operation opts into it, and every operation a personal key can + * reach is subject to it. + */ +async function requirePersonalApiKeysAllowed( + userId: string, + context: WorkspaceAuthorizationContext +): Promise { + if (context.workspaceOrganizationId === null) return + + const config = await resolvePermissionGroupConfig( + userId, + context.workspaceId, + context.workspaceOrganizationId + ) + if (config?.disablePersonalApiKeys) throw new PersonalApiKeysDisabledError() +} + /** * The workspace role check, then the permission-group capability check. * @@ -243,9 +264,20 @@ export async function authorizeWorkspaceOperation config.disableSkills, }, + /** + * Not declarable on an operation: it refuses a *principal kind* rather than a + * capability of the resource, so it applies to every operation a personal key + * could reach. Asserted in the authorization funnel's personal-key branch. + */ + 'personal_api_key.use': { + kind: 'static', + configKeys: ['disablePersonalApiKeys'], + detailCode: 'PERSONAL_API_KEYS_DISABLED', + describe: 'Personal API keys', + deniedBy: (config) => config.disablePersonalApiKeys, + }, 'logs.trace_spans': { kind: 'static', configKeys: ['hideTraceSpans'], diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index 61676d8ca57..b93aee5a503 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -314,6 +314,17 @@ export const PERMISSION_GROUP_FIELDS = { 'Chat deployment authentication is limited to effectiveConfig.allowedChatDeployAuthTypes.', empty: 'No chat deployment authentication modes are allowed.', }), + /** + * Appended rather than grouped with the other restrictions: declaration order + * is the wire order, and moving an existing key would read as an unsaved + * change in every open group editor. + */ + disablePersonalApiKeys: booleanRestriction('capability', { + id: 'disable-personal-api-keys', + label: 'Personal API Keys', + category: 'Settings Tabs', + hint: 'Prevent members from using a personal API key against this workspace.', + }), } satisfies Record export type PermissionGroupFields = typeof PERMISSION_GROUP_FIELDS diff --git a/apps/sim/lib/permission-groups/types.test.ts b/apps/sim/lib/permission-groups/types.test.ts index b94d7b4a5f2..c6477d361af 100644 --- a/apps/sim/lib/permission-groups/types.test.ts +++ b/apps/sim/lib/permission-groups/types.test.ts @@ -148,6 +148,7 @@ const fixtures: readonly CoercionFixture[] = [ hideDeployMcp: true, hideDeployChatbot: true, allowedChatDeployAuthTypes: ['password'], + disablePersonalApiKeys: true, }, expected: { allowedIntegrations: ['slack_v2'], @@ -174,6 +175,7 @@ const fixtures: readonly CoercionFixture[] = [ hideDeployMcp: true, hideDeployChatbot: true, allowedChatDeployAuthTypes: ['password'], + disablePersonalApiKeys: true, }, }, ] From b37d8220bd7948d947e2f7976523283ebce6b071 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 20:41:46 -0700 Subject: [PATCH 007/179] feat(permission-groups): govern log export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSV export hands over every execution log the workspace ever recorded, including a column holding the full trace spans, to any member with read access. It was the widest read in the product and the only one with no control of its own — an organization could withhold a single log's trace spans in the UI and still have the whole history downloaded in bulk. `disableLogExport` withholds the export separately from reading a log, because those are different exposures: one payload someone is looking at, versus the entire history in a file. The export also applies the same trace-span projection the detail view does, so the two agree — a group that withholds spans no longer discloses them here. Checked inline rather than through an application use case: this route queries the log tables directly and predates that boundary. Migrating it is worth doing on its own, and is not a reason to leave the export ungoverned until then. --- apps/sim/app/api/logs/export/route.ts | 31 ++++++++++++++++++- .../sim/lib/permission-groups/capabilities.ts | 8 +++++ apps/sim/lib/permission-groups/fields.ts | 6 ++++ apps/sim/lib/permission-groups/types.test.ts | 2 ++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index a2819700aed..e12b606d040 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -12,6 +12,7 @@ import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-s import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('LogsExportAPI') const LOG_EXPORT_PAGE_SIZE = 100 @@ -94,6 +95,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } + /** + * permission-group-enforced: logs.export — one download carries every + * execution payload the workspace ever recorded, including a trace-span + * column, so this is the widest read in the product and the one most worth + * withholding separately from reading a single log. + * + * Checked here rather than in an application use case because this route + * queries directly and predates that boundary; migrating it is worth doing, + * and is not a reason to leave the export ungoverned meanwhile. + */ + const permissionConfig = await getUserPermissionConfig(userId, params.workspaceId) + if (permissionConfig?.disableLogExport) { + return NextResponse.json( + { + error: + "Exporting execution logs is not available under your organization's permission group", + }, + { status: 403 } + ) + } + + const hideTraceSpans = permissionConfig?.hideTraceSpans === true + const encoder = new TextEncoder() const csvChunks = (async function* () { yield encoder.encode(`${header}\n`) @@ -146,7 +170,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : (JSON.stringify(executionData.finalOutput) ?? '') } if (executionData.message) message = executionData.message - if (executionData.traceSpans) { + /** + * The same projection the log detail applies. A group that + * withholds trace spans in the UI would otherwise hand them over + * in bulk here, which is the larger disclosure of the two. + */ + if (executionData.traceSpans && !hideTraceSpans) { tracesJson = JSON.stringify(executionData.traceSpans) ?? '' } } catch (rowError) { diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts index 97e444234e2..3d510009925 100644 --- a/apps/sim/lib/permission-groups/capabilities.ts +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -43,6 +43,7 @@ export const CAPABILITY_IDS = [ 'skills.use', 'logs.trace_spans', 'personal_api_key.use', + 'logs.export', ] as const export type PermissionGroupCapability = (typeof CAPABILITY_IDS)[number] @@ -238,6 +239,13 @@ export const CAPABILITY_RULES = { describe: 'Personal API keys', deniedBy: (config) => config.disablePersonalApiKeys, }, + 'logs.export': { + kind: 'static', + configKeys: ['disableLogExport'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Exporting execution logs', + deniedBy: (config) => config.disableLogExport, + }, 'logs.trace_spans': { kind: 'static', configKeys: ['hideTraceSpans'], diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index b93aee5a503..cd342a2dcd3 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -325,6 +325,12 @@ export const PERMISSION_GROUP_FIELDS = { category: 'Settings Tabs', hint: 'Prevent members from using a personal API key against this workspace.', }), + disableLogExport: booleanRestriction('capability', { + id: 'disable-log-export', + label: 'Log Export', + category: 'Logs', + hint: 'Prevent downloading execution logs as a CSV.', + }), } satisfies Record export type PermissionGroupFields = typeof PERMISSION_GROUP_FIELDS diff --git a/apps/sim/lib/permission-groups/types.test.ts b/apps/sim/lib/permission-groups/types.test.ts index c6477d361af..53f554d8046 100644 --- a/apps/sim/lib/permission-groups/types.test.ts +++ b/apps/sim/lib/permission-groups/types.test.ts @@ -149,6 +149,7 @@ const fixtures: readonly CoercionFixture[] = [ hideDeployChatbot: true, allowedChatDeployAuthTypes: ['password'], disablePersonalApiKeys: true, + disableLogExport: true, }, expected: { allowedIntegrations: ['slack_v2'], @@ -176,6 +177,7 @@ const fixtures: readonly CoercionFixture[] = [ hideDeployChatbot: true, allowedChatDeployAuthTypes: ['password'], disablePersonalApiKeys: true, + disableLogExport: true, }, }, ] From 395d47f64426373e908181fc170f28b1333de8a6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 20:42:43 -0700 Subject: [PATCH 008/179] chore(docs): publish the permission-group 403 code `FORBIDDEN_DETAIL_CODES` generates the v2 `403` description, so the new code has to reach the OpenAPI documents or `check:openapi` fails. That coupling is the point: a refusal a caller can branch on is published rather than left to be discovered by matching on prose. --- apps/docs/openapi-v2-billing.json | 2 +- apps/docs/openapi-v2-files-audit.json | 2 +- apps/docs/openapi-v2-knowledge.json | 2 +- apps/docs/openapi-v2-logs.json | 2 +- apps/docs/openapi-v2-resources.json | 2 +- apps/docs/openapi-v2-tables.json | 2 +- apps/docs/openapi-v2-workflows.json | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 8649896cb0e..cf11d88abfa 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -452,7 +452,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 696ff4e3623..8808de72923 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2793,7 +2793,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 38eecd23994..3b938370ae0 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -4499,7 +4499,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 3f69f6dd896..98d9c140284 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -787,7 +787,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 402e5d1dfdd..c23ff476d61 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -4372,7 +4372,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 359c8e1d4b9..5dd941e80ba 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -4897,7 +4897,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index ca61a2adaca..4a880d04765 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -3908,7 +3908,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], From 8992e095a06b4d8cd653e14cde57a74dd19dec64 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 21:18:36 -0700 Subject: [PATCH 009/179] feat(permission-groups): declare the remaining governed capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the thirteen keys the coverage audit found missing, and the rules that give each one meaning. Nothing enforces them yet — the enforcement audit lists all thirteen as pending, which is the point: the registry cannot quietly ship a key that refuses nothing. They divide into the two exposures the audit kept turning up. Extraction: table export, bulk file download, execution cost. Provenance and scope: which connectors may pull an external corpus in, whether a member may create a knowledge base or a table rather than only use one, whether they may attach personal credentials, approve a CLI login, or create a workspace that no existing group would govern. `allowedKnowledgeConnectors` is parameterized on the connector id, which required widening the parameterized rule from an auth mode to any request value. That is the right shape for it: an organization that sanctions Drive rarely sanctions the other sixty, and a connector is the one integration that copies a whole external corpus into the workspace. `maxLoopIterations` is deliberately not here. It is a resource ceiling rather than an access control — nothing is withheld from anyone — so it needs a numeric control the group editor has no affordance for, and folding it in as a boolean would misrepresent it. --- .../sim/lib/permission-groups/capabilities.ts | 122 +++++++++++++++++- apps/sim/lib/permission-groups/fields.ts | 76 +++++++++++ apps/sim/lib/permission-groups/types.test.ts | 26 ++++ 3 files changed, 221 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts index 3d510009925..b6726b413d5 100644 --- a/apps/sim/lib/permission-groups/capabilities.ts +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -44,6 +44,19 @@ export const CAPABILITY_IDS = [ 'logs.trace_spans', 'personal_api_key.use', 'logs.export', + 'logs.cost', + 'knowledge.create', + 'knowledge.upload', + 'knowledge.connectors', + 'tables.create', + 'tables.export', + 'files.bulk_download', + 'credentials.personal', + 'workspace.create', + 'organization.member_directory', + 'cli.use', + 'triggers.webhook', + 'copilot.tool_auto_approval', ] as const export type PermissionGroupCapability = (typeof CAPABILITY_IDS)[number] @@ -74,13 +87,18 @@ export interface StaticCapabilityRule extends CapabilityRuleBase { */ export interface ParameterizedCapabilityRule extends CapabilityRuleBase { readonly kind: 'parameterized' - deniedBy(config: PermissionGroupConfig, parameter: ShareAuthMode): boolean + deniedBy(config: PermissionGroupConfig, parameter: string): boolean } export type CapabilityRule = StaticCapabilityRule | ParameterizedCapabilityRule -function authModeDeniedBy(allowed: ShareAuthMode[] | null, mode: ShareAuthMode): boolean { - return allowed !== null && !allowed.includes(mode) +function authModeDeniedBy(allowed: ShareAuthMode[] | null, mode: string): boolean { + return allowed !== null && !allowed.some((allowedMode) => allowedMode === mode) +} + +/** An allowlist denies a member when it is set and does not name it; `null` names everything. */ +function allowlistDenies(allowed: readonly string[] | null, member: string): boolean { + return allowed !== null && !allowed.includes(member) } /** @@ -246,6 +264,104 @@ export const CAPABILITY_RULES = { describe: 'Exporting execution logs', deniedBy: (config) => config.disableLogExport, }, + 'logs.cost': { + kind: 'static', + configKeys: ['hideCostInfo'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Execution cost', + deniedBy: (config) => config.hideCostInfo, + }, + 'knowledge.create': { + kind: 'static', + configKeys: ['disableKnowledgeBaseCreation'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Creating knowledge bases', + deniedBy: (config) => config.disableKnowledgeBaseCreation, + }, + 'knowledge.upload': { + kind: 'static', + configKeys: ['disableKnowledgeBaseFileUpload'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Uploading documents to a knowledge base', + deniedBy: (config) => config.disableKnowledgeBaseFileUpload, + }, + /** + * Parameterized on the connector id, because the decision is which source a + * member may sync — a connector pulls a whole external corpus into the + * workspace, and an organization that sanctions Drive rarely sanctions every + * one of the other sixty. + */ + 'knowledge.connectors': { + kind: 'parameterized', + configKeys: ['allowedKnowledgeConnectors'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'This knowledge base connector', + deniedBy: (config, connectorType) => + allowlistDenies(config.allowedKnowledgeConnectors, connectorType), + }, + 'tables.create': { + kind: 'static', + configKeys: ['disableTableCreation'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Creating tables', + deniedBy: (config) => config.disableTableCreation, + }, + 'tables.export': { + kind: 'static', + configKeys: ['disableTableExport'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Exporting a table', + deniedBy: (config) => config.disableTableExport, + }, + 'files.bulk_download': { + kind: 'static', + configKeys: ['disableBulkFileDownload'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Downloading files in bulk', + deniedBy: (config) => config.disableBulkFileDownload, + }, + 'credentials.personal': { + kind: 'static', + configKeys: ['disablePersonalCredentials'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Connecting personal credentials', + deniedBy: (config) => config.disablePersonalCredentials, + }, + 'workspace.create': { + kind: 'static', + configKeys: ['disableWorkspaceCreation'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Creating workspaces', + deniedBy: (config) => config.disableWorkspaceCreation, + }, + 'organization.member_directory': { + kind: 'static', + configKeys: ['hideOrgMemberDirectory'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'The organization member directory', + deniedBy: (config) => config.hideOrgMemberDirectory, + }, + 'cli.use': { + kind: 'static', + configKeys: ['disableCliAccess'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'CLI access', + deniedBy: (config) => config.disableCliAccess, + }, + 'triggers.webhook': { + kind: 'static', + configKeys: ['disableWebhookTriggers'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Webhook triggers', + deniedBy: (config) => config.disableWebhookTriggers, + }, + 'copilot.tool_auto_approval': { + kind: 'static', + configKeys: ['disableToolAutoApproval'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Silencing a tool confirmation', + deniedBy: (config) => config.disableToolAutoApproval, + }, 'logs.trace_spans': { kind: 'static', configKeys: ['hideTraceSpans'], diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index cd342a2dcd3..47b1e2a8a21 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -331,6 +331,82 @@ export const PERMISSION_GROUP_FIELDS = { category: 'Logs', hint: 'Prevent downloading execution logs as a CSV.', }), + hideCostInfo: booleanRestriction('capability', { + id: 'hide-cost-info', + label: 'Execution Cost', + category: 'Logs', + hint: 'Hide per-execution cost and token spend in logs.', + }), + disableKnowledgeBaseCreation: booleanRestriction('capability', { + id: 'disable-knowledge-base-creation', + label: 'Knowledge Base Creation', + category: 'Sidebar', + hint: 'Allow querying existing knowledge bases without creating new ones.', + }), + disableKnowledgeBaseFileUpload: booleanRestriction('capability', { + id: 'disable-knowledge-base-upload', + label: 'Knowledge Base Uploads', + category: 'Sidebar', + hint: 'Allow documents only from sanctioned connectors, never local upload.', + }), + allowedKnowledgeConnectors: allowlist(z.string(), 'capability', { + limited: 'Knowledge base connectors are limited to effectiveConfig.allowedKnowledgeConnectors.', + empty: 'No knowledge base connectors are allowed.', + }), + disableTableCreation: booleanRestriction('capability', { + id: 'disable-table-creation', + label: 'Table Creation', + category: 'Sidebar', + hint: 'Allow using existing tables without creating new ones.', + }), + disableTableExport: booleanRestriction('capability', { + id: 'disable-table-export', + label: 'Table Export', + category: 'Sidebar', + hint: 'Prevent downloading a whole table as CSV or JSON.', + }), + disableBulkFileDownload: booleanRestriction('capability', { + id: 'disable-bulk-file-download', + label: 'Bulk Download', + category: 'Files', + hint: 'Prevent downloading folders as an archive.', + }), + disablePersonalCredentials: booleanRestriction('capability', { + id: 'disable-personal-credentials', + label: 'Personal Credentials', + category: 'Settings Tabs', + hint: 'Allow only workspace-shared credentials, never personally connected ones.', + }), + disableWorkspaceCreation: booleanRestriction('capability', { + id: 'disable-workspace-creation', + label: 'Workspace Creation', + category: 'Collaboration', + hint: 'Prevent creating new workspaces, which no existing group would govern.', + }), + hideOrgMemberDirectory: booleanRestriction('capability', { + id: 'hide-org-member-directory', + label: 'Member Directory', + category: 'Collaboration', + hint: 'Hide the names and email addresses of other organization members.', + }), + disableCliAccess: booleanRestriction('capability', { + id: 'disable-cli-access', + label: 'CLI Access', + category: 'Features', + hint: 'Prevent approving a CLI login, which mints a key for the public API.', + }), + disableWebhookTriggers: booleanRestriction('capability', { + id: 'disable-webhook-triggers', + label: 'Webhook Triggers', + category: 'Deploy Tabs', + hint: 'Prevent making a workflow reachable from an inbound webhook.', + }), + disableToolAutoApproval: booleanRestriction('capability', { + id: 'disable-tool-auto-approval', + label: 'Tool Auto-Approval', + category: 'Tools', + hint: 'Require confirmation every time, so a member cannot silence a tool prompt permanently.', + }), } satisfies Record export type PermissionGroupFields = typeof PERMISSION_GROUP_FIELDS diff --git a/apps/sim/lib/permission-groups/types.test.ts b/apps/sim/lib/permission-groups/types.test.ts index 53f554d8046..fef28f88d2f 100644 --- a/apps/sim/lib/permission-groups/types.test.ts +++ b/apps/sim/lib/permission-groups/types.test.ts @@ -150,6 +150,19 @@ const fixtures: readonly CoercionFixture[] = [ allowedChatDeployAuthTypes: ['password'], disablePersonalApiKeys: true, disableLogExport: true, + hideCostInfo: true, + disableKnowledgeBaseCreation: true, + disableKnowledgeBaseFileUpload: true, + allowedKnowledgeConnectors: ['google_drive'], + disableTableCreation: true, + disableTableExport: true, + disableBulkFileDownload: true, + disablePersonalCredentials: true, + disableWorkspaceCreation: true, + hideOrgMemberDirectory: true, + disableCliAccess: true, + disableWebhookTriggers: true, + disableToolAutoApproval: true, }, expected: { allowedIntegrations: ['slack_v2'], @@ -178,6 +191,19 @@ const fixtures: readonly CoercionFixture[] = [ allowedChatDeployAuthTypes: ['password'], disablePersonalApiKeys: true, disableLogExport: true, + hideCostInfo: true, + disableKnowledgeBaseCreation: true, + disableKnowledgeBaseFileUpload: true, + allowedKnowledgeConnectors: ['google_drive'], + disableTableCreation: true, + disableTableExport: true, + disableBulkFileDownload: true, + disablePersonalCredentials: true, + disableWorkspaceCreation: true, + hideOrgMemberDirectory: true, + disableCliAccess: true, + disableWebhookTriggers: true, + disableToolAutoApproval: true, }, }, ] From 25f13a0773abdbc54bdf6201eae743afe931b080 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 21:26:18 -0700 Subject: [PATCH 010/179] fix(permission-groups): gate the MCP route that bypasses its use case `POST /api/mcp/workflow-servers` calls `performCreateWorkflowMcpServer` directly rather than going through `createWorkflowDeploymentServer`, so the `deploy.mcp` capability declared on that operation never fired here. A group that hid MCP deployment stopped the v2 route and not this one. Gating both is what keeps the two doors agreeing. Migrating this handler onto the use case is the better end state and is worth doing on its own; it is not a reason to leave the second door open until then. --- apps/sim/app/api/mcp/workflow-servers/route.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 10398e6eeb4..35293d6290b 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -17,6 +17,7 @@ import { createMcpSuccessResponse, mcpOrchestrationStatus, } from '@/lib/mcp/utils' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('WorkflowMcpServersAPI') @@ -109,6 +110,23 @@ export const POST = withRouteHandler( const body = parsedBody.data + /** + * permission-group-enforced: deploy.mcp — this route calls the + * orchestration helper directly instead of the application use case, so + * the capability declared on `createWorkflowDeploymentServer` never + * fires here. Gating both is what keeps the two doors agreeing; the + * alternative is migrating this handler to the use case, which is worth + * doing and is not a reason to leave the second door open meanwhile. + */ + const permissionConfig = await getUserPermissionConfig(userId, workspaceId) + if (permissionConfig?.hideDeployMcp) { + return createMcpErrorResponse( + null, + "MCP server deployment is not available under your organization's permission group", + 403 + ) + } + logger.info(`[${requestId}] Creating workflow MCP server:`, { name: body.name, workspaceId, From 9aa8fb97472074651c4efc0eed4e4feb8f309047 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 21:28:57 -0700 Subject: [PATCH 011/179] fix(permission-groups): gate API-key management, closing the workspace-key escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key CRUD routes are raw handlers with inline queries, so `hideApiKeysTab` hid the settings tab while `POST /api/workspaces/{id}/api-keys` and `POST /api/users/me/api-keys` still minted keys. This is also the mitigation the capability gate's design depends on. A workspace API key authorizes as the workspace and resolves no permission group, so the funnel's capability check does not apply to it — deliberately, since substituting the key's creator would apply a bystander's group to every caller and break the key when that person left. That leaves one escape: a governed member minting themselves a workspace key that outranks their own group. Gating the minting closes it at the door. Keys that already exist keep working. Revoking those is an admin decision, not something a policy change should do silently to live integrations. Personal keys are user-global and belong to no workspace, so they resolve the organization's default group — the same resolution invitations already use for an organization-level action. --- apps/sim/app/api/users/me/api-keys/route.ts | 8 +++ .../app/api/workspaces/[id]/api-keys/route.ts | 10 ++++ apps/sim/lib/api-key/access.ts | 50 +++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 apps/sim/lib/api-key/access.ts diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts index b6776b51db6..1b14f9986b6 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createPersonalApiKeyContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' +import { personalApiKeyManagementWithheldResponse } from '@/lib/api-key/access' import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { performCreatePersonalApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' @@ -23,6 +24,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const userId = session.user.id + const withheld = await personalApiKeyManagementWithheldResponse(userId) + if (withheld) return withheld + const keys = await db .select({ id: apiKey.id, @@ -66,6 +70,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = session.user.id + + const withheld = await personalApiKeyManagementWithheldResponse(userId) + if (withheld) return withheld + const parsed = await parseRequest(createPersonalApiKeyContract, request, {}) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index ea9521e37b6..9e8da327023 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -10,6 +10,7 @@ import { deleteWorkspaceApiKeysContract, } from '@/lib/api/contracts/api-keys' import { parseRequest } from '@/lib/api/server' +import { apiKeyManagementWithheldResponse } from '@/lib/api-key/access' import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' @@ -45,6 +46,9 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const withheld = await apiKeyManagementWithheldResponse(userId, workspaceId) + if (withheld) return withheld + const workspaceKeys = await db .select({ id: apiKey.id, @@ -106,6 +110,9 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + const withheld = await apiKeyManagementWithheldResponse(userId, workspaceId) + if (withheld) return withheld + const parsed = await parseRequest(createWorkspaceApiKeyContract, request, context) if (!parsed.success) return parsed.response const { name, source } = parsed.data.body @@ -167,6 +174,9 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + const withheld = await apiKeyManagementWithheldResponse(userId, workspaceId) + if (withheld) return withheld + const parsed = await parseRequest(deleteWorkspaceApiKeysContract, request, context) if (!parsed.success) return parsed.response const { keys } = parsed.data.body diff --git a/apps/sim/lib/api-key/access.ts b/apps/sim/lib/api-key/access.ts new file mode 100644 index 00000000000..3f687f0616c --- /dev/null +++ b/apps/sim/lib/api-key/access.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server' +import { getUserOrganization } from '@/lib/billing/organizations/membership' +import { + getUserPermissionConfig, + getUserPermissionConfigForOrganization, +} from '@/ee/access-control/utils/permission-check' + +const REFUSAL = "Managing API keys is not available under your organization's permission group" + +/** + * Refuses API-key management when the caller's permission group withholds it. + * + * permission-group-enforced: api_keys.manage — the key CRUD routes are raw + * handlers with inline queries rather than workspace operations, so the + * authorization funnel never sees them. + * + * This is also what closes the workspace-API-key pass-through. A workspace key + * authorizes as the workspace and resolves no group, so the funnel's capability + * gate does not apply to it; gating the minting of one keeps a governed member + * from issuing themselves a credential that outranks their own group. Keys that + * already exist keep working — revoking those is the admin's call, not + * something a policy change should do silently. + * + * Returns a response rather than throwing, to match how these handlers already + * report refusals, and `null` when nothing withholds the capability. + */ +export async function apiKeyManagementWithheldResponse( + userId: string, + workspaceId: string +): Promise { + const permissionConfig = await getUserPermissionConfig(userId, workspaceId) + if (!permissionConfig?.hideApiKeysTab) return null + return NextResponse.json({ error: REFUSAL }, { status: 403 }) +} + +/** + * The same refusal for personal keys, which are user-global and so belong to no + * workspace. Resolves the organization's default group, which is the group that + * governs an organization-level action — the same resolution invitations use. + */ +export async function personalApiKeyManagementWithheldResponse( + userId: string +): Promise { + const membership = await getUserOrganization(userId) + if (!membership?.organizationId) return null + + const permissionConfig = await getUserPermissionConfigForOrganization(membership.organizationId) + if (!permissionConfig?.hideApiKeysTab) return null + return NextResponse.json({ error: REFUSAL }, { status: 403 }) +} From 8db1db2ab290db17bb15dcec8de3a32a34d321c4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 21:33:25 -0700 Subject: [PATCH 012/179] feat(permission-groups): declare capabilities on the remaining resource operations Annotates 71 `defineWorkspaceOperation` declarations across eleven domains so the permission-group audit can tell an unreviewed operation from a deliberately ungoverned one. Capability mappings: mcp_servers.* mcp_tools.use registering an MCP server and storing its credentials was a side door around the key that blocks calling MCP tools mcp_servers.workflow_* deploy.mcp reads included, so a group with the surface hidden is not still told what is published on it skills.* skills.use custom_tools.* custom_tools.use chat_deployments.* deploy.chat chat.send copilot.use catalog.connector_types.list knowledge.use it enumerates knowledge-base connectors and nothing else Declared `'none'` with a reason: the block and tool catalogs (the set the editor renders at all), memory and function execution (the executor's own per-run work, where a gate fails runs the group permits rather than withholding anything), credential groups (an admin-only, entitlement-gated section no key names), the BYOK inherited-status read, and platform context. Adds funnel-level refusal tests for MCP, skills, and the catalog split, so a capability cannot be declared on an operation and then read by nothing. --- .../sim/lib/api-key/application/operations.ts | 8 ++ .../catalog/application/operations.test.ts | 67 ++++++++++++- .../sim/lib/catalog/application/operations.ts | 21 +++++ .../application/operations.ts | 9 ++ .../sim/lib/copilot/application/operations.ts | 1 + .../application/operations.ts | 48 ++++++++++ .../custom-tools/application/operations.ts | 16 ++++ .../application/operations.ts | 2 + .../lib/mcp/application/operations.test.ts | 94 ++++++++++++++++++- apps/sim/lib/mcp/application/operations.ts | 29 ++++++ apps/sim/lib/memory/application/operations.ts | 12 +++ .../application/operations.ts | 11 +++ .../lib/skills/application/operations.test.ts | 63 ++++++++++++- apps/sim/lib/skills/application/operations.ts | 17 ++++ 14 files changed, 395 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/api-key/application/operations.ts b/apps/sim/lib/api-key/application/operations.ts index f252c98fea6..040f3d08bd6 100644 --- a/apps/sim/lib/api-key/application/operations.ts +++ b/apps/sim/lib/api-key/application/operations.ts @@ -57,10 +57,18 @@ export const byokKeyOperations = { principalKinds: ['session'], entitlement: 'cleanup_allowed', }), + /** + * Not `api_keys.manage`: that capability hides the API Keys settings tab, + * which holds Sim's own keys. BYOK is a separate, entitlement-gated section + * for provider keys, and this read only reports which providers the + * organization already supplies — no group key names it. + */ + // permission-group-exempt: BYOK is its own entitlement-gated section, and api_keys.manage names the Sim API Keys tab instead readInheritedStatus: defineWorkspaceOperation({ id: 'byok_keys.inherited_status.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), } as const diff --git a/apps/sim/lib/catalog/application/operations.test.ts b/apps/sim/lib/catalog/application/operations.test.ts index 21e4fa1ae93..b295ab48786 100644 --- a/apps/sim/lib/catalog/application/operations.test.ts +++ b/apps/sim/lib/catalog/application/operations.test.ts @@ -1,8 +1,26 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + resolvePermissionGroupConfig: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => ({ + resolvePermissionGroupConfig: mocks.resolvePermissionGroupConfig, +})) + import { catalogOperations } from '@/lib/catalog/application/operations' +import type { WorkspaceOperation } from '@/lib/core/application' +import { authorizeWorkspaceOperation, PermissionGroupCapabilityError } from '@/lib/core/application' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' /** * Operation metadata is executable policy, not documentation: it decides which @@ -53,3 +71,50 @@ describe('catalogOperations', () => { } }) }) + +const sessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, +} + +/** + * The connector-type catalog is the one entry with a capability, so the split + * is pinned from both sides: hiding knowledge bases must close it, and must not + * close the block and tool catalogs the editor needs to render at all. + */ +describe('catalog operations under a group that hides knowledge bases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + mocks.resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideKnowledgeBaseTab: true, + }) + }) + + it('refuses the connector-type catalog', async () => { + await expect( + authorizeWorkspaceOperation( + sessionPrincipal, + catalogOperations.listConnectorTypes as WorkspaceOperation, + context + ) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + }) + + it('still answers the block and tool catalogs', async () => { + for (const operation of [ + catalogOperations.listBlocks, + catalogOperations.readBlock, + catalogOperations.listTools, + catalogOperations.readTool, + ]) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).resolves.toBeUndefined() + } + }) +}) diff --git a/apps/sim/lib/catalog/application/operations.ts b/apps/sim/lib/catalog/application/operations.ts index 19a85eddbfa..8ee9e1453d5 100644 --- a/apps/sim/lib/catalog/application/operations.ts +++ b/apps/sim/lib/catalog/application/operations.ts @@ -13,36 +13,57 @@ import { defineWorkspaceOperation } from '@/lib/core/application' * No `delegated` principal kind: Copilot reads these catalogs through its own * tools, which share the projection rather than the use case, so adding one * would widen authorization for a caller that does not exist. + * + * The block and tool catalogs name no capability. They are the set of things + * the editor can render at all, already filtered per workspace, and the + * capabilities that matter — MCP tools, custom tools, skills — are enforced on + * the operations that use those tools rather than on the list that describes + * them. Withholding the catalog would empty the builder rather than withhold + * anything a group names. */ export const catalogOperations = { + // permission-group-exempt: the block catalog is what the editor renders; emptying it hides the product rather than restricting it listBlocks: defineWorkspaceOperation({ id: 'catalog.blocks.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), + // permission-group-exempt: one entry of the same catalog listBlocks returns, so it cannot be governed differently readBlock: defineWorkspaceOperation({ id: 'catalog.blocks.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), + // permission-group-exempt: describes which tools exist; whether a member may call one is decided on that tool's own operation listTools: defineWorkspaceOperation({ id: 'catalog.tools.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), + // permission-group-exempt: one entry of the same catalog listTools returns, so it cannot be governed differently readTool: defineWorkspaceOperation({ id: 'catalog.tools.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), + /** + * The only catalog with a capability: it enumerates knowledge-base connector + * types and nothing else, so it exists to configure a knowledge base. A group + * with `hideKnowledgeBaseTab` set has no use for the list. + */ listConnectorTypes: defineWorkspaceOperation({ id: 'catalog.connector_types.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), } as const diff --git a/apps/sim/lib/chat-deployments/application/operations.ts b/apps/sim/lib/chat-deployments/application/operations.ts index 6776bbe9926..0ea237e3977 100644 --- a/apps/sim/lib/chat-deployments/application/operations.ts +++ b/apps/sim/lib/chat-deployments/application/operations.ts @@ -10,6 +10,10 @@ import { defineWorkspaceOperation } from '@/lib/core/application' * by its own id; the resource and its policy are the same either way, so the * operation is too. * + * Every one declares `deploy.chat`, the capability behind `hideDeployChatbot`. + * `list` takes it too: a group with the chat deployment surface withheld should + * not still be told which workflows are published on it. + * * `workflows.chat.deploy` and `workflows.chat.undeploy` remain the entry points * for the surfaces that name a workflow and ask for it to be published — the * internal deploy route and the Copilot tool. They converge on the same domain @@ -42,30 +46,35 @@ export const chatDeploymentOperations = { id: 'chat_deployments.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'deploy.chat', ...CHAT_DEPLOYMENT_LIST_POLICY, }), replace: defineWorkspaceOperation({ id: 'chat_deployments.replace', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.chat', ...CHAT_DEPLOYMENT_ADMIN_POLICY, }), read: defineWorkspaceOperation({ id: 'chat_deployments.read', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.chat', ...CHAT_DEPLOYMENT_ADMIN_POLICY, }), update: defineWorkspaceOperation({ id: 'chat_deployments.update', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.chat', ...CHAT_DEPLOYMENT_ADMIN_POLICY, }), delete: defineWorkspaceOperation({ id: 'chat_deployments.delete', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.chat', ...CHAT_DEPLOYMENT_ADMIN_POLICY, }), } as const diff --git a/apps/sim/lib/copilot/application/operations.ts b/apps/sim/lib/copilot/application/operations.ts index 2fc9830226b..7a910261cb5 100644 --- a/apps/sim/lib/copilot/application/operations.ts +++ b/apps/sim/lib/copilot/application/operations.ts @@ -11,6 +11,7 @@ export const chatOperations = { id: 'chat.send', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'copilot.use', principalKinds: ['personal_api_key'], }), } as const diff --git a/apps/sim/lib/credential-groups/application/operations.ts b/apps/sim/lib/credential-groups/application/operations.ts index 9339f5dfd9f..409920b039e 100644 --- a/apps/sim/lib/credential-groups/application/operations.ts +++ b/apps/sim/lib/credential-groups/application/operations.ts @@ -1,111 +1,159 @@ import { defineWorkspaceOperation } from '@/lib/core/application' +/** + * Credential groups collect OAuth credentials from people outside the workspace + * so a workflow can act as them — a distinct, entitlement-gated settings + * section, not part of the Integrations tab. + * + * None of them declares a capability. `integrations.manage` names the + * Integrations tab, where a member connects their own accounts, and + * `credentials.personal` withholds exactly that; both describe a member acting + * for themselves, which is the opposite of this section. Every operation here + * already requires workspace `admin`, and the executor-delegated reads run + * inside a workflow whose credential access is decided by the group's own + * enrollment rows. Borrowing a key that names a different surface would make + * hiding the Integrations tab silently disable an unrelated admin section. + */ export const credentialGroupOperations = { + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section listSettings: defineWorkspaceOperation({ id: 'credential_groups.settings.list', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section create: defineWorkspaceOperation({ id: 'credential_groups.create', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section readSettings: defineWorkspaceOperation({ id: 'credential_groups.settings.read', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section update: defineWorkspaceOperation({ id: 'credential_groups.update', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section readAccess: defineWorkspaceOperation({ id: 'credential_groups.access.read', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section updateAccess: defineWorkspaceOperation({ id: 'credential_groups.access.update', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section delete: defineWorkspaceOperation({ id: 'credential_groups.delete', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section inviteBatch: defineWorkspaceOperation({ id: 'credential_groups.invites.send_batch', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section resendEnrollment: defineWorkspaceOperation({ id: 'credential_groups.enrollments.resend', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section deleteEnrollment: defineWorkspaceOperation({ id: 'credential_groups.enrollments.delete', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: read by the executor to resolve an enrolled person's credential; the group's enrollment rows are the gate, and no group key names them listCredentials: defineWorkspaceOperation({ id: 'credential_groups.credentials.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['executor'], }), + // permission-group-exempt: read by the executor to resolve an enrolled person's credential; the group's enrollment rows are the gate, and no group key names them listGroups: defineWorkspaceOperation({ id: 'credential_groups.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['executor'], }), + // permission-group-exempt: read by the executor to resolve an enrolled person's credential; the group's enrollment rows are the gate, and no group key names them listPeople: defineWorkspaceOperation({ id: 'credential_groups.people.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['executor'], }), + // permission-group-exempt: enrolls an outside person in a credential group, not a member in a workspace, so invitations.send does not name it sendInvite: defineWorkspaceOperation({ id: 'credential_groups.invites.send', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['executor'], }), + // permission-group-exempt: mints an enrollment link for an outside person, not a workspace invitation, so invitations.send does not name it createInviteLink: defineWorkspaceOperation({ id: 'credential_groups.invites.link.create', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['executor'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section startSlackConfiguration: defineWorkspaceOperation({ id: 'credential_groups.slack_configuration.start', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section completeSlackConfiguration: defineWorkspaceOperation({ id: 'credential_groups.slack_configuration.complete', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), } as const diff --git a/apps/sim/lib/custom-tools/application/operations.ts b/apps/sim/lib/custom-tools/application/operations.ts index fbeace41633..1de8b666ec4 100644 --- a/apps/sim/lib/custom-tools/application/operations.ts +++ b/apps/sim/lib/custom-tools/application/operations.ts @@ -9,29 +9,39 @@ const HUMAN_PRINCIPAL_POLICY = { delegatedServices: ['copilot'], } as const +/** + * Every operation declares `custom_tools.use`, reads included. A custom tool is + * a user-authored function an agent calls; a group that withholds them has no + * use for the definitions either, and gating only execution would leave the + * authoring surface open to a member who can never run what it produces. + */ export const customToolOperations = { list: defineWorkspaceOperation({ id: 'custom_tools.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'custom_tools.use', ...ALL_PRINCIPAL_POLICY, }), listAvailable: defineWorkspaceOperation({ id: 'custom_tools.list_available', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'custom_tools.use', ...HUMAN_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'custom_tools.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'custom_tools.use', ...ALL_PRINCIPAL_POLICY, }), readAvailableByIdOrTitle: defineWorkspaceOperation({ id: 'custom_tools.read_available_by_id_or_title', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'custom_tools.use', principalKinds: ['delegated'], delegatedServices: ['copilot', 'executor'], }), @@ -39,36 +49,42 @@ export const customToolOperations = { id: 'custom_tools.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'custom_tools.use', ...ALL_PRINCIPAL_POLICY, }), save: defineWorkspaceOperation({ id: 'custom_tools.save', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'custom_tools.use', ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'custom_tools.update', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'custom_tools.use', ...ALL_PRINCIPAL_POLICY, }), updateAvailable: defineWorkspaceOperation({ id: 'custom_tools.update_available', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'custom_tools.use', ...HUMAN_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'custom_tools.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'custom_tools.use', ...ALL_PRINCIPAL_POLICY, }), deleteAvailable: defineWorkspaceOperation({ id: 'custom_tools.delete_available', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'custom_tools.use', ...HUMAN_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/function-execution/application/operations.ts b/apps/sim/lib/function-execution/application/operations.ts index ba37ad117e5..66231142760 100644 --- a/apps/sim/lib/function-execution/application/operations.ts +++ b/apps/sim/lib/function-execution/application/operations.ts @@ -1,10 +1,12 @@ import { defineWorkspaceOperation } from '@/lib/core/application' export const functionExecutionOperations = { + // permission-group-exempt: running a Function block is the workflow executing its own code; no group key names code execution, and a gate here would fail runs the group permits execute: defineWorkspaceOperation({ id: 'function-executions.execute', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['executor', 'copilot'], }), diff --git a/apps/sim/lib/mcp/application/operations.test.ts b/apps/sim/lib/mcp/application/operations.test.ts index 75218e8f402..1780fc657c5 100644 --- a/apps/sim/lib/mcp/application/operations.test.ts +++ b/apps/sim/lib/mcp/application/operations.test.ts @@ -1,8 +1,26 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + resolvePermissionGroupConfig: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => ({ + resolvePermissionGroupConfig: mocks.resolvePermissionGroupConfig, +})) + +import type { WorkspaceOperation } from '@/lib/core/application' +import { authorizeWorkspaceOperation, PermissionGroupCapabilityError } from '@/lib/core/application' import { mcpServerOperations } from '@/lib/mcp/application/operations' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' describe('MCP server operation registry', () => { it('requires a human subject for tool discovery', () => { @@ -103,3 +121,77 @@ describe('MCP server operation registry', () => { expect(new Set(ids).size).toBe(ids.length) }) }) + +const sessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, +} + +/** `tools.execute` admits only the executor delegation, so a session cannot stand in for it. */ +function sessionReachable(capability: string) { + return Object.values(mcpServerOperations).filter( + (operation) => + operation.capability === capability && operation.principalKinds.includes('session') + ) +} + +/** + * The declaration is only half the gate; these prove the funnel actually + * refuses, so a capability could not be renamed into one nothing reads. + */ +describe('MCP operations under a withholding permission group', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + }) + + it('refuses every mcp_servers operation when the group blocks MCP tools', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableMcpTools: true, + }) + + const registryOperations = sessionReachable('mcp_tools.use') + expect(registryOperations.length).toBeGreaterThan(0) + + for (const operation of registryOperations) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + } + }) + + it('refuses every workflow-deployment operation when the group hides MCP deployment', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployMcp: true, + }) + + const deploymentOperations = sessionReachable('deploy.mcp') + expect(deploymentOperations.length).toBeGreaterThan(0) + + for (const operation of deploymentOperations) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + } + }) + + it('allows the same operations when the group withholds neither', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + for (const operation of [ + ...sessionReachable('mcp_tools.use'), + ...sessionReachable('deploy.mcp'), + ]) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).resolves.toBeUndefined() + } + }) +}) diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index 7b5bd098fbc..e5cba22f155 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -17,23 +17,40 @@ const EXECUTION_PRINCIPAL_POLICY = { delegatedServices: ['executor'], } as const +/** + * Two capabilities, because the family covers two different things. + * + * `mcp_servers.*` is the workspace's registry of external MCP servers — the + * connections an agent calls tools through — so every one of them declares + * `mcp_tools.use`. Gating only `tools.execute` would leave a group that blocks + * MCP tools able to keep registering servers and storing their credentials + * against the workspace, which is the accumulation the key exists to stop. + * + * `mcp_servers.workflow_deployments.*` is the opposite direction: publishing a + * workflow *as* an MCP server, which is what `hideDeployMcp` names. Reads carry + * `deploy.mcp` alongside the writes, so a group that withholds the deployment + * surface does not still answer with what is published on it. + */ export const mcpServerOperations = { list: defineWorkspaceOperation({ id: 'mcp_servers.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), discoverTools: defineWorkspaceOperation({ id: 'mcp_servers.tools.discover', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'mcp_tools.use', ...DISCOVERY_PRINCIPAL_POLICY, }), executeTool: defineWorkspaceOperation({ id: 'mcp_servers.tools.execute', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'mcp_tools.use', ...EXECUTION_PRINCIPAL_POLICY, }), /** @@ -56,6 +73,7 @@ export const mcpServerOperations = { id: 'mcp_servers.workflow_deployments.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), /** @@ -70,12 +88,14 @@ export const mcpServerOperations = { id: 'mcp_servers.workflow_deployments.read_server', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), listWorkflowDeploymentTools: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.list_tools', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), createWorkflowDeploymentServer: defineWorkspaceOperation({ @@ -109,54 +129,63 @@ export const mcpServerOperations = { id: 'mcp_servers.workflow_deployments.delete_server', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), deployWorkflowTool: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.deploy_tool', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), undeployWorkflowTool: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.undeploy_tool', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'mcp_servers.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'mcp_servers.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), register: defineWorkspaceOperation({ id: 'mcp_servers.register', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'mcp_servers.update', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), reconfigure: defineWorkspaceOperation({ id: 'mcp_servers.reconfigure', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'mcp_servers.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/memory/application/operations.ts b/apps/sim/lib/memory/application/operations.ts index 253df541c3d..3a99abef426 100644 --- a/apps/sim/lib/memory/application/operations.ts +++ b/apps/sim/lib/memory/application/operations.ts @@ -5,11 +5,18 @@ const MEMORY_EXECUTOR_PRINCIPAL_POLICY = { delegatedServices: ['executor'], } as const +/** + * Memory is the executor's own store: an Agent block writes and reads it inside + * a run the workspace already authorized, and no permission-group key names it. + * A gate here would fail runs the group permits rather than withhold a + * capability from a member, so all four operations declare `'none'`. + */ function readOperation(id: Id) { return defineWorkspaceOperation({ id, minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...MEMORY_EXECUTOR_PRINCIPAL_POLICY, }) } @@ -19,13 +26,18 @@ function writeOperation(id: Id) { id, minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...MEMORY_EXECUTOR_PRINCIPAL_POLICY, }) } export const memoryOperations = { + // permission-group-exempt: the executor's own per-run store; no group key names it, and refusing would fail runs the group allows list: readOperation('memory.list'), + // permission-group-exempt: the executor's own per-run store; no group key names it, and refusing would fail runs the group allows read: readOperation('memory.read'), + // permission-group-exempt: the executor's own per-run store; no group key names it, and refusing would fail runs the group allows append: writeOperation('memory.append'), + // permission-group-exempt: the executor's own per-run store; no group key names it, and refusing would fail runs the group allows delete: writeOperation('memory.delete'), } as const diff --git a/apps/sim/lib/platform-context/application/operations.ts b/apps/sim/lib/platform-context/application/operations.ts index 36c650a064f..80658e1acc9 100644 --- a/apps/sim/lib/platform-context/application/operations.ts +++ b/apps/sim/lib/platform-context/application/operations.ts @@ -5,17 +5,28 @@ const LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY = { delegatedServices: ['copilot'], } as const +/** + * What Sim reads about itself before it can answer at all: the workspace's plan, + * its seat and usage state, and whether the organization is on the enterprise + * tier. No permission-group key names them, and a member whose group withheld + * them would get an agent that cannot tell them why anything is unavailable — + * withholding the description of a restriction is not the same as applying one. + */ export const platformContextOperations = { + // permission-group-exempt: the plan and usage state every answer is framed against; withholding it blanks the agent rather than restricting it readAccountBilling: defineWorkspaceOperation({ id: 'platform_context.account_billing.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY, }), + // permission-group-exempt: reports which enterprise features the organization has, the frame the restrictions themselves are described in readEnterpriseContext: defineWorkspaceOperation({ id: 'platform_context.enterprise.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/skills/application/operations.test.ts b/apps/sim/lib/skills/application/operations.test.ts index 5669aadb134..f5868115379 100644 --- a/apps/sim/lib/skills/application/operations.test.ts +++ b/apps/sim/lib/skills/application/operations.test.ts @@ -2,7 +2,25 @@ * @vitest-environment node */ import { requirePrincipalSubjectUserId } from '@sim/auth/principal' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + resolvePermissionGroupConfig: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => ({ + resolvePermissionGroupConfig: mocks.resolvePermissionGroupConfig, +})) + +import type { WorkspaceOperation } from '@/lib/core/application' +import { authorizeWorkspaceOperation, PermissionGroupCapabilityError } from '@/lib/core/application' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { skillOperations } from '@/lib/skills/application/operations' /** @@ -100,3 +118,46 @@ describe('skill operation registry', () => { expect(new Set(ids).size).toBe(ids.length) }) }) + +const sessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, +} + +/** + * The declaration is only half the gate. These call the funnel so a capability + * cannot be declared on the operations and then read by nothing. + */ +describe('skill operations under a group that blocks skills', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + }) + + it('refuses authoring and editor grants, not only loading', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableSkills: true, + }) + + for (const operation of Object.values(skillOperations)) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + } + }) + + it('allows them all when the group withholds nothing', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + for (const operation of Object.values(skillOperations)) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).resolves.toBeUndefined() + } + }) +}) diff --git a/apps/sim/lib/skills/application/operations.ts b/apps/sim/lib/skills/application/operations.ts index 32ed520ccad..06cb71eb7fd 100644 --- a/apps/sim/lib/skills/application/operations.ts +++ b/apps/sim/lib/skills/application/operations.ts @@ -36,35 +36,47 @@ const HUMAN_HTTP_SKILL_EDITOR_POLICY = { * act. Denying it keeps the whole lifecycle under one authorization model. * Pinned in `operations.test.ts`. */ +/** + * Every operation declares `skills.use`. The key reads "block agents from + * loading skills", and the skills a group's members author are exactly the ones + * their agents would load — so authoring, sharing, and editor grants are gated + * with execution rather than left as a side door that fills the workspace with + * skills the group may not run. + */ export const skillOperations = { list: defineWorkspaceOperation({ id: 'skills.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'skills.use', ...ALL_PRINCIPAL_POLICY, }), listAvailable: defineWorkspaceOperation({ id: 'skills.list_available', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'skills.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'skills.use', ...ALL_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'skills.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'skills.update', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_PRINCIPAL_POLICY, }), /** @@ -81,30 +93,35 @@ export const skillOperations = { id: 'skills.upsert', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'skills.delete', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_PRINCIPAL_POLICY, }), listEditors: defineWorkspaceOperation({ id: 'skills.editors.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'skills.use', ...HTTP_SKILL_EDITOR_READ_POLICY, }), grantEditor: defineWorkspaceOperation({ id: 'skills.editors.grant', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_HTTP_SKILL_EDITOR_POLICY, }), revokeEditor: defineWorkspaceOperation({ id: 'skills.editors.revoke', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_HTTP_SKILL_EDITOR_POLICY, }), } as const From 9c7800c5a20fa9d3b7cde5ab6bdb82e9201ea70b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 21:35:44 -0700 Subject: [PATCH 013/179] feat(permission-groups): enforce workspace.create, member directory and CLI access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three capabilities reached the admin editor with a checkbox, a hint, and no server gate: an organization that set `disableWorkspaceCreation`, `hideOrgMemberDirectory` or `disableCliAccess` believed it had withheld something while every route still answered. None is workspace-operation shaped, so each is wired at an annotated call site rather than through the declarative funnel. workspace.create — extended `getWorkspaceCreationPolicy` rather than the POST route, so forking and the sidebar's "can I create?" signal are covered by the same decision with no route changes. A new workspace carries no `permissionGroupWorkspace` row, so a scoped-group member creating one lands outside every group targeting them — the cleanest escape from the regime today. The gate resolves the caller's organization even when the resulting workspace would be personal, because a personal workspace is precisely that escape. `blockedReasonCode` gains `'permission-group-denied'`; the one caller that switches on it (`app/workspace/page.tsx`) gets matching copy, since the existing organization branch would have told a blocked member to ask for workspace access they already have. organization.member_directory — `/api/organizations/[id]/members` and `/api/organizations/[id]/roster` gated on bare organization membership, so every member could list every colleague's name and email. Both now consult the organization's default group. No role exemption: the default group governs owners and admins for every other capability, and carving one out here would make this the only key whose meaning depended on who was asking. cli.use — gated at `/api/cli/auth/approve`, the only moment a human is present in the device-auth handoff. The poll route that redeems the approval for an API key is deliberately unauthenticated and is left alone: it has no session to resolve a group against, and re-deciding there would duplicate this check while racing a config change between the two calls. `workspaceId` is set only for platform scope, so a personal-scope login falls back to the organization's default group instead of being the unguarded path. The workspace-level member list (`/api/workspaces/[id]/members`) is deliberately NOT gated. It returns id, name and image for people who already share a workspace with the caller — identities the collaboration surfaces publish continuously anyway (presence cursors, canvas avatars, log actors, the sharing dialog), and it exposes no email at all. Hiding it would blank those surfaces while disclosing the same names over the realtime channel, so the restriction would read as breakage rather than policy. An organization-wide roster of colleagues with their email addresses is a materially different disclosure from "who is in this room with me", and that is what the capability is named for. --- .../app/api/cli/auth/approve/route.test.ts | 50 +++++++++-- apps/sim/app/api/cli/auth/approve/route.ts | 21 ++++- .../organizations/[id]/members/route.test.ts | 88 +++++++++++++++++++ .../api/organizations/[id]/members/route.ts | 12 +++ .../organizations/[id]/roster/route.test.ts | 24 ++++- .../api/organizations/[id]/roster/route.ts | 12 +++ apps/sim/app/workspace/page.tsx | 8 +- .../access-control/utils/permission-check.ts | 47 ++++++++++ apps/sim/lib/api/contracts/workspaces.ts | 4 +- apps/sim/lib/workspaces/policy.test.ts | 44 ++++++++++ apps/sim/lib/workspaces/policy.ts | 39 +++++++- 11 files changed, 333 insertions(+), 16 deletions(-) create mode 100644 apps/sim/app/api/organizations/[id]/members/route.test.ts diff --git a/apps/sim/app/api/cli/auth/approve/route.test.ts b/apps/sim/app/api/cli/auth/approve/route.test.ts index ff7902092a5..3cad0fe684b 100644 --- a/apps/sim/app/api/cli/auth/approve/route.test.ts +++ b/apps/sim/app/api/cli/auth/approve/route.test.ts @@ -5,13 +5,23 @@ import { createHash } from 'node:crypto' import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit, mockGetPermissions } = - vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockCreateApproval: vi.fn(), - mockEnforceUserRateLimit: vi.fn(), - mockGetPermissions: vi.fn(), - })) +const { + mockGetSession, + mockCreateApproval, + mockEnforceUserRateLimit, + mockGetPermissions, + mockIsCliAccessDisabled, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockCreateApproval: vi.fn(), + mockEnforceUserRateLimit: vi.fn(), + mockGetPermissions: vi.fn(), + mockIsCliAccessDisabled: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + isCliAccessDisabled: mockIsCliAccessDisabled, +})) vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, @@ -42,6 +52,32 @@ describe('POST /api/cli/auth/approve', () => { mockEnforceUserRateLimit.mockResolvedValue(null) mockCreateApproval.mockResolvedValue(undefined) mockGetPermissions.mockResolvedValue('admin') + mockIsCliAccessDisabled.mockResolvedValue(false) + }) + + it('refuses an approver whose permission group disables CLI access', async () => { + mockIsCliAccessDisabled.mockResolvedValue(true) + + const response = await POST( + createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE }) + ) + + expect(response.status).toBe(403) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('resolves the governing group from the bound workspace when one is given', async () => { + await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(mockIsCliAccessDisabled).toHaveBeenCalledWith('user-1', 'ws-1') }) it('records the approval for the signed-in user', async () => { diff --git a/apps/sim/app/api/cli/auth/approve/route.ts b/apps/sim/app/api/cli/auth/approve/route.ts index 3c361a9bf45..e98d05b39a1 100644 --- a/apps/sim/app/api/cli/auth/approve/route.ts +++ b/apps/sim/app/api/cli/auth/approve/route.ts @@ -7,6 +7,7 @@ import { createApproval } from '@/lib/cli-auth/approval-store' import { enforceUserRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { isCliAccessDisabled } from '@/ee/access-control/utils/permission-check' const logger = createLogger('CliAuthApproveAPI') @@ -18,9 +19,11 @@ const logger = createLogger('CliAuthApproveAPI') * user id here would let any caller approve a request redeemable for someone * else's key. No key is generated until the CLI polls. * - * Workspace binding is authorized here rather than at poll time: the poll is - * unauthenticated by necessity, so it has no session to check a permission - * against. Approving is the only moment a human is present. + * Workspace binding and CLI-access permission are authorized here rather than at + * poll time: the poll is unauthenticated by necessity, so it has no session to + * check a permission against, and re-checking there would duplicate this + * decision while racing a permission-group change made between the two calls. + * Approving is the only moment a human is present. */ export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() @@ -73,6 +76,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } + if (await isCliAccessDisabled(session.user.id, workspaceId)) { + logger.warn('CLI authorization blocked by permission group', { + userId: session.user.id, + scope, + workspaceId: workspaceId ?? null, + }) + return NextResponse.json( + { error: 'CLI access is not allowed based on your permission group settings' }, + { status: 403 } + ) + } + await createApproval(session.user.id, requestId, challenge, { scope, workspaceId, diff --git a/apps/sim/app/api/organizations/[id]/members/route.test.ts b/apps/sim/app/api/organizations/[id]/members/route.test.ts new file mode 100644 index 00000000000..e75cf435f40 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/members/route.test.ts @@ -0,0 +1,88 @@ +/** + * @vitest-environment node + */ +import { member } from '@sim/db/schema' +import { + authMockFns, + createMockRequest, + createSession, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsOrgMemberDirectoryHidden, mockGetUsageSnapshot } = vi.hoisted(() => ({ + mockIsOrgMemberDirectoryHidden: vi.fn(), + mockGetUsageSnapshot: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + isOrgAdminRole: (role: string | null | undefined) => role === 'owner' || role === 'admin', +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + isOrgMemberDirectoryHidden: mockIsOrgMemberDirectoryHidden, +})) + +vi.mock('@/lib/billing/core/organization', () => ({ + getOrganizationMemberUsageSnapshot: mockGetUsageSnapshot, +})) + +import { GET } from '@/app/api/organizations/[id]/members/route' + +const mockGetSession = authMockFns.mockGetSession + +const REQUEST_URL = 'http://localhost/api/organizations/org-1/members' + +function request() { + return GET(createMockRequest('GET', undefined, {}, REQUEST_URL), { + params: Promise.resolve({ id: 'org-1' }), + }) +} + +afterAll(resetDbChainMock) + +describe('GET /api/organizations/[id]/members', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetSession.mockResolvedValue(createSession({ userId: 'user-reader' })) + mockIsOrgMemberDirectoryHidden.mockResolvedValue(false) + }) + + it('lists members for an organization member', async () => { + queueTableRows(member, [{ id: 'member-reader', role: 'member' }]) + queueTableRows(member, [ + { + id: 'member-admin', + userId: 'user-admin', + organizationId: 'org-1', + role: 'admin', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + userName: 'Admin User', + userEmail: 'admin@example.com', + }, + ]) + queueTableRows(member, [{ value: 1 }]) + + const response = await request() + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.success).toBe(true) + expect(body.data).toHaveLength(1) + expect(body.data[0].userEmail).toBe('admin@example.com') + }) + + it('refuses a member whose permission group hides the member directory', async () => { + mockIsOrgMemberDirectoryHidden.mockResolvedValue(true) + queueTableRows(member, [{ id: 'member-reader', role: 'member' }]) + + const response = await request() + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: 'Forbidden - The organization member directory is not available to you', + }) + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 894c0204a98..1a07090b14c 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -12,6 +12,7 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getOrganizationMemberUsageSnapshot } from '@/lib/billing/core/organization' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isOrgMemberDirectoryHidden } from '@/ee/access-control/utils/permission-check' const logger = createLogger('OrganizationMembersAPI') @@ -63,6 +64,17 @@ export const GET = withRouteHandler( ) } + if (await isOrgMemberDirectoryHidden(organizationId)) { + logger.warn('Organization member directory blocked by permission group', { + organizationId, + userId: session.user.id, + }) + return NextResponse.json( + { error: 'Forbidden - The organization member directory is not available to you' }, + { status: 403 } + ) + } + const userRole = memberEntry[0].role const hasAdminAccess = isOrgAdminRole(userRole) diff --git a/apps/sim/app/api/organizations/[id]/roster/route.test.ts b/apps/sim/app/api/organizations/[id]/roster/route.test.ts index eabd3cf5350..42e8c04b2e2 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.test.ts @@ -17,8 +17,13 @@ import { } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockExpireStaleInvitations } = vi.hoisted(() => ({ +const { mockExpireStaleInvitations, mockIsOrgMemberDirectoryHidden } = vi.hoisted(() => ({ mockExpireStaleInvitations: vi.fn(), + mockIsOrgMemberDirectoryHidden: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + isOrgMemberDirectoryHidden: mockIsOrgMemberDirectoryHidden, })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -61,6 +66,23 @@ describe('GET /api/organizations/[id]/roster', () => { vi.clearAllMocks() resetDbChainMock() mockExpireStaleInvitations.mockResolvedValue(undefined) + mockIsOrgMemberDirectoryHidden.mockResolvedValue(false) + }) + + it('refuses a member whose permission group hides the member directory', async () => { + mockGetSession.mockResolvedValue(createSession({ userId: 'user-reader' })) + mockIsOrgMemberDirectoryHidden.mockResolvedValue(true) + queueTableRows(member, [{ role: 'member' }]) + + const response = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost/api/organizations/org-1/roster'), + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: 'Forbidden - The organization member directory is not available to you', + }) }) it('returns a redacted roster to a target-organization member', async () => { diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index 17bdf76153e..3b934031412 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -21,6 +21,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { expireStalePendingInvitationsForOrganization } from '@/lib/invitations/core' +import { isOrgMemberDirectoryHidden } from '@/ee/access-control/utils/permission-check' const logger = createLogger('OrganizationRosterAPI') @@ -49,6 +50,17 @@ export const GET = withRouteHandler( ) } + if (await isOrgMemberDirectoryHidden(organizationId)) { + logger.warn('Organization roster blocked by permission group', { + organizationId, + userId: session.user.id, + }) + return NextResponse.json( + { error: 'Forbidden - The organization member directory is not available to you' }, + { status: 403 } + ) + } + const memberRows = await db .select({ memberId: member.id, diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index ecb796e9a78..72ed6873e6b 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -192,9 +192,11 @@ export default function WorkspacePage() { description={ blockedPolicy.blockedReasonCode === 'organization-subscription-inactive' ? "Your organization's subscription is inactive, so new workspaces can't be created. Ask an organization owner to reactivate it." - : blockedPolicy.workspaceMode === 'organization' - ? "Your account is linked to an organization, but you don't have access to any of its workspaces. Ask an organization admin for workspace access, then check again — or sign out and back in if you recently left the organization." - : 'Your plan has reached its workspace limit and none of your workspaces are active. Upgrade your plan to create another workspace, or contact support to restore an archived one.' + : blockedPolicy.blockedReasonCode === 'permission-group-denied' + ? "Your permission group doesn't allow creating workspaces, and you don't have access to an existing one. Ask an organization admin for workspace access." + : blockedPolicy.workspaceMode === 'organization' + ? "Your account is linked to an organization, but you don't have access to any of its workspaces. Ask an organization admin for workspace access, then check again — or sign out and back in if you recently left the organization." + : 'Your plan has reached its workspace limit and none of your workspaces are active. Upgrade your plan to create another workspace, or contact support to restore an archived one.' } primaryLabel='Check again' onPrimary={() => window.location.reload()} diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index e543f80ced4..f4c38063125 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, asc, eq, sql } from 'drizzle-orm' import type { ShareAuthType } from '@/lib/api/contracts/public-shares' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' +import { getUserOrganization } from '@/lib/billing/organizations/membership' import { getAllowedIntegrationsFromEnv, isAccessControlEnabled, @@ -422,6 +423,52 @@ export async function getUserPermissionConfigForOrganization( return mergeEnvAllowlist(resolved?.config ?? null) } +/** + * Whether the organization's permission group withholds its member directory. + * + * A directory read has no workspace and no resource, so there is no workspace + * operation for the funnel to hang a capability on — the two routes that serve + * it check bare organization membership, which is why every member can read + * every colleague's name and email today. + * + * No role exemption: the default group governs owners and admins the same way it + * governs everyone else for every other capability, and carving one out here + * would make this the only key whose meaning depends on who is asking. + */ +/** permission-group-enforced: organization.member_directory — organization-scoped read with no workspace or resource for the funnel to authorize */ +export async function isOrgMemberDirectoryHidden(organizationId: string): Promise { + const config = await getUserPermissionConfigForOrganization(organizationId) + return config?.hideOrgMemberDirectory === true +} + +/** + * Whether CLI access is withheld from `userId`. + * + * Asked at approval time, which is the only moment a human is present: the + * device-auth poll that redeems the approval for an API key is unauthenticated + * by necessity, so it has no session to resolve a group against, and re-asking + * there would only duplicate this decision while racing a config change between + * the two calls. + * + * `workspaceId` is set only for a platform-scope handoff. A personal-scope login + * has no workspace, so it falls back to the organization's default group rather + * than going ungoverned — otherwise the narrower scope would be the unguarded + * one. + */ +/** permission-group-enforced: cli.use — gates a device-auth handoff, which owns no workspace resource for the funnel to authorize */ +export async function isCliAccessDisabled(userId: string, workspaceId?: string): Promise { + if (workspaceId) { + const config = await getUserPermissionConfig(userId, workspaceId) + return config?.disableCliAccess === true + } + + const membership = await getUserOrganization(userId) + if (!membership) return false + + const config = await getUserPermissionConfigForOrganization(membership.organizationId) + return config?.disableCliAccess === true +} + /** * Cache-aware wrapper around `getUserPermissionConfig`. When an * `ExecutionContext` is provided, the resolved config is memoized on the diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index beac7ce329f..9bfc6cb99eb 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -52,7 +52,9 @@ export const workspaceCreationPolicySchema = z.object({ * Machine-readable discriminant for blocked states whose correct user-facing * copy the workspace mode alone cannot determine. */ - blockedReasonCode: z.literal('organization-subscription-inactive').optional(), + blockedReasonCode: z + .enum(['organization-subscription-inactive', 'permission-group-denied']) + .optional(), }) export type WorkspaceCreationPolicy = z.output diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 1c30e6c1d1d..2fae8784fd8 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -17,11 +17,17 @@ const { mockGetUserOrganization, mockGetOrganizationSubscription, mockGetHighestPrioritySubscription, + mockGetUserPermissionConfigForOrganization, } = vi.hoisted(() => ({ mockAcquireOrganizationUserMutationLocks: vi.fn(), mockGetUserOrganization: vi.fn(), mockGetOrganizationSubscription: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), + mockGetUserPermissionConfigForOrganization: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfigForOrganization: mockGetUserPermissionConfigForOrganization, })) vi.mock('@/lib/billing/organizations/membership', () => ({ @@ -158,6 +164,44 @@ describe('getWorkspaceCreationPolicy', () => { mockGetUserOrganization.mockResolvedValue(null) mockGetOrganizationSubscription.mockResolvedValue(null) mockGetHighestPrioritySubscription.mockResolvedValue(null) + mockGetUserPermissionConfigForOrganization.mockResolvedValue(null) + }) + + it('blocks a member whose permission group disables workspace creation', async () => { + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'org-1', + role: 'member', + memberId: 'member-1', + }) + mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + disableWorkspaceCreation: true, + }) + queueTableRows(member, [{ role: 'member' }]) + + const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) + + expect(result.canCreate).toBe(false) + expect(result.status).toBe(403) + expect(result.blockedReasonCode).toBe('permission-group-denied') + expect(mockGetUserPermissionConfigForOrganization).toHaveBeenCalledWith('org-1') + }) + + it('governs the personal workspace a scoped-group member would otherwise escape into', async () => { + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'org-1', + role: 'member', + memberId: 'member-1', + }) + mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + disableWorkspaceCreation: true, + }) + queueTableRows(member, [{ role: 'member' }]) + + const result = await getWorkspaceCreationPolicy({ userId: 'user-1', pinOrganization: true }) + + expect(result.canCreate).toBe(false) + expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL) + expect(result.blockedReasonCode).toBe('permission-group-denied') }) it('blocks free users once they already own one non-organization workspace', async () => { diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 8f727647c01..20594269f00 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -18,6 +18,7 @@ import { CONTACT_OWNER_TO_UPGRADE_REASON, UPGRADE_TO_INVITE_REASON, } from '@/lib/workspaces/policy-constants' +import { getUserPermissionConfigForOrganization } from '@/ee/access-control/utils/permission-check' const logger = createLogger('WorkspacePolicy') @@ -92,7 +93,7 @@ export interface WorkspaceCreationPolicy { */ observedOrganizationId: string | null /** Discriminant for blocked states the workspace mode cannot distinguish. */ - blockedReasonCode?: 'organization-subscription-inactive' + blockedReasonCode?: 'organization-subscription-inactive' | 'permission-group-denied' } export class WorkspaceCreationContextChangedError extends Error { @@ -350,6 +351,42 @@ export async function getWorkspaceCreationPolicy({ .limit(1) )[0]?.role + const governingOrganizationId = organizationId ?? membership?.organizationId ?? null + if (governingOrganizationId) { + /** + * A new workspace carries no `permissionGroupWorkspace` row, so a member of + * a scoped group would land in a workspace that group does not target — the + * one place the whole regime can be stepped out of. Gating the policy rather + * than the create route also covers forking and the "can I create?" signal + * the sidebar renders from the same decision. + * + * Governed by the organization the caller belongs to even when the resulting + * workspace would be personal: a personal workspace is precisely the escape, + * so exempting it would leave the gate answering only the case it is not for. + */ + // permission-group-enforced: workspace.create — no workspace exists yet, so the workspace-scoped funnel has nothing to resolve a group against + const config = await getUserPermissionConfigForOrganization(governingOrganizationId) + if (config?.disableWorkspaceCreation) { + return { + canCreate: false, + workspaceMode: + organizationId === null ? WORKSPACE_MODE.PERSONAL : WORKSPACE_MODE.ORGANIZATION, + organizationId, + billedAccountUserId: + organizationId === null + ? userId + : ((await getOrganizationOwnerId(organizationId)) ?? userId), + maxWorkspaces: null, + currentWorkspaceCount: 0, + reason: + 'Your permission group does not allow creating workspaces. Ask an organization admin to change it.', + status: 403, + observedOrganizationId: membership?.organizationId ?? null, + blockedReasonCode: 'permission-group-denied', + } + } + } + if (activeOrganizationId && !orgRole) { const billedAccountUserId = await requireOrganizationOwnerId(activeOrganizationId) From 724f2e9a65392711120f78a742639d8778f61d35 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 21:36:07 -0700 Subject: [PATCH 014/179] feat(permission-groups): enforce the knowledge capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `knowledge.create`, `knowledge.upload` and `knowledge.connectors` shipped as declared capabilities with nothing reading them, so an admin who set the matching keys got a checkbox and no refusal. - knowledge.create governs the one operation that opens a knowledge base, so a group may query, populate and organize the bases it has without creating new ones. - knowledge.upload governs every path carrying caller-supplied bytes: the single-request document upload and the four upload-session operations. The connector sync path is untouched — a connector's documents are the sanctioned source, which is the point of the key. - Both rules also read `hideKnowledgeBaseTab`. An operation declares exactly one capability, so moving these off `knowledge.use` would otherwise have let a group that withheld the whole module still reach them through the API. - knowledge.connectors is parameterized on the connector id, which the authorization funnel never sees, so it is asserted inside `createKnowledgeConnector` ahead of the write, through `CAPABILITY_RULES`. Update is not gated: it cannot change a connector's type, and re-asserting would strand an existing connector the moment an admin narrowed the allowlist. - The admin editor grows a nested connector picker under Knowledge Base, keyed off the client-safe connector meta registry. --- .../components/group-detail.tsx | 59 +++++++++++-- .../knowledge/application/connectors.test.ts | 85 +++++++++++++++++++ .../lib/knowledge/application/connectors.ts | 45 ++++++++++ .../knowledge/application/operations.test.ts | 39 +++++++++ .../lib/knowledge/application/operations.ts | 29 +++++-- .../permission-groups/capabilities.test.ts | 49 +++++++++++ .../sim/lib/permission-groups/capabilities.ts | 15 +++- 7 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 apps/sim/lib/permission-groups/capabilities.test.ts diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 93352103f8d..c1400d63455 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -54,6 +54,7 @@ import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/ import { getAllBlocks } from '@/blocks' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import type { BlockConfig } from '@/blocks/types' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select' import { type PermissionGroup, @@ -106,6 +107,18 @@ const ALL_CHAT_DEPLOY_AUTH_TYPES: ShareAuthType[] = CHAT_DEPLOY_AUTH_TYPE_OPTION (o) => o.value ) +/** + * Knowledge base connectors an admin can allow/disallow. `null` config = all + * allowed. Sorted by display name because the picker is read alphabetically, + * while the registry is keyed by the snake_case id the server stores. + */ +const KNOWLEDGE_CONNECTOR_OPTIONS: { value: string; label: string }[] = Object.values( + CONNECTOR_META_REGISTRY +) + .map((meta) => ({ value: meta.id, label: meta.name })) + .sort((a, b) => a.label.localeCompare(b.label)) +const ALL_KNOWLEDGE_CONNECTORS: string[] = KNOWLEDGE_CONNECTOR_OPTIONS.map((o) => o.value) + type StatusFilter = 'all' | 'enabled' | 'disabled' const STATUS_FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ @@ -136,21 +149,21 @@ function StatusFilterChip({ value, onChange }: StatusFilterChipProps) { ) } -interface AuthModeFieldProps { +interface AllowlistFieldProps { label: string - value: ShareAuthType[] + value: string[] onChange: (values: string[]) => void - options: { value: ShareAuthType; label: string }[] + options: { value: string; label: string }[] disabled: boolean } /** - * The allowed-auth-modes multi-select nested under a platform toggle. Dims and + * The allowed-values multi-select nested under a platform toggle. Dims and * disables together with the toggle that owns it. The left padding lines both * children up with the parent's label text — row gutter (8) + checkbox (16) + * gap (8) = 32 — so the field reads as subordinate rather than as a sibling row. */ -function AuthModeField({ label, value, onChange, options, disabled }: AuthModeFieldProps) { +function AllowlistField({ label, value, onChange, options, disabled }: AllowlistFieldProps) { const labelId = useId() const triggerId = useId() return ( @@ -1191,13 +1204,30 @@ export function GroupDetail({ })) }, []) + const knowledgeConnectorValue = useMemo( + () => editingConfig.allowedKnowledgeConnectors ?? ALL_KNOWLEDGE_CONNECTORS, + [editingConfig.allowedKnowledgeConnectors] + ) + + const setKnowledgeConnectors = useCallback((values: string[]) => { + // At least one connector must stay allowed while the Knowledge Base module + // is visible — an empty allow-list would silently block every connector + // while the add-connector button still offered them. To withhold connectors + // along with the rest of the module, uncheck Knowledge Base instead. + if (values.length === 0) return + setEditingConfig((prev) => ({ + ...prev, + allowedKnowledgeConnectors: values.length === ALL_KNOWLEDGE_CONNECTORS.length ? null : values, + })) + }, []) + /** * Nested controls rendered under a platform feature's checkbox, keyed by * feature id. Kept out of `PLATFORM_FEATURES` so that array stays pure data. */ const featureExtras: Partial> = { 'hide-deploy-chatbot': ( - ), 'disable-public-file-sharing': ( - ), + /** + * Nested under Knowledge Base rather than Knowledge Base Creation: a + * connector attaches to an existing knowledge base, so the allow-list still + * governs a group that may sync but never create. Hanging it off creation + * would dim the picker for exactly the group it was written for. + */ + 'hide-knowledge-base': ( + + ), } /** Persists the editing buffer — name/description are only sent when they changed. */ diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index 3c2abeac045..c931c5c9513 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ refreshToken: vi.fn(), validateConnectorConfig: vi.fn(), recordAudit: vi.fn(), + getUserPermissionConfig: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -67,6 +68,10 @@ vi.mock('@/lib/oauth/credential-service', () => ({ refreshAccessTokenIfNeeded: mocks.refreshToken, })) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + vi.mock('@/connectors/registry.server', () => ({ CONNECTOR_REGISTRY: { confluence: { @@ -84,6 +89,7 @@ import { updateKnowledgeConnector, updateKnowledgeConnectorDocuments, } from '@/lib/knowledge/application/connectors' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' const crossWorkspaceContext = { workspaceId: 'workspace-b', @@ -141,6 +147,7 @@ describe('knowledge connector application use cases', () => { mocks.refreshToken.mockResolvedValue('access-token') mocks.validateConnectorConfig.mockResolvedValue({ valid: true }) mocks.resolveBilling.mockResolvedValue(BILLING) + mocks.getUserPermissionConfig.mockResolvedValue(null) }) afterAll(resetDbChainMock) @@ -661,4 +668,82 @@ describe('knowledge connector application use cases', () => { ) } ) + + describe('connector allow-list', () => { + const sameWorkspaceContext = { + ...crossWorkspaceContext, + workspaceId: 'workspace-a', + knowledgeBaseId: 'knowledge-a', + knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' }, + } + + const createInput = { + knowledgeBaseId: 'knowledge-a', + assertedWorkspaceId: 'workspace-a', + connectorType: 'confluence', + credentialId: 'credential-1', + sourceConfig: {}, + syncIntervalMinutes: 1440, + resolveBillingAttribution: mocks.resolveBilling, + } + + beforeEach(() => { + mocks.resolveKnowledgeBase.mockResolvedValue(sameWorkspaceContext) + }) + + function allowOnly(connectorTypes: string[] | null) { + mocks.getUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedKnowledgeConnectors: connectorTypes, + }) + } + + it('refuses a connector the group withholds, before the connector is created', async () => { + allowOnly(['google_drive']) + + await expect( + createKnowledgeConnector.execute({ principal: delegatedPrincipal, input: createInput }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: expect.stringContaining('confluence'), + }) + + expect(mocks.createConnector).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it.each([ + ['the group names it', ['confluence', 'google_drive']], + ['the group restricts nothing', null], + ])('permits a connector when %s', async (_case, allowed) => { + allowOnly(allowed as string[] | null) + mocks.createConnector.mockResolvedValueOnce({ + success: true, + connector: { id: 'connector-a', connectorType: 'confluence', syncIntervalMinutes: 1440 }, + }) + + const result = await createKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: createInput, + }) + + expect(result.connector.id).toBe('connector-a') + expect(mocks.createConnector).toHaveBeenCalledTimes(1) + }) + + it('leaves an ungoverned caller unaffected', async () => { + mocks.getUserPermissionConfig.mockResolvedValue(null) + mocks.createConnector.mockResolvedValueOnce({ + success: true, + connector: { id: 'connector-a', connectorType: 'confluence', syncIntervalMinutes: 1440 }, + }) + + await createKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: createInput, + }) + + expect(mocks.createConnector).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index d659aece16b..812b1b54121 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -1,9 +1,11 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { db } from '@sim/db' import { document, knowledgeConnector, knowledgeConnectorSyncLog } from '@sim/db/schema' import { and, asc, count, desc, eq, inArray, isNull } from 'drizzle-orm' import { decryptApiKey } from '@/lib/api-key/crypto' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { ForbiddenOperationError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { @@ -41,6 +43,8 @@ import type { KnowledgeOrchestrationResult, } from '@/lib/knowledge/orchestration/shared' import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' interface KnowledgeConnectorApplicationInput { assertedWorkspaceId?: string @@ -102,6 +106,34 @@ export interface UpdateKnowledgeConnectorDocumentsInput extends ReadKnowledgeCon documentIds: string[] } +const CONNECTOR_ALLOWLIST_RULE = CAPABILITY_RULES['knowledge.connectors'] + +/** + * Refuses a connector the caller's permission group has not sanctioned. + * + * A connector pulls a whole external corpus into the workspace, so which source + * a member may attach is a per-request decision — the authorization funnel + * applies an operation's capability knowing only the principal, the workspace + * and the operation, and never sees `connectorType`. Hence the assertion here, + * ahead of the write, rather than a `capability` on `knowledge.connectors.create`. + * + * No-op when no permission group governs the caller, which is what keeps + * non-enterprise and ungoverned organizations unaffected. + */ +async function assertConnectorTypeAllowed( + userId: string, + workspaceId: string, + connectorType: string +): Promise { + const config = await getUserPermissionConfig(userId, workspaceId) + if (!config || !CONNECTOR_ALLOWLIST_RULE.deniedBy(config, connectorType)) return + + throw new ForbiddenOperationError( + CONNECTOR_ALLOWLIST_RULE.detailCode, + `The ${connectorType} connector is not available under your organization's permission group` + ) +} + function requireSuccessfulOutcome( outcome: KnowledgeOrchestrationResult, fallback: string @@ -293,6 +325,12 @@ export const createKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ const requestId = generateRequestId() const workspaceId = requireConnectorWorkspaceId(context) const actingUserId = resolveKnowledgeAttributedUserId(principal, context) + // permission-group-enforced: knowledge.connectors — needs the request's connector id, which the funnel never sees + await assertConnectorTypeAllowed( + requirePrincipalSubjectUserId(principal), + workspaceId, + input.connectorType + ) const outcome = await performCreateKnowledgeConnector({ knowledgeBase: connectorTarget(context), connectorType: input.connectorType, @@ -337,6 +375,13 @@ export const createKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ }), }) +/** + * Deliberately not gated by `knowledge.connectors`: an update may change the + * source config, sync interval or status, never the connector type. The + * sanctioned-source decision was made when the connector was created, and + * re-asserting it here would strand an existing connector — including the + * ability to pause it — the moment an admin narrowed the allowlist. + */ export const updateKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.updateConnector, resolveContext: ({ input }: { input: UpdateKnowledgeConnectorInput }) => diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 803a4223c3b..9180ee81346 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -156,4 +156,43 @@ describe('knowledge operation registry', () => { expect(knowledgeOperations.search.delegatedServices).toEqual(['copilot', 'executor']) expect(knowledgeOperations.uploadComplete.delegatedServices).toBeUndefined() }) + + it('withholds knowledge base creation separately from using existing ones', () => { + expect(knowledgeOperations.create.capability).toBe('knowledge.create') + for (const operation of [ + knowledgeOperations.list, + knowledgeOperations.read, + knowledgeOperations.search, + knowledgeOperations.update, + knowledgeOperations.delete, + knowledgeOperations.createFolder, + knowledgeOperations.createTag, + knowledgeOperations.createConnector, + ]) { + expect(operation.capability).toBe('knowledge.use') + } + }) + + it('withholds every path that carries caller-supplied document bytes', () => { + for (const operation of [ + knowledgeOperations.uploadDocument, + knowledgeOperations.uploadCreate, + knowledgeOperations.uploadParts, + knowledgeOperations.uploadComplete, + knowledgeOperations.uploadCancel, + ]) { + expect(operation.capability).toBe('knowledge.upload') + } + }) + + it('leaves the connector sync path on the shared knowledge capability', () => { + /** A connector's documents are the sanctioned source, so an upload ban must not reach them. */ + for (const operation of [ + knowledgeOperations.syncConnector, + knowledgeOperations.updateConnectorDocuments, + knowledgeOperations.addWorkspaceFiles, + ]) { + expect(operation.capability).toBe('knowledge.use') + } + }) }) diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index 5abcde41d4c..a384afcb2f6 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -52,11 +52,16 @@ export const knowledgeOperations = { capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), + /** + * The only operation that brings a knowledge base into existence, so it is the + * only one `knowledge.create` governs — a group may be allowed to query, + * populate and organize the bases it already has without opening new ones. + */ create: defineWorkspaceOperation({ id: 'knowledge.create', minimumRole: 'write', workspaceApiKey: 'allow', - capability: 'knowledge.use', + capability: 'knowledge.create', ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ @@ -185,11 +190,17 @@ export const knowledgeOperations = { capability: 'knowledge.use', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), + /** + * The single-request upload path: the caller hands over file bytes, so the + * document's provenance is whatever the caller chose. `knowledge.upload` is + * what an organization withholds to admit documents only from the connectors + * it sanctioned. + */ uploadDocument: defineWorkspaceOperation({ id: 'knowledge.documents.upload', minimumRole: 'write', workspaceApiKey: 'allow', - capability: 'knowledge.use', + capability: 'knowledge.upload', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), addWorkspaceFiles: defineWorkspaceOperation({ @@ -402,32 +413,38 @@ export const knowledgeOperations = { capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), + /** + * The four session operations are one upload, split across requests only + * because a large file cannot arrive in one. They carry the same capability + * for that reason — including cancel, which would otherwise be the one open + * door into a surface the group was denied. + */ uploadCreate: defineWorkspaceOperation({ id: 'knowledge.documents.upload.create', minimumRole: 'write', workspaceApiKey: 'allow', - capability: 'knowledge.use', + capability: 'knowledge.upload', principalKinds: HTTP_PRINCIPAL_KINDS, }), uploadParts: defineWorkspaceOperation({ id: 'knowledge.documents.upload.parts', minimumRole: 'write', workspaceApiKey: 'allow', - capability: 'knowledge.use', + capability: 'knowledge.upload', principalKinds: HTTP_PRINCIPAL_KINDS, }), uploadComplete: defineWorkspaceOperation({ id: 'knowledge.documents.upload.complete', minimumRole: 'write', workspaceApiKey: 'allow', - capability: 'knowledge.use', + capability: 'knowledge.upload', principalKinds: HTTP_PRINCIPAL_KINDS, }), uploadCancel: defineWorkspaceOperation({ id: 'knowledge.documents.upload.cancel', minimumRole: 'write', workspaceApiKey: 'allow', - capability: 'knowledge.use', + capability: 'knowledge.upload', principalKinds: HTTP_PRINCIPAL_KINDS, }), } as const diff --git a/apps/sim/lib/permission-groups/capabilities.test.ts b/apps/sim/lib/permission-groups/capabilities.test.ts new file mode 100644 index 00000000000..30e19dc4cf0 --- /dev/null +++ b/apps/sim/lib/permission-groups/capabilities.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + type PermissionGroupConfig, +} from '@/lib/permission-groups/fields' + +function configWith(overrides: Partial): PermissionGroupConfig { + return { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...overrides } +} + +describe('knowledge capability rules', () => { + const create = CAPABILITY_RULES['knowledge.create'] + const upload = CAPABILITY_RULES['knowledge.upload'] + const connectors = CAPABILITY_RULES['knowledge.connectors'] + + it('permits creation and upload under the unrestricted config', () => { + expect(create.deniedBy(DEFAULT_PERMISSION_GROUP_CONFIG)).toBe(false) + expect(upload.deniedBy(DEFAULT_PERMISSION_GROUP_CONFIG)).toBe(false) + }) + + it('withholds creation and upload from their own keys', () => { + expect(create.deniedBy(configWith({ disableKnowledgeBaseCreation: true }))).toBe(true) + expect(upload.deniedBy(configWith({ disableKnowledgeBaseFileUpload: true }))).toBe(true) + }) + + it('subsumes the module-wide key, since an operation declares only one capability', () => { + const hidden = configWith({ hideKnowledgeBaseTab: true }) + expect(create.deniedBy(hidden)).toBe(true) + expect(upload.deniedBy(hidden)).toBe(true) + }) + + it('reads the connector allow-list as a named set, with null meaning unrestricted', () => { + expect(connectors.deniedBy(DEFAULT_PERMISSION_GROUP_CONFIG, 'confluence')).toBe(false) + + const narrowed = configWith({ allowedKnowledgeConnectors: ['google_drive'] }) + expect(connectors.deniedBy(narrowed, 'google_drive')).toBe(false) + expect(connectors.deniedBy(narrowed, 'confluence')).toBe(true) + }) + + it('withholds every connector when the allow-list is emptied rather than cleared', () => { + const emptied = configWith({ allowedKnowledgeConnectors: [] }) + expect(connectors.deniedBy(emptied, 'google_drive')).toBe(true) + }) +}) diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts index b6726b413d5..9b25aac5d3d 100644 --- a/apps/sim/lib/permission-groups/capabilities.ts +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -271,19 +271,26 @@ export const CAPABILITY_RULES = { describe: 'Execution cost', deniedBy: (config) => config.hideCostInfo, }, + /** + * Also reads `hideKnowledgeBaseTab`, because an operation declares exactly one + * capability: moving knowledge-base creation off `knowledge.use` would + * otherwise let a group that withheld the whole module still create one + * through the API. The narrower capability has to subsume the broader. + */ 'knowledge.create': { kind: 'static', - configKeys: ['disableKnowledgeBaseCreation'], + configKeys: ['disableKnowledgeBaseCreation', 'hideKnowledgeBaseTab'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', describe: 'Creating knowledge bases', - deniedBy: (config) => config.disableKnowledgeBaseCreation, + deniedBy: (config) => config.disableKnowledgeBaseCreation || config.hideKnowledgeBaseTab, }, + /** Subsumes `knowledge.use` for the same reason as {@link CAPABILITY_RULES}'s `knowledge.create`. */ 'knowledge.upload': { kind: 'static', - configKeys: ['disableKnowledgeBaseFileUpload'], + configKeys: ['disableKnowledgeBaseFileUpload', 'hideKnowledgeBaseTab'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', describe: 'Uploading documents to a knowledge base', - deniedBy: (config) => config.disableKnowledgeBaseFileUpload, + deniedBy: (config) => config.disableKnowledgeBaseFileUpload || config.hideKnowledgeBaseTab, }, /** * Parameterized on the connector id, because the decision is which source a From 02b4bca147e7e5334e9f95ec0b2366fb8fee67b1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 21:36:48 -0700 Subject: [PATCH 015/179] feat(permission-groups): enforce tables.create, tables.export and files.bulk_download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three capabilities shipped with an admin checkbox and no server gate: an organization that set disableTableCreation, disableTableExport or disableBulkFileDownload believed it had withheld something while every path still answered. tables.create The table operation factories that mint more than one kind of operation now take the capability as an argument with no default — a default would let a new operation inherit tables.use without anyone deciding it should, which is the unreviewed omission this gate exists to prevent. tables.create and the copilot create-from-workspace-file import declare it. An import targeting `new` also creates a table, but one targeting `existing` only fills one and the operation cannot tell them apart: the target is request input the funnel never sees. Asserted inside the use case instead. tables.export Declared on createExport and downloadExport. Generating the file is the extraction and handing over its bytes completes it; readExport carries no rows and cancelExport stops an extraction rather than performing one, so gating either would strand a member with an export they can neither watch nor stop after the group changed. Both raw export routes — the synchronous CSV/JSON stream and the async job — bypass the use case entirely and query directly, so they gate inline after their existing access check and before any data moves. files.bulk_download files.download serves both a single file and a zipped folder tree, so declaring the capability on the operation would also take away saving one file, which is not what the key means. The use case asserts it only when the request is actually bulk, reusing the same single-file predicate the resource authorization already resolved so the two cannot drift. Every gate decides through CAPABILITY_RULES rather than a config key spelled out at the call site, so a renamed key cannot silently stop denying anything. --- .../[tableId]/export-async/route.test.ts | 30 +++++++- .../api/table/[tableId]/export-async/route.ts | 11 +++ .../api/table/[tableId]/export/route.test.ts | 23 +++++- .../app/api/table/[tableId]/export/route.ts | 13 ++++ .../application/workspace-authorization.ts | 3 +- .../sim/lib/permission-groups/capabilities.ts | 10 +++ .../capability-assertions.ts | 49 ++++++++++++ .../sim/lib/table/application/exports.test.ts | 77 +++++++++++++++++-- .../sim/lib/table/application/imports.test.ts | 54 +++++++++++++ apps/sim/lib/table/application/imports.ts | 20 ++++- apps/sim/lib/table/application/operations.ts | 74 ++++++++++++------ .../download-workspace-file-items.test.ts | 51 ++++++++++++ .../download-workspace-file-items.ts | 18 +++++ 13 files changed, 400 insertions(+), 33 deletions(-) create mode 100644 apps/sim/lib/permission-groups/capability-assertions.ts diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts index 4922d49041c..110486a7cb5 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts @@ -5,10 +5,20 @@ import { createTableDefinition, hybridAuthMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckAccess, mockMarkTableJobRunning, mockRunTableExport } = vi.hoisted(() => ({ +const { + mockCheckAccess, + mockMarkTableJobRunning, + mockRunTableExport, + mockGetUserPermissionConfig, +} = vi.hoisted(() => ({ mockCheckAccess: vi.fn(), mockMarkTableJobRunning: vi.fn(), mockRunTableExport: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, })) vi.mock('@sim/utils/id', () => ({ @@ -31,6 +41,7 @@ vi.mock('@/app/api/table/utils', async () => { } }) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { POST } from '@/app/api/table/[tableId]/export-async/route' function makeRequest(body: unknown, tableId = 'tbl_1') { @@ -59,6 +70,7 @@ describe('POST /api/table/[tableId]/export-async', () => { rowCount: 50000, }), }) + mockGetUserPermissionConfig.mockResolvedValue(null) mockMarkTableJobRunning.mockResolvedValue(true) mockRunTableExport.mockResolvedValue(undefined) }) @@ -112,4 +124,20 @@ describe('POST /api/table/[tableId]/export-async', () => { expect(response.status).toBe(400) expect(mockMarkTableJobRunning).not.toHaveBeenCalled() }) + + it('refuses before claiming a job when the group withholds tables.export', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableExport: true, + }) + + const response = await makeRequest(validBody) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "Exporting a table is not available under your organization's permission group", + }) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + expect(mockRunTableExport).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts index 7ad5cdb251d..d86b7f342e3 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.ts @@ -9,11 +9,16 @@ import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityDeniedBy, + capabilityRefusal, +} from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' import type { TableExportJobPayload } from '@/lib/table/types' import { accessError, checkAccess } from '@/app/api/table/utils' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('TableExportAsync') @@ -51,6 +56,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } + // permission-group-enforced: tables.export — raw route that queries directly and predates the operation boundary + const permissionConfig = await getUserPermissionConfig(authResult.userId, workspaceId) + if (capabilityDeniedBy('tables.export', permissionConfig)) { + return NextResponse.json({ error: capabilityRefusal('tables.export') }, { status: 403 }) + } + const jobId = generateId() const jobPayload: TableExportJobPayload = { format } const claimed = await markTableJobRunning(tableId, jobId, 'export', jobPayload) diff --git a/apps/sim/app/api/table/[tableId]/export/route.test.ts b/apps/sim/app/api/table/[tableId]/export/route.test.ts index 4e1dd314b3c..11aec7c6c32 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.test.ts @@ -5,9 +5,14 @@ import { createTableDefinition, hybridAuthMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckAccess, mockQueryRows } = vi.hoisted(() => ({ +const { mockCheckAccess, mockQueryRows, mockGetUserPermissionConfig } = vi.hoisted(() => ({ mockCheckAccess: vi.fn(), mockQueryRows: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, })) vi.mock('@/app/api/table/utils', async () => { @@ -23,6 +28,7 @@ vi.mock('@/lib/table/rows/service', () => ({ queryRows: mockQueryRows, })) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { GET } from '@/app/api/table/[tableId]/export/route' /** Table with an id-native column whose stable id (`col_email`) differs from its display name. */ @@ -56,6 +62,7 @@ describe('table export route — id→name translation', () => { }), }) // Row data is keyed by stable column id (`col_email`), not the display name. + mockGetUserPermissionConfig.mockResolvedValue(null) mockQueryRows.mockResolvedValue({ rows: [{ id: 'r1', data: { col_email: 'a@b.c', legacy: 'x' }, executions: {}, position: 0 }], rowCount: 1, @@ -82,4 +89,18 @@ describe('table export route — id→name translation', () => { expect(parsed).toEqual([{ email: 'a@b.c', legacy: 'x' }]) expect(JSON.stringify(parsed)).not.toContain('col_email') }) + + it('refuses the stream when the group withholds tables.export', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableExport: true, + }) + + const res = await callGet('csv') + + expect(res.status).toBe(403) + expect(await res.json()).toEqual({ + error: "Exporting a table is not available under your organization's permission group", + }) + }) }) diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index 6cc9a9d50c5..1c1a160ab43 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -5,10 +5,15 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityDeniedBy, + capabilityRefusal, +} from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { sanitizeExportFilename } from '@/lib/table/export-format' import { createTableExportStream, exportContentType } from '@/lib/table/export-stream' import { accessError, checkAccess } from '@/app/api/table/utils' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' interface RouteParams { params: Promise<{ tableId: string }> @@ -41,6 +46,14 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou if (!access.ok) return accessError(access, requestId, tableId) const { table } = access + // permission-group-enforced: tables.export — raw route that queries directly and predates the operation boundary + if (table.workspaceId) { + const config = await getUserPermissionConfig(userId, table.workspaceId) + if (capabilityDeniedBy('tables.export', config)) { + return NextResponse.json({ error: capabilityRefusal('tables.export') }, { status: 403 }) + } + } + // Audit before streaming: rows leave incrementally, so a mid-stream failure still exfiltrates partial data. recordAudit({ workspaceId: table.workspaceId ?? null, diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index d3e086555a8..afdc69cb0f5 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -17,6 +17,7 @@ import type { import { OrchestrationError } from '@/lib/core/orchestration/types' import { CAPABILITY_RULES, + capabilityRefusalMessage, type PermissionGroupCapability, } from '@/lib/permission-groups/capabilities' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' @@ -72,7 +73,7 @@ export class PermissionGroupCapabilityError extends ForbiddenOperationError { detailCode: ForbiddenDetailCode, describe: string ) { - super(detailCode, `${describe} is not available under your organization's permission group`) + super(detailCode, capabilityRefusalMessage(describe)) this.name = 'PermissionGroupCapabilityError' } } diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts index b6726b413d5..545a306b3af 100644 --- a/apps/sim/lib/permission-groups/capabilities.ts +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -92,6 +92,16 @@ export interface ParameterizedCapabilityRule extends CapabilityRuleBase { export type CapabilityRule = StaticCapabilityRule | ParameterizedCapabilityRule +/** + * The one sentence every capability refusal uses, wherever it is raised. + * + * Shared so a raw route that gates inline cannot drift from what the + * authorization funnel tells a caller refused for the same reason. + */ +export function capabilityRefusalMessage(describe: string): string { + return `${describe} is not available under your organization's permission group` +} + function authModeDeniedBy(allowed: ShareAuthMode[] | null, mode: string): boolean { return allowed !== null && !allowed.some((allowedMode) => allowedMode === mode) } diff --git a/apps/sim/lib/permission-groups/capability-assertions.ts b/apps/sim/lib/permission-groups/capability-assertions.ts new file mode 100644 index 00000000000..caccf6d4fde --- /dev/null +++ b/apps/sim/lib/permission-groups/capability-assertions.ts @@ -0,0 +1,49 @@ +import { PermissionGroupCapabilityError } from '@/lib/core/application/workspace-authorization' +import { + CAPABILITY_RULES, + capabilityRefusalMessage, + type StaticPermissionGroupCapability, +} from '@/lib/permission-groups/capabilities' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' + +/** + * The capability gate for callers the authorization funnel cannot serve. + * + * The funnel decides from the operation alone, which is right for a capability + * that describes the whole operation. Two cases fall outside it: a request whose + * capability depends on its own input — one download is a single file, the next + * is a folder tree — and a raw route that predates the operation boundary. Both + * assert here so the decision still comes from {@link CAPABILITY_RULES} rather + * than from a config key spelled out at the call site, where a renamed key would + * silently stop denying anything. + */ +export function capabilityDeniedBy( + capability: StaticPermissionGroupCapability, + config: PermissionGroupConfig | null +): boolean { + if (!config) return false + const rule = CAPABILITY_RULES[capability] + return rule.kind === 'static' && rule.deniedBy(config) +} + +/** The refusal a caller sees, identical to the funnel's for the same capability. */ +export function capabilityRefusal(capability: StaticPermissionGroupCapability): string { + return capabilityRefusalMessage(CAPABILITY_RULES[capability].describe) +} + +/** + * Throws {@link PermissionGroupCapabilityError} when `userId`'s group in + * `workspaceId` withholds `capability`. A no-op when no group governs the user, + * so workspaces outside an enterprise organization are unaffected. + */ +export async function assertWorkspaceCapability( + userId: string, + workspaceId: string, + capability: StaticPermissionGroupCapability +): Promise { + const config = await getUserPermissionConfig(userId, workspaceId) + if (!capabilityDeniedBy(capability, config)) return + const rule = CAPABILITY_RULES[capability] + throw new PermissionGroupCapabilityError(capability, rule.detailCode, rule.describe) +} diff --git a/apps/sim/lib/table/application/exports.test.ts b/apps/sim/lib/table/application/exports.test.ts index 736ecd7c0c2..247bccca4d5 100644 --- a/apps/sim/lib/table/application/exports.test.ts +++ b/apps/sim/lib/table/application/exports.test.ts @@ -8,6 +8,8 @@ import type { TableDefinition } from '@/lib/table/types' const mocks = vi.hoisted(() => ({ cancel: vi.fn(), + resolveGroupConfig: vi.fn(), + resolveWorkspaceContext: vi.fn(), create: vi.fn(), getTable: vi.fn(), require: vi.fn(), @@ -23,15 +25,13 @@ vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: () => true, resolveEffectiveWorkspacePermission: vi.fn(), })) +vi.mock('@/lib/permission-groups/config-scope.server', () => ({ + resolvePermissionGroupConfig: mocks.resolveGroupConfig, +})) vi.mock('@/lib/table', () => ({ getTableById: mocks.getTable })) vi.mock('@/lib/table/application/context', () => ({ resolveActiveTableContext: mocks.resolveContext, - resolveTableWorkspaceContext: vi.fn(async (workspaceId: string) => ({ - workspaceId, - workspaceOrganizationId: null, - allowPersonalApiKeys: true, - billedAccountUserId: 'billing-owner-1', - })), + resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, })) vi.mock('@/lib/table/orchestration/export-resource', () => ({ cancelTableExportResource: mocks.cancel, @@ -43,6 +43,7 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ generatePresignedDownloadUrl: vi.fn(), })) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { cancelTableExportUseCase, createTableExportUseCase, @@ -110,6 +111,13 @@ describe('table export application use cases', () => { mocks.create.mockResolvedValue(record) mocks.require.mockResolvedValue(record) mocks.cancel.mockResolvedValue({ ...record, status: 'canceled' }) + mocks.resolveGroupConfig.mockResolvedValue(null) + mocks.resolveWorkspaceContext.mockImplementation(async (workspaceId: string) => ({ + workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + })) }) it('returns domain records for create and read operations', async () => { @@ -164,4 +172,61 @@ describe('table export application use cases', () => { expect(mocks.create).not.toHaveBeenCalled() }) + + describe('permission-group capability', () => { + const member = { kind: 'session' as const, userId: 'user-1' } + const governedContext = { + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + } + + beforeEach(() => { + mocks.resolveContext.mockResolvedValue(governedContext) + mocks.resolveWorkspaceContext.mockImplementation(async (workspaceId: string) => ({ + workspaceId, + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + })) + mocks.resolveGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableExport: true, + }) + }) + + it('refuses to generate an export when the group withholds tables.export', async () => { + await expect( + createTableExportUseCase.execute({ + principal: member, + input: { tableId: 'table-1', workspaceId: 'workspace-1', format: 'csv' }, + }) + ).rejects.toMatchObject({ capability: 'tables.export' }) + + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('still allows cancelling an export, which stops extraction rather than performing it', async () => { + await expect( + cancelTableExportUseCase.execute({ + principal: member, + input: { exportId: 'export-1', workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ export: { status: 'canceled' } }) + }) + + it('generates an export when the group withholds nothing', async () => { + mocks.resolveGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + await expect( + createTableExportUseCase.execute({ + principal: member, + input: { tableId: 'table-1', workspaceId: 'workspace-1', format: 'csv' }, + }) + ).resolves.toEqual({ export: record }) + }) + }) }) diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts index c5226c24e5d..3512087ba4b 100644 --- a/apps/sim/lib/table/application/imports.test.ts +++ b/apps/sim/lib/table/application/imports.test.ts @@ -22,6 +22,11 @@ const mocks = vi.hoisted(() => ({ startUploadedImport: vi.fn(), tableImportBodyFromUpload: vi.fn(), resourceFromUpload: vi.fn(), + getUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -71,6 +76,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile: mocks.getWorkspaceFile, })) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { cancelTableImportUseCase, completeTableImportUseCase, @@ -167,6 +173,7 @@ describe('table import application use cases', () => { mocks.createResource.mockResolvedValue({ record, upload: null }) mocks.getWorkspaceFile.mockResolvedValue(workspaceFile) mocks.resourceFromUpload.mockReturnValue(record) + mocks.getUserPermissionConfig.mockResolvedValue(null) }) it('creates an import through the domain resource boundary without presenting a v2 DTO', async () => { @@ -456,4 +463,51 @@ describe('table import application use cases', () => { expect(mocks.resolveTableContext).not.toHaveBeenCalled() expect(mocks.createResource).not.toHaveBeenCalled() }) + + describe('permission-group capability', () => { + beforeEach(() => { + mocks.getUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableCreation: true, + }) + mocks.resolveTableContext.mockResolvedValue({ + tableId: 'table-1', + ...workspaceContext, + }) + }) + + it('refuses an import that would create a table when the group withholds tables.create', async () => { + await expect( + createTableImportUseCase.execute({ + principal: reader, + input: { + body: { + workspaceId: 'workspace-1', + source: record.source, + target: { type: 'new', name: 'People' }, + }, + }, + request: new Request('http://localhost:3000/api/table/imports', { method: 'POST' }), + }) + ).rejects.toMatchObject({ capability: 'tables.create' }) + + expect(mocks.createResource).not.toHaveBeenCalled() + }) + + it('still allows importing into an existing table, which creates nothing', async () => { + await expect( + createTableImportUseCase.execute({ + principal: reader, + input: { + body: { + workspaceId: 'workspace-1', + source: record.source, + target: { type: 'existing', tableId: 'table-1' }, + }, + }, + request: new Request('http://localhost:3000/api/table/imports', { method: 'POST' }), + }) + ).resolves.toEqual({ import: { record, upload: null } }) + }) + }) }) diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts index 0b14531c299..bc9786a8434 100644 --- a/apps/sim/lib/table/application/imports.ts +++ b/apps/sim/lib/table/application/imports.ts @@ -1,4 +1,8 @@ -import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { + type Principal, + resolvePrincipalAttribution, + resolvePrincipalSubjectUserId, +} from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { authorizeWorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -6,6 +10,7 @@ import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { withFolderTreeLock } from '@/lib/folders/locks' import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' import { type TableAuthorizationContext, tableDelegationPolicy, @@ -168,6 +173,19 @@ export const createTableImportUseCase = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: CreateTableImportInput }) => resolveCreateTableImportContext(input), async execute({ principal, input, context, request }): Promise { + /** + * permission-group-enforced: tables.create — an import targeting `new` + * creates a table, but one targeting `existing` only fills one, and the + * operation cannot tell them apart: the target is request input the + * authorization funnel never sees. Asserted for the acting person only; + * an actorless deployment run has no group, like everywhere else. + */ + if (input.body.target.type === 'new') { + const actingUserId = resolvePrincipalSubjectUserId(principal) + if (actingUserId) { + await assertWorkspaceCapability(actingUserId, context.workspaceId, 'tables.create') + } + } const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }) diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index 5077e56c9ff..484231f79a0 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -1,4 +1,5 @@ import { defineWorkspaceOperation } from '@/lib/core/application' +import type { StaticPermissionGroupCapability } from '@/lib/permission-groups/capabilities' const ALL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], @@ -39,12 +40,24 @@ function writeOperation(id: Id) { }) } -function toolWriteOperation(id: Id) { +/** + * Not every table operation needs the same capability — creating a table and + * exporting one are each withheld separately from ordinary table use — so the + * factories that mint more than one kind take the capability as an argument. + * + * No default, deliberately: a default would let a new operation inherit + * `tables.use` without anyone deciding it should, which is exactly the + * unreviewed omission this gate exists to prevent. + */ +function toolWriteOperation( + id: Id, + capability: StaticPermissionGroupCapability +) { return defineWorkspaceOperation({ id, minimumRole: 'write', workspaceApiKey: 'allow', - capability: 'tables.use', + capability, ...ALL_TABLE_TOOL_PRINCIPAL_POLICY, }) } @@ -59,12 +72,15 @@ function toolReadOperation(id: Id) { }) } -function internalExecutorReadOperation(id: Id) { +function internalExecutorReadOperation( + id: Id, + capability: StaticPermissionGroupCapability +) { return defineWorkspaceOperation({ id, minimumRole: 'read', workspaceApiKey: 'allow', - capability: 'tables.use', + capability, ...INTERNAL_EXECUTOR_PRINCIPAL_POLICY, }) } @@ -79,12 +95,15 @@ function internalExecutorWriteOperation(id: Id) { }) } -function delegatedWriteOperation(id: Id) { +function delegatedWriteOperation( + id: Id, + capability: StaticPermissionGroupCapability +) { return defineWorkspaceOperation({ id, minimumRole: 'write', workspaceApiKey: 'deny', - capability: 'tables.use', + capability, principalKinds: ['delegated'], delegatedServices: ['copilot'], }) @@ -93,7 +112,7 @@ function delegatedWriteOperation(id: Id) { export const tableOperations = { list: toolReadOperation('tables.list'), read: toolReadOperation('tables.read'), - create: toolWriteOperation('tables.create'), + create: toolWriteOperation('tables.create', 'tables.create'), update: writeOperation('tables.update'), delete: writeOperation('tables.delete'), restore: writeOperation('tables.restore'), @@ -132,37 +151,46 @@ export const tableOperations = { queryRows: toolReadOperation('tables.rows.query'), searchRows: readOperation('tables.rows.search'), readRow: toolReadOperation('tables.rows.read'), - createRows: toolWriteOperation('tables.rows.create'), + createRows: toolWriteOperation('tables.rows.create', 'tables.use'), replaceRows: writeOperation('tables.rows.replace'), - updateRow: toolWriteOperation('tables.rows.update'), - updateRows: toolWriteOperation('tables.rows.update_many'), - deleteRow: toolWriteOperation('tables.rows.delete'), - deleteRows: toolWriteOperation('tables.rows.delete_many'), - upsertRow: toolWriteOperation('tables.rows.upsert'), + updateRow: toolWriteOperation('tables.rows.update', 'tables.use'), + updateRows: toolWriteOperation('tables.rows.update_many', 'tables.use'), + deleteRow: toolWriteOperation('tables.rows.delete', 'tables.use'), + deleteRows: toolWriteOperation('tables.rows.delete_many', 'tables.use'), + upsertRow: toolWriteOperation('tables.rows.upsert', 'tables.use'), listViews: readOperation('tables.views.list'), readView: readOperation('tables.views.read'), createView: writeOperation('tables.views.create'), updateView: writeOperation('tables.views.update'), deleteView: writeOperation('tables.views.delete'), listGroups: readOperation('tables.groups.list'), - createGroup: toolWriteOperation('tables.groups.create'), - updateGroup: toolWriteOperation('tables.groups.update'), - deleteGroup: toolWriteOperation('tables.groups.delete'), + createGroup: toolWriteOperation('tables.groups.create', 'tables.use'), + updateGroup: toolWriteOperation('tables.groups.update', 'tables.use'), + deleteGroup: toolWriteOperation('tables.groups.delete', 'tables.use'), startRun: writeOperation('tables.runs.start'), /** Reading the state of a run — including one you started — is a read. */ readRun: readOperation('tables.runs.read'), cancelRuns: writeOperation('tables.runs.cancel'), createImport: internalExecutorWriteOperation('tables.imports.create'), - createFromWorkspaceFile: delegatedWriteOperation('tables.imports.create_from_workspace_file'), - importWorkspaceFile: delegatedWriteOperation('tables.imports.workspace_file'), - readImport: internalExecutorReadOperation('tables.imports.read'), + createFromWorkspaceFile: delegatedWriteOperation( + 'tables.imports.create_from_workspace_file', + 'tables.create' + ), + importWorkspaceFile: delegatedWriteOperation('tables.imports.workspace_file', 'tables.use'), + readImport: internalExecutorReadOperation('tables.imports.read', 'tables.use'), createImportParts: internalExecutorWriteOperation('tables.imports.create_parts'), completeImport: internalExecutorWriteOperation('tables.imports.complete'), cancelImport: internalExecutorWriteOperation('tables.imports.cancel'), - createExport: internalExecutorReadOperation('tables.exports.create'), - readExport: internalExecutorReadOperation('tables.exports.read'), - cancelExport: internalExecutorReadOperation('tables.exports.cancel'), - downloadExport: internalExecutorReadOperation('tables.exports.download'), + /** + * Only generating the file and fetching it are extraction. Reading an + * export's status carries no rows, and cancelling one stops an extraction + * rather than performing it — gating either would strand a member with an + * export they can neither watch nor stop after the group changed. + */ + createExport: internalExecutorReadOperation('tables.exports.create', 'tables.export'), + readExport: internalExecutorReadOperation('tables.exports.read', 'tables.use'), + cancelExport: internalExecutorReadOperation('tables.exports.cancel', 'tables.use'), + downloadExport: internalExecutorReadOperation('tables.exports.download', 'tables.export'), } as const export type TableOperation = (typeof tableOperations)[keyof typeof tableOperations] diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts index d858615398a..aff4731b45b 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts @@ -14,6 +14,7 @@ const { mockIsGenerated, mockIsRenderable, mockIsDocNotReady, + mockGetUserPermissionConfig, } = vi.hoisted(() => ({ events: [] as string[], mockLoadContext: vi.fn(), @@ -25,6 +26,11 @@ const { mockIsGenerated: vi.fn(), mockIsRenderable: vi.fn(), mockIsDocNotReady: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ @@ -60,6 +66,7 @@ vi.mock('@sim/audit', () => ({ recordAudit: mockRecordAudit, })) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' const principal = { kind: 'session' as const, userId: 'u1', sessionId: 's1' } @@ -105,6 +112,7 @@ describe('downloadWorkspaceFileItems', () => { mockIsGenerated.mockReturnValue(false) mockIsRenderable.mockReturnValue(false) mockIsDocNotReady.mockReturnValue(false) + mockGetUserPermissionConfig.mockResolvedValue(null) }) it('authorizes the workspace once and returns the bounded selection', async () => { @@ -264,4 +272,47 @@ describe('downloadWorkspaceFileItems', () => { expect(result.filesToZip.map((item) => item.id)).toEqual(['f1']) }) + + describe('permission-group capability', () => { + beforeEach(() => { + mockGetUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableBulkFileDownload: true, + }) + mockListFolders.mockResolvedValue([{ id: 'folder-1', parentId: null, name: 'Reports' }]) + mockListFiles.mockImplementation(async () => { + events.push('execute') + return [file('f1', 'clip.mp4'), file('f2', 'notes.txt', 'folder-1')] + }) + }) + + it('refuses a folder archive when the group withholds files.bulk_download', async () => { + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: [], folderIds: ['folder-1'] }, + }) + ).rejects.toMatchObject({ capability: 'files.bulk_download' }) + + expect(events).not.toContain('execute') + }) + + it('refuses a multi-file archive, which is the same bulk extraction', async () => { + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['f1', 'f2'], folderIds: [] }, + }) + ).rejects.toMatchObject({ capability: 'files.bulk_download' }) + }) + + it('still allows downloading a single named file, which the key does not withhold', async () => { + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [] }, + }) + ).resolves.toMatchObject({ filesToZip: [expect.objectContaining({ id: 'f1' })] }) + }) + }) }) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts index 69bedcc7bc7..c6857716c55 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts @@ -1,8 +1,10 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { parseFolderPath } from '@/lib/folders/paths' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' import { buildWorkspaceFileFolderPathMap, listWorkspaceFileFolders, @@ -118,6 +120,22 @@ async function executeDownloadWorkspaceFileItems({ validationError('No files selected for download') } + /** + * permission-group-enforced: files.bulk_download — one operation serves both + * a single file and a whole folder tree, and only the archive is what the key + * withholds; declaring the capability on `files.download` would take away + * saving one file too. `context.fileId` is the same single-file predicate the + * resource authorization already resolved, reused so the two cannot drift. + * Asserted for the acting person only: an actorless deployment run has no + * permission group. + */ + if (context.fileId === undefined) { + const actingUserId = resolvePrincipalSubjectUserId(principal) + if (actingUserId) { + await assertWorkspaceCapability(actingUserId, context.workspaceId, 'files.bulk_download') + } + } + const [files, folders] = await Promise.all([ listWorkspaceFiles(context.workspaceId, { hydrateFolderPaths: false, throwOnError: true }), listWorkspaceFileFolders(context.workspaceId), From 21020cafc55e47a9e3aa4c763e6d809b170590a1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 21:38:19 -0700 Subject: [PATCH 016/179] feat(permission-groups): annotate workflow operations and close the persist-time block bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `defineWorkspaceOperation` in the workflow registry now declares what a permission group withholds, so an unfilled field can no longer be mistaken for an unreviewed one. Most workflow CRUD is honestly `'none'` — the workflow module has no hide key, and its reads and writes are governed by workspace role — and each of those carries a reason naming why. `workflows.versions.activate` gains `deploy.api`: activating a different deployed version changes what the deployed API serves, so a group that withholds API deployment must withhold it too. `workflows.public_api.update` stays exempt on purpose: `public_api.use` is asserted inside the use case and only for the enabling direction, because a group that withholds public execution must still let an admin withdraw execution a workflow already has. Closes a real bypass on the two paths that persist a whole graph. Import and the graph replace never went through the editing operations, so a member could save or import a workflow containing a block their group's `allowedIntegrations` denies; the allowlist was then a property of one authoring route rather than of what is stored, and the block was refused only by the executor mid-run — after the workflow had been saved, shared, and possibly deployed. Both paths now resolve the caller's permission config and refuse with 403 before anything is written, so there is nothing to roll back, and `importErrorCode` maps 403 to `forbidden` instead of letting a refusal surface as a 500. --- .../workflows/application/import-export.ts | 1 + .../lib/workflows/application/operations.ts | 94 ++++++++++++++ .../operations/import-workflow.test.ts | 117 ++++++++++++++++++ .../workflows/operations/import-workflow.ts | 23 ++++ .../persistence/block-access-guard.test.ts | 64 ++++++++++ .../persistence/block-access-guard.ts | 45 +++++++ .../persistence/save-normalized-state.ts | 26 ++++ .../save-workflow-normalized-state.test.ts | 58 +++++++++ 8 files changed, 428 insertions(+) create mode 100644 apps/sim/lib/workflows/operations/import-workflow.test.ts create mode 100644 apps/sim/lib/workflows/persistence/block-access-guard.test.ts create mode 100644 apps/sim/lib/workflows/persistence/block-access-guard.ts diff --git a/apps/sim/lib/workflows/application/import-export.ts b/apps/sim/lib/workflows/application/import-export.ts index ecad2d84428..662b98542ee 100644 --- a/apps/sim/lib/workflows/application/import-export.ts +++ b/apps/sim/lib/workflows/application/import-export.ts @@ -48,6 +48,7 @@ export interface ExportWorkflowResult { function importErrorCode(status: number): OrchestrationErrorCode { if (status === 400) return 'validation' if (status === 404) return 'not_found' + if (status === 403) return 'forbidden' if (status === 409) return 'conflict' if (status === 423) return 'locked' return 'internal' diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 84dff231f8d..f11f4b0e4f2 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -26,52 +26,68 @@ const COPILOT_WORKFLOW_PRINCIPAL_POLICY = { } as const export const workflowOperations = { + // permission-group-exempt: listing the workflows in a workspace is governed by workspace role; no group hides the workflow module list: defineWorkspaceOperation({ id: 'workflows.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: reading a workflow is governed by workspace role, not by a group capability read: defineWorkspaceOperation({ id: 'workflows.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...WORKFLOW_READ_PRINCIPAL_POLICY, }), + // permission-group-exempt: reporting where a workflow is already deployed is a read of existing state; a group withholds the act of deploying, not the record of it readDeploymentOverview: defineWorkspaceOperation({ id: 'workflows.deployment_overview.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: reading a workflow's run inputs is workflow content; Chat itself is withheld by copilot.use at the chat surface readCopilotRunOptions: defineWorkspaceOperation({ id: 'workflows.copilot.run_options.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: reading a block's declared outputs is workflow content; Chat itself is withheld by copilot.use at the chat surface readCopilotBlockOutputs: defineWorkspaceOperation({ id: 'workflows.copilot.block_outputs.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: resolving which upstream blocks a block may reference is workflow content; Chat itself is withheld by copilot.use at the chat surface readCopilotUpstreamReferences: defineWorkspaceOperation({ id: 'workflows.copilot.upstream_references.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow module has no hide key, so creating a workflow is governed by workspace role alone create: defineWorkspaceOperation({ id: 'workflows.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: renaming or re-describing a workflow is governed by workspace role update: defineWorkspaceOperation({ id: 'workflows.update', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), /** @@ -87,11 +103,14 @@ export const workflowOperations = { * * Personal keys keep the capability, so headless authoring is unaffected for a * credential that names a human. + * + * permission-group-exempt: which blocks a member may store is judged against allowedIntegrations at persist time, not by a capability the authorization funnel can apply */ replaceState: defineWorkspaceOperation({ id: 'workflows.state.replace', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), /** @@ -110,126 +129,167 @@ export const workflowOperations = { * Personal keys keep the capability, so headless editing is unaffected for a * credential that names a human. Re-open this to workspace keys only once the * three lookups can express a workspace-scoped policy that fails closed. + * + * permission-group-exempt: which blocks a member may store is judged against allowedIntegrations at persist time, not by a capability the authorization funnel can apply */ applyOperations: defineWorkspaceOperation({ id: 'workflows.operations.apply', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: restoring a soft-deleted workflow is governed by workspace role restore: defineWorkspaceOperation({ id: 'workflows.restore', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: a workflow's run policy is workspace-admin configuration; no group capability withholds it updatePolicy: defineWorkspaceOperation({ id: 'workflows.policy.update', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workflow variables are workflow content, governed by workspace role applyVariableOperations: defineWorkspaceOperation({ id: 'workflows.variables.apply_operations', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: toggling a block edits workflow content; which integrations a member may use is allowedIntegrations, enforced against the block type rather than the operation setBlockEnabled: defineWorkspaceOperation({ id: 'workflows.blocks.set_enabled', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: moving workflows between folders is placement, governed by workspace role moveBulk: defineWorkspaceOperation({ id: 'workflows.bulk.move', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow file tree has no hide key; arranging it is governed by workspace role createVfsFolders: defineWorkspaceOperation({ id: 'workflows.vfs.folders.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow file tree has no hide key; arranging it is governed by workspace role moveVfsItems: defineWorkspaceOperation({ id: 'workflows.vfs.move', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow file tree has no hide key; arranging it is governed by workspace role copyVfsItems: defineWorkspaceOperation({ id: 'workflows.vfs.copy', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow file tree has no hide key; arranging it is governed by workspace role deleteVfsItems: defineWorkspaceOperation({ id: 'workflows.vfs.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: duplicating copies a graph the caller may already read into the same workspace, so it crosses no capability boundary duplicate: defineWorkspaceOperation({ id: 'workflows.duplicate', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: running a workflow is governed by workspace role; Chat itself is withheld by copilot.use at the chat surface runFromCopilot: defineWorkspaceOperation({ id: 'workflows.copilot.run', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), + // permission-group-exempt: running a workflow is governed by workspace role; Chat itself is withheld by copilot.use at the chat surface runUntilFromCopilot: defineWorkspaceOperation({ id: 'workflows.copilot.run_until', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: running a workflow is governed by workspace role; Chat itself is withheld by copilot.use at the chat surface runFromBlockFromCopilot: defineWorkspaceOperation({ id: 'workflows.copilot.run_from_block', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: running a single block is governed by workspace role; Chat itself is withheld by copilot.use at the chat surface runBlockFromCopilot: defineWorkspaceOperation({ id: 'workflows.copilot.run_block', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: deleting a workflow is governed by workspace role delete: defineWorkspaceOperation({ id: 'workflows.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow folder tree has no hide key; reading it is governed by workspace role listFolders: defineWorkspaceOperation({ id: 'workflows.folders.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow folder tree has no hide key; arranging it is governed by workspace role createFolder: defineWorkspaceOperation({ id: 'workflows.folders.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow folder tree has no hide key; arranging it is governed by workspace role relocateFolder: defineWorkspaceOperation({ id: 'workflows.folders.relocate', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow folder tree has no hide key; arranging it is governed by workspace role deleteFolder: defineWorkspaceOperation({ id: 'workflows.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), deploy: defineWorkspaceOperation({ @@ -267,89 +327,117 @@ export const workflowOperations = { * a principal here: the operation removes the authentication requirement from * a deployed workflow, which needs an accountable human rather than a machine * credential or an agent acting on a prompt. + * + * permission-group-exempt: `public_api.use` is asserted inside the use case and only for the enabling direction, because a group that withholds public execution must still let an admin withdraw execution a workflow already has */ updatePublicApi: defineWorkspaceOperation({ id: 'workflows.public_api.update', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session', 'personal_api_key'], }), activateVersion: defineWorkspaceOperation({ id: 'workflows.versions.activate', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.api', ...WORKFLOW_DEPLOYMENT_PRINCIPAL_POLICY, }), + // permission-group-exempt: reverting the draft to an earlier version edits workflow content; deployment capabilities govern what is served, not what is edited revertVersion: defineWorkspaceOperation({ id: 'workflows.versions.revert', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: a version's name and description are metadata on workflow content, governed by workspace role updateVersion: defineWorkspaceOperation({ id: 'workflows.versions.update', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: version history is workflow content, governed by workspace role listVersions: defineWorkspaceOperation({ id: 'workflows.versions.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...WORKFLOW_READ_PRINCIPAL_POLICY, }), + // permission-group-exempt: version history is workflow content, governed by workspace role readVersion: defineWorkspaceOperation({ id: 'workflows.versions.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...WORKFLOW_READ_PRINCIPAL_POLICY, }), + // permission-group-exempt: comparing references across two versions reads workflow content the caller may already open compareReferences: defineWorkspaceOperation({ id: 'workflows.versions.compare_references', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: an export returns the graph its reader can already open; logs.export withholds execution logs, not definitions export: defineWorkspaceOperation({ id: 'workflows.export', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: importing is workflow authoring governed by workspace role; the blocks the payload carries are judged against allowedIntegrations before they are persisted import: defineWorkspaceOperation({ id: 'workflows.import', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: running a workflow from an authenticated surface is governed by workspace role; public_api.use withholds the unauthenticated surface, which does not reach this operation execute: defineWorkspaceOperation({ id: 'workflows.execute', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: a manual run is governed by workspace role; public_api.use withholds the unauthenticated surface, which does not reach this operation executeManual: defineWorkspaceOperation({ id: 'workflows.manual.execute', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['personal_api_key'], }), + // permission-group-exempt: a manual run is governed by workspace role; public_api.use withholds the unauthenticated surface, which does not reach this operation executeManualFromBlock: defineWorkspaceOperation({ id: 'workflows.manual.execute_from_block', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['personal_api_key'], }), + // permission-group-exempt: execution history is governed by workspace role; logs.cost and logs.trace_spans withhold fields inside a run, not the right to read one listRuns: defineWorkspaceOperation({ id: 'workflows.runs.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: execution history is governed by workspace role; logs.cost and logs.trace_spans withhold fields inside a run, not the right to read one readRun: defineWorkspaceOperation({ id: 'workflows.runs.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), /** @@ -359,22 +447,28 @@ export const workflowOperations = { * being authorized is still the run — a run file is reachable only through * the run that recorded it, never as a standalone workspace file. */ + // permission-group-exempt: a run's own output bytes belong to the run its reader may already open; files.bulk_download withholds the workspace file store, not run artifacts downloadRunFile: defineWorkspaceOperation({ id: 'workflows.download_run_file', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: stopping a run already in flight is governed by workspace role cancelRun: defineWorkspaceOperation({ id: 'workflows.runs.cancel', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: answering a paused run is governed by workspace role resumeRun: defineWorkspaceOperation({ id: 'workflows.runs.resume', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/workflows/operations/import-workflow.test.ts b/apps/sim/lib/workflows/operations/import-workflow.test.ts new file mode 100644 index 00000000000..e5539f3fa23 --- /dev/null +++ b/apps/sim/lib/workflows/operations/import-workflow.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getUserPermissionConfig: vi.fn(), + performCreateWorkflow: vi.fn(), + performCreateWorkflowTransition: vi.fn(), + saveWorkflowToNormalizedTables: vi.fn(), + extractAndPersistCustomTools: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) +vi.mock('@/lib/workflows/orchestration', () => ({ + performCreateWorkflow: mocks.performCreateWorkflow, + performCreateWorkflowTransition: mocks.performCreateWorkflowTransition, +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + saveWorkflowToNormalizedTables: mocks.saveWorkflowToNormalizedTables, +})) +vi.mock('@/lib/workflows/persistence/custom-tools-persistence', () => ({ + extractAndPersistCustomTools: mocks.extractAndPersistCustomTools, +})) + +import { importWorkflowIntoWorkspace } from '@/lib/workflows/operations/import-workflow' + +function block(id: string, type: string) { + return { + id, + type, + name: id, + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + } +} + +function payload(...blocks: ReturnType[]) { + return { + blocks: Object.fromEntries(blocks.map((entry) => [entry.id, entry])), + edges: [], + loops: {}, + parallels: {}, + } +} + +function params(workflowPayload: Record) { + return { + workspaceId: 'workspace-1', + userId: 'user-1', + requestId: 'request-1', + workflow: workflowPayload, + } +} + +describe('importWorkflowIntoWorkspace block access', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }]) + mocks.getUserPermissionConfig.mockResolvedValue(null) + mocks.performCreateWorkflow.mockResolvedValue({ + success: true, + workflow: { + id: 'workflow-1', + name: 'Imported Workflow', + description: null, + folderId: null, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + }) + mocks.saveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + mocks.extractAndPersistCustomTools.mockResolvedValue({ saved: 0, errors: [] }) + }) + + /** + * The bypass this closes: import never went through the editing operations, + * so a denied integration reached the normalized tables and was refused only + * at run time, if ever. + */ + it('refuses a payload carrying a block type the permission group withholds', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + + const result = await importWorkflowIntoWorkspace( + params(payload(block('b1', 'slack'), block('b2', 'gmail'))) + ) + + expect(result).toMatchObject({ success: false, status: 403 }) + expect(result.success === false && result.error).toContain('gmail') + }) + + /** Nothing may be written before the refusal, or the caller is left an orphan. */ + it('refuses before any workflow row is created', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + + await importWorkflowIntoWorkspace(params(payload(block('b1', 'gmail')))) + + expect(mocks.performCreateWorkflow).not.toHaveBeenCalled() + expect(mocks.saveWorkflowToNormalizedTables).not.toHaveBeenCalled() + }) + + it('imports a payload whose block types the allowlist names', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + + const result = await importWorkflowIntoWorkspace(params(payload(block('b1', 'slack')))) + + expect(result.success).toBe(true) + expect(mocks.performCreateWorkflow).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/lib/workflows/operations/import-workflow.ts b/apps/sim/lib/workflows/operations/import-workflow.ts index 5103b939c9a..23913c5e81d 100644 --- a/apps/sim/lib/workflows/operations/import-workflow.ts +++ b/apps/sim/lib/workflows/operations/import-workflow.ts @@ -23,6 +23,10 @@ import { performCreateWorkflow, performCreateWorkflowTransition, } from '@/lib/workflows/orchestration' +import { + findWithheldBlockType, + withheldBlockTypeMessage, +} from '@/lib/workflows/persistence/block-access-guard' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -256,6 +260,25 @@ async function executeImportWorkflowIntoWorkspace( const workflowState: WorkflowState = { ...parsedState, ...preparedState } + /** + * Nothing has been written yet, which is why the check sits here: an import + * carries blocks the caller never added through the editing operations, so + * this is the only place the workspace's integration allowlist is consulted + * before the graph becomes a stored workflow. + */ + const withheldBlockType = await findWithheldBlockType({ + userId, + workspaceId, + blocks: Object.values(workflowState.blocks), + }) + if (withheldBlockType) { + return { + success: false, + status: 403, + error: withheldBlockTypeMessage(withheldBlockType), + } + } + let parsedPayload: unknown = rawWorkflow if (typeof rawWorkflow === 'string') { try { diff --git a/apps/sim/lib/workflows/persistence/block-access-guard.test.ts b/apps/sim/lib/workflows/persistence/block-access-guard.test.ts new file mode 100644 index 00000000000..239ccc3d902 --- /dev/null +++ b/apps/sim/lib/workflows/persistence/block-access-guard.test.ts @@ -0,0 +1,64 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + +import { findWithheldBlockType } from '@/lib/workflows/persistence/block-access-guard' + +const PARAMS = { userId: 'user-1', workspaceId: 'workspace-1' } + +describe('findWithheldBlockType', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getUserPermissionConfig.mockResolvedValue(null) + }) + + it('permits every block type when no permission group governs the workspace', async () => { + await expect( + findWithheldBlockType({ ...PARAMS, blocks: [{ type: 'gmail' }, { type: 'slack' }] }) + ).resolves.toBeNull() + }) + + it('permits every block type when the allowlist names every integration', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: null }) + + await expect( + findWithheldBlockType({ ...PARAMS, blocks: [{ type: 'gmail' }] }) + ).resolves.toBeNull() + }) + + it('names the first block type the allowlist withholds', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + + await expect( + findWithheldBlockType({ + ...PARAMS, + blocks: [{ type: 'slack' }, { type: 'gmail' }, { type: 'notion' }], + }) + ).resolves.toBe('gmail') + }) + + /** + * Containers resolve to no integration, so an allowlist naming every + * permitted one would still withhold them — and a graph the editor happily + * builds could never be written back. + */ + it('does not withhold loop and parallel containers', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + + await expect( + findWithheldBlockType({ + ...PARAMS, + blocks: [{ type: 'loop' }, { type: 'parallel' }, { type: 'slack' }], + }) + ).resolves.toBeNull() + }) +}) diff --git a/apps/sim/lib/workflows/persistence/block-access-guard.ts b/apps/sim/lib/workflows/persistence/block-access-guard.ts new file mode 100644 index 00000000000..3c5a53f4e58 --- /dev/null +++ b/apps/sim/lib/workflows/persistence/block-access-guard.ts @@ -0,0 +1,45 @@ +import { isBlockTypeAllowed } from '@/lib/workflows/editing/validation' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { BlockType } from '@/executor/constants' + +/** + * Loop and parallel are canvas containers rather than registry blocks, so they + * resolve to no integration and an allowlist naming every permitted integration + * would still withhold them. The editing operations skip them for the same + * reason, and the two paths must agree or a graph the editor accepts would be + * refused when it is written back. + */ +const CONTAINER_BLOCK_TYPES: ReadonlySet = new Set([BlockType.LOOP, BlockType.PARALLEL]) + +/** + * The first block type in `blocks` that the user's permission group withholds, + * or `null` when every one of them is permitted. + * + * `allowedIntegrations` is checked when a block is added through the editing + * operations, but a whole-graph write does not go through that path: the caller + * hands over the finished blocks, naming whatever types it likes. Validating at + * persist time is what makes the allowlist a property of what is *stored* + * rather than of one authoring route — otherwise a withheld integration lands + * in the workspace and is caught only by the executor refusing it mid-run, + * after the workflow has been saved, shared, and possibly deployed. + */ +export async function findWithheldBlockType(params: { + userId: string + workspaceId: string + blocks: Iterable<{ type?: string }> +}): Promise { + const permissionConfig = await getUserPermissionConfig(params.userId, params.workspaceId) + + for (const block of params.blocks) { + const blockType = block.type + if (!blockType || CONTAINER_BLOCK_TYPES.has(blockType)) continue + if (!isBlockTypeAllowed(blockType, permissionConfig)) return blockType + } + + return null +} + +/** The refusal text every persist path renders for a withheld block type. */ +export function withheldBlockTypeMessage(blockType: string): string { + return `Block type "${blockType}" is unavailable in this deployment or blocked by access control` +} diff --git a/apps/sim/lib/workflows/persistence/save-normalized-state.ts b/apps/sim/lib/workflows/persistence/save-normalized-state.ts index a1c2a036513..b77f969b231 100644 --- a/apps/sim/lib/workflows/persistence/save-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/save-normalized-state.ts @@ -16,6 +16,10 @@ import { statusForOrchestrationError, } from '@/lib/core/orchestration/types' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { + findWithheldBlockType, + withheldBlockTypeMessage, +} from '@/lib/workflows/persistence/block-access-guard' import { replaceWorkflowNormalizedState, WorkflowStatePersistenceError, @@ -94,6 +98,28 @@ export async function saveWorkflowNormalizedState(params: { throw error } + /** + * A whole-graph replace does not go through the editing operations, so this + * is the only point at which the blocks it carries meet the workspace's + * integration allowlist. Checking before the write keeps a withheld + * integration out of the stored workflow rather than leaving it for the + * executor to refuse mid-run. A workflow with no workspace has no permission + * group to resolve. + */ + if (workflowData.workspaceId) { + const withheldBlockType = await findWithheldBlockType({ + userId, + workspaceId: workflowData.workspaceId, + blocks: Object.values(state.blocks), + }) + if (withheldBlockType) { + logger.warn( + `[${requestId}] User ${userId} attempted to save workflow ${workflowId} with withheld block type ${withheldBlockType}` + ) + return { success: false, status: 403, error: withheldBlockTypeMessage(withheldBlockType) } + } + } + let warnings: string[] try { const saved = await replaceWorkflowNormalizedState({ diff --git a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts index 99c1dde6531..28d8e8dd54d 100644 --- a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts @@ -12,6 +12,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ replace: vi.fn(), notify: vi.fn(), + getUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, })) vi.mock('@/lib/workflows/persistence/replace-normalized-state', async () => { @@ -68,6 +73,59 @@ describe('saveWorkflowNormalizedState', () => { }) workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) mocks.replace.mockResolvedValue({ warnings: ['dropped an edge'], state: STATE }) + mocks.getUserPermissionConfig.mockResolvedValue(null) + }) + + /** + * The bypass this closes: a graph replace never went through the editing + * operations, so a member whose group withholds an integration could still + * store a block using it and have it refused only at run time, if ever. + */ + it('refuses a state carrying a block type the permission group withholds', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + + const result = await saveWorkflowNormalizedState( + params({ + state: { + blocks: { + 'block-1': { + id: 'block-1', + type: 'gmail', + name: 'Send', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + edges: [], + }, + }) + ) + + expect(result).toMatchObject({ success: false, status: 403 }) + expect(result.success === false && result.error).toContain('gmail') + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('stores a state whose block types the allowlist names', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['starter'] }) + + await expect(saveWorkflowNormalizedState(params())).resolves.toMatchObject({ success: true }) + }) + + /** A workflow with no workspace has no permission group to resolve. */ + it('skips the block-type check for a workflow outside any workspace', async () => { + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'workflow-1', workspaceId: null }, + workspacePermission: 'write', + }) + + await expect(saveWorkflowNormalizedState(params())).resolves.toMatchObject({ success: true }) + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() }) it('returns success with the preparation warnings and notifies once', async () => { From dacf6764f19f334735a10a301d5e79dc71d50133 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 21:46:58 -0700 Subject: [PATCH 017/179] feat(permission-groups): enforce the four declared-but-unwired capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logs.cost, credentials.personal, triggers.webhook and copilot.tool_auto_approval each shipped with an admin checkbox and no server check, so an organization that set one believed it had withheld a capability while every surface still answered. logs.cost is a projection rather than a refusal, following the logs.trace_spans precedent: the log stays readable and its spend does not. Applied to the detail (run total, itemized ledger, per-block and per-span cost and tokens), to the list summaries, and to the export CSV — a hidden detail cost still printed in the list or downloaded in bulk withholds nothing. The withheld detail still satisfies the wire contract: `cost` is nullable and `costLedger` optional. credentials.personal is operation-shaped for the three OAuth connection operations, which can only ever produce a personal account-linked grant, and request-shaped for `credentials.create`, whose `type` decides scope — so that one asserts inside the use case through the capability rule rather than reimplementing the predicate. triggers.webhook gates creation only. An already-created webhook must keep firing: inbound delivery has no session to resolve a group against, and refusing there would silently break live integrations. copilot.tool_auto_approval is honoured at read time as well as at write time, so a stored auto-allow saved before the key was set stops silencing the prompt immediately rather than only for new entries. --- .../api/copilot/tool-permission/route.test.ts | 55 ++++++++ .../app/api/copilot/tool-permission/route.ts | 20 ++- apps/sim/app/api/logs/export/route.test.ts | 41 ++++++ apps/sim/app/api/logs/export/route.ts | 15 +- apps/sim/app/api/webhooks/route.test.ts | 115 ++++++++++++++++ apps/sim/app/api/webhooks/route.ts | 31 +++++ apps/sim/lib/copilot/async-runs/repository.ts | 2 + .../request/context/request-context.ts | 2 +- .../copilot/request/context/result.test.ts | 1 + .../sim/lib/copilot/request/go/stream.test.ts | 1 + .../copilot/request/handlers/handlers.test.ts | 5 + .../lib/copilot/request/lifecycle/run.test.ts | 29 ++++ apps/sim/lib/copilot/request/lifecycle/run.ts | 25 +++- .../copilot/request/tools/permission.test.ts | 42 ++++++ .../lib/copilot/request/tools/permission.ts | 15 +- apps/sim/lib/copilot/request/types.ts | 6 + apps/sim/lib/core/application/index.ts | 1 + .../application/workspace-authorization.ts | 59 ++++++-- .../create-credential-connection.test.ts | 1 + .../application/credential-crud.test.ts | 110 ++++++++++++++- .../application/credential-crud.ts | 27 +++- .../lib/credentials/application/operations.ts | 16 ++- .../lib/logs/application/list-logs.test.ts | 87 ++++++++++++ apps/sim/lib/logs/application/list-logs.ts | 21 ++- .../logs/application/read-log-detail.test.ts | 37 +++++ .../lib/logs/application/read-log-detail.ts | 7 + apps/sim/lib/logs/fetch-log-detail.test.ts | 129 ++++++++++++++++++ apps/sim/lib/logs/fetch-log-detail.ts | 52 ++++++- apps/sim/lib/logs/list-logs.test.ts | 28 ++++ apps/sim/lib/logs/list-logs.ts | 11 +- 30 files changed, 955 insertions(+), 36 deletions(-) create mode 100644 apps/sim/app/api/webhooks/route.test.ts create mode 100644 apps/sim/lib/logs/application/list-logs.test.ts diff --git a/apps/sim/app/api/copilot/tool-permission/route.test.ts b/apps/sim/app/api/copilot/tool-permission/route.test.ts index bdc42bd79c1..471c67ccea8 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.test.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.test.ts @@ -13,6 +13,7 @@ const { publishToolPermissionDecision, addAutoAllowedTool, addChatAutoAllowedTool, + getUserPermissionConfig, } = vi.hoisted(() => ({ getAsyncToolCall: vi.fn(), getRunSegment: vi.fn(), @@ -20,6 +21,7 @@ const { publishToolPermissionDecision: vi.fn(), addAutoAllowedTool: vi.fn(), addChatAutoAllowedTool: vi.fn(), + getUserPermissionConfig: vi.fn(), })) vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) @@ -49,6 +51,10 @@ vi.mock('@/lib/core/config/env-flags', () => ({ isCopilotToolPermissionsEnabled: true, })) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig, +})) + import { POST } from './route' describe('Copilot tool permission API', () => { @@ -69,7 +75,9 @@ describe('Copilot tool permission API', () => { id: 'run-1', userId: 'user-1', chatId: 'chat-1', + workspaceId: 'workspace-1', }) + getUserPermissionConfig.mockResolvedValue(null) recordToolPermissionDecision.mockResolvedValue({ toolCallId: 'tool-1', runId: 'run-1', @@ -136,4 +144,51 @@ describe('Copilot tool permission API', () => { expect(response.status).toBe(200) expect(recordToolPermissionDecision).toHaveBeenCalledWith('tool-1', decision) }) + + describe('when the permission group withholds tool auto-approval', () => { + beforeEach(() => { + getUserPermissionConfig.mockResolvedValue({ disableToolAutoApproval: true }) + }) + + it.each(['always_allow', 'allow_chat'] as const)( + 'answers the %s prompt without remembering it', + async (decision) => { + recordToolPermissionDecision.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: decision, + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + + const response = await POST(createRequest(decision)) + + // The waiting orchestrator still gets its answer; only the durable + // preference is refused, so the next call prompts again. + expect(response.status).toBe(200) + expect(publishToolPermissionDecision).toHaveBeenCalledWith( + expect.objectContaining({ toolCallId: 'tool-1', decision }) + ) + expect(addAutoAllowedTool).not.toHaveBeenCalled() + expect(addChatAutoAllowedTool).not.toHaveBeenCalled() + } + ) + + it('remembers it again once the group allows it', async () => { + getUserPermissionConfig.mockResolvedValue({ disableToolAutoApproval: false }) + recordToolPermissionDecision.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: 'always_allow', + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + + await POST(createRequest('always_allow')) + + expect(addAutoAllowedTool).toHaveBeenCalledWith('user-1', 'run_workflow') + }) + }) }) diff --git a/apps/sim/app/api/copilot/tool-permission/route.ts b/apps/sim/app/api/copilot/tool-permission/route.ts index 536f0d1b3cc..649d5bcd29a 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.ts @@ -29,6 +29,7 @@ import { import { withIncomingGoSpan } from '@/lib/copilot/request/otel' import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('CopilotToolPermissionAPI') @@ -74,9 +75,26 @@ async function applyDecision( : null } + /** + * permission-group-enforced: copilot.tool_auto_approval — nothing durable is + * written when the group withholds it. The decision itself stands — this call + * runs the tool the user just allowed — only the memory of it is refused, so + * the next call prompts again. Not a 403: the answer to *this* prompt was + * legitimate, and failing the request would strand the waiting orchestrator. + */ + const permissionConfig = run.workspaceId + ? await getUserPermissionConfig(userId, run.workspaceId) + : null + const mayRemember = permissionConfig?.disableToolAutoApproval !== true + // Best-effort: failing to remember the preference must not block the tool the // user just allowed. Worst case they get prompted again next time. - if (decision === TOOL_PERMISSION_DECISION.always_allow) { + if (!mayRemember) { + logger.info('Not persisting an always-allow decision withheld by permission group', { + toolCallId, + toolName: claimed.toolName, + }) + } else if (decision === TOOL_PERMISSION_DECISION.always_allow) { await addAutoAllowedTool(userId, claimed.toolName).catch((err) => { logger.error('Failed to persist always-allow preference', { toolCallId, diff --git a/apps/sim/app/api/logs/export/route.test.ts b/apps/sim/app/api/logs/export/route.test.ts index ab31532d74a..0fd04dbf897 100644 --- a/apps/sim/app/api/logs/export/route.test.ts +++ b/apps/sim/app/api/logs/export/route.test.ts @@ -16,11 +16,17 @@ const { mockExpandFolderIdsWithDescendants, mockMapWithConcurrency, mockMaterializeExecutionDataForDisplay, + mockGetUserPermissionConfig, } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), mockExpandFolderIdsWithDescendants: vi.fn(), mockMapWithConcurrency: vi.fn(), mockMaterializeExecutionDataForDisplay: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -88,6 +94,7 @@ describe('GET /api/logs/export', () => { resetDbChainMock() mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true }) + mockGetUserPermissionConfig.mockResolvedValue(null) mockExpandFolderIdsWithDescendants.mockImplementation( async (_workspaceId: string, folderIds: string | undefined) => folderIds ) @@ -236,4 +243,38 @@ describe('GET /api/logs/export', () => { await expect(Promise.all([pendingRead, cancellation])).resolves.toBeDefined() }) + + /** + * A whole-workspace CSV of run spend is the widest disclosure of the figures + * the detail view already withholds. The header keeps its column so the file + * shape does not depend on who downloaded it. + */ + it('blanks the cost column and span spend when the group withholds cost', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ hideCostInfo: true }) + queueTableRows(workflowExecutionLogs, [ + logRow(0, { + executionData: { + message: 'message-0', + traceSpans: [{ id: 'span-1', name: 'Agent', type: 'agent', cost: { total: 0.01 } }], + }, + }), + ]) + + const response = await GET(makeRequest()) + const lines = (await response.text()).trimEnd().split('\n') + + expect(lines[0]).toContain('costTotal') + expect(lines[1].split(',')[5]).toBe('') + expect(lines[1]).toContain('span-1') + expect(lines[1]).not.toContain('0.01') + }) + + it('keeps the cost column when no group withholds it', async () => { + queueTableRows(workflowExecutionLogs, [logRow(0)]) + + const response = await GET(makeRequest()) + const lines = (await response.text()).trimEnd().split('\n') + + expect(lines[1].split(',')[5]).toBe('0.01') + }) }) diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index e12b606d040..9899514d50a 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -9,6 +9,7 @@ import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/co import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { withheldSpendData } from '@/lib/logs/fetch-log-detail' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -117,6 +118,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const hideTraceSpans = permissionConfig?.hideTraceSpans === true + /** + * permission-group-enforced: logs.cost — the `costTotal` column is the same + * run spend the detail view withholds, and a whole-workspace CSV of it is + * the widest disclosure of the two. The column stays in the header so the + * file shape does not depend on who downloaded it; only its values go. + */ + const hideCostInfo = permissionConfig?.hideCostInfo === true const encoder = new TextEncoder() const csvChunks = (async function* () { @@ -159,7 +167,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { for (let index = 0; index < chunk.length; index++) { const row = chunk[index] - const executionData = materialized[index] + const materializedRow = materialized[index] + const executionData = hideCostInfo + ? withheldSpendData(materializedRow) + : materializedRow let message: unknown = '' let tracesJson = '' try { @@ -190,7 +201,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { formatCsvValue(row.workflowName), formatCsvValue(row.trigger), formatCsvValue(row.totalDurationMs ?? ''), - formatCsvValue(row.costTotal ?? ''), + formatCsvValue(hideCostInfo ? '' : (row.costTotal ?? '')), formatCsvValue(row.workflowId ?? ''), formatCsvValue(row.executionId), formatCsvValue(message), diff --git a/apps/sim/app/api/webhooks/route.test.ts b/apps/sim/app/api/webhooks/route.test.ts new file mode 100644 index 00000000000..1c2a85d3572 --- /dev/null +++ b/apps/sim/app/api/webhooks/route.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ + +import { webhook, workflow } from '@sim/db/schema' +import { createMockRequest, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + authorizeWorkflow: vi.fn(), + assertWorkflowMutable: vi.fn(), + getUserPermissionConfig: vi.fn(), + createExternalWebhookSubscription: vi.fn(), + findConflictingWebhookPathOwner: vi.fn(), + resolveEnvVarsInObject: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mocks.getSession, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + authorizeWorkflowByWorkspacePermission: mocks.authorizeWorkflow, + assertWorkflowMutable: mocks.assertWorkflowMutable, + WorkflowLockedError: class WorkflowLockedError extends Error { + status = 423 + }, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + +vi.mock('@/lib/webhooks/provider-subscriptions', () => ({ + createExternalWebhookSubscription: mocks.createExternalWebhookSubscription, + cleanupExternalWebhook: vi.fn(), + shouldRecreateExternalWebhookSubscription: () => false, +})) + +vi.mock('@/lib/webhooks/providers', () => ({ getProviderHandler: () => undefined })) +vi.mock('@/lib/webhooks/utils', () => ({ mergeNonUserFields: vi.fn() })) +vi.mock('@/lib/webhooks/utils.server', () => ({ + findConflictingWebhookPathOwner: mocks.findConflictingWebhookPathOwner, +})) +vi.mock('@/lib/webhooks/env-resolver', () => ({ + resolveEnvVarsInObject: mocks.resolveEnvVarsInObject, +})) +vi.mock('@/lib/workspaces/utils', () => ({ listAccessibleWorkspaceRowsForUser: vi.fn() })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { POST } from '@/app/api/webhooks/route' + +const WORKFLOW_ID = 'workflow-1' +const WORKSPACE_ID = 'workspace-1' + +function upsertRequest() { + return createMockRequest('POST', { + workflowId: WORKFLOW_ID, + path: 'inbound-orders', + provider: 'generic', + providerConfig: {}, + }) +} + +/** The reads the create path makes, in the order the handler issues them. */ +function queueCreatePathRows(): void { + queueTableRows(workflow, [{ id: WORKFLOW_ID, userId: 'user-1', workspaceId: WORKSPACE_ID }]) + queueTableRows(webhook, []) +} + +describe('POST /api/webhooks', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' } }) + mocks.authorizeWorkflow.mockResolvedValue({ allowed: true }) + mocks.assertWorkflowMutable.mockResolvedValue(undefined) + mocks.getUserPermissionConfig.mockResolvedValue(null) + mocks.findConflictingWebhookPathOwner.mockResolvedValue(null) + mocks.resolveEnvVarsInObject.mockImplementation(async (config: unknown) => config) + mocks.createExternalWebhookSubscription.mockResolvedValue({ + updatedProviderConfig: {}, + externalSubscriptionCreated: false, + }) + }) + + afterAll(resetDbChainMock) + + /** + * Making a workflow reachable from an inbound webhook is the only external + * exposure with no deploy-tab equivalent, so the group key has to stop it at + * creation or it is not withheld at all. + */ + it('refuses to create a webhook when the group withholds webhook triggers', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ disableWebhookTriggers: true }) + queueCreatePathRows() + + const response = await POST(upsertRequest()) + + expect(response.status).toBe(403) + // Refused before the provider is told to start delivering. + expect(mocks.createExternalWebhookSubscription).not.toHaveBeenCalled() + }) + + it('creates the webhook when no group withholds the capability', async () => { + queueCreatePathRows() + + const response = await POST(upsertRequest()) + + expect(response.status).not.toBe(403) + expect(mocks.createExternalWebhookSubscription).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 0efbb4b3f0f..ec2b45499ea 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -28,6 +28,7 @@ import { getProviderHandler } from '@/lib/webhooks/providers' import { mergeNonUserFields } from '@/lib/webhooks/utils' import { findConflictingWebhookPathOwner } from '@/lib/webhooks/utils.server' import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('WebhooksAPI') @@ -381,6 +382,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => { existingWebhook = existingRows[0] || null } + /** + * permission-group-enforced: triggers.webhook — a raw upsert handler with no + * application operation to declare the capability on, so it is asserted + * here. + * + * Creation only. An already-created webhook must keep firing: inbound + * delivery runs with no session to resolve a group against, and refusing + * there would silently break live integrations the moment an admin ticked + * the box, with the failure surfacing at the provider rather than in Sim. + * Withholding the capability stops new exposure; removing existing exposure + * is a deliberate act of deleting the webhook. + */ + if (!existingWebhook) { + const permissionConfig = workflowRecord.workspaceId + ? await getUserPermissionConfig(userId, workflowRecord.workspaceId) + : null + if (permissionConfig?.disableWebhookTriggers) { + logger.warn(`[${requestId}] Webhook creation blocked by permission group`, { + userId, + workflowId, + }) + return NextResponse.json( + { + error: "Webhook triggers are not available under your organization's permission group", + }, + { status: 403 } + ) + } + } + const shouldRecreateSubscription = existingWebhook && shouldRecreateExternalWebhookSubscription({ diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index 58efd23875b..b5b1b2c6315 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -187,6 +187,8 @@ export async function getRunSegment(runId: string) { workflowId: copilotRuns.workflowId, // Needed to scope an "allow for this chat" decision to its chat. chatId: copilotRuns.chatId, + // Needed to resolve the deciding user's permission group. + workspaceId: copilotRuns.workspaceId, }) .from(copilotRuns) .where(eq(copilotRuns.id, runId)) diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/copilot/request/context/request-context.ts index 1fd556a76bf..2ba04104dd0 100644 --- a/apps/sim/lib/copilot/request/context/request-context.ts +++ b/apps/sim/lib/copilot/request/context/request-context.ts @@ -28,7 +28,7 @@ export function createStreamingContext(overrides?: Partial): S errors: [], activeFileIntents: new Map(), trace: new TraceCollector(), - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { enabled: false, autoAllowed: new Set(), autoAllowPermitted: true }, ...overrides, } } diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index b8659cb2d6c..bcc441d02ee 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -35,6 +35,7 @@ function makeContext(): StreamingContext { toolPermissions: { enabled: false, autoAllowed: new Set(), + autoAllowPermitted: true, }, } } diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 991570b1343..d27a1482891 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -143,6 +143,7 @@ function createStreamingContext(): StreamingContext { toolPermissions: { enabled: false, autoAllowed: new Set(), + autoAllowPermitted: true, }, } } diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index aa4ef86a6a6..12bccb625bd 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -134,6 +134,7 @@ describe('sse-handlers tool lifecycle', () => { toolPermissions: { enabled: false, autoAllowed: new Set(), + autoAllowPermitted: true, }, } execContext = { @@ -238,6 +239,7 @@ describe('sse-handlers tool lifecycle', () => { context.toolPermissions = { enabled: true, autoAllowed: new Set(), + autoAllowPermitted: true, } const event = { @@ -275,6 +277,7 @@ describe('sse-handlers tool lifecycle', () => { context.toolPermissions = { enabled: false, autoAllowed: new Set(), + autoAllowPermitted: true, } const event = { @@ -302,6 +305,7 @@ describe('sse-handlers tool lifecycle', () => { context.toolPermissions = { enabled: true, autoAllowed: new Set(), + autoAllowPermitted: true, } const event = { @@ -331,6 +335,7 @@ describe('sse-handlers tool lifecycle', () => { context.toolPermissions = { enabled: true, autoAllowed: new Set(['deploy_as_api']), + autoAllowPermitted: true, } const event = { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 5eacd781290..f6069b4df03 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -19,6 +19,7 @@ const { mockRunStreamLoop, mockPendingToolWaitBudgetMs, mockGetAutoAllowedTools, + mockGetUserPermissionConfig, mockFilterModelSafeWorkspaceFileAttachments, mockUpdateRunStatus, mockEnv, @@ -32,6 +33,7 @@ const { mockRunStreamLoop: vi.fn(), mockPendingToolWaitBudgetMs: vi.fn((_toolCall?: { name?: string }) => 60_000 as number | null), mockGetAutoAllowedTools: vi.fn(async () => new Set()), + mockGetUserPermissionConfig: vi.fn(async () => null), mockFilterModelSafeWorkspaceFileAttachments: vi.fn(async (attachments: unknown[]) => attachments), mockUpdateRunStatus: vi.fn(), mockEnv: { @@ -112,6 +114,10 @@ vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ addChatAutoAllowedTool: vi.fn(), })) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, +})) + vi.mock('@/lib/copilot/environment-context', () => ({ prepareCopilotEnvironmentContext: mockPrepareCopilotEnvironmentContext, })) @@ -161,6 +167,7 @@ describe('runCopilotLifecycle', () => { isCopilotToolPermissionsEnabled: false, }) mockGetAutoAllowedTools.mockResolvedValue(new Set()) + mockGetUserPermissionConfig.mockResolvedValue(null) mockPendingToolWaitBudgetMs.mockImplementation(() => 60_000) mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test') mockGetMothershipSourceEnvHeaders.mockReturnValue({}) @@ -1064,6 +1071,28 @@ describe('runCopilotLifecycle', () => { expect(mockGetAutoAllowedTools).toHaveBeenCalledWith('user-1', 'chat-1') }) + /** + * The gate itself stays armed — every call still prompts. Only the memory + * that would silence it is withheld, and the stored list is not read at + * all, so an entry saved before the key was set cannot outlive it. + */ + it('arms the gate but ignores stored auto-allows when the group withholds them', async () => { + setEnvFlags({ isCopilotToolPermissionsEnabled: true }) + mockGetUserPermissionConfig.mockResolvedValue({ disableToolAutoApproval: true }) + mockGetAutoAllowedTools.mockResolvedValue(new Set(['terminal_run'])) + let captured: StreamingContext | undefined + mockRunStreamLoop.mockImplementation(async (_u, _o, context: StreamingContext) => { + captured = context + }) + + await runMothershipTurn() + + expect(captured?.toolPermissions.enabled).toBe(true) + expect(captured?.toolPermissions.autoAllowPermitted).toBe(false) + expect(captured?.toolPermissions.autoAllowed.size).toBe(0) + expect(mockGetAutoAllowedTools).not.toHaveBeenCalled() + }) + it('stays off for the workflow-scoped copilot even with the flag on', async () => { // That panel has no permission card, so gating there would hang the turn // on a prompt nothing draws. diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 7c8a1e0e103..1ec3d4dd648 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -64,6 +64,7 @@ import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { env } from '@/lib/core/config/env' import { isCopilotToolPermissionsEnabled, isHosted } from '@/lib/core/config/env-flags' import { filterModelSafeWorkspaceFileAttachments } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -205,8 +206,28 @@ async function resolveToolPermissions( isCopilotToolPermissionsEnabled && options.interactive !== false && (options.goRoute ?? '').startsWith('/api/mothership') - if (!enabled) return { enabled: false, autoAllowed: new Set() } - return { enabled: true, autoAllowed: await getAutoAllowedTools(options.userId, options.chatId) } + if (!enabled) return { enabled: false, autoAllowed: new Set(), autoAllowPermitted: true } + + /** + * permission-group-enforced: copilot.tool_auto_approval — read at the point + * the decision is made, not only where one is saved. A member who clicked + * "always allow" before the key was set would otherwise keep the prompt + * silenced forever, so the stored list is not even loaded once the group + * withholds the capability. + */ + const permissionConfig = + options.userId && options.workspaceId + ? await getUserPermissionConfig(options.userId, options.workspaceId) + : null + if (permissionConfig?.disableToolAutoApproval) { + return { enabled: true, autoAllowed: new Set(), autoAllowPermitted: false } + } + + return { + enabled: true, + autoAllowed: await getAutoAllowedTools(options.userId, options.chatId), + autoAllowPermitted: true, + } } export async function runCopilotLifecycle( diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index 58a6e6c8113..befe73f0f34 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -36,6 +36,7 @@ function makeContext() { context.toolPermissions = { enabled: true, autoAllowed: new Set(), + autoAllowPermitted: true, } context.trace = new TraceCollector() return context @@ -412,3 +413,44 @@ describe('runGatedToolExecution', () => { expect(call?.payload).toMatchObject({ status: 'executing', toolCallId: 'call-1' }) }) }) + +describe('when the permission group withholds tool auto-approval', () => { + /** + * The stored list is what `toolCallNeedsApproval` reads, so an entry saved + * before an admin set the key would otherwise keep silencing the prompt for + * as long as it sat in the table. Turning the key on has to take effect on + * the next call, not on the next entry. + */ + it('prompts for a tool the user already always-allowed', () => { + const context = makeContext() + context.toolPermissions.autoAllowed.add('terminal') + context.toolPermissions.autoAllowPermitted = false + + expect(toolCallNeedsApproval('terminal', context, {}, false, { operation: 'run' })).toBe(true) + }) + + it('leaves an ungated tool ungated', () => { + toolRequiresApproval.mockReturnValue(false) + const context = makeContext() + context.toolPermissions.autoAllowPermitted = false + + expect(toolCallNeedsApproval('gmail_read_v2', context, {}, false)).toBe(false) + toolRequiresApproval.mockReturnValue(true) + }) + + it('does not let an always-allow answer suppress the rest of the turn', async () => { + const context = makeContext() + context.toolPermissions.autoAllowPermitted = false + const toolCall = makeToolCall() + waitForToolPermissionDecision.mockResolvedValue({ + toolCallId: 'call-1', + decision: 'always_allow', + }) + + await gate(context, toolCall, () => Promise.resolve({ status: 'success' }), []) + + // The answer still ran the tool; only its memory is refused. + expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(false) + expect(toolCallNeedsApproval('terminal', context, {}, false, { operation: 'run' })).toBe(true) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/permission.ts b/apps/sim/lib/copilot/request/tools/permission.ts index 08e751dc204..2b10811a747 100644 --- a/apps/sim/lib/copilot/request/tools/permission.ts +++ b/apps/sim/lib/copilot/request/tools/permission.ts @@ -88,6 +88,13 @@ export function toolCallNeedsApproval( } } + /** + * permission-group-enforced: copilot.tool_auto_approval — the stored list is + * consulted here, so honouring the key only where an entry is written would + * leave every entry saved before the policy changed still silencing prompts. + */ + if (!context.toolPermissions.autoAllowPermitted) return true + return !context.toolPermissions.autoAllowed.has(toolName) } @@ -271,10 +278,14 @@ export function runGatedToolExecution( span.setAttribute(TraceAttr.CopilotAsyncToolPermissionDecision, decision.decision) - if (decisionSuppressesFuturePrompts(decision.decision)) { + if ( + decisionSuppressesFuturePrompts(decision.decision) && + context.toolPermissions.autoAllowPermitted + ) { // Same-turn effect: a second call to this tool later in the turn must // not re-prompt. The durable write (chat row or user settings) happens - // in the endpoint. + // in the endpoint, which refuses it under the same capability this + // guard reads — so the two cannot disagree within a turn. context.toolPermissions.autoAllowed.add(toolName) } diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index f11a9b15a4d..c293c5b8140 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -186,6 +186,12 @@ export interface StreamingContext { toolPermissions: { enabled: boolean autoAllowed: Set + /** + * Whether this user may silence a confirmation at all. False when the + * permission group withholds `copilot.tool_auto_approval`, in which case + * every gated call prompts however the stored list reads. + */ + autoAllowPermitted: boolean } } diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index 144b10d9785..d0584f25be7 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -38,6 +38,7 @@ export { PersonalApiKeysDisabledError, PrincipalKindAuthorizationError, requireAllowedWorkspacePrincipal, + requireWorkspaceCapability, WorkspaceApiKeyAuthorizationError, WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application/workspace-authorization' diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index d3e086555a8..0f2a28b0cb5 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -17,9 +17,11 @@ import type { import { OrchestrationError } from '@/lib/core/orchestration/types' import { CAPABILITY_RULES, + type CapabilityRule, type PermissionGroupCapability, } from '@/lib/permission-groups/capabilities' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' export interface WorkspaceAuthorizationContext { workspaceId: string @@ -173,6 +175,50 @@ function requirePermission(permission: PermissionType | null, required: Permissi } } +/** + * Refuses a capability against an already-resolved config. A `null` config means + * no group governs the caller, which is not a denial. + */ +function requireCapabilityFromConfig( + config: PermissionGroupConfig | null, + capability: PermissionGroupCapability +): void { + if (!config) return + + const rule: CapabilityRule = CAPABILITY_RULES[capability] + if (rule.kind !== 'static' || !rule.deniedBy(config)) return + + throw new PermissionGroupCapabilityError(capability, rule.detailCode, rule.describe) +} + +/** + * Resolves the caller's group for this workspace, then refuses the capability. + * + * Exported for a use case whose capability depends on request input the + * operation cannot name — which credential scope is being created, say — so the + * funnel cannot decide it from the operation alone. Going through here rather + * than reading the config key directly keeps the refusal, its detail code and + * its message identical to a funnel refusal, and it shares the per-request memo + * the funnel uses, so asserting on top of an operation's own capability costs no + * extra query. + */ +export async function requireWorkspaceCapability( + userId: string, + context: WorkspaceAuthorizationContext, + capability: PermissionGroupCapability +): Promise { + if (context.workspaceOrganizationId === null) return + + requireCapabilityFromConfig( + await resolvePermissionGroupConfig( + userId, + context.workspaceId, + context.workspaceOrganizationId + ), + capability + ) +} + /** * Refuses an operation whose capability the caller's permission group withholds. * @@ -187,19 +233,8 @@ async function requireCapability( ): Promise { const capability = operation.capability if (capability === undefined || capability === 'none') return - if (context.workspaceOrganizationId === null) return - const config = await resolvePermissionGroupConfig( - userId, - context.workspaceId, - context.workspaceOrganizationId - ) - if (!config) return - - const rule = CAPABILITY_RULES[capability] - if (rule.kind !== 'static' || !rule.deniedBy(config)) return - - throw new PermissionGroupCapabilityError(capability, rule.detailCode, rule.describe) + await requireWorkspaceCapability(userId, context, capability) } /** diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts index c89fc68718c..1f095959a97 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.test.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -31,6 +31,7 @@ vi.mock('@/lib/credentials/connect-draft', () => ({ vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: mocks.getBaseUrl, + SITE_URL: 'http://localhost:3000', })) import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' diff --git a/apps/sim/lib/credentials/application/credential-crud.test.ts b/apps/sim/lib/credentials/application/credential-crud.test.ts index 83e98bee4f7..01dcc4b7867 100644 --- a/apps/sim/lib/credentials/application/credential-crud.test.ts +++ b/apps/sim/lib/credentials/application/credential-crud.test.ts @@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({ getCredentialById: vi.fn(), getActor: vi.fn(), updateRecord: vi.fn(), + createRecord: vi.fn(), + resolvePermissionGroupConfig: vi.fn(), })) vi.mock('@sim/audit', () => auditMock) @@ -32,17 +34,24 @@ vi.mock('@/lib/credentials/access', () => ({ })) vi.mock('@/lib/credentials/orchestration', () => ({ updateCredentialRecord: mocks.updateRecord, - createCredentialRecord: vi.fn(), + createCredentialRecord: mocks.createRecord, isProviderOutageCode: () => false, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => ({ + resolvePermissionGroupConfig: mocks.resolvePermissionGroupConfig, +})) vi.mock('@/lib/credentials/oauth', () => ({ syncWorkspaceOAuthCredentialsForUser: vi.fn() })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: vi.fn() })) +import { PermissionGroupCapabilityError } from '@/lib/core/application' import { CredentialProviderOperationError, + createWorkspaceCredential, updateWorkspaceCredentialUseCase, } from '@/lib/credentials/application/credential-crud' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' const WORKSPACE_ID = 'workspace-1' const OTHER_WORKSPACE_ID = 'workspace-2' @@ -299,3 +308,102 @@ describe('updateWorkspaceCredentialUseCase', () => { expect(error.code).toBe('validation') }) }) + +describe('personal-credential capability', () => { + const ORGANIZATION_ID = 'organization-1' + const governedWorkspace = { ...workspace, workspaceOrganizationId: ORGANIZATION_ID } + + function createdCredential(type: 'env_personal' | 'env_workspace') { + return { ...credential, type, envKey: 'OPENAI_API_KEY', encryptedServiceAccountKey: null } + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(governedWorkspace) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalCredentials: true, + }) + }) + + /** + * The connection flow is personal by construction, so it can carry the + * capability on the operation itself. Pinned because the alternative — + * `integrations.manage`, which every other credential operation declares — + * compiles just as well and would silently gate on the wrong key. + */ + it.each(['createConnection', 'prepareConnection', 'launchConnection'] as const)( + 'declares the capability on %s, which can only produce a personal OAuth grant', + (operationName) => { + expect(credentialOperations[operationName].capability).toBe('credentials.personal') + } + ) + + it('refuses a personal environment secret before it reaches the manager', async () => { + await expect( + createWorkspaceCredential.execute({ + principal: sessionPrincipal, + input: { + workspaceId: WORKSPACE_ID, + type: 'env_personal', + displayName: 'My OpenAI key', + envKey: 'OPENAI_API_KEY', + }, + }) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + + expect(mocks.createRecord).not.toHaveBeenCalled() + }) + + /** + * Scope is the request's `type`, so the same operation must still serve the + * workspace-shared secret the organization is steering members toward. + */ + it('still creates a workspace-shared secret under the same restriction', async () => { + const created = createdCredential('env_workspace') + mocks.createRecord.mockResolvedValue({ success: true, created: true, credential: created }) + mocks.getActor.mockResolvedValue({ + credential: created, + member: { role: 'admin', status: 'active' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + const result = await createWorkspaceCredential.execute({ + principal: sessionPrincipal, + input: { + workspaceId: WORKSPACE_ID, + type: 'env_workspace', + displayName: 'Shared OpenAI key', + envKey: 'OPENAI_API_KEY', + }, + }) + + expect(result.credential).toEqual(created) + }) + + it('creates the personal secret when no group withholds it', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue(null) + const created = createdCredential('env_personal') + mocks.createRecord.mockResolvedValue({ success: true, created: true, credential: created }) + mocks.getActor.mockResolvedValue({ + credential: created, + member: { role: 'admin', status: 'active' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + const result = await createWorkspaceCredential.execute({ + principal: sessionPrincipal, + input: { + workspaceId: WORKSPACE_ID, + type: 'env_personal', + displayName: 'My OpenAI key', + envKey: 'OPENAI_API_KEY', + }, + }) + + expect(result.credential).toEqual(created) + }) +}) diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts index c28bb5302a0..1204ab9f35a 100644 --- a/apps/sim/lib/credentials/application/credential-crud.ts +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -1,6 +1,9 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId } from '@sim/auth/principal' -import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { + defineAuthorizedWorkspaceUseCase, + requireWorkspaceCapability, +} from '@/lib/core/application' import { getBlockVisibility } from '@/lib/core/config/block-visibility' import { OrchestrationError } from '@/lib/core/orchestration/types' import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' @@ -166,6 +169,16 @@ export interface CreateWorkspaceCredentialResult { auditMetadata: Record } +/** + * The credential types that belong to one person rather than to the workspace: + * a personal environment secret, and an OAuth grant bound to the connecting + * user's own linked account. `env_workspace`, `service_account` and + * `managed_oauth` are workspace-shared and stay available. + */ +const PERSONAL_SCOPE_CREDENTIAL_TYPES: ReadonlySet = new Set( + ['env_personal', 'oauth'] +) + export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ operation: credentialOperations.create, resolveContext: async ({ input }: { input: CreateWorkspaceCredentialInput }) => { @@ -174,8 +187,18 @@ export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ return context }, authorizationOptions: {}, - async execute({ principal, input }): Promise { + async execute({ principal, input, context }): Promise { const userId = requirePrincipalSubjectUserId(principal) + /** + * permission-group-enforced: credentials.personal — scope is the request's + * `type`, not a property of the operation: the same `credentials.create` + * makes a personal secret and a workspace-shared one. Declaring the + * capability on the operation would refuse both, so it is asserted here + * against the type actually being created. + */ + if (PERSONAL_SCOPE_CREDENTIAL_TYPES.has(input.type)) { + await requireWorkspaceCapability(userId, context, 'credentials.personal') + } const result = await createCredentialRecord({ ...input, userId }, { authorizeWorkspace: false }) if (!result.success) throwCredentialMutationFailure(result) if (!result.credential) throw new Error('Credential creation succeeded without a credential') diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index da5b26a319c..6c1bea4224c 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -51,18 +51,28 @@ export const credentialOperations = { capability: 'integrations.manage', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), + /** + * Every OAuth connection flow ends in a `type: 'oauth'` credential bound to the + * connecting user's own linked account — `resolveCredentialConnectionTarget` + * refuses any other credential type — so the whole flow is personal-scope by + * construction, not by a field the caller chooses. That makes + * `credentials.personal` an operation-level capability for these three, and it + * replaces `integrations.manage` rather than joining it: an operation declares + * one capability, and the narrower of the two is the one an organization that + * mandates workspace-shared credentials is actually setting. + */ createConnection: defineWorkspaceOperation({ id: 'credentials.connections.create', minimumRole: 'write', workspaceApiKey: 'deny', - capability: 'integrations.manage', + capability: 'credentials.personal', principalKinds: ['session', 'personal_api_key'], }), prepareConnection: defineWorkspaceOperation({ id: 'credentials.connections.prepare', minimumRole: 'write', workspaceApiKey: 'deny', - capability: 'integrations.manage', + capability: 'credentials.personal', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), @@ -156,7 +166,7 @@ export const credentialOperations = { id: 'credentials.connections.launch', minimumRole: 'write', workspaceApiKey: 'deny', - capability: 'integrations.manage', + capability: 'credentials.personal', principalKinds: ['session'], }), useManagedOAuth: defineWorkspaceOperation({ diff --git a/apps/sim/lib/logs/application/list-logs.test.ts b/apps/sim/lib/logs/application/list-logs.test.ts new file mode 100644 index 00000000000..3019f8d4903 --- /dev/null +++ b/apps/sim/lib/logs/application/list-logs.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ + +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + readLogs: vi.fn(), + resolveWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/lib/logs/list-logs', () => ({ + readLogs: mocks.readLogs, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (held: string | null, required: string) => + held === 'admin' || held === required || (held === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + +import { listLogsUseCase } from '@/lib/logs/application/list-logs' + +const WORKSPACE_ID = 'workspace-1' +const SESSION: Principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } +const INPUT = { workspaceId: WORKSPACE_ID, limit: 100, sortBy: 'date', sortOrder: 'desc' } as never + +describe('listLogsUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspace.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + }) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.readLogs.mockResolvedValue({ data: [], nextCursor: null }) + mocks.getUserPermissionConfig.mockResolvedValue(null) + }) + + /** + * A cost withheld on the detail but still printed on the list withholds + * nothing, so the same key has to reach both queries. + */ + it('tells the list query to withhold spend when the group does', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ hideCostInfo: true }) + + await listLogsUseCase.execute({ principal: SESSION, input: INPUT }) + + expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ hideCostInfo: true })) + }) + + it('leaves spend in place when no group withholds it', async () => { + await listLogsUseCase.execute({ principal: SESSION, input: INPUT }) + + expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ hideCostInfo: false })) + }) + + /** + * An actorless run has no user, so there is no group to resolve — it reads its + * own workspace's logs whole rather than being handed a stand-in viewer. + */ + it('does not resolve a group for a principal with no subject', async () => { + await listLogsUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + } as Principal, + input: INPUT, + }) + + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ hideCostInfo: false })) + }) +}) diff --git a/apps/sim/lib/logs/application/list-logs.ts b/apps/sim/lib/logs/application/list-logs.ts index 9fed67ced18..e68d9f2ca9d 100644 --- a/apps/sim/lib/logs/application/list-logs.ts +++ b/apps/sim/lib/logs/application/list-logs.ts @@ -1,3 +1,4 @@ +import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import type { ListLogsResponse } from '@/lib/api/contracts/logs' import { defineAuthorizedWorkspaceUseCase, type OperationUseCase } from '@/lib/core/application' import { asOrchestrationError } from '@/lib/core/orchestration/types' @@ -8,14 +9,30 @@ import { import { logOperations } from '@/lib/logs/application/operations' import { type ListLogsParams, readLogs } from '@/lib/logs/list-logs' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const authorizedListLogsUseCase = defineAuthorizedWorkspaceUseCase({ operation: logOperations.list, resolveContext: ({ input }: { input: ListLogsParams }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), authorizationOptions: logDelegationAuthorization(), - async execute({ input, context }) { - return readLogs({ ...input, workspaceId: context.workspaceId }) + async execute({ principal, input, context }) { + /** + * permission-group-enforced: logs.cost — the list carries the same run + * total the detail does, so withholding it only on the detail would hide + * nothing. A projection rather than a refusal, for the reason given in + * `read-log-detail.ts`. + */ + const viewerUserId = resolvePrincipalSubjectUserId(principal) + const permissionConfig = viewerUserId + ? await getUserPermissionConfig(viewerUserId, context.workspaceId) + : null + + return readLogs({ + ...input, + workspaceId: context.workspaceId, + hideCostInfo: permissionConfig?.hideCostInfo === true, + }) }, }) diff --git a/apps/sim/lib/logs/application/read-log-detail.test.ts b/apps/sim/lib/logs/application/read-log-detail.test.ts index ee291213706..07255b1e179 100644 --- a/apps/sim/lib/logs/application/read-log-detail.test.ts +++ b/apps/sim/lib/logs/application/read-log-detail.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ readLogDetail: vi.fn(), resolveWorkspace: vi.fn(), resolvePermission: vi.fn(), + getUserPermissionConfig: vi.fn(), })) vi.mock('@/lib/logs/fetch-log-detail', () => ({ @@ -21,6 +22,10 @@ vi.mock('@/lib/workspaces/application/workspace-context', () => ({ resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspace, })) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: (held: string | null, required: string) => held === 'admin' || held === required || (held === 'write' && required === 'read'), @@ -93,6 +98,7 @@ describe('readLogDetailUseCase', () => { }) mocks.readLogDetail.mockResolvedValue({ id: 'log-1', executionId: EXECUTION_ID }) mocks.resolvePermission.mockResolvedValue('admin') + mocks.getUserPermissionConfig.mockResolvedValue(null) }) afterAll(resetDbChainMock) @@ -123,4 +129,35 @@ describe('readLogDetailUseCase', () => { expect.objectContaining({ viewerUserId: 'user-1' }) ) }) + + /** + * A projection, not a refusal: the loader is still asked for the log, just + * told to leave the spend out of it. + */ + it('tells the loader to withhold spend when the group does', async () => { + queueLogRow() + mocks.getUserPermissionConfig.mockResolvedValue({ hideCostInfo: true }) + + await readLogDetailUseCase.execute({ + principal: HUMAN_PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, lookupColumn: 'executionId', lookupValue: EXECUTION_ID }, + }) + + expect(mocks.readLogDetail).toHaveBeenCalledWith( + expect.objectContaining({ hideCostInfo: true }) + ) + }) + + it('leaves spend in place when no group withholds it', async () => { + queueLogRow() + + await readLogDetailUseCase.execute({ + principal: HUMAN_PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, lookupColumn: 'executionId', lookupValue: EXECUTION_ID }, + }) + + expect(mocks.readLogDetail).toHaveBeenCalledWith( + expect.objectContaining({ hideCostInfo: false }) + ) + }) }) diff --git a/apps/sim/lib/logs/application/read-log-detail.ts b/apps/sim/lib/logs/application/read-log-detail.ts index 405e9cbfadb..0a5c07cb03c 100644 --- a/apps/sim/lib/logs/application/read-log-detail.ts +++ b/apps/sim/lib/logs/application/read-log-detail.ts @@ -86,6 +86,12 @@ const authorizedReadLogDetailUseCase = defineAuthorizedWorkspaceUseCase({ * permission-group-enforced: logs.trace_spans — a projection rather than a * refusal: the log stays readable, its execution payloads do not. An * actorless run has no group and reads its own workspace's logs whole. + * + * permission-group-enforced: logs.cost — the same projection, applied to + * spend: the run total, its itemized ledger and the per-block and per-span + * figures. Refusing the read instead would withhold the status and the + * error message too, which is not what an organization restricting spend + * visibility to admins asked for. */ const permissionConfig = viewerUserId ? await getUserPermissionConfig(viewerUserId, context.workspaceId) @@ -98,6 +104,7 @@ const authorizedReadLogDetailUseCase = defineAuthorizedWorkspaceUseCase({ lookupValue: input.lookupValue, signal: input.signal, hideTraceSpans: permissionConfig?.hideTraceSpans === true, + hideCostInfo: permissionConfig?.hideCostInfo === true, }) input.signal?.throwIfAborted() if (!detail) throw new OrchestrationError('not_found', 'Not found') diff --git a/apps/sim/lib/logs/fetch-log-detail.test.ts b/apps/sim/lib/logs/fetch-log-detail.test.ts index 326032257bc..ce5d4be6b78 100644 --- a/apps/sim/lib/logs/fetch-log-detail.test.ts +++ b/apps/sim/lib/logs/fetch-log-detail.test.ts @@ -23,8 +23,83 @@ vi.mock('@/lib/logs/execution-origin', () => ({ workflowExecutionOriginSql: () => ({ as: () => ({}) }), })) +import { workflowLogDetailSchema } from '@/lib/api/contracts/logs' import { readLogDetail } from '@/lib/logs/fetch-log-detail' +function queueWorkflowLogRow(overrides: Record = {}): void { + queueTableRows(workflowExecutionLogs, [ + { + id: 'log-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + deploymentVersionId: null, + deploymentVersion: null, + deploymentVersionName: null, + level: 'info', + status: 'completed', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: new Date('2026-01-01T00:00:01.000Z'), + totalDurationMs: 1000, + executionData: {}, + costTotal: '1.25', + files: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + workflowName: 'Workflow', + workflowDescription: null, + workflowFolderId: null, + workflowUserId: 'user-1', + workflowWorkspaceId: 'workspace-1', + workflowCreatedAt: new Date('2026-01-01T00:00:00.000Z'), + workflowUpdatedAt: new Date('2026-01-01T00:00:00.000Z'), + pausedStatus: null, + pausedTotalPauseCount: 0, + pausedResumedCount: 0, + executionOrigin: null, + ...overrides, + }, + ]) +} + +const SPEND_BEARING_EXECUTION_DATA = { + traceSpans: [ + { + id: 'span-1', + name: 'Agent 1', + type: 'agent', + duration: 5, + startTime: '2026-01-01T00:00:00.000Z', + endTime: '2026-01-01T00:00:00.005Z', + cost: { total: 0.75 }, + tokens: { total: 900 }, + children: [ + { + id: 'span-2', + name: 'Model', + type: 'model', + cost: { total: 0.5 }, + tokens: { total: 400 }, + }, + ], + }, + ], + blockExecutions: [ + { + id: 'block-exec-1', + blockId: 'block-1', + blockName: 'Agent 1', + blockType: 'agent', + startedAt: '2026-01-01T00:00:00.000Z', + endedAt: '2026-01-01T00:00:00.005Z', + durationMs: 5, + status: 'success', + inputData: {}, + outputData: {}, + cost: { total: 0.75 }, + }, + ], +} + describe('readLogDetail', () => { beforeEach(() => { vi.clearAllMocks() @@ -154,4 +229,58 @@ describe('readLogDetail', () => { viewerUserId: undefined, }) }) + + describe("when the viewer's permission group withholds cost", () => { + beforeEach(() => { + queueTableRows(usageLog, []) + mocks.materializeExecutionData.mockResolvedValue( + structuredClone(SPEND_BEARING_EXECUTION_DATA) + ) + }) + + it('still returns a log the contract accepts, with every spend figure gone', async () => { + queueWorkflowLogRow() + + const result = await readLogDetail({ + viewerUserId: 'user-1', + workspaceId: 'workspace-1', + lookupColumn: 'id', + lookupValue: 'log-1', + hideCostInfo: true, + }) + + // The projection must stay inside the wire contract: a withheld log is + // still a log, and a client parsing the response cannot be made to fail. + expect(() => workflowLogDetailSchema.parse(result)).not.toThrow() + + expect(result?.cost).toBeNull() + expect(result).not.toHaveProperty('costLedger') + + const [span] = result?.executionData.traceSpans ?? [] + expect(span).not.toHaveProperty('cost') + expect(span).not.toHaveProperty('tokens') + // Nested spans carry their own figures; summing children would otherwise + // reconstruct exactly the total that was withheld. + expect(span?.children?.[0]).not.toHaveProperty('cost') + expect(result?.executionData.blockExecutions?.[0]).not.toHaveProperty('cost') + + // Everything the restriction does not cover is untouched. + expect(result).toMatchObject({ id: 'log-1', status: 'completed' }) + expect(span).toMatchObject({ id: 'span-1', name: 'Agent 1' }) + }) + + it('reports the run total when the group does not withhold it', async () => { + queueWorkflowLogRow() + + const result = await readLogDetail({ + viewerUserId: 'user-1', + workspaceId: 'workspace-1', + lookupColumn: 'id', + lookupValue: 'log-1', + }) + + expect(result?.cost).toEqual({ total: 1.25 }) + expect(result?.executionData.traceSpans?.[0]).toHaveProperty('cost') + }) + }) }) diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts index 3e2bc896429..8edcd2c9ce2 100644 --- a/apps/sim/lib/logs/fetch-log-detail.ts +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -48,6 +48,14 @@ interface FetchLogDetailArgs { * a caller reading the route directly. */ hideTraceSpans?: boolean + /** + * Whether the viewer's permission group withholds spend. Applied here for the + * same reason as {@link FetchLogDetailArgs.hideTraceSpans}: the run total, the + * itemized ledger and the per-block and per-span costs are the figures the + * restriction exists to withhold, and a hidden column withholds nothing from a + * caller reading the route directly. + */ + hideCostInfo?: boolean } /** @@ -70,6 +78,33 @@ function withheldExecutionData(executionData: Record): Record + return Array.isArray(children) ? { ...retained, children: children.map(withoutSpend) } : retained +} + +/** + * Strips spend from the execution payloads a permission group withholds. + * + * Reaches into trace spans and block executions rather than only blanking the + * run total: both carry their own `cost` and `tokens`, and a viewer who can sum + * the spans has not been withheld anything. Deletes rather than relies on the + * schema, because `executionDataDetailSchema` is a passthrough and a span's own + * shape is a `catchall`, so a field left in place would survive validation. + */ +export function withheldSpendData(executionData: Record): Record { + const projected: Record = { ...executionData } + if (Array.isArray(projected.traceSpans)) { + projected.traceSpans = projected.traceSpans.map(withoutSpend) + } + if (Array.isArray(projected.blockExecutions)) { + projected.blockExecutions = projected.blockExecutions.map(withoutSpend) + } + return projected +} + /** * Canonical workflow-log detail loader after workspace authorization. Returns * `null` when no matching row exists in either execution-log table. @@ -85,6 +120,7 @@ export async function readLogDetail({ lookupValue, signal, hideTraceSpans = false, + hideCostInfo = false, }: FetchLogDetailArgs): Promise { signal?.throwIfAborted() const workflowMatch: SQL = @@ -157,7 +193,7 @@ export async function readLogDetail({ // Cost is sourced exclusively from the usage_log ledger (itemized breakdown) // and its cost_total projection (run total). The cost jsonb is never read. - const costLedger = await buildCostLedger(log.executionId) + const costLedger = hideCostInfo ? null : await buildCostLedger(log.executionId) signal?.throwIfAborted() const totalDollars = costLedger?.total ?? (log.costTotal != null ? Number(log.costTotal) : null) @@ -172,7 +208,8 @@ export async function readLogDetail({ userId: viewerUserId, } ) - const executionData = hideTraceSpans ? withheldExecutionData(materialized) : materialized + const withheldPayloads = hideTraceSpans ? withheldExecutionData(materialized) : materialized + const executionData = hideCostInfo ? withheldSpendData(withheldPayloads) : withheldPayloads signal?.throwIfAborted() // A custom block's child ran in another workspace and kept its spans on its @@ -212,8 +249,8 @@ export async function readLogDetail({ createdAt: log.startedAt.toISOString(), workflow: workflowSummary, jobTitle: null, - cost: totalDollars != null ? { total: totalDollars } : null, - costLedger, + cost: hideCostInfo || totalDollars == null ? null : { total: totalDollars }, + ...(hideCostInfo ? {} : { costLedger }), pauseSummary: { status: log.pausedStatus ?? null, total: totalPauseCount, @@ -267,7 +304,10 @@ export async function readLogDetail({ userId: viewerUserId, } ) - const execData = hideTraceSpans ? withheldExecutionData(materializedJobData) : materializedJobData + const withheldJobPayloads = hideTraceSpans + ? withheldExecutionData(materializedJobData) + : materializedJobData + const execData = hideCostInfo ? withheldSpendData(withheldJobPayloads) : withheldJobPayloads signal?.throwIfAborted() return workflowLogDetailSchema.parse({ id: jobLog.id, @@ -284,7 +324,7 @@ export async function readLogDetail({ createdAt: jobLog.startedAt.toISOString(), workflow: null, jobTitle: ((execData.trigger as Record | undefined)?.source as string) ?? null, - cost: jobCostTotal(jobLog.cost), + cost: hideCostInfo ? null : jobCostTotal(jobLog.cost), pauseSummary: { status: null, total: 0, resumed: 0 }, hasPendingPause: false, executionData: { diff --git a/apps/sim/lib/logs/list-logs.test.ts b/apps/sim/lib/logs/list-logs.test.ts index 75da730b2f0..3ab31ecc96e 100644 --- a/apps/sim/lib/logs/list-logs.test.ts +++ b/apps/sim/lib/logs/list-logs.test.ts @@ -183,3 +183,31 @@ describe('readLogs', () => { expect(result.data[0].workflowId).toBe('wf-1') }) }) + +describe('readLogs cost projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + /** + * The list carries the same run total the detail does, so a group that + * withholds spend on one and not the other has withheld nothing. + */ + it('blanks the run total on workflow and job summaries alike', async () => { + queueTableRows(workflowExecutionLogs, [workflowRow()]) + queueTableRows(jobExecutionLogs, [jobRow()]) + + const result = await readLogs(baseParams({ hideCostInfo: true })) + + expect(result.data).toHaveLength(2) + for (const summary of result.data) { + expect(summary.cost).toBeNull() + } + // Nothing else about the row is withheld. + expect(result.data.find((row) => row.id === 'log-1')).toMatchObject({ + executionId: 'exec-1', + duration: '1000ms', + }) + }) +}) diff --git a/apps/sim/lib/logs/list-logs.ts b/apps/sim/lib/logs/list-logs.ts index 074cdd4753b..a5a2bf3fd3d 100644 --- a/apps/sim/lib/logs/list-logs.ts +++ b/apps/sim/lib/logs/list-logs.ts @@ -41,6 +41,12 @@ import { export type ListLogsParams = z.output & { signal?: AbortSignal + /** + * Whether the viewer's permission group withholds spend. Resolved by the + * application use case, never from the query: the contract does not carry it, + * so a client cannot ask for a row it is not entitled to. + */ + hideCostInfo?: boolean } type SortBy = 'date' | 'duration' | 'cost' | 'status' @@ -51,6 +57,7 @@ type SortOrder = 'asc' | 'desc' */ export async function readLogs(params: ListLogsParams): Promise { params.signal?.throwIfAborted() + const hideCostInfo = params.hideCostInfo === true const sortBy = params.sortBy as SortBy const sortOrder = params.sortOrder as SortOrder const cursor = params.cursor ? decodeLogSortCursor(params.cursor) : null @@ -352,7 +359,7 @@ export async function readLogs(params: ListLogsParams): Promise Date: Fri, 28 Aug 2026 21:51:02 -0700 Subject: [PATCH 018/179] chore(permission-groups): re-record the settings page module baseline --- .../settings/components/api-keys/api-keys.tsx | 11 +- .../components/group-detail.tsx | 9 +- ...check-tool-registry-boundary.baseline.json | 628 +++++++++--------- 3 files changed, 329 insertions(+), 319 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index c6586b29ff9..dd76082989b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -19,6 +19,7 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' +import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' import { type ApiKey, type ApiKeyScope, @@ -27,7 +28,6 @@ import { useUpdateWorkspaceApiKeySettings, } from '@/hooks/queries/api-keys' import { useWorkspaceSettings } from '@/hooks/queries/workspace' -import { usePermissionConfig } from '@/hooks/use-permission-config' import { CreateApiKeyModal } from './components' const logger = createLogger('ApiKeys') @@ -106,7 +106,12 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { const conflictNames = useMemo(() => new Set(conflicts), [conflicts]) const isLoading = isLoadingKeys || (showsWorkspaceKeys && isLoadingSettings) - const { config: permissionConfig } = usePermissionConfig() + /** + * The raw group config, not `usePermissionConfig` — that hook also projects + * block and model availability, which would pull the block registry into this + * settings page's module graph for one boolean. + */ + const { data: permissionData } = useUserPermissionConfig(workspaceId) /** * Both layers have to agree. The workspace column is the coarse switch every @@ -116,7 +121,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { */ const allowPersonalApiKeys = (workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true) && - !permissionConfig.disablePersonalApiKeys + !permissionData?.config?.disablePersonalApiKeys const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const [deleteKey, setDeleteKey] = useState(null) diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index c1400d63455..559fce0e015 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -108,19 +108,24 @@ const ALL_CHAT_DEPLOY_AUTH_TYPES: ShareAuthType[] = CHAT_DEPLOY_AUTH_TYPE_OPTION ) /** - * Knowledge base connectors an admin can allow/disallow. `null` config = all + * Knowledge base connectors an admin can allow or disallow. `null` config = all * allowed. Sorted by display name because the picker is read alphabetically, * while the registry is keyed by the snake_case id the server stores. + * + * The registry is the metadata half of the connector split — one small `meta.ts` + * per connector, deliberately client-safe — not the executable half, so the page + * weight it adds is sixty-eight tiny modules rather than connector runtimes. */ const KNOWLEDGE_CONNECTOR_OPTIONS: { value: string; label: string }[] = Object.values( CONNECTOR_META_REGISTRY ) .map((meta) => ({ value: meta.id, label: meta.name })) .sort((a, b) => a.label.localeCompare(b.label)) -const ALL_KNOWLEDGE_CONNECTORS: string[] = KNOWLEDGE_CONNECTOR_OPTIONS.map((o) => o.value) type StatusFilter = 'all' | 'enabled' | 'disabled' +const ALL_KNOWLEDGE_CONNECTORS: string[] = KNOWLEDGE_CONNECTOR_OPTIONS.map((o) => o.value) + const STATUS_FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ { value: 'all', label: 'Show all' }, { value: 'enabled', label: 'Show enabled' }, diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 34c38e86d06..7b8d74f30e5 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -6,76 +6,76 @@ }, "entries": { "app/api/v2/blocks/[blockId]/route.ts": { - "modules": 1652, + "modules": 1670, "gateways": { - "apps/sim/blocks/registry.ts": 532, + "apps/sim/blocks/registry.ts": 539, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/api/server/routes/index.ts": 364, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 322, - "apps/sim/lib/auth/index.ts": 311, - "apps/sim/blocks/blocks/credential-group.ts": 189, - "apps/sim/stores/workflows/registry/store.ts": 169 + "apps/sim/lib/api/server/routes/index.ts": 371, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 329, + "apps/sim/lib/auth/index.ts": 316, + "apps/sim/blocks/blocks/credential-group.ts": 191, + "apps/sim/stores/workflows/registry/store.ts": 170 } }, "app/api/v2/blocks/route.ts": { - "modules": 1651, + "modules": 1669, "gateways": { - "apps/sim/blocks/registry.ts": 532, + "apps/sim/blocks/registry.ts": 539, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/api/server/routes/index.ts": 357, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 324, - "apps/sim/lib/auth/index.ts": 313, - "apps/sim/blocks/blocks/credential-group.ts": 189, - "apps/sim/stores/workflows/registry/store.ts": 169 + "apps/sim/lib/api/server/routes/index.ts": 364, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 331, + "apps/sim/lib/auth/index.ts": 318, + "apps/sim/blocks/blocks/credential-group.ts": 191, + "apps/sim/stores/workflows/registry/store.ts": 170 } }, "app/api/v2/connector-types/route.ts": { - "modules": 1714, + "modules": 1732, "gateways": { - "apps/sim/blocks/registry.ts": 532, + "apps/sim/blocks/registry.ts": 539, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/api/server/routes/index.ts": 366, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 324, - "apps/sim/lib/auth/index.ts": 313, - "apps/sim/blocks/blocks/credential-group.ts": 189, - "apps/sim/stores/workflows/registry/store.ts": 169 + "apps/sim/lib/api/server/routes/index.ts": 373, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 331, + "apps/sim/lib/auth/index.ts": 318, + "apps/sim/blocks/blocks/credential-group.ts": 191, + "apps/sim/stores/workflows/registry/store.ts": 170 } }, "app/api/v2/tools/[toolId]/route.ts": { - "modules": 1649, + "modules": 1667, "gateways": { - "apps/sim/blocks/registry.ts": 532, + "apps/sim/blocks/registry.ts": 539, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/api/server/routes/index.ts": 364, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 322, - "apps/sim/lib/auth/index.ts": 311, - "apps/sim/blocks/blocks/credential-group.ts": 189, - "apps/sim/stores/workflows/registry/store.ts": 169 + "apps/sim/lib/api/server/routes/index.ts": 371, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 329, + "apps/sim/lib/auth/index.ts": 316, + "apps/sim/blocks/blocks/credential-group.ts": 191, + "apps/sim/stores/workflows/registry/store.ts": 170 } }, "app/api/v2/tools/route.ts": { - "modules": 1650, + "modules": 1668, "gateways": { - "apps/sim/blocks/registry.ts": 532, + "apps/sim/blocks/registry.ts": 539, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/api/server/routes/index.ts": 355, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 322, - "apps/sim/lib/auth/index.ts": 311, - "apps/sim/blocks/blocks/credential-group.ts": 189, - "apps/sim/stores/workflows/registry/store.ts": 169 + "apps/sim/lib/api/server/routes/index.ts": 362, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 329, + "apps/sim/lib/auth/index.ts": 316, + "apps/sim/blocks/blocks/credential-group.ts": 191, + "apps/sim/stores/workflows/registry/store.ts": 170 } }, "app/workspace/[workspaceId]/chat/[chatId]/error.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/chat/[chatId]/layout.tsx": { @@ -83,43 +83,43 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 2882, + "modules": 2893, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1261, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 906, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 758, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 755, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1265, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 908, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 760, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 757, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 318, - "apps/sim/lib/auth/index.ts": 238, + "apps/sim/blocks/registry.ts": 320, + "apps/sim/lib/auth/index.ts": 220, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 198 } }, "app/workspace/[workspaceId]/error.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/files/[fileId]/loading.tsx": { - "modules": 144, + "modules": 147, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 1952, + "modules": 1979, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 341, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 276, - "apps/sim/lib/auth/index.ts": 245, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 154, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 137, + "apps/sim/blocks/registry.ts": 346, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 282, + "apps/sim/lib/auth/index.ts": 232, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 161, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 144, "apps/sim/lib/webhooks/providers/index.ts": 109, "apps/sim/lib/webhooks/providers/registry.ts": 107 } @@ -132,40 +132,40 @@ } }, "app/workspace/[workspaceId]/files/error.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/files/loading.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 1952, + "modules": 1979, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 341, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 278, - "apps/sim/lib/auth/index.ts": 245, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 154, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 137, + "apps/sim/blocks/registry.ts": 346, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 284, + "apps/sim/lib/auth/index.ts": 232, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 161, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 144, "apps/sim/lib/webhooks/providers/index.ts": 109, "apps/sim/lib/webhooks/providers/registry.ts": 107 } }, "app/workspace/[workspaceId]/home/error.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/home/layout.tsx": { @@ -173,192 +173,192 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 2882, + "modules": 2893, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1261, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 906, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 758, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 755, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1265, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 908, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 760, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 757, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 318, - "apps/sim/lib/auth/index.ts": 238, + "apps/sim/blocks/registry.ts": 320, + "apps/sim/lib/auth/index.ts": 220, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 198 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1247, + "modules": 1258, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1221, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1229, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 491, - "apps/sim/blocks/blocks/credential-group.ts": 145, + "apps/sim/blocks/registry.ts": 497, + "apps/sim/blocks/blocks/credential-group.ts": 146, "apps/sim/stores/workflows/registry/store.ts": 128, "apps/sim/hooks/queries/deployments.ts": 121, "apps/sim/lib/workflows/comparison/describe.ts": 111 } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1225, + "modules": 1234, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1224, + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1233, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 349, - "apps/sim/stores/workflows/registry/store.ts": 124, - "apps/sim/hooks/queries/deployments.ts": 121, - "apps/sim/lib/workflows/comparison/describe.ts": 111, + "apps/sim/blocks/registry.ts": 357, + "apps/sim/stores/workflows/registry/store.ts": 123, + "apps/sim/hooks/queries/deployments.ts": 120, + "apps/sim/lib/workflows/comparison/describe.ts": 110, "apps/sim/hooks/selectors/registry.ts": 106, "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 54 } }, "app/workspace/[workspaceId]/integrations/error.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1232, + "modules": 1241, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1090, - "apps/sim/blocks/registry.ts": 1005, + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1096, + "apps/sim/blocks/registry.ts": 1011, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/blocks/credential-group.ts": 146, + "apps/sim/blocks/blocks/credential-group.ts": 147, "apps/sim/stores/workflows/registry/store.ts": 128, "apps/sim/hooks/queries/deployments.ts": 121, "apps/sim/lib/workflows/comparison/describe.ts": 111 } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx": { - "modules": 144, + "modules": 147, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1432, + "modules": 1441, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1287, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1293, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 344, - "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/blocks/registry.ts": 349, + "apps/sim/blocks/registry-maps.ts": 346, "apps/sim/hooks/selectors/registry.ts": 91, "apps/sim/connectors/registry.ts": 65, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 60, - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 51 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 59, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 53 } }, "app/workspace/[workspaceId]/knowledge/[id]/error.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/knowledge/[id]/loading.tsx": { - "modules": 145, + "modules": 148, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1435, + "modules": 1444, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1289, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1295, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 349, - "apps/sim/blocks/registry-maps.ts": 346, + "apps/sim/blocks/registry.ts": 357, + "apps/sim/blocks/registry-maps.ts": 354, "apps/sim/hooks/selectors/registry.ts": 91, "apps/sim/connectors/registry.ts": 65, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 60, - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 44 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 59, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 46 } }, "app/workspace/[workspaceId]/knowledge/error.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/knowledge/loading.tsx": { - "modules": 144, + "modules": 147, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2173, + "modules": 2207, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 339, - "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 290, - "apps/sim/lib/knowledge/application/knowledge-bases.ts": 234, - "apps/sim/lib/auth/index.ts": 193, + "apps/sim/blocks/registry.ts": 344, + "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 302, + "apps/sim/lib/knowledge/application/knowledge-bases.ts": 246, + "apps/sim/lib/auth/index.ts": 183, "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 154, - "apps/sim/lib/knowledge/orchestration/index.ts": 145, - "apps/sim/lib/knowledge/orchestration/connectors.ts": 141 + "apps/sim/lib/knowledge/orchestration/index.ts": 146, + "apps/sim/lib/knowledge/orchestration/connectors.ts": 142 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 2033, + "modules": 2054, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 340, - "apps/sim/lib/auth/index.ts": 317, - "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 309, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 304, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 209, + "apps/sim/blocks/registry.ts": 345, + "apps/sim/lib/auth/index.ts": 324, + "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 311, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 306, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 210, "apps/sim/lib/webhooks/providers/index.ts": 109, "apps/sim/lib/webhooks/providers/registry.ts": 107 } }, "app/workspace/[workspaceId]/logs/error.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/logs/loading.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1684, + "modules": 1708, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1541, + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1562, "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 362, - "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 316, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 310, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 272, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 261 + "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 372, + "apps/sim/blocks/registry.ts": 340, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 325, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 319, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 281, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 270 } }, "app/workspace/[workspaceId]/not-found.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/page.tsx": { @@ -366,11 +366,11 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/error.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/settings/[section]/layout.tsx": { @@ -382,16 +382,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 2130, + "modules": 2206, "gateways": { + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 538, "apps/sim/triggers/registry.ts": 472, - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 470, - "apps/sim/blocks/registry.ts": 342, - "apps/sim/lib/auth/index.ts": 299, + "apps/sim/blocks/registry.ts": 344, + "apps/sim/lib/auth/index.ts": 300, "apps/sim/lib/webhooks/providers/index.ts": 109, "apps/sim/lib/webhooks/providers/registry.ts": 107, "apps/sim/hooks/selectors/registry.ts": 71, - "apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts": 51 + "apps/sim/ee/access-control/components/access-control.tsx": 70 } }, "app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": { @@ -403,24 +403,24 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1604, + "modules": 1624, "gateways": { - "apps/sim/lib/auth/index.ts": 1467, - "apps/sim/blocks/registry.ts": 530, - "apps/sim/blocks/registry-maps.ts": 527, + "apps/sim/lib/auth/index.ts": 1487, + "apps/sim/blocks/registry.ts": 537, + "apps/sim/blocks/registry-maps.ts": 534, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/blocks/credential-group.ts": 185, - "apps/sim/stores/workflows/registry/store.ts": 168, - "apps/sim/hooks/queries/deployments.ts": 160 + "apps/sim/blocks/blocks/credential-group.ts": 187, + "apps/sim/stores/workflows/registry/store.ts": 169, + "apps/sim/hooks/queries/deployments.ts": 161 } }, "app/workspace/[workspaceId]/settings/error.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/settings/layout.tsx": { @@ -432,24 +432,24 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/loading.tsx": { - "modules": 1194, + "modules": 1202, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1191, - "apps/sim/components/permissions/index.ts": 1074, - "apps/sim/components/permissions/add-people-modal.tsx": 1065, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 1063, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1199, + "apps/sim/components/permissions/index.ts": 1077, + "apps/sim/components/permissions/add-people-modal.tsx": 1068, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 1066, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 351, - "apps/sim/blocks/registry-maps.ts": 348, + "apps/sim/blocks/registry.ts": 359, + "apps/sim/blocks/registry-maps.ts": 356, "apps/sim/stores/workflows/registry/store.ts": 124 } }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1272, + "modules": 1280, "gateways": { "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 351, - "apps/sim/blocks/registry-maps.ts": 348, + "apps/sim/blocks/registry.ts": 359, + "apps/sim/blocks/registry-maps.ts": 356, "apps/sim/stores/workflows/registry/store.ts": 123, "apps/sim/hooks/queries/deployments.ts": 120, "apps/sim/lib/workflows/comparison/describe.ts": 110, @@ -466,219 +466,219 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/usage/events/page.tsx": { - "modules": 1626, + "modules": 1632, "gateways": { - "apps/sim/lib/auth/index.ts": 1482, - "apps/sim/blocks/registry.ts": 535, - "apps/sim/blocks/registry-maps.ts": 532, + "apps/sim/lib/auth/index.ts": 1488, + "apps/sim/blocks/registry.ts": 538, + "apps/sim/blocks/registry-maps.ts": 535, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/blocks/credential-group.ts": 187, - "apps/sim/stores/workflows/registry/store.ts": 169, - "apps/sim/hooks/queries/deployments.ts": 161 + "apps/sim/blocks/blocks/credential-group.ts": 188, + "apps/sim/stores/workflows/registry/store.ts": 170, + "apps/sim/hooks/queries/deployments.ts": 162 } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1354, + "modules": 1366, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1353, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1365, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 349, - "apps/sim/blocks/registry-maps.ts": 347, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 95, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 92, + "apps/sim/blocks/registry.ts": 357, + "apps/sim/blocks/registry-maps.ts": 355, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 100, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 97, "apps/sim/hooks/queries/deployments.ts": 90, "apps/sim/lib/workflows/comparison/describe.ts": 83 } }, "app/workspace/[workspaceId]/skills/error.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1352, + "modules": 1364, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1351, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1363, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 349, - "apps/sim/blocks/registry-maps.ts": 347, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 95, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 92, + "apps/sim/blocks/registry.ts": 357, + "apps/sim/blocks/registry-maps.ts": 355, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 100, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 97, "apps/sim/hooks/queries/deployments.ts": 90, "apps/sim/lib/workflows/comparison/describe.ts": 83 } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1215, + "modules": 1222, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1073, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 1061, - "apps/sim/blocks/registry.ts": 1049, - "apps/sim/blocks/registry-maps.ts": 1047, + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1077, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 1065, + "apps/sim/blocks/registry.ts": 1053, + "apps/sim/blocks/registry-maps.ts": 1051, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/blocks/credential-group.ts": 160, + "apps/sim/blocks/blocks/credential-group.ts": 161, "apps/sim/stores/workflows/registry/store.ts": 140 } }, "app/workspace/[workspaceId]/tables/[tableId]/error.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/tables/[tableId]/loading.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 1803, + "modules": 1812, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1659, + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1666, "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 332, - "apps/sim/blocks/registry.ts": 319, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 286, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 282, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 249, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 238 + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 335, + "apps/sim/blocks/registry.ts": 321, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 289, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 285, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 252, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 241 } }, "app/workspace/[workspaceId]/tables/error.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/tables/loading.tsx": { - "modules": 142, + "modules": 145, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 1792, + "modules": 1812, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 341, - "apps/sim/lib/auth/index.ts": 339, + "apps/sim/lib/auth/index.ts": 351, + "apps/sim/blocks/registry.ts": 346, "apps/sim/lib/webhooks/providers/index.ts": 109, "apps/sim/lib/webhooks/providers/registry.ts": 107, - "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 106, + "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 105, "apps/sim/stores/workflows/registry/store.ts": 98, "apps/sim/lib/workflows/comparison/describe.ts": 88 } }, "app/workspace/[workspaceId]/upgrade/page.tsx": { - "modules": 132, + "modules": 134, "gateways": { - "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 125, - "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 77, - "apps/sim/lib/billing/client/upgrade.ts": 69, - "apps/sim/hooks/queries/organization.ts": 65, - "apps/sim/hooks/queries/workspace.ts": 56, - "apps/sim/lib/api/contracts/index.ts": 54 + "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 127, + "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 79, + "apps/sim/lib/billing/client/upgrade.ts": 71, + "apps/sim/hooks/queries/organization.ts": 67, + "apps/sim/hooks/queries/workspace.ts": 58, + "apps/sim/lib/api/contracts/index.ts": 56 } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 145, + "modules": 147, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 144, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 146, + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 109, + "apps/sim/hooks/queries/copilot-feedback.ts": 72 } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2053, + "modules": 2061, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2052, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2060, "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 344, - "apps/sim/blocks/registry.ts": 338, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 307, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 245, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 151, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 144 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 345, + "apps/sim/blocks/registry.ts": 340, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 308, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 246, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 152, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 145 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2035, + "modules": 2043, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 818, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 542, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 821, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 545, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 338, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 310, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 153, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 146, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 140 + "apps/sim/blocks/registry.ts": 340, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 311, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 154, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 147, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 141 } }, "app/workspace/layout.tsx": { - "modules": 1160, + "modules": 1168, "gateways": { - "apps/sim/app/workspace/providers/socket-provider.tsx": 1150, + "apps/sim/app/workspace/providers/socket-provider.tsx": 1158, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 351, - "apps/sim/blocks/registry-maps.ts": 348, - "apps/sim/stores/workflows/registry/store.ts": 180, - "apps/sim/hooks/queries/deployments.ts": 177, - "apps/sim/lib/workflows/comparison/describe.ts": 166, + "apps/sim/blocks/registry.ts": 359, + "apps/sim/blocks/registry-maps.ts": 356, + "apps/sim/stores/workflows/registry/store.ts": 182, + "apps/sim/hooks/queries/deployments.ts": 179, + "apps/sim/lib/workflows/comparison/describe.ts": 167, "apps/sim/hooks/selectors/registry.ts": 106 } }, "app/workspace/page.tsx": { - "modules": 1157, + "modules": 1163, "gateways": { - "apps/sim/lib/auth/stale-session-recovery.ts": 1068, + "apps/sim/lib/auth/stale-session-recovery.ts": 1072, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 351, - "apps/sim/blocks/registry-maps.ts": 348, + "apps/sim/blocks/registry.ts": 359, + "apps/sim/blocks/registry-maps.ts": 356, "apps/sim/stores/workflows/registry/store.ts": 130, "apps/sim/hooks/queries/deployments.ts": 123, "apps/sim/lib/workflows/comparison/describe.ts": 113 } }, "lib/catalog/projection/block-detail.ts": { - "modules": 1138, + "modules": 1146, "gateways": { - "apps/sim/lib/catalog/projection/block-summary.ts": 597, - "apps/sim/blocks/registry-maps.ts": 593, + "apps/sim/lib/catalog/projection/block-summary.ts": 602, + "apps/sim/blocks/registry-maps.ts": 598, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/blocks/credential-group.ts": 219, - "apps/sim/stores/workflows/registry/store.ts": 190, - "apps/sim/hooks/queries/deployments.ts": 181, - "apps/sim/lib/workflows/comparison/describe.ts": 170 + "apps/sim/blocks/blocks/credential-group.ts": 223, + "apps/sim/stores/workflows/registry/store.ts": 193, + "apps/sim/hooks/queries/deployments.ts": 184, + "apps/sim/lib/workflows/comparison/describe.ts": 172 } }, "lib/catalog/projection/block-summary.ts": { - "modules": 1134, + "modules": 1142, "gateways": { - "apps/sim/blocks/registry.ts": 1126, - "apps/sim/blocks/registry-maps.ts": 1123, + "apps/sim/blocks/registry.ts": 1134, + "apps/sim/blocks/registry-maps.ts": 1131, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/blocks/credential-group.ts": 220, - "apps/sim/stores/workflows/registry/store.ts": 190, - "apps/sim/hooks/queries/deployments.ts": 181, - "apps/sim/lib/workflows/comparison/describe.ts": 170 + "apps/sim/blocks/blocks/credential-group.ts": 224, + "apps/sim/stores/workflows/registry/store.ts": 193, + "apps/sim/hooks/queries/deployments.ts": 184, + "apps/sim/lib/workflows/comparison/describe.ts": 172 } }, "lib/catalog/projection/connector-type.ts": { @@ -694,15 +694,15 @@ "gateways": {} }, "lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts": { - "modules": 1257, + "modules": 1266, "gateways": { - "apps/sim/blocks/registry.ts": 553, - "apps/sim/blocks/registry-maps.ts": 551, + "apps/sim/blocks/registry.ts": 560, + "apps/sim/blocks/registry-maps.ts": 558, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/blocks/credential-group.ts": 205, - "apps/sim/stores/workflows/registry/store.ts": 181, - "apps/sim/hooks/queries/deployments.ts": 172, + "apps/sim/blocks/blocks/credential-group.ts": 207, + "apps/sim/stores/workflows/registry/store.ts": 182, + "apps/sim/hooks/queries/deployments.ts": 173, "apps/sim/lib/workflows/comparison/describe.ts": 161 } } From 015fde25a4fa9abebff747452f463b3abe3dd95f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 28 Aug 2026 22:02:11 -0700 Subject: [PATCH 019/179] feat(permission-groups): require a capability on every workspace operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 287 operations now declare one, so the field becomes required and omitting it is a compile error rather than an unreviewed gap. That is the whole point: an absent field could not be told apart from an operation nobody had looked at, which is how twelve config keys shipped with an admin checkbox and no server gate. The last seven are logs reads and the public-API workspace reads, all `'none'` with reasons. The logs ones say the thing worth remembering: a group withholds *fields* inside a run — trace spans, cost — not the fact that it ran, so refusing the read would be a different restriction from the one the admin set. Also made the connector allowlist actorless-safe. It required a human subject, so a scheduled sync would have hit a 500 instead of a refusal; it now passes through with no user, exactly as the funnel treats an actorless caller. `check:actorless-executor-operations` caught that — one audit catching the other's blind spot is the argument for having both. --- .../core/application/workspace-authorization.ts | 2 +- .../lib/core/application/workspace-operation.ts | 7 ++++--- apps/sim/lib/knowledge/application/connectors.ts | 14 +++++++++++--- apps/sim/lib/logs/application/operations.ts | 8 ++++++++ apps/sim/lib/workspaces/application/operations.ts | 6 ++++++ 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index 0bb4fa88938..598d84e2129 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -233,7 +233,7 @@ async function requireCapability( operation: WorkspaceOperation ): Promise { const capability = operation.capability - if (capability === undefined || capability === 'none') return + if (capability === 'none') return await requireWorkspaceCapability(userId, context, capability) } diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts index d8b82bf8613..10a6774700d 100644 --- a/apps/sim/lib/core/application/workspace-operation.ts +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -56,10 +56,11 @@ export interface WorkspaceOperation< * `'none'` is spelled out rather than left as an omission, because an absent * field cannot be told apart from an unreviewed one — and unreviewed omission * is exactly how twelve config keys shipped with an admin checkbox and no - * server gate. Optional only while the operations are being annotated; - * `check:permission-group-enforcement` reports what is still unfilled. + * server gate. Required, so the question has to be answered once per + * operation; `check:permission-group-enforcement` additionally requires a + * `// permission-group-exempt:` reason wherever the answer is `'none'`. */ - readonly capability?: StaticPermissionGroupCapability | 'none' + readonly capability: StaticPermissionGroupCapability | 'none' } type WorkspaceApiKeyPrincipalConsistency< diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index 812b1b54121..d87ec80762c 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import { db } from '@sim/db' import { document, knowledgeConnector, knowledgeConnectorSyncLog } from '@sim/db/schema' import { and, asc, count, desc, eq, inArray, isNull } from 'drizzle-orm' @@ -120,11 +120,19 @@ const CONNECTOR_ALLOWLIST_RULE = CAPABILITY_RULES['knowledge.connectors'] * No-op when no permission group governs the caller, which is what keeps * non-enterprise and ungoverned organizations unaffected. */ +/** + * A permission group is a membership of users, so an actorless caller — a + * schedule, or a webhook with no external subject — resolves no group and + * passes through, exactly as the authorization funnel treats one. Requiring a + * subject here would turn every scheduled connector sync into a 500 rather than + * a refusal anyone could act on. + */ async function assertConnectorTypeAllowed( - userId: string, + userId: string | undefined, workspaceId: string, connectorType: string ): Promise { + if (!userId) return const config = await getUserPermissionConfig(userId, workspaceId) if (!config || !CONNECTOR_ALLOWLIST_RULE.deniedBy(config, connectorType)) return @@ -327,7 +335,7 @@ export const createKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ const actingUserId = resolveKnowledgeAttributedUserId(principal, context) // permission-group-enforced: knowledge.connectors — needs the request's connector id, which the funnel never sees await assertConnectorTypeAllowed( - requirePrincipalSubjectUserId(principal), + resolvePrincipalSubjectUserId(principal), workspaceId, input.connectorType ) diff --git a/apps/sim/lib/logs/application/operations.ts b/apps/sim/lib/logs/application/operations.ts index 2d84652e988..e0b760eb506 100644 --- a/apps/sim/lib/logs/application/operations.ts +++ b/apps/sim/lib/logs/application/operations.ts @@ -7,28 +7,36 @@ const LOG_READER_PRINCIPAL_POLICY = { } as const export const logOperations = { + // permission-group-exempt: reading the log list is governed by workspace role; the group withholds fields inside a run — trace spans and cost — not the fact that it ran list: defineWorkspaceOperation({ id: 'logs.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...LOG_READER_PRINCIPAL_POLICY, }), + // permission-group-exempt: aggregate run counts carry no execution payload, so there is nothing here for a group to withhold readStats: defineWorkspaceOperation({ id: 'logs.read_stats', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: PUBLIC_API_PRINCIPAL_KINDS, }), + // permission-group-exempt: as with the list, the group projects trace spans and cost out of the response rather than refusing the read readDetail: defineWorkspaceOperation({ id: 'logs.read_detail', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...LOG_READER_PRINCIPAL_POLICY, }), + // permission-group-exempt: the executor reading its own run's snapshot mid-flight; refusing it would fail runs the group permits readExecutionSnapshot: defineWorkspaceOperation({ id: 'logs.read_execution_snapshot', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session', 'delegated'], delegatedServices: ['executor'], }), diff --git a/apps/sim/lib/workspaces/application/operations.ts b/apps/sim/lib/workspaces/application/operations.ts index e9db632e151..b1ee6303784 100644 --- a/apps/sim/lib/workspaces/application/operations.ts +++ b/apps/sim/lib/workspaces/application/operations.ts @@ -3,22 +3,28 @@ import { defineWorkspaceOperation } from '@/lib/core/application' const PUBLIC_API_PRINCIPAL_KINDS = ['personal_api_key', 'workspace_api_key'] as const export const workspaceOperations = { + // permission-group-exempt: the public API's own view of the workspaces a key can reach; it answers what that credential already proves, and `disablePublicApi` governs whether the key reaches the surface at all listPublic: defineWorkspaceOperation({ id: 'workspaces.list_public', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: PUBLIC_API_PRINCIPAL_KINDS, }), + // permission-group-exempt: the same surface as the list, describing one workspace the key already reaches readPublicDetail: defineWorkspaceOperation({ id: 'workspaces.read_public_detail', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: PUBLIC_API_PRINCIPAL_KINDS, }), + // permission-group-exempt: names of people the caller already shares a workspace with; `hideOrgMemberDirectory` covers the organization-wide roster and its email addresses, which is the materially different disclosure listPublicMembers: defineWorkspaceOperation({ id: 'workspaces.members.list_public', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: PUBLIC_API_PRINCIPAL_KINDS, }), } as const From 31cce78e24a761c2b2ab5718999258d7f53adde5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 29 Aug 2026 08:53:52 -0700 Subject: [PATCH 020/179] refactor(permission-groups): one way to ask whether a capability is withheld MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four had accumulated: the authorization funnel, a separate assertions module, five bespoke per-domain helpers, and raw config-key reads at call sites. Two of them resolved the config through different helpers, so the assertions module silently bypassed the per-request memo the funnel uses, and the two refusal messages were built independently and could drift. `capability-assertions.ts` is now the single API — workspace-scoped and organization-scoped, throwing and non-throwing — and the funnel delegates to it. Everything reads `CAPABILITY_RULES` rather than a config key spelled out locally, so a renamed key cannot quietly stop denying anything. `resolvePermissionGroupConfig` takes an optional organization id: a caller that already loaded the workspace passes it, a raw route omits it, and both share one memo keyed on user and workspace. Previously the second case had no memo at all. `PermissionGroupCapabilityError` moved into the permission-groups module. The assertions need to throw it and the funnel needs to call them, so leaving it beside the funnel made the two import each other. The personal-API-key check now reads its rule instead of the config key directly, so it cannot disagree with the capability of the same name. --- apps/sim/lib/core/application/index.ts | 1 - .../application/workspace-authorization.ts | 85 +++-------------- .../application/credential-crud.ts | 13 ++- .../capability-assertions.ts | 91 +++++++++++++++---- .../lib/permission-groups/capability-error.ts | 24 +++++ .../permission-groups/config-scope.server.ts | 17 +++- 6 files changed, 133 insertions(+), 98 deletions(-) create mode 100644 apps/sim/lib/permission-groups/capability-error.ts diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index d0584f25be7..144b10d9785 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -38,7 +38,6 @@ export { PersonalApiKeysDisabledError, PrincipalKindAuthorizationError, requireAllowedWorkspacePrincipal, - requireWorkspaceCapability, WorkspaceApiKeyAuthorizationError, WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application/workspace-authorization' diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index 598d84e2129..8e5c59c8924 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -9,20 +9,18 @@ import { permissionSatisfies, resolveEffectiveWorkspacePermission, } from '@sim/platform-authz/workspace' -import { type ForbiddenDetailCode, ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import type { PrincipalForOperation, WorkspaceOperation, } from '@/lib/core/application/workspace-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' import { - CAPABILITY_RULES, - type CapabilityRule, - capabilityRefusalMessage, - type PermissionGroupCapability, -} from '@/lib/permission-groups/capabilities' + assertWorkspaceCapability, + capabilityDeniedBy, +} from '@/lib/permission-groups/capability-assertions' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' -import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' export interface WorkspaceAuthorizationContext { workspaceId: string @@ -61,24 +59,7 @@ export class NoWorkspaceAccessError extends OrchestrationError { } } -/** - * The caller's permission group withholds a capability the operation needs. - * - * Carries the capability so a log line and an audit entry can name it; the - * message names it for the caller. One detail code covers every capability - * because the remedy is the same for all of them — the closed code set is - * closed over remedies, not over causes. - */ -export class PermissionGroupCapabilityError extends ForbiddenOperationError { - constructor( - readonly capability: PermissionGroupCapability, - detailCode: ForbiddenDetailCode, - describe: string - ) { - super(detailCode, capabilityRefusalMessage(describe)) - this.name = 'PermissionGroupCapabilityError' - } -} +export { PermissionGroupCapabilityError } export class PersonalApiKeysDisabledError extends ForbiddenOperationError { constructor() { @@ -176,50 +157,6 @@ function requirePermission(permission: PermissionType | null, required: Permissi } } -/** - * Refuses a capability against an already-resolved config. A `null` config means - * no group governs the caller, which is not a denial. - */ -function requireCapabilityFromConfig( - config: PermissionGroupConfig | null, - capability: PermissionGroupCapability -): void { - if (!config) return - - const rule: CapabilityRule = CAPABILITY_RULES[capability] - if (rule.kind !== 'static' || !rule.deniedBy(config)) return - - throw new PermissionGroupCapabilityError(capability, rule.detailCode, rule.describe) -} - -/** - * Resolves the caller's group for this workspace, then refuses the capability. - * - * Exported for a use case whose capability depends on request input the - * operation cannot name — which credential scope is being created, say — so the - * funnel cannot decide it from the operation alone. Going through here rather - * than reading the config key directly keeps the refusal, its detail code and - * its message identical to a funnel refusal, and it shares the per-request memo - * the funnel uses, so asserting on top of an operation's own capability costs no - * extra query. - */ -export async function requireWorkspaceCapability( - userId: string, - context: WorkspaceAuthorizationContext, - capability: PermissionGroupCapability -): Promise { - if (context.workspaceOrganizationId === null) return - - requireCapabilityFromConfig( - await resolvePermissionGroupConfig( - userId, - context.workspaceId, - context.workspaceOrganizationId - ), - capability - ) -} - /** * Refuses an operation whose capability the caller's permission group withholds. * @@ -234,8 +171,14 @@ async function requireCapability( ): Promise { const capability = operation.capability if (capability === 'none') return + if (context.workspaceOrganizationId === null) return - await requireWorkspaceCapability(userId, context, capability) + await assertWorkspaceCapability( + userId, + context.workspaceId, + capability, + context.workspaceOrganizationId + ) } /** @@ -256,7 +199,7 @@ async function requirePersonalApiKeysAllowed( context.workspaceId, context.workspaceOrganizationId ) - if (config?.disablePersonalApiKeys) throw new PersonalApiKeysDisabledError() + if (capabilityDeniedBy('personal_api_key.use', config)) throw new PersonalApiKeysDisabledError() } /** diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts index 1204ab9f35a..e0ba3090979 100644 --- a/apps/sim/lib/credentials/application/credential-crud.ts +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -1,9 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId } from '@sim/auth/principal' -import { - defineAuthorizedWorkspaceUseCase, - requireWorkspaceCapability, -} from '@/lib/core/application' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { getBlockVisibility } from '@/lib/core/config/block-visibility' import { OrchestrationError } from '@/lib/core/orchestration/types' import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' @@ -32,6 +29,7 @@ import { } from '@/lib/credentials/queries' import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -197,7 +195,12 @@ export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ * against the type actually being created. */ if (PERSONAL_SCOPE_CREDENTIAL_TYPES.has(input.type)) { - await requireWorkspaceCapability(userId, context, 'credentials.personal') + await assertWorkspaceCapability( + userId, + context.workspaceId, + 'credentials.personal', + context.workspaceOrganizationId + ) } const result = await createCredentialRecord({ ...input, userId }, { authorizeWorkspace: false }) if (!result.success) throwCredentialMutationFailure(result) diff --git a/apps/sim/lib/permission-groups/capability-assertions.ts b/apps/sim/lib/permission-groups/capability-assertions.ts index caccf6d4fde..f8869351a1a 100644 --- a/apps/sim/lib/permission-groups/capability-assertions.ts +++ b/apps/sim/lib/permission-groups/capability-assertions.ts @@ -1,22 +1,24 @@ -import { PermissionGroupCapabilityError } from '@/lib/core/application/workspace-authorization' import { CAPABILITY_RULES, capabilityRefusalMessage, type StaticPermissionGroupCapability, } from '@/lib/permission-groups/capabilities' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { getUserPermissionConfigForOrganization } from '@/ee/access-control/utils/permission-check' /** - * The capability gate for callers the authorization funnel cannot serve. + * The one way to ask whether a permission group withholds a capability. * - * The funnel decides from the operation alone, which is right for a capability - * that describes the whole operation. Two cases fall outside it: a request whose - * capability depends on its own input — one download is a single file, the next - * is a folder tree — and a raw route that predates the operation boundary. Both - * assert here so the decision still comes from {@link CAPABILITY_RULES} rather - * than from a config key spelled out at the call site, where a renamed key would - * silently stop denying anything. + * The authorization funnel decides from the operation alone, which is right when + * the capability describes the whole operation. Three cases fall outside it: a + * decision that depends on request input (one download is a single file, the + * next a folder tree), a raw route that predates the operation boundary, and an + * organization-level action with no workspace. All of them come through here, so + * the decision always reads {@link CAPABILITY_RULES} rather than a config key + * spelled out at a call site — where a renamed key would silently stop denying + * anything, and the refusal wording would drift from the funnel's. */ export function capabilityDeniedBy( capability: StaticPermissionGroupCapability, @@ -32,18 +34,73 @@ export function capabilityRefusal(capability: StaticPermissionGroupCapability): return capabilityRefusalMessage(CAPABILITY_RULES[capability].describe) } +function refuse(capability: StaticPermissionGroupCapability): never { + throw new PermissionGroupCapabilityError( + capability, + CAPABILITY_RULES[capability].detailCode, + capabilityRefusal(capability) + ) +} + /** - * Throws {@link PermissionGroupCapabilityError} when `userId`'s group in - * `workspaceId` withholds `capability`. A no-op when no group governs the user, - * so workspaces outside an enterprise organization are unaffected. + * Throws when `userId`'s group in `workspaceId` withholds `capability`. + * + * A no-op when no group governs the user, so a personal workspace or a + * non-enterprise organization is unaffected. Pass `organizationId` when the + * caller has already loaded the workspace; omitting it costs one lookup, and + * both forms share the same per-request memo either way. */ export async function assertWorkspaceCapability( userId: string, workspaceId: string, + capability: StaticPermissionGroupCapability, + organizationId?: string | null +): Promise { + const config = await resolvePermissionGroupConfig(userId, workspaceId, organizationId) + if (capabilityDeniedBy(capability, config)) refuse(capability) +} + +/** + * The same refusal for an action that names an organization rather than a + * workspace — creating one, or reading its member directory. + * + * Resolves the organization's default group, which is what governs a member for + * an action no workspace scopes; a non-default group targets specific + * workspaces and has nothing to say here. + */ +export async function assertOrganizationCapability( + organizationId: string, capability: StaticPermissionGroupCapability ): Promise { - const config = await getUserPermissionConfig(userId, workspaceId) - if (!capabilityDeniedBy(capability, config)) return - const rule = CAPABILITY_RULES[capability] - throw new PermissionGroupCapabilityError(capability, rule.detailCode, rule.describe) + const config = await getUserPermissionConfigForOrganization(organizationId) + if (capabilityDeniedBy(capability, config)) refuse(capability) +} + +/** + * Whether the capability is withheld, without throwing. + * + * For a caller that must answer rather than refuse — a raw handler rendering its + * own response shape, or a policy that reports a structured decision. + */ +export async function isWorkspaceCapabilityWithheld( + userId: string, + workspaceId: string, + capability: StaticPermissionGroupCapability, + organizationId?: string | null +): Promise { + return capabilityDeniedBy( + capability, + await resolvePermissionGroupConfig(userId, workspaceId, organizationId) + ) +} + +/** The organization-scoped counterpart of {@link isWorkspaceCapabilityWithheld}. */ +export async function isOrganizationCapabilityWithheld( + organizationId: string, + capability: StaticPermissionGroupCapability +): Promise { + return capabilityDeniedBy( + capability, + await getUserPermissionConfigForOrganization(organizationId) + ) } diff --git a/apps/sim/lib/permission-groups/capability-error.ts b/apps/sim/lib/permission-groups/capability-error.ts new file mode 100644 index 00000000000..9c54c30295b --- /dev/null +++ b/apps/sim/lib/permission-groups/capability-error.ts @@ -0,0 +1,24 @@ +import { type ForbiddenDetailCode, ForbiddenOperationError } from '@/lib/core/application/forbidden' +import type { PermissionGroupCapability } from '@/lib/permission-groups/capabilities' + +/** + * The caller's permission group withholds a capability the request needs. + * + * Carries the capability so a log line or an audit entry can name it; the + * message names it for the caller. One detail code covers every capability + * because the remedy is the same for all of them — the closed code set is closed + * over remedies, not over causes. + * + * Lives here rather than beside the authorization funnel so the assertion + * helpers can throw it without importing the funnel, which imports them. + */ +export class PermissionGroupCapabilityError extends ForbiddenOperationError { + constructor( + readonly capability: PermissionGroupCapability, + detailCode: ForbiddenDetailCode, + message: string + ) { + super(detailCode, message) + this.name = 'PermissionGroupCapabilityError' + } +} diff --git a/apps/sim/lib/permission-groups/config-scope.server.ts b/apps/sim/lib/permission-groups/config-scope.server.ts index 8e3735838a6..9840bc9ea30 100644 --- a/apps/sim/lib/permission-groups/config-scope.server.ts +++ b/apps/sim/lib/permission-groups/config-scope.server.ts @@ -1,6 +1,9 @@ import { cache } from 'react' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' -import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check' +import { + getUserPermissionConfig, + resolveVerifiedUserAccessControlContext, +} from '@/ee/access-control/utils/permission-check' type ConfigKey = `${string}:${string}` type ConfigStore = Map> @@ -44,9 +47,11 @@ const resolveCached = cache( async ( userId: string, workspaceId: string, - organizationId: string | null + organizationId: string | null | undefined ): Promise => - (await resolveVerifiedUserAccessControlContext(userId, workspaceId, organizationId)).config + organizationId === undefined + ? await getUserPermissionConfig(userId, workspaceId) + : (await resolveVerifiedUserAccessControlContext(userId, workspaceId, organizationId)).config ) /** @@ -59,11 +64,15 @@ const resolveCached = cache( * * Outside a scope this degrades to the React memo, and outside a request to a * direct call: slower, never wrong. + * + * Pass `undefined` for `organizationId` when the caller has not already loaded + * the workspace — a raw route, typically. The resolver looks it up, and both + * forms share this memo, so a request that mixes them still queries once. */ export function resolvePermissionGroupConfig( userId: string, workspaceId: string, - organizationId: string | null + organizationId: string | null | undefined ): Promise { const store = storage.getStore() if (!store) return resolveCached(userId, workspaceId, organizationId) From 85975b484bc20ae9df4e3d7a55ae9ae5f1ad20b9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 29 Aug 2026 09:01:36 -0700 Subject: [PATCH 021/179] docs(skills): add add-permission-group-item and validate-permission-group-item Two skills for the enterprise permission-group system: the end-to-end procedure for wiring a new governed item (field registry -> capability rule -> operation declaration or use-case assertion -> golden corpus), and the procedure for auditing an existing one by proving the refusal rather than assuming it. --- .../skills/add-permission-group-item/SKILL.md | 252 ++++++++++++++++++ .../validate-permission-group-item/SKILL.md | 127 +++++++++ .claude/skills/add-permission-group-item | 1 + .claude/skills/validate-permission-group-item | 1 + 4 files changed, 381 insertions(+) create mode 100644 .agents/skills/add-permission-group-item/SKILL.md create mode 100644 .agents/skills/validate-permission-group-item/SKILL.md create mode 120000 .claude/skills/add-permission-group-item create mode 120000 .claude/skills/validate-permission-group-item diff --git a/.agents/skills/add-permission-group-item/SKILL.md b/.agents/skills/add-permission-group-item/SKILL.md new file mode 100644 index 00000000000..1026783f877 --- /dev/null +++ b/.agents/skills/add-permission-group-item/SKILL.md @@ -0,0 +1,252 @@ +--- +name: add-permission-group-item +description: Add a new governed item to Sim's enterprise permission groups — a boolean restriction, an allowlist, or a denylist — wired end-to-end from the field registry through the capability rule to the server gate that actually refuses. Use when adding a key to `PERMISSION_GROUP_FIELDS` or a capability to `CAPABILITY_RULES`. +argument-hint: +--- + +# Add Permission Group Item Skill + +You are adding one governed item to the enterprise permission-group system: something an organization admin can withhold from a cohort of members. The system is registry-driven — one entry in `apps/sim/lib/permission-groups/fields.ts` produces the write schema, the read schema, the `PermissionGroupConfig` type, the defaults, the tolerant parser, and (for a boolean) the admin editor row. + +**What the registry does not produce is enforcement.** Twelve keys once shipped with an admin checkbox, a hint describing what they restrict, and no server check at all — an organization that ticked `hideCopilot` believed it had withheld a capability while every API route still answered. That failure is the reason for the `enforcement` field, the `capability` field on every operation, and `scripts/check-permission-group-enforcement.ts`. Your job is not done when the key parses; it is done when something refuses. + +## Read the system first + +Read these completely before editing. Do not infer their shape from this document. + +- `apps/sim/lib/permission-groups/fields.ts` — the registry, the three field builders, the tolerant parser +- `apps/sim/lib/permission-groups/capabilities.ts` — `CAPABILITY_IDS`, `CAPABILITY_RULES`, the static/parameterized split +- `apps/sim/lib/permission-groups/capability-assertions.ts` — the only sanctioned way to ask whether a group withholds something +- `apps/sim/lib/core/application/workspace-operation.ts` — the required `capability` field +- `apps/sim/lib/core/application/workspace-authorization.ts` — where the funnel enforces, and who passes through +- `scripts/check-permission-group-enforcement.ts` — the audit you have to satisfy + +## Step 0: Decide what kind of thing it is + +Three questions, in order. Answer all three before writing any code. + +**What shape is the value?** + +| Kind | Builder | Default | Semantics | +|---|---|---|---| +| Boolean restriction | `booleanRestriction(enforcement, feature)` | `false` | `true` withholds. Named `hideX` / `disableX`, never `allowX` | +| Allowlist | `allowlist(item, enforcement, { limited, empty })` | `null` | `null` allows everything; a set names the only permitted members; `[]` permits nothing | +| Denylist | `denylist(item, enforcement, phrasing)` | `[]` | Empty permits everything; members are refused | + +Choose an allowlist when the safe posture is "only what the admin named" and the member set is enumerable and stable (auth modes, connectors, model providers). Choose a denylist when the safe posture is "everything except what the admin named" and the member set is open-ended (individual tool ids, individual models — an allowlist over a thousand tools is unmaintainable and grows a hole every time a tool ships). + +**Which mechanism refuses?** This is the `enforcement` value and it is a claim the audit checks. + +- `'capability'` — an operation declares a capability whose rule reads the key, so the authorization funnel refuses before the use case runs. This is the default answer for anything reachable through an application operation. +- `'executor'` — read per block, tool, or model at execution time by `assertPermissionsAllowed` in `apps/sim/ee/access-control/utils/permission-check.ts`. It governs what a *run* may do, which no operation-level gate can express: one API call can execute fifty blocks. `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, and `deniedTools` are the four that live here. +- `'ui-only'` — the key hides a surface without withholding it, so a caller who skips the UI still reaches the API. **Almost never the right answer.** Choose it only when you can say, in the `enforcement` comment, why a determined caller reaching the data anyway is acceptable. Nothing currently ships as `ui-only`; if yours is the first, expect that to be questioned in review. + +**Is the decision knowable from the config alone?** A rule that needs a value only the request carries — an auth mode, a connector id, a file id — is *parameterized*, and parameterized rules cannot be declared on an operation. See Step 3. + +## Step 1: Add the field entry — at the end + +Append one entry to `PERMISSION_GROUP_FIELDS`. **Append, never insert.** + +```ts + disableWidgetSharing: booleanRestriction('capability', { + id: 'disable-widget-sharing', + label: 'Widget Sharing', + category: 'Features', + hint: 'Prevent sharing a widget outside the workspace.', + }), +``` + +Declaration order here is the key order of `PermissionGroupConfig`, of both zod schemas, and of every config JSON that crosses the API boundary. The group editor in `apps/sim/ee/access-control/components/group-detail.tsx` runs its dirty check by comparing stringified configs, so moving an existing key makes every open editor read as having unsaved changes. The registry already carries a TSDoc note on `disablePersonalApiKeys` saying exactly this — extend the tail, do not tidy the middle. + +Three things to get right in the entry itself: + +**The default must be the permissive value.** Every config row already stored in the `permission_group.config` column predates your key. `parsePermissionGroupConfig` fills the gap from the field's default, and the create/update route merges a partial write over the stored config. If your default is the restrictive value, adding the key silently applies a new restriction to every existing group in every enterprise organization, with nothing in the admin UI having changed. This is why the boolean builder hardcodes `false`, the allowlist `null`, and the denylist `[]` — but it is also why a *new* key must be phrased so that the permissive value is falsy. `disableWidgetSharing: false` is correct; a hypothetical `requireWidgetApproval` whose safe default is `true` cannot use `booleanRestriction` and needs its meaning inverted before it can. + +**The admin checkbox is inverted.** `group-detail.tsx` renders `checked={!editingConfig[feature.configKey]}` — ticked means *allowed*. A key named `allowX` would render backwards. Name it `hideX` or `disableX`. + +**The category must be in `PLATFORM_CATEGORY_ORDER`.** That constant lives in `apps/sim/lib/permission-groups/features.ts`. An unlisted category still renders, but at the end, after every ordered section. + +Note that `PLATFORM_FEATURES` — the array the editor renders — is *derived* from the registry in `features.ts`, not hand-listed. A boolean key cannot reach the config without reaching the editor, which is deliberate: an unrendered key is one an admin can neither set nor see. + +## Step 2: Only booleans get an admin UI for free + +`PLATFORM_FEATURES` filters on `field.kind === 'boolean-restriction'`. An allowlist or denylist you add renders **nothing** — the key exists, the API accepts it, and no admin can ever set it. + +Nested pickers hang off the `featureExtras` map in `group-detail.tsx`, keyed by the **feature id of the boolean it nests under**, not by the allowlist's own config key: + +```ts + const featureExtras: Partial> = { + 'hide-knowledge-base': ( + + ), + } +``` + +Copy the shape of `setKnowledgeConnectors` for your setter. Two behaviors are load-bearing and easy to drop: + +- **Refuse an empty selection** (`if (values.length === 0) return`). An emptied allowlist denies every member while the parent checkbox still reads as allowed, which is an admin footgun with no visible cause. Withholding the whole thing is what the parent checkbox is for. +- **Collapse "everything selected" back to `null`** (`values.length === ALL.length ? null : values`). Storing the full set works, but it freezes the allowlist at today's members — a connector added next release would be denied by a group that had selected "all". + +Choose the parent deliberately. `allowedKnowledgeConnectors` nests under `hide-knowledge-base` rather than under `disable-knowledge-base-creation`, because a connector attaches to an *existing* knowledge base: hanging it off creation would dim the picker for exactly the cohort it was written for. + +## Step 3: Add the capability id and rule + +Skip this step only if `enforcement` is `'executor'` or `'ui-only'`. For `'capability'`, add the id to `CAPABILITY_IDS` and an entry to `CAPABILITY_RULES` in `apps/sim/lib/permission-groups/capabilities.ts`. `CAPABILITY_RULES` uses `satisfies { readonly [K in PermissionGroupCapability]: CapabilityRule }`, so adding an id fails to compile until the rule exists. + +Capability ids are **domain-shaped** (`tables.create`), while config keys are **surface-shaped** (`disableTableCreation`). That is intentional: an operation names what it does, the config names what an admin sees, and `CAPABILITY_RULES` is the only place the two vocabularies meet. + +A static rule: + +```ts + 'widgets.share': { + kind: 'static', + configKeys: ['disableWidgetSharing'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Sharing widgets', + deniedBy: (config) => config.disableWidgetSharing, + }, +``` + +`configKeys` is what the audit reads to prove your key is enforced — it must list every key `deniedBy` actually reads. `describe` is substituted into `capabilityRefusalMessage`, which produces `" is not available under your organization's permission group"`, so write it as a noun phrase that fits. + +Use `'PERMISSION_GROUP_CAPABILITY_BLOCKED'` for `detailCode` unless a caller can act differently on this specific refusal. The set in `apps/sim/lib/core/application/forbidden.ts` is closed **over remedies, not over causes** — a new code is warranted only when the remedy differs from "ask an organization admin". Adding one also requires an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS` (a compile-time gate) and publishes a new value in the generated OpenAPI 403 description. + +### Static vs parameterized + +If the decision needs a request value, the rule is `kind: 'parameterized'` and takes a second argument: + +```ts + 'knowledge.connectors': { + kind: 'parameterized', + configKeys: ['allowedKnowledgeConnectors'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'This knowledge base connector', + deniedBy: (config, connectorType) => + allowlistDenies(config.allowedKnowledgeConnectors, connectorType), + }, +``` + +A parameterized capability **cannot be declared on an operation**. The authorization funnel decides from the principal, the workspace, and the operation — it never sees request input, and widening the authorization context to carry it would reach every one of the ~287 operations for the sake of two keys. `defineWorkspaceOperation` throws at definition time if you try: + +``` +Operation declares parameterized capability ; assert it from the use case instead +``` + +That throw is deliberate. Left unchecked, the operation would read as gated and the gate would silently never fire. + +Do not annotate `CAPABILITY_RULES` with its type instead of using `satisfies`. Annotating widens every entry to `CapabilityRule`, at which point `StaticPermissionGroupCapability` resolves to `never`, no operation can declare any capability, and every gate stops firing — with nothing at runtime looking wrong. `AssertsStaticCapabilityResolves` at the bottom of the file exists to catch exactly that. + +## Step 4: Declare it on the operations it governs, or assert it at the call site + +**Static, and the operation is the whole decision** — set `capability` on the `defineWorkspaceOperation` call. The funnel does the rest; you write no gate code. + +```ts +export const shareWidget = defineWorkspaceOperation({ + id: 'widgets.share', + minimumRole: 'write', + workspaceApiKey: 'allow', + capability: 'widgets.share', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], +}) +``` + +If the domain wraps `defineWorkspaceOperation` in a same-file factory, the audit resolves the capability through it — either fixed in the factory body or taken as a positional second argument. `apps/sim/lib/table/application/operations.ts` shows both, and deliberately gives the positional form **no default**: a default would let a new operation inherit `tables.use` without anyone deciding it should, which is the unreviewed omission the whole gate exists to prevent. + +**Parameterized, or no operation to hang it on** — assert from inside the use case through `capability-assertions.ts`, and annotate the call site: + +```ts + // permission-group-enforced: knowledge.connectors — needs the request's connector id, which the funnel never sees + await assertConnectorTypeAllowed( + resolvePrincipalSubjectUserId(principal), + workspaceId, + input.connectorType + ) +``` + +Always route the decision through `CAPABILITY_RULES` (via `assertWorkspaceCapability`, `assertOrganizationCapability`, `capabilityDeniedBy`, or the `isWorkspaceCapabilityWithheld` / `isOrganizationCapabilityWithheld` non-throwing pair). Never spell the config key out at the call site: a renamed key would silently stop denying anything, and the refusal wording would drift from the funnel's. + +Use `assertOrganizationCapability` for an action that names an organization rather than a workspace — creating a workspace, reading the member directory. It resolves the organization's *default* group, because a non-default group targets specific workspaces and has nothing to say about an action no workspace scopes. + +Guard on the acting user being present. A permission group is a membership of users, so an actorless caller resolves no group; `assertConnectorTypeAllowed` returns early on a missing `userId` rather than throwing, which is what keeps a scheduled sync from becoming a 500 instead of a refusal anyone could act on. + +**The operation is genuinely ungoverned** — write `capability: 'none'` with a `// permission-group-exempt: ` comment directly above it. `'none'` is spelled out rather than omitted because an absent field cannot be told apart from an unreviewed one. Good exemption reasons name why no key applies *and* why a gate would be wrong: + +```ts + // permission-group-exempt: the executor's own per-run store; no group key names it, and refusing would fail runs the group allows +``` + +## Step 5: Add it to the golden corpus + +Add your key to **both** the `input` and the `expected` object of the `'a fully populated config'` fixture in `apps/sim/lib/permission-groups/types.test.ts`, set to a non-default value. + +That file is the pinned coercion corpus: every row states what a stored `jsonb` value coerces to, so a row that changes in a later diff is a deliberate semantic decision someone defends rather than a silent regression. Its other assertions are derived from `DEFAULT_PERMISSION_GROUP_CONFIG` — wire-order, idempotence, read-schema acceptance, the 2000-iteration seeded fuzz, and the boolean-key-to-`PLATFORM_FEATURES` coverage check — so they pick your key up for free. Likewise `features.test.ts` iterates `PLATFORM_FEATURES` and needs no edit for a boolean. + +Add a targeted case to `capabilities.test.ts` for a rule with any logic beyond reading one key. For an allowlist, assert the three states explicitly, because they are what the parser and the UI conspire to confuse: `null` permits every member, a populated list permits only the named ones, and `[]` permits **none**. `capabilities.test.ts` already pins this for `knowledge.connectors`; copy it. + +## Step 6: Verify + +```bash +bun run check:permission-group-enforcement +cd apps/sim && bun run type-check +cd apps/sim && bunx vitest run lib/permission-groups +``` + +If you touched a contract or the group routes, also `bun run check:api-validation`. `bun run check:audits` runs the whole audit set including the enforcement check. + +Read the audit's success line, not just its exit code: + +``` +✓ permission-group enforcement: 287 operations declare a capability, 35 capabilities all enforced +``` + +While any operation is still unannotated the audit runs in **count-down mode** and exits 0 with a `(N to go)` line — and in that mode it *suppresses* the "capability declared but nothing enforces it" finding. If your run prints a count-down, your new capability being unreachable will not fail the build. Check the `pending enforcement:` line for your capability id by name. + +## Traps + +These are the ones that actually bite. Each has a reason; understand the reason and you will get the cases this list does not enumerate right too. + +**The default must be permissive.** Every stored config predates your key, and the parser fills the gap from the default. A restrictive default applies a new restriction retroactively to every existing group, invisibly. + +**Append, never insert.** Declaration order is the wire order, and the editor's dirty check compares stringified configs — a moved key reads as an unsaved change in every open editor. + +**An operation carries exactly ONE capability.** Splitting a narrower capability off a broader one opens a hole unless the narrower rule *also* reads the broader key. This is real, not hypothetical: `knowledge.create` and `knowledge.upload` both list `hideKnowledgeBaseTab` alongside their own key — + +```ts + configKeys: ['disableKnowledgeBaseCreation', 'hideKnowledgeBaseTab'], + deniedBy: (config) => config.disableKnowledgeBaseCreation || config.hideKnowledgeBaseTab, +``` + +— because moving knowledge-base creation off `knowledge.use` would otherwise let a group that withheld the entire module still create one through the API. **The narrower capability has to subsume the broader.** Any time you re-point an operation from a general capability to a specific one, the specific rule must read both keys. + +**`.catch()` is whole-value tolerant; array coercion must be element-wise.** `z.array(item).catch(fallback)` discards every good member because one was bad. On an allowlist the fallback is `null`, and `null` means unrestricted — so whole-value tolerance is **fail-open**: a partially corrupt allowlist would stop restricting anything at all. `tolerantArray` filters element by element instead, keeping the members that parse. Never replace it with `.catch()` on an array field, and never hand-roll a parallel coercion path. + +**An empty allowlist denies everything; `null` allows everything.** These must never collapse into one another — not in the parser, not in the UI setter, not in a rule's `deniedBy`. `allowlistDenies` encodes it as `allowed !== null && !allowed.includes(member)`. A `?? []` anywhere on this path inverts the meaning of the unrestricted case. + +**A parameterized capability declared on an operation is refused at definition time.** `defineWorkspaceOperation` throws rather than accepting it, because the funnel never sees request input and the gate would silently never fire. + +**Non-boolean keys get no admin UI.** `PLATFORM_FEATURES` filters to booleans. An allowlist without a `featureExtras` picker is a key no admin can ever set. + +**Not everyone goes through the funnel.** A **workspace API key** authorizes as the workspace — there is no user, so no permission group resolves and `operation.capability` does not apply. (Substituting the key's creator would apply a bystander's group to every caller of a shared key, and break the key outright when that person left. The escape is closed at the door instead: minting a workspace key is itself capability-gated.) An **actorless deployment run** — a delegated executor principal with no subject — also passes through, because a deployed workflow acts with the workspace's authority, not its author's group; denying there would 403 every scheduled run, webhook, and public-API call in the organization the moment a group withheld anything. What such a run *does* is still governed, by `assertPermissionsAllowed` in the executor. If your item must bind a deployed run, it belongs at `enforcement: 'executor'`, not `'capability'`. + +**Capability is checked after the role check, on purpose.** `requireCurrentHumanAccess` runs `requirePermission` first. `NoWorkspaceAccessError` is concealed as a 404 by the v2 surface so a non-member cannot learn the resource exists; refusing on capability first would tell a complete outsider which capabilities the organization withholds. Do not reorder it, and do not add a capability check upstream of the role check in a raw route. + +## Checklist Before Finishing + +- [ ] Kind and `enforcement` chosen deliberately; `ui-only` justified in writing if used +- [ ] Entry **appended** to `PERMISSION_GROUP_FIELDS`, permissive default, restriction-phrased name +- [ ] Category present in `PLATFORM_CATEGORY_ORDER` +- [ ] Non-boolean key has a `featureExtras` picker that refuses empty and collapses "all" to `null` +- [ ] Capability id in `CAPABILITY_IDS`, rule in `CAPABILITY_RULES`, `configKeys` lists every key `deniedBy` reads +- [ ] A narrower capability replacing a broader one also reads the broader key +- [ ] Declared on every operation it governs, or asserted from the use case with a `// permission-group-enforced:` annotation +- [ ] Any `capability: 'none'` you added carries a `// permission-group-exempt:` reason +- [ ] Added to the `'a fully populated config'` fixture in `types.test.ts`, input and expected +- [ ] Allowlist three-state (`null` / populated / `[]`) covered in `capabilities.test.ts` +- [ ] `check:permission-group-enforcement` passes and names your capability as enforced, not pending +- [ ] `type-check` clean, `lib/permission-groups` suite green diff --git a/.agents/skills/validate-permission-group-item/SKILL.md b/.agents/skills/validate-permission-group-item/SKILL.md new file mode 100644 index 00000000000..7769b79a31c --- /dev/null +++ b/.agents/skills/validate-permission-group-item/SKILL.md @@ -0,0 +1,127 @@ +--- +name: validate-permission-group-item +description: Audit an existing enterprise permission-group item end-to-end — registry entry, schemas, type, defaults, tolerant parser, admin UI, capability rule, enforcement site, and tests — proving the gate actually refuses rather than assuming it. Use when checking a key in `PERMISSION_GROUP_FIELDS` or a capability in `CAPABILITY_RULES`. +argument-hint: +--- + +# Validate Permission Group Item Skill + +You are auditing one governed item in Sim's enterprise permission-group system. The question you are answering is not "does this key exist in the right places" — the registry makes most of that compiler-enforced. The question is: + +> **If an organization admin sets this, what refuses, and can I make that refusal happen?** + +Twelve keys once shipped with an admin checkbox, a hint describing what they restrict, and no server check at all. Every one of them would have passed a structural audit. Assume nothing enforces until you have found the throw. + +## Read the system first + +- `apps/sim/lib/permission-groups/fields.ts` — the registry every config surface derives from +- `apps/sim/lib/permission-groups/capabilities.ts` — `CAPABILITY_IDS`, `CAPABILITY_RULES` +- `apps/sim/lib/permission-groups/capability-assertions.ts` — the canonical assertion API +- `apps/sim/lib/core/application/workspace-authorization.ts` — the funnel, and who bypasses it +- `scripts/check-permission-group-enforcement.ts` — what the audit does and does not prove + +## Step 1: Registry entry + +Find the key in `PERMISSION_GROUP_FIELDS`. Record its builder (`booleanRestriction` / `allowlist` / `denylist`), its `enforcement`, and its position. + +- **Default is permissive?** Boolean `false`, allowlist `null`, denylist `[]`. The builders hardcode these, so the real risk is a key whose *name* inverts the meaning — an `allowX` boolean whose permissive value would be `true`. Every stored config predates the key, so a restrictive default silently applies retroactively to every existing group. +- **Named as a restriction?** `hideX` / `disableX` / `allowedX` / `deniedX`. The admin checkbox renders `checked={!editingConfig[feature.configKey]}` — ticked means allowed — so a positively-named boolean renders backwards. +- **Position stable?** Declaration order is the wire order of `PermissionGroupConfig`, both zod schemas, and every config JSON crossing the API boundary. If `git log -p` shows the key was ever *moved* rather than appended, that shipped as a dirty-check regression in the group editor. +- **Phrasing present and accurate?** An allowlist's `{ limited, empty }` and a denylist's string are read by `getActivePermissionGroupRestrictions` in `features.ts` and surface to users through the Copilot context and the group roster. Check the `empty` string genuinely says "none allowed" and not "unrestricted". + +## Step 2: Schemas, type, defaults, parser + +These are derived by `collectFieldProperty` — `permissionGroupWriteShape`, `permissionGroupReadShape`, `DEFAULT_PERMISSION_GROUP_CONFIG`, and the tolerant parser all read the same registry. **Do not hand-verify them one by one.** Verify instead that nothing has been introduced that bypasses the derivation: + +```bash +grep -rn "" apps/sim --include='*.ts' --include='*.tsx' | grep -v 'lib/permission-groups/' +``` + +Every hit outside `lib/permission-groups/` is either a rule's `deniedBy`, an enforcement site, a UI binding, or a test. Anything else — a route restating the key, a client re-deriving a default, a second coercion path — is a leak. In particular: + +- A `z.array(...).catch(...)` anywhere on this key's path. `.catch()` is whole-value tolerant: one bad member discards every good one. On an **allowlist** that is fail-**open**, because the fallback is `null` and `null` means unrestricted — a partially corrupt allowlist would stop restricting anything. `tolerantArray` filters element-wise for exactly this reason. +- A `?? []` applied to an allowlist. `null` allows everything and `[]` allows nothing; collapsing them inverts the unrestricted case. +- Any read of the config that does not come from `parsePermissionGroupConfig` or a `resolvePermissionGroupConfig` caller. + +Confirm the type assertions at the bottom of `fields.ts` still name a field of this kind (`AssertsAllowlistStaysPrecise`, `AssertsDenylistStaysPrecise`, `AssertsRestrictionStaysPrecise`, `AssertsAuthTypesStayPrecise`). They exist because a zod generic degrading to `unknown` is invisible at runtime — the values stay right, no test fails, and every call site quietly loses its narrowing. + +## Step 3: Admin UI + +Open `apps/sim/ee/access-control/components/group-detail.tsx`. + +- **Boolean:** it should appear automatically — `PLATFORM_FEATURES` in `features.ts` is derived by filtering `field.kind === 'boolean-restriction'`. Confirm its `category` is in `PLATFORM_CATEGORY_ORDER`; an unlisted category renders after every ordered section. +- **Allowlist or denylist:** it renders **nothing** unless something puts it there. Look for the key in the `featureExtras` map — which is keyed by the *feature id of the parent boolean*, not by the allowlist's own config key. A non-boolean key with no picker and no bespoke section is a key no admin can ever set. Report it. +- For a picker, check both behaviors: the setter refuses an empty selection (`if (values.length === 0) return`), and collapses a full selection back to `null` (`values.length === ALL.length ? null : values`). Storing the full set freezes the allowlist at today's members, so a member added next release is denied by a group that had chosen "all". +- Check the parent the picker nests under is the right one. `allowedKnowledgeConnectors` hangs off `hide-knowledge-base`, not `disable-knowledge-base-creation`, because a connector attaches to an existing knowledge base — nesting it under creation would dim the picker for exactly the cohort it was written for. + +## Step 4: Capability rule + +If `enforcement` is `'capability'`, the key must appear in some rule's `configKeys` in `CAPABILITY_RULES` — the audit asserts this (assertion D) and also asserts the converse (E): a key declared `'executor'` or `'ui-only'` that a rule reads is flagged, so a key cannot gain enforcement while staying documented as something weaker. + +Then check the things the audit cannot: + +- **`configKeys` lists every key `deniedBy` actually reads.** The audit parses `configKeys` textually; it does not read the closure. A key read by `deniedBy` but missing from `configKeys` is invisible to assertions D and E. +- **`kind` is right.** A rule whose decision needs a request value must be `'parameterized'`. A parameterized rule can never be declared on an operation — `defineWorkspaceOperation` throws at definition time — so if you find one named on an operation, that code does not run in production; something else is wrong. +- **A narrower capability subsumes the broader one it replaced.** An operation carries exactly one capability. If this capability was split off a more general one, its rule must also read the general key. The precedent is `knowledge.create` and `knowledge.upload`, which both read `hideKnowledgeBaseTab` alongside their own key — without that, a group withholding the entire Knowledge Base module could still create one through the API. Check `git log` for a re-pointed `capability:` field and verify the narrower rule grew the broader key in the same commit. +- **`detailCode` matches the remedy.** `FORBIDDEN_DETAIL_CODES` is closed over *remedies*, not causes. A distinct code is warranted only when a caller would act differently; otherwise `PERMISSION_GROUP_CAPABILITY_BLOCKED` is correct. Any code in use must have an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS`, which is a compile-time gate and also publishes the OpenAPI 403 text. +- **`describe` reads correctly in the sentence.** `capabilityRefusalMessage` produces `" is not available under your organization's permission group"`. + +## Step 5: Prove the enforcement — do not assume it + +This is the step the whole skill exists for. Find the **actual refusal**, name the file and line, and describe what a caller sees. + +```bash +grep -rn "''" apps/sim --include='*.ts' --include='*.tsx' +grep -rn "permission-group-enforced: " apps/sim +``` + +Classify what you find into exactly one of: + +1. **Declared on operations.** `capability: ''` on one or more `defineWorkspaceOperation` calls. The funnel enforces in `requireCurrentHumanAccess` → `requireCapability`. Verify the set of operations is *complete*: enumerate every route and tool that reaches the same behavior and check each one's operation declares it. One route declaring `capability: 'none'` for the same behavior is the hole. +2. **Asserted at a call site**, with a `// permission-group-enforced: ` annotation. Verify the assertion goes through `capability-assertions.ts` or a `CAPABILITY_RULES` entry rather than spelling the config key out inline — a call site reading `config.disableX` directly stops denying anything the moment the key is renamed, and its wording drifts from the funnel's. Some older helpers in `apps/sim/ee/access-control/utils/permission-check.ts` (`validatePublicFileSharing`, `validateChatDeployAuth`) still read config keys directly; note that as a finding rather than a blocker, and cite `assertConnectorTypeAllowed` in `apps/sim/lib/knowledge/application/connectors.ts` as the pattern they should converge on. +3. **Executor-gated.** Read by `assertPermissionsAllowed` in `permission-check.ts`, per block / tool / model. Verify the branch exists and throws a real error, and that the id it compares against is the same vocabulary the admin UI writes — `deniedTools` holds block `tools.access` ids verbatim, version suffix included. +4. **Nothing.** Report it as a defect, with the sentence "an organization that sets this believes it applied a restriction that does not exist". + +Then make the refusal happen. Either write a failing case, or take the existing test and **remove the gate** — delete the `capability:` field, or the `deniedBy` body, or the assertion call — and confirm the test goes red. A test that still passes with the gate removed is proving nothing. Restore the code afterward. + +For an allowlist, the three states have to be tested separately, because they are what the parser and the UI conspire to confuse: `null` permits every member, a populated list permits only the named ones, `[]` permits **none**. `capabilities.test.ts` pins all three for `knowledge.connectors`; anything less than that for another allowlist is a gap. + +## Step 6: Tests + +- **`apps/sim/lib/permission-groups/types.test.ts`** — the key must appear in both the `input` and `expected` halves of the `'a fully populated config'` fixture. The corpus is pinned deliberately: a row that changes in a later diff has to be defended as a semantic decision rather than slipping through as a regression. The rest of that file (wire order, idempotence, read-schema acceptance, the seeded 2000-iteration fuzz, boolean-to-`PLATFORM_FEATURES` coverage) derives from `DEFAULT_PERMISSION_GROUP_CONFIG` and needs no per-key edit. +- **`capabilities.test.ts`** — a case for any rule with logic beyond reading one key: subsumption, allowlist three-state, auth-mode membership. +- **`features.test.ts`** — derived from `PLATFORM_FEATURES`; a boolean key needs no edit. A non-boolean key contributing user-facing prose should have its `limited` / `empty` strings pinned there. + +## Step 7: Run the checks + +```bash +bun run check:permission-group-enforcement +cd apps/sim && bun run type-check +cd apps/sim && bunx vitest run lib/permission-groups +``` + +Read the audit's output, not just its exit code. Two ways it can pass without proving what you want: + +- **Count-down mode.** While any operation is still unannotated it prints `(N to go)` and exits 0 — and in that mode it *suppresses* the "capability declared but nothing enforces it" finding entirely. Check the `pending enforcement:` line for your capability by name. A capability listed there is unenforced and the build is green anyway. +- **Vacuous parse.** The audit reads source text with regexes. It has a self-check that refuses to report success when `CAPABILITY_IDS`, `CAPABILITY_RULES`, or `PERMISSION_GROUP_FIELDS` parse to nothing, and it cross-checks that the rule count equals the capability count. If either of those errors fires, the audit is broken, not the code — fix the parsers rather than leaving it passing. + +The audit proves *reachability*, not correctness: it proves a capability is named somewhere and a key is read by some rule. It cannot tell whether the rule's logic is right, whether every relevant operation declares it, or whether an annotated call site actually calls anything. Step 5 is what covers that, and no amount of green CI substitutes for it. + +## Known gaps — recognize these, do not re-report them + +These are understood, deliberate, and documented in the code. Note them if they are material to what you were asked about; do not file them as new findings. + +- **A workspace API key resolves no permission group.** It authorizes as the workspace, so there is no user and `operation.capability` does not apply — the `workspace_api_key` branch of `authorizeWorkspaceOperation` returns before any capability check. Substituting the key's creator would apply a bystander's group to every caller of a shared key and break the key outright when that person left the organization. The escape is closed at the door instead: minting a workspace key is itself capability-gated. +- **An actorless deployment run passes through.** A delegated `executor` principal in `mode: 'deployment'` with no resolvable subject is authorized without a capability check, because a deployed workflow acts with the workspace's authority rather than its author's permission group. Denying there would 403 every scheduled run, webhook, and public-API call in the organization the moment a group withheld anything. What such a run *does* is still governed, by `assertPermissionsAllowed` in the executor — which is precisely why the four run-scoped keys carry `enforcement: 'executor'` rather than `'capability'`. +- **Capability is checked after the role check.** `requireCurrentHumanAccess` runs `requirePermission` first. `NoWorkspaceAccessError` is concealed as a 404 by the v2 surface so a non-member cannot learn the resource exists; refusing on capability first would leak which capabilities the organization withholds to a complete outsider. It is also the cheaper check and names the remedy the caller can act on. Do not report the ordering as a bug. +- **`allowedEgressHosts` does not exist.** There is no network-egress allowlist in `PERMISSION_GROUP_FIELDS`. Requests for one are a feature, not a missing wiring of an existing key. + +## Report Format + +For each item audited, state: + +1. **Kind and enforcement** — as declared, and whether the declaration is true. +2. **The refusal** — file, line, the error thrown, and what a caller sees (status, `detailCode`, message). Or: *nothing refuses*. +3. **Proof** — the test that fails when the gate is removed, or the statement that no such test exists. +4. **Coverage gaps** — routes, tools, or surfaces reaching the same behavior without the gate. +5. **Findings**, ordered: unenforced key > incomplete operation coverage > fail-open coercion > allowlist three-state confusion > missing admin UI > missing test > cosmetic. diff --git a/.claude/skills/add-permission-group-item b/.claude/skills/add-permission-group-item new file mode 120000 index 00000000000..37547b94cdb --- /dev/null +++ b/.claude/skills/add-permission-group-item @@ -0,0 +1 @@ +../../.agents/skills/add-permission-group-item \ No newline at end of file diff --git a/.claude/skills/validate-permission-group-item b/.claude/skills/validate-permission-group-item new file mode 120000 index 00000000000..d867334b614 --- /dev/null +++ b/.claude/skills/validate-permission-group-item @@ -0,0 +1 @@ +../../.agents/skills/validate-permission-group-item \ No newline at end of file From fb3339de24b504f40efa9b2571e3bceed72104e9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 29 Aug 2026 09:05:47 -0700 Subject: [PATCH 022/179] refactor(permission-groups): remove the count-down mode and settle the module's wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capability` became a required field, so the audit's count-down mode could no longer be reached by an operation that had simply not been annotated yet — and while it lingered, an un-annotated `capability: 'none'` silently suppressed the check that every declared capability is enforced. Both causes now fail: - a capability the parsers cannot read, which the type system guarantees is a declaration form this text-based audit does not follow, not an omission - `'none'` without a `permission-group-exempt:` reason The unreached-capability assertion runs unconditionally as a result. Also collapses `capabilityRefusalMessage` into its only caller, drops the callerless `isStaticCapability`, un-exports the compile-time assertion aliases in `fields.ts` and `capabilities.ts` (the constraint is checked at the declaration; the export implied a consumer that never existed), and moves the `logs.trace_spans` rule into `CAPABILITY_IDS` order. Wording, all of it user- or admin-facing: - sixteen capability `describe` strings now agree with the verb in the shared refusal sentence ("Knowledge bases is not available" -> "The Knowledge Base module is not available"; "Skills" -> "Loading skills") - five field hints stated what stayed permitted rather than what the key withholds, which reads backwards where the same string is reported as an active restriction --- .../sim/lib/permission-groups/capabilities.ts | 74 ++++++++----------- .../capability-assertions.ts | 11 ++- apps/sim/lib/permission-groups/fields.ts | 41 ++++++---- scripts/check-permission-group-enforcement.ts | 56 ++++++-------- 4 files changed, 90 insertions(+), 92 deletions(-) diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts index b16ed670001..18fe2ba89c8 100644 --- a/apps/sim/lib/permission-groups/capabilities.ts +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -65,7 +65,11 @@ interface CapabilityRuleBase { /** The config keys this rule reads, so the audit can prove a key is enforced. */ readonly configKeys: readonly PermissionGroupConfigKey[] readonly detailCode: ForbiddenDetailCode - /** Named in the refusal message; the remedy is always an organization admin. */ + /** + * The subject of the shared refusal sentence — ` is not available + * under your organization's permission group` — so it is written as a + * singular noun or gerund phrase that agrees with the verb. + */ readonly describe: string } @@ -92,16 +96,6 @@ export interface ParameterizedCapabilityRule extends CapabilityRuleBase { export type CapabilityRule = StaticCapabilityRule | ParameterizedCapabilityRule -/** - * The one sentence every capability refusal uses, wherever it is raised. - * - * Shared so a raw route that gates inline cannot drift from what the - * authorization funnel tells a caller refused for the same reason. - */ -export function capabilityRefusalMessage(describe: string): string { - return `${describe} is not available under your organization's permission group` -} - function authModeDeniedBy(allowed: ShareAuthMode[] | null, mode: string): boolean { return allowed !== null && !allowed.some((allowedMode) => allowedMode === mode) } @@ -126,21 +120,21 @@ export const CAPABILITY_RULES = { kind: 'static', configKeys: ['hideKnowledgeBaseTab'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'Knowledge bases', + describe: 'The Knowledge Base module', deniedBy: (config) => config.hideKnowledgeBaseTab, }, 'tables.use': { kind: 'static', configKeys: ['hideTablesTab'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'Tables', + describe: 'The Tables module', deniedBy: (config) => config.hideTablesTab, }, 'files.use': { kind: 'static', configKeys: ['hideFilesTab'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'Files', + describe: 'The Files module', deniedBy: (config) => config.hideFilesTab, }, 'inbox.use': { @@ -161,21 +155,21 @@ export const CAPABILITY_RULES = { kind: 'static', configKeys: ['hideSecretsTab'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'Secrets', + describe: 'Managing secrets', deniedBy: (config) => config.hideSecretsTab, }, 'api_keys.manage': { kind: 'static', configKeys: ['hideApiKeysTab'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'API keys', + describe: 'Managing API keys', deniedBy: (config) => config.hideApiKeysTab, }, 'integrations.manage': { kind: 'static', configKeys: ['hideIntegrationsTab'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'Integrations', + describe: 'Managing integrations', deniedBy: (config) => config.hideIntegrationsTab, }, 'deploy.api': { @@ -231,30 +225,37 @@ export const CAPABILITY_RULES = { kind: 'static', configKeys: ['disableInvitations'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'Invitations', + describe: 'Sending invitations', deniedBy: (config) => config.disableInvitations, }, 'mcp_tools.use': { kind: 'static', configKeys: ['disableMcpTools'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'MCP tools', + describe: 'Calling MCP tools', deniedBy: (config) => config.disableMcpTools, }, 'custom_tools.use': { kind: 'static', configKeys: ['disableCustomTools'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'Custom tools', + describe: 'Calling custom tools', deniedBy: (config) => config.disableCustomTools, }, 'skills.use': { kind: 'static', configKeys: ['disableSkills'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'Skills', + describe: 'Loading skills', deniedBy: (config) => config.disableSkills, }, + 'logs.trace_spans': { + kind: 'static', + configKeys: ['hideTraceSpans'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'The per-block execution trace', + deniedBy: (config) => config.hideTraceSpans, + }, /** * Not declarable on an operation: it refuses a *principal kind* rather than a * capability of the resource, so it applies to every operation a personal key @@ -264,7 +265,7 @@ export const CAPABILITY_RULES = { kind: 'static', configKeys: ['disablePersonalApiKeys'], detailCode: 'PERSONAL_API_KEYS_DISABLED', - describe: 'Personal API keys', + describe: 'Using a personal API key', deniedBy: (config) => config.disablePersonalApiKeys, }, 'logs.export': { @@ -291,7 +292,7 @@ export const CAPABILITY_RULES = { kind: 'static', configKeys: ['disableKnowledgeBaseCreation', 'hideKnowledgeBaseTab'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'Creating knowledge bases', + describe: 'Creating a knowledge base', deniedBy: (config) => config.disableKnowledgeBaseCreation || config.hideKnowledgeBaseTab, }, /** Subsumes `knowledge.use` for the same reason as {@link CAPABILITY_RULES}'s `knowledge.create`. */ @@ -320,7 +321,7 @@ export const CAPABILITY_RULES = { kind: 'static', configKeys: ['disableTableCreation'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'Creating tables', + describe: 'Creating a table', deniedBy: (config) => config.disableTableCreation, }, 'tables.export': { @@ -348,7 +349,7 @@ export const CAPABILITY_RULES = { kind: 'static', configKeys: ['disableWorkspaceCreation'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'Creating workspaces', + describe: 'Creating a workspace', deniedBy: (config) => config.disableWorkspaceCreation, }, 'organization.member_directory': { @@ -369,7 +370,7 @@ export const CAPABILITY_RULES = { kind: 'static', configKeys: ['disableWebhookTriggers'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'Webhook triggers', + describe: 'Creating a webhook trigger', deniedBy: (config) => config.disableWebhookTriggers, }, 'copilot.tool_auto_approval': { @@ -379,13 +380,6 @@ export const CAPABILITY_RULES = { describe: 'Silencing a tool confirmation', deniedBy: (config) => config.disableToolAutoApproval, }, - 'logs.trace_spans': { - kind: 'static', - configKeys: ['hideTraceSpans'], - detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'Execution trace spans', - deniedBy: (config) => config.hideTraceSpans, - }, } satisfies { readonly [K in PermissionGroupCapability]: CapabilityRule } /** @@ -399,13 +393,6 @@ export type StaticPermissionGroupCapability = { : never }[PermissionGroupCapability] -/** Whether a capability id is one an operation may declare. */ -export function isStaticCapability( - capability: PermissionGroupCapability -): capability is StaticPermissionGroupCapability { - return CAPABILITY_RULES[capability].kind === 'static' -} - /** * Proof that the static/parameterized split resolves. * @@ -414,6 +401,11 @@ export function isStaticCapability( * and collapse this type to `never` — at which point no operation could declare * any capability and every gate would silently never fire. Nothing at runtime * would look wrong, so it is asserted here. + * + * The aliases below are deliberately unexported: the constraint on + * {@link Assert} is checked where the alias is declared, so an export bought + * nothing but the appearance of a consumer that never existed. They are unused + * on purpose, and deleting one deletes the proof. */ type Assert = T @@ -423,5 +415,3 @@ type AssertsStaticCapabilityResolves = Assert< type AssertsParameterizedCapabilityIsExcluded = Assert< 'deploy.chat.auth_mode' extends StaticPermissionGroupCapability ? false : true > - -export type { AssertsParameterizedCapabilityIsExcluded, AssertsStaticCapabilityResolves } diff --git a/apps/sim/lib/permission-groups/capability-assertions.ts b/apps/sim/lib/permission-groups/capability-assertions.ts index f8869351a1a..d50e8be29e6 100644 --- a/apps/sim/lib/permission-groups/capability-assertions.ts +++ b/apps/sim/lib/permission-groups/capability-assertions.ts @@ -1,6 +1,5 @@ import { CAPABILITY_RULES, - capabilityRefusalMessage, type StaticPermissionGroupCapability, } from '@/lib/permission-groups/capabilities' import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' @@ -29,9 +28,15 @@ export function capabilityDeniedBy( return rule.kind === 'static' && rule.deniedBy(config) } -/** The refusal a caller sees, identical to the funnel's for the same capability. */ +/** + * The one sentence every capability refusal uses, wherever it is raised. + * + * Shared so a raw route that gates inline cannot drift from what the + * authorization funnel tells a caller refused for the same reason. Each rule's + * `describe` is written to read as this sentence's subject. + */ export function capabilityRefusal(capability: StaticPermissionGroupCapability): string { - return capabilityRefusalMessage(CAPABILITY_RULES[capability].describe) + return `${CAPABILITY_RULES[capability].describe} is not available under your organization's permission group` } function refuse(capability: StaticPermissionGroupCapability): never { diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index 47b1e2a8a21..b1ce2fed375 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -21,6 +21,15 @@ const shareAuthType = z.enum(FILE_SHARE_AUTH_TYPES) * * Declared rather than inferred, because `ui-only` is the value an admin is * most likely to mistake for a control — twelve keys shipped that way. + * + * Not derived from the capability rules, which also name config keys, and not + * worth collapsing into them: `capabilities.ts` imports this module for + * `PermissionGroupConfigKey`, so the dependency runs one way only, and the two + * are different facts anyway — a field is storage plus admin UI, a rule is a + * decision — related many-to-many (`knowledge.create` reads two keys; + * `hideKnowledgeBaseTab` is read by three rules). This value is the single fact + * they share, and `check:permission-group-enforcement` is what keeps it honest + * in both directions. */ export type PermissionGroupEnforcement = 'capability' | 'executor' | 'ui-only' @@ -29,6 +38,12 @@ interface PlatformFeatureMeta { readonly id: string readonly label: string readonly category: string + /** + * What setting the key withholds, one sentence. Read twice — as the editor's + * hint and as the prose `getActivePermissionGroupRestrictions` reports for an + * active restriction — so it states the restriction rather than what remains + * permitted, which reads backwards in the second place. + */ readonly hint: string } @@ -341,13 +356,13 @@ export const PERMISSION_GROUP_FIELDS = { id: 'disable-knowledge-base-creation', label: 'Knowledge Base Creation', category: 'Sidebar', - hint: 'Allow querying existing knowledge bases without creating new ones.', + hint: 'Prevent creating knowledge bases, leaving existing ones queryable.', }), disableKnowledgeBaseFileUpload: booleanRestriction('capability', { id: 'disable-knowledge-base-upload', label: 'Knowledge Base Uploads', category: 'Sidebar', - hint: 'Allow documents only from sanctioned connectors, never local upload.', + hint: 'Prevent uploading local documents, leaving sanctioned connectors as the only source.', }), allowedKnowledgeConnectors: allowlist(z.string(), 'capability', { limited: 'Knowledge base connectors are limited to effectiveConfig.allowedKnowledgeConnectors.', @@ -357,7 +372,7 @@ export const PERMISSION_GROUP_FIELDS = { id: 'disable-table-creation', label: 'Table Creation', category: 'Sidebar', - hint: 'Allow using existing tables without creating new ones.', + hint: 'Prevent creating tables, leaving existing ones usable.', }), disableTableExport: booleanRestriction('capability', { id: 'disable-table-export', @@ -375,7 +390,7 @@ export const PERMISSION_GROUP_FIELDS = { id: 'disable-personal-credentials', label: 'Personal Credentials', category: 'Settings Tabs', - hint: 'Allow only workspace-shared credentials, never personally connected ones.', + hint: 'Prevent connecting personal credentials, leaving only workspace-shared ones.', }), disableWorkspaceCreation: booleanRestriction('capability', { id: 'disable-workspace-creation', @@ -405,7 +420,7 @@ export const PERMISSION_GROUP_FIELDS = { id: 'disable-tool-auto-approval', label: 'Tool Auto-Approval', category: 'Tools', - hint: 'Require confirmation every time, so a member cannot silence a tool prompt permanently.', + hint: 'Prevent silencing a tool confirmation, so every call is confirmed again.', }), } satisfies Record @@ -489,7 +504,13 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf */ type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false -/** Fails to compile unless `T` is exactly `true`, which is what makes the aliases below load-bearing. */ +/** + * Fails to compile unless `T` is exactly `true`, which is what makes the aliases + * below load-bearing. They are deliberately unexported: the constraint is + * checked where the alias is declared, so an export bought nothing but the + * appearance of a consumer that never existed. Nothing may import them; they + * are unused on purpose, and deleting one deletes the proof. + */ type Assert = T type AssertsAllowlistStaysPrecise = Assert< @@ -506,11 +527,3 @@ type AssertsAuthTypesStayPrecise = Assert< type AssertsParserReturnsTheConfig = Assert< Exact, PermissionGroupConfig> > - -export type { - AssertsAllowlistStaysPrecise, - AssertsAuthTypesStayPrecise, - AssertsDenylistStaysPrecise, - AssertsParserReturnsTheConfig, - AssertsRestrictionStaysPrecise, -} diff --git a/scripts/check-permission-group-enforcement.ts b/scripts/check-permission-group-enforcement.ts index 1966c1c1e78..38ed87bbfec 100644 --- a/scripts/check-permission-group-enforcement.ts +++ b/scripts/check-permission-group-enforcement.ts @@ -29,9 +29,12 @@ * * // permission-group-exempt: * - * While the operations are still being annotated the audit runs in count-down - * mode: it reports how many are unfilled and exits 0. Assertions B–E fail the - * build today. + * `capability` is a required field on `defineWorkspaceOperation`, so the half of + * assertion A that asks whether an operation declared one cannot fail through + * the type system. It survives because this audit reads source text rather than + * the type: an operation written in a form the parsers cannot follow yields no + * capability, and without the check it would be skipped in silence — counted as + * reviewed while nothing had actually read what it declares. */ import { readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' @@ -246,7 +249,6 @@ function main(): void { const findings: Finding[] = [] const usedCapabilities = new Set() let declaredOperations = 0 - let unfilledOperations = 0 for (const file of sourceFiles) { const relativePath = relative(ROOT, file) @@ -267,12 +269,20 @@ function main(): void { for (const declaration of parseOperationCapabilities(source)) { declaredOperations++ if (declaration.capability === undefined) { - unfilledOperations++ + findings.push({ + file: relativePath, + line: declaration.line, + message: `operation '${declaration.id}' declares a capability this audit cannot read — the field is required, so this is a declaration form the parsers do not follow; teach parseOperationCapabilities about it rather than leaving the operation unchecked`, + }) continue } if (declaration.capability === 'none') { if (!hasExemptAnnotation(source, declaration.line)) { - unfilledOperations++ + findings.push({ + file: relativePath, + line: declaration.line, + message: `operation '${declaration.id}' declares capability 'none' without a reason — put '${EXEMPT_ANNOTATION} ' in a comment directly above it`, + }) } continue } @@ -287,22 +297,13 @@ function main(): void { } } - /** - * A capability nothing names is a key an admin can set to no effect. While - * the operations are still being annotated that is expected rather than - * broken, so it counts down with them instead of failing the build twice for - * the same unfinished migration. - */ - const unreachedCapabilities = [...capabilityIds].filter( - (capability) => !usedCapabilities.has(capability) - ) - if (unfilledOperations === 0) { - for (const capability of unreachedCapabilities) { - findings.push({ - file: CAPABILITIES_FILE, - message: `capability '${capability}' is declared but nothing enforces it — name it on an operation, or annotate its call site with '${ENFORCED_ANNOTATION} ${capability} — '`, - }) - } + /** A capability nothing names is a key an admin can set to no effect. */ + for (const capability of capabilityIds) { + if (usedCapabilities.has(capability)) continue + findings.push({ + file: CAPABILITIES_FILE, + message: `capability '${capability}' is declared but nothing enforces it — name it on an operation, or annotate its call site with '${ENFORCED_ANNOTATION} ${capability} — '`, + }) } const enforcedByRule = new Set([...rules.values()].flatMap((rule) => rule.configKeys)) @@ -335,17 +336,6 @@ function main(): void { process.exit(1) } - if (unfilledOperations > 0) { - console.log( - `✓ permission-group enforcement: ${declaredOperations - unfilledOperations}/${declaredOperations} operations declare a capability ` + - `(${unfilledOperations} to go; the field becomes required once they all do)` - ) - if (unreachedCapabilities.length > 0) { - console.log(` pending enforcement: ${unreachedCapabilities.join(', ')}`) - } - return - } - console.log( `✓ permission-group enforcement: ${declaredOperations} operations declare a capability, ${capabilityIds.size} capabilities all enforced` ) From 10d8e79a61b451d97eb19ae1ca6c0e37af109439 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 29 Aug 2026 09:11:51 -0700 Subject: [PATCH 023/179] perf(permission-groups): hoist the block-allowlist check out of the save loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unrestricted group is the common case, so every workflow save in every ungoverned workspace was paying two registry lookups per block to reach an answer that could not change. The allowlist becomes a Set via the existing `toAllowedIntegrationTypes`, and the null case returns before the loop. Also corrects `isBlockTypeAllowed`'s doc, which restated its signature and had gone stale: it asks two questions, and the second — deployment visibility — is exactly why the persist-time guard cannot reuse it. --- apps/sim/lib/workflows/editing/validation.ts | 11 ++++++++++- .../lib/workflows/persistence/block-access-guard.ts | 13 ++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index f96f6f55df1..34cd3b58d8b 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -1003,7 +1003,16 @@ export function validateTargetHandle(targetHandle: string): EdgeHandleValidation } /** - * Checks if a block type is allowed by the permission group config + * Whether a block may be added to a graph by this viewer. + * + * Two questions, not one: whether the viewer can see the block at all + * (deployment visibility — an unrevealed preview block, a kill-switched type) + * and whether their permission group's integration allowlist permits it. + * Refusing to *add* something a viewer cannot see is right. + * + * Refusing to *store* it is not, which is why the persist-time guard in + * `@/lib/workflows/persistence/block-access-guard` checks the allowlist alone: + * a graph exported before a block was gated must still save. */ export function isBlockTypeAllowed( blockType: string, diff --git a/apps/sim/lib/workflows/persistence/block-access-guard.ts b/apps/sim/lib/workflows/persistence/block-access-guard.ts index c0182ffbd06..7f5e9d7fa29 100644 --- a/apps/sim/lib/workflows/persistence/block-access-guard.ts +++ b/apps/sim/lib/workflows/persistence/block-access-guard.ts @@ -2,6 +2,7 @@ import { isBlockTypeAccessControlExempt, resolveAccessControlBlockType, } from '@/lib/permission-groups/block-access' +import { toAllowedIntegrationTypes } from '@/lib/permission-groups/integration-allowlist' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { BlockType } from '@/executor/constants' @@ -39,14 +40,20 @@ export async function findWithheldBlockType(params: { blocks: Iterable<{ type?: string }> }): Promise { const permissionConfig = await getUserPermissionConfig(params.userId, params.workspaceId) - const allowed = permissionConfig?.allowedIntegrations ?? null + const allowed = toAllowedIntegrationTypes(permissionConfig?.allowedIntegrations ?? null) + + /** + * Hoisted out of the loop: an unrestricted group is the common case, and every + * workflow save in every ungoverned workspace would otherwise pay two registry + * lookups per block to reach the same answer. + */ + if (allowed === null) return null for (const block of params.blocks) { const blockType = block.type if (!blockType || CONTAINER_BLOCK_TYPES.has(blockType)) continue if (isBlockTypeAccessControlExempt(blockType)) continue - if (allowed === null) continue - if (!allowed.includes(resolveAccessControlBlockType(blockType).toLowerCase())) return blockType + if (!allowed.has(resolveAccessControlBlockType(blockType).toLowerCase())) return blockType } return null From a877371c645ec984378a30c64e752dfb3060b72a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 29 Aug 2026 09:11:51 -0700 Subject: [PATCH 024/179] refactor(permission-groups): one API for every capability gate Five bespoke gates and eleven inline config-key reads all answered the same question in their own vocabulary, so a renamed key would have silently stopped denying anything at each of them and the refusal wording drifted per surface. Every one now asks through `capability-assertions`, which reads `CAPABILITY_RULES`. Deleted: - `inboxWithheldResponse` (`lib/mothership/inbox/access.ts`) - `apiKeyManagementWithheldResponse` and `personalApiKeyManagementWithheldResponse` (`lib/api-key/access.ts`) - `isOrgMemberDirectoryHidden` and `isCliAccessDisabled` (`ee/access-control/utils/permission-check.ts`) Routes that render their own response shape use `isWorkspaceCapabilityWithheld`/`isOrganizationCapabilityWithheld` and build their own 403 from `capabilityRefusal`; the projections that strip fields out of a log response keep reading the config but ask `capabilityDeniedBy` rather than naming the key. Every `permission-group-enforced:` annotation moved with its decision. --- .../app/api/cli/auth/approve/route.test.ts | 27 +++++++--- apps/sim/app/api/cli/auth/approve/route.ts | 33 +++++++++--- .../app/api/copilot/tool-permission/route.ts | 9 ++-- apps/sim/app/api/logs/export/route.ts | 18 +++---- .../sim/app/api/mcp/workflow-servers/route.ts | 14 +++--- .../organizations/[id]/members/route.test.ts | 22 +++++--- .../api/organizations/[id]/members/route.ts | 10 ++-- .../organizations/[id]/roster/route.test.ts | 22 +++++--- .../api/organizations/[id]/roster/route.ts | 10 ++-- apps/sim/app/api/users/me/api-keys/route.ts | 34 +++++++++++-- apps/sim/app/api/v1/middleware.ts | 10 ++-- apps/sim/app/api/webhooks/route.ts | 24 +++++---- .../app/api/workspaces/[id]/api-keys/route.ts | 34 ++++++++++--- .../app/api/workspaces/[id]/inbox/route.ts | 17 +++++-- .../workspaces/[id]/inbox/senders/route.ts | 23 ++++++--- .../api/workspaces/[id]/inbox/tasks/route.ts | 11 ++-- .../access-control/utils/permission-check.ts | 47 ----------------- apps/sim/lib/api-key/access.ts | 50 ------------------- apps/sim/lib/copilot/chat/post.ts | 17 ++++--- apps/sim/lib/copilot/request/lifecycle/run.ts | 14 ++++-- apps/sim/lib/logs/application/list-logs.ts | 3 +- .../lib/logs/application/read-log-detail.ts | 5 +- apps/sim/lib/mothership/inbox/access.ts | 23 --------- .../application/read-enterprise-context.ts | 7 +-- apps/sim/lib/workflows/editing/builders.ts | 5 +- apps/sim/lib/workspaces/policy.ts | 3 +- 26 files changed, 256 insertions(+), 236 deletions(-) delete mode 100644 apps/sim/lib/api-key/access.ts delete mode 100644 apps/sim/lib/mothership/inbox/access.ts diff --git a/apps/sim/app/api/cli/auth/approve/route.test.ts b/apps/sim/app/api/cli/auth/approve/route.test.ts index 3cad0fe684b..d0d39aff4e0 100644 --- a/apps/sim/app/api/cli/auth/approve/route.test.ts +++ b/apps/sim/app/api/cli/auth/approve/route.test.ts @@ -10,17 +10,29 @@ const { mockCreateApproval, mockEnforceUserRateLimit, mockGetPermissions, - mockIsCliAccessDisabled, + mockGetUserPermissionConfig, + mockGetOrgPermissionConfig, + mockResolveVerifiedContext, + mockGetUserOrganization, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockCreateApproval: vi.fn(), mockEnforceUserRateLimit: vi.fn(), mockGetPermissions: vi.fn(), - mockIsCliAccessDisabled: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), + mockGetOrgPermissionConfig: vi.fn(), + mockResolveVerifiedContext: vi.fn(), + mockGetUserOrganization: vi.fn(), })) vi.mock('@/ee/access-control/utils/permission-check', () => ({ - isCliAccessDisabled: mockIsCliAccessDisabled, + getUserPermissionConfig: mockGetUserPermissionConfig, + getUserPermissionConfigForOrganization: mockGetOrgPermissionConfig, + resolveVerifiedUserAccessControlContext: mockResolveVerifiedContext, +})) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, })) vi.mock('@/lib/auth', () => ({ @@ -52,11 +64,13 @@ describe('POST /api/cli/auth/approve', () => { mockEnforceUserRateLimit.mockResolvedValue(null) mockCreateApproval.mockResolvedValue(undefined) mockGetPermissions.mockResolvedValue('admin') - mockIsCliAccessDisabled.mockResolvedValue(false) + mockGetUserPermissionConfig.mockResolvedValue(null) + mockGetOrgPermissionConfig.mockResolvedValue(null) + mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1' }) }) it('refuses an approver whose permission group disables CLI access', async () => { - mockIsCliAccessDisabled.mockResolvedValue(true) + mockGetOrgPermissionConfig.mockResolvedValue({ disableCliAccess: true }) const response = await POST( createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE }) @@ -77,7 +91,8 @@ describe('POST /api/cli/auth/approve', () => { }) ) - expect(mockIsCliAccessDisabled).toHaveBeenCalledWith('user-1', 'ws-1') + expect(mockGetUserPermissionConfig).toHaveBeenCalledWith('user-1', 'ws-1') + expect(mockGetOrgPermissionConfig).not.toHaveBeenCalled() }) it('records the approval for the signed-in user', async () => { diff --git a/apps/sim/app/api/cli/auth/approve/route.ts b/apps/sim/app/api/cli/auth/approve/route.ts index e98d05b39a1..9ab1dd1715b 100644 --- a/apps/sim/app/api/cli/auth/approve/route.ts +++ b/apps/sim/app/api/cli/auth/approve/route.ts @@ -3,14 +3,38 @@ import { type NextRequest, NextResponse } from 'next/server' import { approveCliAuthContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { getUserOrganization } from '@/lib/billing/organizations/membership' import { createApproval } from '@/lib/cli-auth/approval-store' import { enforceUserRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityRefusal, + isOrganizationCapabilityWithheld, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { isCliAccessDisabled } from '@/ee/access-control/utils/permission-check' const logger = createLogger('CliAuthApproveAPI') +/** + * Whether `userId`'s permission group withholds CLI access. + * + * permission-group-enforced: cli.use — gates a device-auth handoff, which owns + * no workspace resource for the authorization funnel to authorize. + * + * `workspaceId` is set only for a platform-scope handoff. A personal-scope + * login has no workspace, so it falls back to the organization's default group + * rather than going ungoverned — otherwise the narrower scope would be the + * unguarded one. + */ +async function cliAccessWithheld(userId: string, workspaceId?: string): Promise { + if (workspaceId) return isWorkspaceCapabilityWithheld(userId, workspaceId, 'cli.use') + + const membership = await getUserOrganization(userId) + if (!membership) return false + return isOrganizationCapabilityWithheld(membership.organizationId, 'cli.use') +} + /** * Records a signed-in user's approval of a CLI handoff so the waiting terminal's * poll can complete. @@ -76,16 +100,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } - if (await isCliAccessDisabled(session.user.id, workspaceId)) { + if (await cliAccessWithheld(session.user.id, workspaceId)) { logger.warn('CLI authorization blocked by permission group', { userId: session.user.id, scope, workspaceId: workspaceId ?? null, }) - return NextResponse.json( - { error: 'CLI access is not allowed based on your permission group settings' }, - { status: 403 } - ) + return NextResponse.json({ error: capabilityRefusal('cli.use') }, { status: 403 }) } await createApproval(session.user.id, requestId, challenge, { diff --git a/apps/sim/app/api/copilot/tool-permission/route.ts b/apps/sim/app/api/copilot/tool-permission/route.ts index 649d5bcd29a..320e491531c 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.ts @@ -29,7 +29,7 @@ import { import { withIncomingGoSpan } from '@/lib/copilot/request/otel' import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' const logger = createLogger('CopilotToolPermissionAPI') @@ -82,10 +82,9 @@ async function applyDecision( * the next call prompts again. Not a 403: the answer to *this* prompt was * legitimate, and failing the request would strand the waiting orchestrator. */ - const permissionConfig = run.workspaceId - ? await getUserPermissionConfig(userId, run.workspaceId) - : null - const mayRemember = permissionConfig?.disableToolAutoApproval !== true + const mayRemember = run.workspaceId + ? !(await isWorkspaceCapabilityWithheld(userId, run.workspaceId, 'copilot.tool_auto_approval')) + : true // Best-effort: failing to remember the preference must not block the tool the // user just allowed. Worst case they get prompted again next time. diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index 9899514d50a..e0061c22282 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -12,6 +12,10 @@ import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-s import { withheldSpendData } from '@/lib/logs/fetch-log-detail' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { + capabilityDeniedBy, + capabilityRefusal, +} from '@/lib/permission-groups/capability-assertions' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -107,24 +111,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { * and is not a reason to leave the export ungoverned meanwhile. */ const permissionConfig = await getUserPermissionConfig(userId, params.workspaceId) - if (permissionConfig?.disableLogExport) { - return NextResponse.json( - { - error: - "Exporting execution logs is not available under your organization's permission group", - }, - { status: 403 } - ) + if (capabilityDeniedBy('logs.export', permissionConfig)) { + return NextResponse.json({ error: capabilityRefusal('logs.export') }, { status: 403 }) } - const hideTraceSpans = permissionConfig?.hideTraceSpans === true + const hideTraceSpans = capabilityDeniedBy('logs.trace_spans', permissionConfig) /** * permission-group-enforced: logs.cost — the `costTotal` column is the same * run spend the detail view withholds, and a whole-workspace CSV of it is * the widest disclosure of the two. The column stays in the header so the * file shape does not depend on who downloaded it; only its values go. */ - const hideCostInfo = permissionConfig?.hideCostInfo === true + const hideCostInfo = capabilityDeniedBy('logs.cost', permissionConfig) const encoder = new TextEncoder() const csvChunks = (async function* () { diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 35293d6290b..a6c9609dfdd 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -17,7 +17,10 @@ import { createMcpSuccessResponse, mcpOrchestrationStatus, } from '@/lib/mcp/utils' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' const logger = createLogger('WorkflowMcpServersAPI') @@ -118,13 +121,8 @@ export const POST = withRouteHandler( * alternative is migrating this handler to the use case, which is worth * doing and is not a reason to leave the second door open meanwhile. */ - const permissionConfig = await getUserPermissionConfig(userId, workspaceId) - if (permissionConfig?.hideDeployMcp) { - return createMcpErrorResponse( - null, - "MCP server deployment is not available under your organization's permission group", - 403 - ) + if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'deploy.mcp')) { + return createMcpErrorResponse(null, capabilityRefusal('deploy.mcp'), 403) } logger.info(`[${requestId}] Creating workflow MCP server:`, { diff --git a/apps/sim/app/api/organizations/[id]/members/route.test.ts b/apps/sim/app/api/organizations/[id]/members/route.test.ts index e75cf435f40..68638a3a74e 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.test.ts @@ -11,8 +11,15 @@ import { } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockIsOrgMemberDirectoryHidden, mockGetUsageSnapshot } = vi.hoisted(() => ({ - mockIsOrgMemberDirectoryHidden: vi.fn(), +const { + mockGetOrgPermissionConfig, + mockGetUserPermissionConfig, + mockResolveVerifiedContext, + mockGetUsageSnapshot, +} = vi.hoisted(() => ({ + mockGetOrgPermissionConfig: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), + mockResolveVerifiedContext: vi.fn(), mockGetUsageSnapshot: vi.fn(), })) @@ -21,13 +28,16 @@ vi.mock('@sim/platform-authz/workspace', () => ({ })) vi.mock('@/ee/access-control/utils/permission-check', () => ({ - isOrgMemberDirectoryHidden: mockIsOrgMemberDirectoryHidden, + getUserPermissionConfig: mockGetUserPermissionConfig, + getUserPermissionConfigForOrganization: mockGetOrgPermissionConfig, + resolveVerifiedUserAccessControlContext: mockResolveVerifiedContext, })) vi.mock('@/lib/billing/core/organization', () => ({ getOrganizationMemberUsageSnapshot: mockGetUsageSnapshot, })) +import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' import { GET } from '@/app/api/organizations/[id]/members/route' const mockGetSession = authMockFns.mockGetSession @@ -47,7 +57,7 @@ describe('GET /api/organizations/[id]/members', () => { vi.clearAllMocks() resetDbChainMock() mockGetSession.mockResolvedValue(createSession({ userId: 'user-reader' })) - mockIsOrgMemberDirectoryHidden.mockResolvedValue(false) + mockGetOrgPermissionConfig.mockResolvedValue(null) }) it('lists members for an organization member', async () => { @@ -75,14 +85,14 @@ describe('GET /api/organizations/[id]/members', () => { }) it('refuses a member whose permission group hides the member directory', async () => { - mockIsOrgMemberDirectoryHidden.mockResolvedValue(true) + mockGetOrgPermissionConfig.mockResolvedValue({ hideOrgMemberDirectory: true }) queueTableRows(member, [{ id: 'member-reader', role: 'member' }]) const response = await request() expect(response.status).toBe(403) await expect(response.json()).resolves.toEqual({ - error: 'Forbidden - The organization member directory is not available to you', + error: capabilityRefusal('organization.member_directory'), }) }) }) diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 1a07090b14c..5e7b7950e8d 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -12,7 +12,10 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getOrganizationMemberUsageSnapshot } from '@/lib/billing/core/organization' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { isOrgMemberDirectoryHidden } from '@/ee/access-control/utils/permission-check' +import { + capabilityRefusal, + isOrganizationCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' const logger = createLogger('OrganizationMembersAPI') @@ -64,13 +67,14 @@ export const GET = withRouteHandler( ) } - if (await isOrgMemberDirectoryHidden(organizationId)) { + // permission-group-enforced: organization.member_directory — an organization-scoped read with no workspace or resource for the funnel to authorize + if (await isOrganizationCapabilityWithheld(organizationId, 'organization.member_directory')) { logger.warn('Organization member directory blocked by permission group', { organizationId, userId: session.user.id, }) return NextResponse.json( - { error: 'Forbidden - The organization member directory is not available to you' }, + { error: capabilityRefusal('organization.member_directory') }, { status: 403 } ) } diff --git a/apps/sim/app/api/organizations/[id]/roster/route.test.ts b/apps/sim/app/api/organizations/[id]/roster/route.test.ts index 42e8c04b2e2..9ccc4c5f04c 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.test.ts @@ -17,13 +17,22 @@ import { } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockExpireStaleInvitations, mockIsOrgMemberDirectoryHidden } = vi.hoisted(() => ({ +const { + mockExpireStaleInvitations, + mockGetOrgPermissionConfig, + mockGetUserPermissionConfig, + mockResolveVerifiedContext, +} = vi.hoisted(() => ({ mockExpireStaleInvitations: vi.fn(), - mockIsOrgMemberDirectoryHidden: vi.fn(), + mockGetOrgPermissionConfig: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), + mockResolveVerifiedContext: vi.fn(), })) vi.mock('@/ee/access-control/utils/permission-check', () => ({ - isOrgMemberDirectoryHidden: mockIsOrgMemberDirectoryHidden, + getUserPermissionConfig: mockGetUserPermissionConfig, + getUserPermissionConfigForOrganization: mockGetOrgPermissionConfig, + resolveVerifiedUserAccessControlContext: mockResolveVerifiedContext, })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -34,6 +43,7 @@ vi.mock('@/lib/invitations/core', () => ({ expireStalePendingInvitationsForOrganization: mockExpireStaleInvitations, })) +import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' import { GET } from '@/app/api/organizations/[id]/roster/route' const mockGetSession = authMockFns.mockGetSession @@ -66,12 +76,12 @@ describe('GET /api/organizations/[id]/roster', () => { vi.clearAllMocks() resetDbChainMock() mockExpireStaleInvitations.mockResolvedValue(undefined) - mockIsOrgMemberDirectoryHidden.mockResolvedValue(false) + mockGetOrgPermissionConfig.mockResolvedValue(null) }) it('refuses a member whose permission group hides the member directory', async () => { mockGetSession.mockResolvedValue(createSession({ userId: 'user-reader' })) - mockIsOrgMemberDirectoryHidden.mockResolvedValue(true) + mockGetOrgPermissionConfig.mockResolvedValue({ hideOrgMemberDirectory: true }) queueTableRows(member, [{ role: 'member' }]) const response = await GET( @@ -81,7 +91,7 @@ describe('GET /api/organizations/[id]/roster', () => { expect(response.status).toBe(403) await expect(response.json()).resolves.toEqual({ - error: 'Forbidden - The organization member directory is not available to you', + error: capabilityRefusal('organization.member_directory'), }) }) diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index 3b934031412..1cbc1275454 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -21,7 +21,10 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { expireStalePendingInvitationsForOrganization } from '@/lib/invitations/core' -import { isOrgMemberDirectoryHidden } from '@/ee/access-control/utils/permission-check' +import { + capabilityRefusal, + isOrganizationCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' const logger = createLogger('OrganizationRosterAPI') @@ -50,13 +53,14 @@ export const GET = withRouteHandler( ) } - if (await isOrgMemberDirectoryHidden(organizationId)) { + // permission-group-enforced: organization.member_directory — an organization-scoped read with no workspace or resource for the funnel to authorize + if (await isOrganizationCapabilityWithheld(organizationId, 'organization.member_directory')) { logger.warn('Organization roster blocked by permission group', { organizationId, userId: session.user.id, }) return NextResponse.json( - { error: 'Forbidden - The organization member directory is not available to you' }, + { error: capabilityRefusal('organization.member_directory') }, { status: 403 } ) } diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts index 1b14f9986b6..652ebbabe79 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -5,15 +5,35 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createPersonalApiKeyContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' -import { personalApiKeyManagementWithheldResponse } from '@/lib/api-key/access' import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { performCreatePersonalApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' +import { getUserOrganization } from '@/lib/billing/organizations/membership' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityRefusal, + isOrganizationCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('ApiKeysAPI') +/** + * Whether the caller's permission group withholds personal-key management. + * + * permission-group-enforced: api_keys.manage — a raw handler with inline + * queries, which the authorization funnel never sees. + * + * Personal keys are user-global and so belong to no workspace, which is why + * this resolves the organization's default group — the group that governs an + * organization-level action, the same resolution invitations use. + */ +async function personalKeyManagementWithheld(userId: string): Promise { + const membership = await getUserOrganization(userId) + if (!membership?.organizationId) return false + return isOrganizationCapabilityWithheld(membership.organizationId, 'api_keys.manage') +} + // GET /api/users/me/api-keys - Get all API keys for the current user export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -24,8 +44,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const userId = session.user.id - const withheld = await personalApiKeyManagementWithheldResponse(userId) - if (withheld) return withheld + const withheld = await personalKeyManagementWithheld(userId) + if (withheld) { + return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) + } const keys = await db .select({ @@ -71,8 +93,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userId = session.user.id - const withheld = await personalApiKeyManagementWithheldResponse(userId) - if (withheld) return withheld + const withheld = await personalKeyManagementWithheld(userId) + if (withheld) { + return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) + } const parsed = await parseRequest(createPersonalApiKeyContract, request, {}) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index bfd3f0f0490..5eee6057386 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -10,13 +10,13 @@ import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import type { SubscriptionPlan } from '@/lib/core/rate-limiter' import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter' import { generateRequestId } from '@/lib/core/utils/request' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBilledAccountUserId, getWorkspaceBillingSettings, } from '@/lib/workspaces/utils' import { authenticateV1Request } from '@/app/api/v1/auth' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('V1Middleware') const rateLimiter = new RateLimiter() @@ -269,8 +269,12 @@ export async function resolveWorkspaceScope( * refuses would still work against v1. */ if (rateLimit.userId) { - const permissionConfig = await getUserPermissionConfig(rateLimit.userId, requestedWorkspaceId) - if (permissionConfig?.disablePersonalApiKeys) { + const withheld = await isWorkspaceCapabilityWithheld( + rateLimit.userId, + requestedWorkspaceId, + 'personal_api_key.use' + ) + if (withheld) { return { status: 403, code: 'FORBIDDEN', diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index ec2b45499ea..259afe92fef 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -17,6 +17,10 @@ import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver' import { @@ -28,7 +32,6 @@ import { getProviderHandler } from '@/lib/webhooks/providers' import { mergeNonUserFields } from '@/lib/webhooks/utils' import { findConflictingWebhookPathOwner } from '@/lib/webhooks/utils.server' import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('WebhooksAPI') @@ -395,20 +398,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { * is a deliberate act of deleting the webhook. */ if (!existingWebhook) { - const permissionConfig = workflowRecord.workspaceId - ? await getUserPermissionConfig(userId, workflowRecord.workspaceId) - : null - if (permissionConfig?.disableWebhookTriggers) { + const withheld = workflowRecord.workspaceId + ? await isWorkspaceCapabilityWithheld( + userId, + workflowRecord.workspaceId, + 'triggers.webhook' + ) + : false + if (withheld) { logger.warn(`[${requestId}] Webhook creation blocked by permission group`, { userId, workflowId, }) - return NextResponse.json( - { - error: "Webhook triggers are not available under your organization's permission group", - }, - { status: 403 } - ) + return NextResponse.json({ error: capabilityRefusal('triggers.webhook') }, { status: 403 }) } } diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index 9e8da327023..6137138f489 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -10,13 +10,16 @@ import { deleteWorkspaceApiKeysContract, } from '@/lib/api/contracts/api-keys' import { parseRequest } from '@/lib/api/server' -import { apiKeyManagementWithheldResponse } from '@/lib/api-key/access' import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' @@ -46,8 +49,10 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const withheld = await apiKeyManagementWithheldResponse(userId, workspaceId) - if (withheld) return withheld + // permission-group-enforced: api_keys.manage — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'api_keys.manage')) { + return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) + } const workspaceKeys = await db .select({ @@ -91,6 +96,17 @@ export const GET = withRouteHandler( } ) +/** + * Mints a workspace API key. + * + * The `api_keys.manage` gate here is also what closes the workspace-key + * pass-through: a workspace key authorizes as the workspace and resolves no + * group, so the authorization funnel's capability gate never applies to it. + * Refusing to mint one keeps a governed member from issuing themselves a + * credential that outranks their own group. Keys that already exist keep + * working — revoking those is an admin's call, not something a policy change + * should do silently. + */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -110,8 +126,10 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const withheld = await apiKeyManagementWithheldResponse(userId, workspaceId) - if (withheld) return withheld + // permission-group-enforced: api_keys.manage — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'api_keys.manage')) { + return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) + } const parsed = await parseRequest(createWorkspaceApiKeyContract, request, context) if (!parsed.success) return parsed.response @@ -174,8 +192,10 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const withheld = await apiKeyManagementWithheldResponse(userId, workspaceId) - if (withheld) return withheld + // permission-group-enforced: api_keys.manage — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'api_keys.manage')) { + return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) + } const parsed = await parseRequest(deleteWorkspaceApiKeysContract, request, context) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index cb25a9125ec..9fae2dc0d73 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -9,8 +9,11 @@ import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { inboxWithheldResponse } from '@/lib/mothership/inbox/access' import { disableInbox, enableInbox, updateInboxAddress } from '@/lib/mothership/inbox/lifecycle' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InboxConfigAPI') @@ -28,8 +31,10 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } - const withheld = await inboxWithheldResponse(session.user.id, workspaceId) - if (withheld) return withheld + // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { + return NextResponse.json({ error: capabilityRefusal('inbox.use') }, { status: 403 }) + } const [wsResult, statsResult, entitled] = await Promise.all([ db @@ -98,8 +103,10 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } - const withheld = await inboxWithheldResponse(session.user.id, workspaceId) - if (withheld) return withheld + // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { + return NextResponse.json({ error: capabilityRefusal('inbox.use') }, { status: 403 }) + } const parsed = await parseRequest(updateInboxConfigContract, req, context) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts index 098858c9556..a587e752284 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts @@ -8,7 +8,10 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { inboxWithheldResponse } from '@/lib/mothership/inbox/access' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InboxSendersAPI') @@ -32,8 +35,10 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } - const withheld = await inboxWithheldResponse(session.user.id, workspaceId) - if (withheld) return withheld + // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { + return NextResponse.json({ error: capabilityRefusal('inbox.use') }, { status: 403 }) + } const [senders, members] = await Promise.all([ db @@ -91,8 +96,10 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } - const withheld = await inboxWithheldResponse(session.user.id, workspaceId) - if (withheld) return withheld + // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { + return NextResponse.json({ error: capabilityRefusal('inbox.use') }, { status: 403 }) + } try { const parsed = await parseRequest(addInboxSenderContract, req, context) @@ -153,8 +160,10 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } - const withheld = await inboxWithheldResponse(session.user.id, workspaceId) - if (withheld) return withheld + // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { + return NextResponse.json({ error: capabilityRefusal('inbox.use') }, { status: 403 }) + } try { const parsed = await parseRequest(removeInboxSenderContract, req, context) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts index b9900c5bdc9..9bc0eac950e 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts @@ -6,7 +6,10 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { inboxWithheldResponse } from '@/lib/mothership/inbox/access' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const GET = withRouteHandler( @@ -35,8 +38,10 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } - const withheld = await inboxWithheldResponse(session.user.id, workspaceId) - if (withheld) return withheld + // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { + return NextResponse.json({ error: capabilityRefusal('inbox.use') }, { status: 403 }) + } const queryResult = inboxTasksQuerySchema.safeParse( Object.fromEntries(req.nextUrl.searchParams.entries()) diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index f4c38063125..e543f80ced4 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger' import { and, asc, eq, sql } from 'drizzle-orm' import type { ShareAuthType } from '@/lib/api/contracts/public-shares' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' -import { getUserOrganization } from '@/lib/billing/organizations/membership' import { getAllowedIntegrationsFromEnv, isAccessControlEnabled, @@ -423,52 +422,6 @@ export async function getUserPermissionConfigForOrganization( return mergeEnvAllowlist(resolved?.config ?? null) } -/** - * Whether the organization's permission group withholds its member directory. - * - * A directory read has no workspace and no resource, so there is no workspace - * operation for the funnel to hang a capability on — the two routes that serve - * it check bare organization membership, which is why every member can read - * every colleague's name and email today. - * - * No role exemption: the default group governs owners and admins the same way it - * governs everyone else for every other capability, and carving one out here - * would make this the only key whose meaning depends on who is asking. - */ -/** permission-group-enforced: organization.member_directory — organization-scoped read with no workspace or resource for the funnel to authorize */ -export async function isOrgMemberDirectoryHidden(organizationId: string): Promise { - const config = await getUserPermissionConfigForOrganization(organizationId) - return config?.hideOrgMemberDirectory === true -} - -/** - * Whether CLI access is withheld from `userId`. - * - * Asked at approval time, which is the only moment a human is present: the - * device-auth poll that redeems the approval for an API key is unauthenticated - * by necessity, so it has no session to resolve a group against, and re-asking - * there would only duplicate this decision while racing a config change between - * the two calls. - * - * `workspaceId` is set only for a platform-scope handoff. A personal-scope login - * has no workspace, so it falls back to the organization's default group rather - * than going ungoverned — otherwise the narrower scope would be the unguarded - * one. - */ -/** permission-group-enforced: cli.use — gates a device-auth handoff, which owns no workspace resource for the funnel to authorize */ -export async function isCliAccessDisabled(userId: string, workspaceId?: string): Promise { - if (workspaceId) { - const config = await getUserPermissionConfig(userId, workspaceId) - return config?.disableCliAccess === true - } - - const membership = await getUserOrganization(userId) - if (!membership) return false - - const config = await getUserPermissionConfigForOrganization(membership.organizationId) - return config?.disableCliAccess === true -} - /** * Cache-aware wrapper around `getUserPermissionConfig`. When an * `ExecutionContext` is provided, the resolved config is memoized on the diff --git a/apps/sim/lib/api-key/access.ts b/apps/sim/lib/api-key/access.ts deleted file mode 100644 index 3f687f0616c..00000000000 --- a/apps/sim/lib/api-key/access.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { NextResponse } from 'next/server' -import { getUserOrganization } from '@/lib/billing/organizations/membership' -import { - getUserPermissionConfig, - getUserPermissionConfigForOrganization, -} from '@/ee/access-control/utils/permission-check' - -const REFUSAL = "Managing API keys is not available under your organization's permission group" - -/** - * Refuses API-key management when the caller's permission group withholds it. - * - * permission-group-enforced: api_keys.manage — the key CRUD routes are raw - * handlers with inline queries rather than workspace operations, so the - * authorization funnel never sees them. - * - * This is also what closes the workspace-API-key pass-through. A workspace key - * authorizes as the workspace and resolves no group, so the funnel's capability - * gate does not apply to it; gating the minting of one keeps a governed member - * from issuing themselves a credential that outranks their own group. Keys that - * already exist keep working — revoking those is the admin's call, not - * something a policy change should do silently. - * - * Returns a response rather than throwing, to match how these handlers already - * report refusals, and `null` when nothing withholds the capability. - */ -export async function apiKeyManagementWithheldResponse( - userId: string, - workspaceId: string -): Promise { - const permissionConfig = await getUserPermissionConfig(userId, workspaceId) - if (!permissionConfig?.hideApiKeysTab) return null - return NextResponse.json({ error: REFUSAL }, { status: 403 }) -} - -/** - * The same refusal for personal keys, which are user-global and so belong to no - * workspace. Resolves the organization's default group, which is the group that - * governs an organization-level action — the same resolution invitations use. - */ -export async function personalApiKeyManagementWithheldResponse( - userId: string -): Promise { - const membership = await getUserOrganization(userId) - if (!membership?.organizationId) return null - - const permissionConfig = await getUserPermissionConfigForOrganization(membership.organizationId) - if (!permissionConfig?.hideApiKeysTab) return null - return NextResponse.json({ error: REFUSAL }, { status: 403 }) -} diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index bbb4a02548d..0e86482d107 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -68,6 +68,10 @@ import { import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import type { AtomicClaimResult } from '@/lib/core/idempotency' import { chatSendIdempotency } from '@/lib/core/idempotency' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { @@ -75,7 +79,6 @@ import { isWorkspaceAccessDeniedError, type PermissionType, } from '@/lib/workspaces/permissions/utils' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import type { ChatContext } from '@/stores/panel' export const maxDuration = 3600 @@ -1064,13 +1067,11 @@ export async function handleUnifiedChatPost(req: NextRequest) { * settles the resume stream: with no run there is nothing to replay. A * request naming no workspace is governed by no group. */ - if (body.workspaceId) { - const permissionConfig = await getUserPermissionConfig(authenticatedUserId, body.workspaceId) - if (permissionConfig?.hideCopilot) { - return createForbiddenResponse( - "Chat is not available under your organization's permission group" - ) - } + if ( + body.workspaceId && + (await isWorkspaceCapabilityWithheld(authenticatedUserId, body.workspaceId, 'copilot.use')) + ) { + return createForbiddenResponse(capabilityRefusal('copilot.use')) } const userMetadata = { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 1ec3d4dd648..4f0ca5483c9 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -63,8 +63,8 @@ import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copil import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { env } from '@/lib/core/config/env' import { isCopilotToolPermissionsEnabled, isHosted } from '@/lib/core/config/env-flags' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { filterModelSafeWorkspaceFileAttachments } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -215,11 +215,15 @@ async function resolveToolPermissions( * silenced forever, so the stored list is not even loaded once the group * withholds the capability. */ - const permissionConfig = + const withheld = options.userId && options.workspaceId - ? await getUserPermissionConfig(options.userId, options.workspaceId) - : null - if (permissionConfig?.disableToolAutoApproval) { + ? await isWorkspaceCapabilityWithheld( + options.userId, + options.workspaceId, + 'copilot.tool_auto_approval' + ) + : false + if (withheld) { return { enabled: true, autoAllowed: new Set(), autoAllowPermitted: false } } diff --git a/apps/sim/lib/logs/application/list-logs.ts b/apps/sim/lib/logs/application/list-logs.ts index e68d9f2ca9d..1eb9c7c34f9 100644 --- a/apps/sim/lib/logs/application/list-logs.ts +++ b/apps/sim/lib/logs/application/list-logs.ts @@ -8,6 +8,7 @@ import { } from '@/lib/logs/application/authorization' import { logOperations } from '@/lib/logs/application/operations' import { type ListLogsParams, readLogs } from '@/lib/logs/list-logs' +import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -31,7 +32,7 @@ const authorizedListLogsUseCase = defineAuthorizedWorkspaceUseCase({ return readLogs({ ...input, workspaceId: context.workspaceId, - hideCostInfo: permissionConfig?.hideCostInfo === true, + hideCostInfo: capabilityDeniedBy('logs.cost', permissionConfig), }) }, }) diff --git a/apps/sim/lib/logs/application/read-log-detail.ts b/apps/sim/lib/logs/application/read-log-detail.ts index 0a5c07cb03c..2fc1da756f9 100644 --- a/apps/sim/lib/logs/application/read-log-detail.ts +++ b/apps/sim/lib/logs/application/read-log-detail.ts @@ -11,6 +11,7 @@ import { } from '@/lib/logs/application/authorization' import { logOperations } from '@/lib/logs/application/operations' import { readLogDetail } from '@/lib/logs/fetch-log-detail' +import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' import { type ActiveWorkspaceApplicationContext, resolveActiveWorkspaceApplicationContext, @@ -103,8 +104,8 @@ const authorizedReadLogDetailUseCase = defineAuthorizedWorkspaceUseCase({ lookupColumn: input.lookupColumn, lookupValue: input.lookupValue, signal: input.signal, - hideTraceSpans: permissionConfig?.hideTraceSpans === true, - hideCostInfo: permissionConfig?.hideCostInfo === true, + hideTraceSpans: capabilityDeniedBy('logs.trace_spans', permissionConfig), + hideCostInfo: capabilityDeniedBy('logs.cost', permissionConfig), }) input.signal?.throwIfAborted() if (!detail) throw new OrchestrationError('not_found', 'Not found') diff --git a/apps/sim/lib/mothership/inbox/access.ts b/apps/sim/lib/mothership/inbox/access.ts deleted file mode 100644 index 01489caea5e..00000000000 --- a/apps/sim/lib/mothership/inbox/access.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextResponse } from 'next/server' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' - -/** - * Refuses the inbox when the caller's permission group withholds it. - * - * permission-group-enforced: inbox.use — the inbox routes are raw handlers with - * inline queries rather than workspace operations, so the authorization funnel - * never sees them. Returns a response instead of throwing to match how those - * handlers already report refusals, and returns `null` when nothing withholds - * the inbox so a caller can read it as a guard. - */ -export async function inboxWithheldResponse( - userId: string, - workspaceId: string -): Promise { - const permissionConfig = await getUserPermissionConfig(userId, workspaceId) - if (!permissionConfig?.hideInboxTab) return null - return NextResponse.json( - { error: "The inbox is not available under your organization's permission group" }, - { status: 403 } - ) -} diff --git a/apps/sim/lib/platform-context/application/read-enterprise-context.ts b/apps/sim/lib/platform-context/application/read-enterprise-context.ts index dab02b17e5d..cbca10f2ca6 100644 --- a/apps/sim/lib/platform-context/application/read-enterprise-context.ts +++ b/apps/sim/lib/platform-context/application/read-enterprise-context.ts @@ -2,6 +2,7 @@ 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 { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' import { platformContextDelegationPolicy } from '@/lib/platform-context/application/authorization' import { resolvePlatformContextWorkspace } from '@/lib/platform-context/application/context' @@ -46,9 +47,9 @@ export const readEnterpriseContext = defineAuthorizedWorkspaceUseCase({ 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 + capabilityDeniedBy('deploy.api', accessControl.config) && + capabilityDeniedBy('deploy.mcp', accessControl.config) && + capabilityDeniedBy('deploy.chat', accessControl.config) return { workspace: { diff --git a/apps/sim/lib/workflows/editing/builders.ts b/apps/sim/lib/workflows/editing/builders.ts index 534333e938a..970fb9605a9 100644 --- a/apps/sim/lib/workflows/editing/builders.ts +++ b/apps/sim/lib/workflows/editing/builders.ts @@ -7,6 +7,7 @@ import { normalizeBlockRetryWaitMs, } from '@sim/workflow-types/workflow' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' import { createModelAccessGate } from '@/lib/permission-groups/model-access' import { createToolAccessGate, @@ -790,7 +791,7 @@ export function filterDisallowedTools( const isToolAllowed = createToolAccessGate(permissionConfig.deniedTools) const allowedTools: any[] = [] for (const tool of deploymentAvailableTools) { - if (tool.type === 'custom-tool' && permissionConfig.disableCustomTools) { + if (tool.type === 'custom-tool' && capabilityDeniedBy('custom_tools.use', permissionConfig)) { logSkippedItem(skippedItems, { type: 'tool_not_allowed', operationType: 'add', @@ -800,7 +801,7 @@ export function filterDisallowedTools( }) continue } - if (tool.type === 'mcp' && permissionConfig.disableMcpTools) { + if (tool.type === 'mcp' && capabilityDeniedBy('mcp_tools.use', permissionConfig)) { logSkippedItem(skippedItems, { type: 'tool_not_allowed', operationType: 'add', diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 20594269f00..985c9027c65 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -14,6 +14,7 @@ import { getPlanType, isEnterprise, isMaxTier, isPro, isTeam } from '@/lib/billi import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/env-flags' import type { DbOrTx } from '@/lib/db/types' +import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' import { CONTACT_OWNER_TO_UPGRADE_REASON, UPGRADE_TO_INVITE_REASON, @@ -366,7 +367,7 @@ export async function getWorkspaceCreationPolicy({ */ // permission-group-enforced: workspace.create — no workspace exists yet, so the workspace-scoped funnel has nothing to resolve a group against const config = await getUserPermissionConfigForOrganization(governingOrganizationId) - if (config?.disableWorkspaceCreation) { + if (capabilityDeniedBy('workspace.create', config)) { return { canCreate: false, workspaceMode: From 037b4e90f69e9fd925569664cd81d29eb0ebdb41 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 29 Aug 2026 09:15:44 -0700 Subject: [PATCH 025/179] fix(permission-groups): stop capabilities refusing workflow runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A delegated executor principal usually carries a user subject — the subject resolver recurses into the delegation context — so a run was getting the triggering member's capabilities as well as their role. That turned every capability on an executor-reachable operation into a runtime kill-switch: ticking "hide the Knowledge Base module from the sidebar" made retrieval 403 mid-run, "hide Tables" broke Table blocks, "hide the Files settings tab" broke `/api/files/serve` and with it every rendered image and PDF, and "hide the Integrations settings tab" failed every OAuth block in the workspace, including credentials an admin had shared deliberately. A capability names what a *person* may reach in the product. A run reaches those resources because a block in the graph does, and what a run may do is governed separately by `assertPermissionsAllowed`, which gates every block, tool and model against the same group. So an executor delegation now carries the triggering user's role but not their capabilities — the same reasoning already documented for a subject-less deployment run, which was only ever half the case. Copilot is deliberately not exempt: it acts as the person, so it must not reach what the person may not. Tested both ways. Found by an adversarial review of the branch, not by a failing test — the existing test pinned the broken behavior as if it were intended, which is why it is now inverted with the reasoning written down. --- .../workspace-authorization.test.ts | 59 ++++++++++++++++++- .../application/workspace-authorization.ts | 42 +++++++++---- 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index e01529a5077..7b32402e206 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -283,6 +283,15 @@ const capabilityOperation = defineWorkspaceOperation({ capability: 'tables.use', }) +const copilotCapabilityOperation = defineWorkspaceOperation({ + id: 'test.copilot-capability-read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + capability: 'tables.use', +}) + const personalKeyPrincipal: PersonalApiKeyPrincipal = { kind: 'personal_api_key', userId: 'user-1', @@ -337,7 +346,13 @@ describe('authorizeWorkspaceOperation permission-group capability', () => { ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) }) - it('refuses a delegated principal that carries a user subject', async () => { + /** + * A run carries the triggering user's role but not their capabilities. The + * alternative would make "hide Tables from the sidebar" a runtime kill-switch + * for every workflow with a Table block, which is not what the checkbox says + * and not what an admin ticking it intends. + */ + it('does not apply to an executor run, even one carrying a user subject', async () => { mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) await expect( @@ -353,6 +368,48 @@ describe('authorizeWorkspaceOperation permission-group capability', () => { context, executorAuthorization ) + ).resolves.toBeUndefined() + }) + + it('still enforces the workspace role for an executor run', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + authorizeWorkspaceOperation( + { + ...executorPrincipal( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { workflowId: 'current-workflow-1', mode: 'draft' } + ), + subjectUserId: 'user-1', + }, + capabilityOperation, + context, + executorAuthorization + ) + ).rejects.toBeInstanceOf(NoWorkspaceAccessError) + }) + + /** + * Copilot acts as the person, so it must not reach what the person may not. + */ + it('does apply to a Copilot delegation, which acts as the person', async () => { + mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation( + { + ...executorPrincipal( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { workflowId: 'current-workflow-1', mode: 'draft' } + ), + serviceId: 'copilot', + subjectUserId: 'user-1', + }, + copilotCapabilityOperation, + context, + executorAuthorization + ) ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) }) diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index 8e5c59c8924..af86e875648 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -213,10 +213,10 @@ async function requirePersonalApiKeysAllowed( * raising a role, rather than chasing an admin about a group setting that is * not why they were refused. */ -async function requireCurrentHumanAccess( +async function requireCurrentHumanRole( userId: string, context: C, - operation: WorkspaceOperation, + required: PermissionType, options?: WorkspaceAuthorizationOptions ): Promise { const permission = await resolveEffectiveWorkspacePermission( @@ -226,7 +226,16 @@ async function requireCurrentHumanAccess( + userId: string, + context: C, + operation: WorkspaceOperation, + options?: WorkspaceAuthorizationOptions +): Promise { + await requireCurrentHumanRole(userId, context, operation.minimumRole, options) await requireCapability(userId, context, operation) } @@ -293,6 +302,24 @@ export async function authorizeWorkspaceOperation Date: Sat, 29 Aug 2026 09:16:46 -0700 Subject: [PATCH 026/179] refactor(permission-groups): keep only the validators that outlive the rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `permission-check.ts` predates `CAPABILITY_RULES`, so several of its `validate*Allowed` helpers restated a config key the rule registry already names, and two of its error classes existed only to be caught one frame later and rethrown as the `ForbiddenOperationError` the rule would have raised. Collapsed: - `validateMcpToolsAllowed`, `validateCustomToolsAllowed` and `validateSkillsAllowed` were `assertPermissionsAllowed`'s `toolKind` branch spelled out three more times. Their four call sites now pass `toolKind` directly, which keeps the `ExecutionContext` config memo and the same error classes. Their enforcement annotations move to `assertPermissionsAllowed`. - `ChatDeployAuthNotAllowedError` and `PublicFileSharingNotAllowedError` were each thrown from one place and immediately translated by every caller into `ForbiddenOperationError` with the very detail code their rule declares. Both gates now raise `PermissionGroupCapabilityError` once, and the five try/catch translations are gone. `validatePublicFileSharing`, `validateChatDeployAuth`, `validateInvitationsAllowed` and `validatePublicApiAllowed` stay: the first two decide on a request auth mode the authorization funnel never sees, and the last two OR a permission group against a deployment-wide env flag. All four now read `CAPABILITY_RULES` rather than a config key, so a renamed key breaks the build instead of silently ceasing to deny anything. `assertPermissionsAllowed` stays for the same reason it always existed — it governs what a run may do, not what an operation is — with its tool-kind branch driven off the rules. The refusal wording for a blocked chat auth mode and a blocked file share now matches the funnel's sentence ("... is not available under your organization's permission group"). HTTP status and detail code are unchanged. --- .../app/api/chat/manage/[id]/route.test.ts | 22 +- apps/sim/app/api/chat/route.test.ts | 22 +- .../app/api/v2/chat-deployments/route.test.ts | 12 +- .../deployments/chat/route.test.ts | 22 +- .../utils/permission-check.test.ts | 24 +- .../access-control/utils/permission-check.ts | 209 ++++++++---------- .../executor/handlers/agent/agent-handler.ts | 25 ++- .../application/update-chat-deployment.ts | 16 +- .../application/workflow-chat-deployment.ts | 24 +- apps/sim/lib/copilot/mcp-tools.test.ts | 8 +- apps/sim/lib/copilot/mcp-tools.ts | 6 +- .../tools/handlers/deployment/deploy.test.ts | 1 - .../workflows/application/chat-deployments.ts | 15 +- .../application/share-workspace-file.ts | 14 +- apps/sim/tools/index.test.ts | 3 - 15 files changed, 180 insertions(+), 243 deletions(-) diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 51d0d94b0f0..361bea5f55a 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -21,6 +21,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), @@ -64,19 +65,12 @@ vi.mock('@/lib/workflows/orchestration', () => ({ vi.mock('@/lib/workflows/deployment-status', () => ({ checkNeedsRedeployment: mocks.checkNeedsRedeployment, })) -vi.mock('@/ee/access-control/utils/permission-check', () => { - class ChatDeployAuthNotAllowedError extends Error { - constructor() { - super('This chat authentication mode is not allowed based on your permission group settings') - this.name = 'ChatDeployAuthNotAllowedError' - } - } - return { validateChatDeployAuth: mocks.validateChatDeployAuth, ChatDeployAuthNotAllowedError } -}) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateChatDeployAuth: mocks.validateChatDeployAuth, +})) import { chatDeploymentOperations } from '@/lib/chat-deployments/application' import { DELETE, GET, PATCH } from '@/app/api/chat/manage/[id]/route' -import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' const CHAT_ID = 'chat-123' const WORKFLOW_ID = 'workflow-1' @@ -477,7 +471,13 @@ describe('internal chat deployment routes', () => { }) it('refuses a mode the permission group blocks', async () => { - mocks.validateChatDeployAuth.mockRejectedValue(new ChatDeployAuthNotAllowedError()) + mocks.validateChatDeployAuth.mockRejectedValue( + new PermissionGroupCapabilityError( + 'deploy.chat.auth_mode', + 'CHAT_AUTH_MODE_NOT_PERMITTED', + "This chat authentication mode is not available under your organization's permission group" + ) + ) const response = await patch({ authType: 'email', allowedEmails: ['a@example.com'] }) diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index 581b1af1e13..c7670c123a4 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -18,6 +18,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), @@ -53,18 +54,11 @@ vi.mock('@/lib/workflows/orchestration', () => ({ performChatDeploy: mocks.performChatDeploy, performChatUndeploy: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => { - class ChatDeployAuthNotAllowedError extends Error { - constructor() { - super('This chat authentication mode is not allowed based on your permission group settings') - this.name = 'ChatDeployAuthNotAllowedError' - } - } - return { validateChatDeployAuth: mocks.validateChatDeployAuth, ChatDeployAuthNotAllowedError } -}) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateChatDeployAuth: mocks.validateChatDeployAuth, +})) import { POST } from '@/app/api/chat/route' -import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' const WORKFLOW_ID = 'workflow-1' const WORKSPACE_ID = 'workspace-1' @@ -282,7 +276,13 @@ describe('Chat API Route', () => { it('refuses an auth mode the permission group blocks', async () => { queueChatLookups(null, null) - mocks.validateChatDeployAuth.mockRejectedValue(new ChatDeployAuthNotAllowedError()) + mocks.validateChatDeployAuth.mockRejectedValue( + new PermissionGroupCapabilityError( + 'deploy.chat.auth_mode', + 'CHAT_AUTH_MODE_NOT_PERMITTED', + "This chat authentication mode is not available under your organization's permission group" + ) + ) const response = await post({ ...validBody, diff --git a/apps/sim/app/api/v2/chat-deployments/route.test.ts b/apps/sim/app/api/v2/chat-deployments/route.test.ts index fa44381d548..3f5914f183a 100644 --- a/apps/sim/app/api/v2/chat-deployments/route.test.ts +++ b/apps/sim/app/api/v2/chat-deployments/route.test.ts @@ -61,15 +61,9 @@ vi.mock('@/lib/workflows/orchestration', () => ({ performChatDeploy: mocks.performChatDeploy, performChatUndeploy: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => { - class ChatDeployAuthNotAllowedError extends Error { - constructor() { - super('This chat authentication mode is not allowed') - this.name = 'ChatDeployAuthNotAllowedError' - } - } - return { validateChatDeployAuth: mocks.validateChatDeployAuth, ChatDeployAuthNotAllowedError } -}) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateChatDeployAuth: mocks.validateChatDeployAuth, +})) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts index 8f9b52c07b3..51900d00d90 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts @@ -12,6 +12,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), @@ -63,20 +64,13 @@ vi.mock('@/lib/workflows/orchestration', () => ({ getWorkflowDeploymentSummary: vi.fn(), performFullDeploy: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => { - class ChatDeployAuthNotAllowedError extends Error { - constructor() { - super('This chat authentication mode is not allowed') - this.name = 'ChatDeployAuthNotAllowedError' - } - } - return { validateChatDeployAuth: mocks.validateChatDeployAuth, ChatDeployAuthNotAllowedError } -}) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateChatDeployAuth: mocks.validateChatDeployAuth, +})) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) import { DELETE, GET, PUT } from '@/app/api/v2/workflows/[workflowId]/deployments/chat/route' -import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' const WORKSPACE_ID = 'workspace-1' const WORKFLOW_ID = 'workflow-1' @@ -447,7 +441,13 @@ describe('/api/v2/workflows/[workflowId]/deployments/chat', () => { }) it('names a blocked auth mode with an actionable forbidden code', async () => { - mocks.validateChatDeployAuth.mockRejectedValue(new ChatDeployAuthNotAllowedError()) + mocks.validateChatDeployAuth.mockRejectedValue( + new PermissionGroupCapabilityError( + 'deploy.chat.auth_mode', + 'CHAT_AUTH_MODE_NOT_PERMITTED', + "This chat authentication mode is not available under your organization's permission group" + ) + ) const response = await put({ ...validBody, 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 c8251378acf..75be191fb27 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -36,23 +36,21 @@ vi.mock('@/providers/utils', () => ({ getProviderFromModel: mockGetProviderFromModel, })) +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' import { assertPermissionsAllowed, - ChatDeployAuthNotAllowedError, CustomToolsNotAllowedError, getUserPermissionConfig, IntegrationNotAllowedError, McpToolsNotAllowedError, ModelNotAllowedError, ProviderNotAllowedError, - PublicFileSharingNotAllowedError, resolveUserAccessControlContext, resolveVerifiedUserAccessControlContext, SkillsNotAllowedError, ToolNotAllowedError, validateBlockType, validateChatDeployAuth, - validateMcpToolsAllowed, validateModelProvider, validatePublicFileSharing, } from './permission-check' @@ -564,7 +562,7 @@ describe('validateModelProvider', () => { }) }) -describe('validateMcpToolsAllowed', () => { +describe('assertPermissionsAllowed (MCP tools)', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() @@ -575,15 +573,19 @@ describe('validateMcpToolsAllowed', () => { it('throws McpToolsNotAllowedError when disableMcpTools is set', async () => { queueGroupResolution([{ config: { disableMcpTools: true } }]) - await expect(validateMcpToolsAllowed('user-123', 'workspace-1')).rejects.toBeInstanceOf( - McpToolsNotAllowedError - ) + await expect( + assertPermissionsAllowed({ userId: 'user-123', workspaceId: 'workspace-1', toolKind: 'mcp' }) + ).rejects.toBeInstanceOf(McpToolsNotAllowedError) }) it('no-ops when disableMcpTools is false', async () => { queueGroupResolution([{ config: {} }]) - await validateMcpToolsAllowed('user-123', 'workspace-1') + await assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + toolKind: 'mcp', + }) }) }) @@ -599,14 +601,14 @@ describe('validatePublicFileSharing', () => { queueGroupResolution([{ config: { disablePublicFileSharing: true } }]) await expect( validatePublicFileSharing('user-123', 'workspace-1', 'password') - ).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) }) it('throws when the auth type is not in the allow-list', async () => { queueGroupResolution([{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }]) await expect( validatePublicFileSharing('user-123', 'workspace-1', 'public') - ).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) }) it('allows an auth type that is in the allow-list', async () => { @@ -637,7 +639,7 @@ describe('validateChatDeployAuth', () => { queueGroupResolution([{ config: { allowedChatDeployAuthTypes: ['password', 'sso'] } }]) await expect( validateChatDeployAuth('user-123', 'workspace-1', 'public') - ).rejects.toBeInstanceOf(ChatDeployAuthNotAllowedError) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) }) it('allows an auth type that is in the allow-list', async () => { diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index f4c38063125..2a8b2594b25 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -16,6 +16,13 @@ import { isBlockTypeAccessControlExempt, resolveAccessControlBlockType, } from '@/lib/permission-groups/block-access' +import { + CAPABILITY_RULES, + capabilityRefusalMessage, + type PermissionGroupCapability, + type StaticCapabilityRule, +} from '@/lib/permission-groups/capabilities' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { @@ -98,18 +105,22 @@ export class PublicApiNotAllowedError extends Error { } } -export class PublicFileSharingNotAllowedError extends Error { - constructor() { - super('Public file sharing is not allowed based on your permission group settings') - this.name = 'PublicFileSharingNotAllowedError' - } -} - -export class ChatDeployAuthNotAllowedError extends Error { - constructor() { - super('This chat authentication mode is not allowed based on your permission group settings') - this.name = 'ChatDeployAuthNotAllowedError' - } +/** + * Refuses with the sentence and detail code the authorization funnel raises for + * the same capability. + * + * The gates below decide from a request value the funnel never sees, so they + * cannot ride on an operation's declared capability — but the refusal a caller + * reads, and the code a client branches on, still come from + * {@link CAPABILITY_RULES} rather than being spelled out here. + */ +function refuseCapability(capability: PermissionGroupCapability): never { + const rule = CAPABILITY_RULES[capability] + throw new PermissionGroupCapabilityError( + capability, + rule.detailCode, + capabilityRefusalMessage(rule.describe) + ) } /** @@ -336,11 +347,14 @@ export async function getUserPermissionConfig( } /** - * Throws {@link PublicFileSharingNotAllowedError} if the user's effective permission - * group for the workspace disables public file sharing, or — when `authType` is - * given — if that auth mode isn't in the group's `allowedFileShareAuthTypes` - * allow-list (`null` allows all). No-op when access control doesn't apply - * (non-enterprise / disabled), so non-governed orgs are unaffected. + * Refuses a public file share the caller's permission group withholds — the + * master switch, and then — when `authType` is given — the auth mode the share + * would carry. No-op when access control doesn't apply (non-enterprise / + * disabled), so non-governed organizations are unaffected. + * + * Kept as one helper because these two rules are always asked together: a share + * is refused if the group withholds sharing at all, or if it sanctions sharing + * but not this way of gating it. */ /** permission-group-enforced: file_share.publish — asserted where a share is created, not per operation */ /** permission-group-enforced: file_share.auth_mode — needs the request auth mode, which the funnel never sees */ @@ -353,29 +367,27 @@ export async function validatePublicFileSharing( if (!config) { return } - if (config.disablePublicFileSharing) { - throw new PublicFileSharingNotAllowedError() + if (CAPABILITY_RULES['file_share.publish'].deniedBy(config)) { + refuseCapability('file_share.publish') } - if ( - authType && - config.allowedFileShareAuthTypes !== null && - !config.allowedFileShareAuthTypes.includes(authType) - ) { + if (authType && CAPABILITY_RULES['file_share.auth_mode'].deniedBy(config, authType)) { logger.warn('File share auth type blocked by permission group', { userId, workspaceId, authType, }) - throw new PublicFileSharingNotAllowedError() + refuseCapability('file_share.auth_mode') } } /** - * Throws {@link ChatDeployAuthNotAllowedError} if the user's effective permission - * group for the workspace doesn't allow the chat deployment's `authType` (i.e. it - * isn't in the group's `allowedChatDeployAuthTypes` allow-list; `null` allows all). + * Refuses a chat deployment auth mode the caller's permission group withholds. * No-op when access control doesn't apply (non-enterprise / disabled), so - * non-governed orgs are unaffected. + * non-governed organizations are unaffected. + * + * Callers ask only when the mode actually changes, so a grandfathered mode + * already saved on a chat survives an edit to some other field. That asymmetry + * belongs to them — it reads the stored deployment, which this never sees. */ /** permission-group-enforced: deploy.chat.auth_mode — needs the request auth mode, which the funnel never sees */ export async function validateChatDeployAuth( @@ -387,16 +399,13 @@ export async function validateChatDeployAuth( if (!config) { return } - if ( - config.allowedChatDeployAuthTypes !== null && - !config.allowedChatDeployAuthTypes.includes(authType) - ) { + if (CAPABILITY_RULES['deploy.chat.auth_mode'].deniedBy(config, authType)) { logger.warn('Chat deploy auth type blocked by permission group', { userId, workspaceId, authType, }) - throw new ChatDeployAuthNotAllowedError() + refuseCapability('deploy.chat.auth_mode') } } @@ -588,71 +597,7 @@ export async function validateBlockType( } } -/** permission-group-enforced: mcp_tools.use — gates tool invocation during a run, not an operation */ -export async function validateMcpToolsAllowed( - userId: string | undefined, - workspaceId: string | undefined, - ctx?: ExecutionContext -): Promise { - if (!userId || !workspaceId) { - return - } - - const config = await getPermissionConfig(userId, workspaceId, ctx) - - if (!config) { - return - } - - if (config.disableMcpTools) { - logger.warn('MCP tools blocked by permission group', { userId, workspaceId }) - throw new McpToolsNotAllowedError() - } -} - -/** permission-group-enforced: custom_tools.use — gates tool invocation during a run, not an operation */ -export async function validateCustomToolsAllowed( - userId: string | undefined, - workspaceId: string | undefined, - ctx?: ExecutionContext -): Promise { - if (!userId || !workspaceId) { - return - } - - const config = await getPermissionConfig(userId, workspaceId, ctx) - - if (!config) { - return - } - - if (config.disableCustomTools) { - logger.warn('Custom tools blocked by permission group', { userId, workspaceId }) - throw new CustomToolsNotAllowedError() - } -} - -/** permission-group-enforced: skills.use — gates skill loading during a run, not an operation */ -export async function validateSkillsAllowed( - userId: string | undefined, - workspaceId: string | undefined, - ctx?: ExecutionContext -): Promise { - if (!userId || !workspaceId) { - return - } - - const config = await getPermissionConfig(userId, workspaceId, ctx) - - if (!config) { - return - } - - if (config.disableSkills) { - logger.warn('Skills blocked by permission group', { userId, workspaceId }) - throw new SkillsNotAllowedError() - } -} +const INVITATIONS_RULE = CAPABILITY_RULES['invitations.send'] /** * Validates if the user is allowed to send invitations. Pass one of: @@ -681,7 +626,7 @@ export async function validateInvitationsAllowed( if (workspaceId) { const config = await getUserPermissionConfig(userId, workspaceId) - if (config?.disableInvitations) { + if (config && INVITATIONS_RULE.deniedBy(config)) { logger.warn('Invitations blocked by permission group', { userId, workspaceId }) throw new InvitationsNotAllowedError() } @@ -690,7 +635,7 @@ export async function validateInvitationsAllowed( if (organizationId) { const config = await getUserPermissionConfigForOrganization(organizationId) - if (config?.disableInvitations) { + if (config && INVITATIONS_RULE.deniedBy(config)) { logger.warn('Invitations blocked by permission group (organization-wide)', { userId, organizationId, @@ -725,13 +670,44 @@ export async function validatePublicApiAllowed( return } - if (config.disablePublicApi) { + if (CAPABILITY_RULES['public_api.use'].deniedBy(config)) { logger.warn('Public API blocked by permission group', { userId, workspaceId }) throw new PublicApiNotAllowedError() } } -export type ToolKind = 'mcp' | 'custom' | 'skill' +type ToolKind = 'mcp' | 'custom' | 'skill' + +/** + * What each tool kind is gated on. The decision reads + * {@link CAPABILITY_RULES}, so a renamed config key breaks the build here + * rather than silently ceasing to deny anything. + * + * These keep their own error classes rather than raising the funnel's + * capability refusal: they surface inside a run, where the executor reports + * them as the failing block's error, and `lib/mcp` branches on + * {@link McpToolsNotAllowedError} by identity. + */ +const TOOL_KIND_GATES = { + mcp: { + rule: CAPABILITY_RULES['mcp_tools.use'], + error: McpToolsNotAllowedError, + blocked: 'MCP tools blocked by permission group', + }, + custom: { + rule: CAPABILITY_RULES['custom_tools.use'], + error: CustomToolsNotAllowedError, + blocked: 'Custom tools blocked by permission group', + }, + skill: { + rule: CAPABILITY_RULES['skills.use'], + error: SkillsNotAllowedError, + blocked: 'Skills blocked by permission group', + }, +} as const satisfies Record< + ToolKind, + { rule: StaticCapabilityRule; error: new () => Error; blocked: string } +> interface PermissionAssertion { userId: string | undefined @@ -751,13 +727,17 @@ interface PermissionAssertion { /** * Unified entry point for workspace-scoped access control. Loads the user's * permission config for `workspaceId` once and runs every applicable gate - * (model provider, block type, tool kind) against it, throwing the existing + * (model provider, block type, tool id, tool kind) against it, throwing the * granular error classes on the first mismatch. * - * Prefer this over calling the individual `validate*Allowed` helpers when - * gating a shared entry point like `executeTool` or an HTTP proxy, so a single - * callsite covers every future config field. + * This decides what a *run* may do, which is not what the authorization funnel + * decides: the funnel refuses an operation up front, while a run reaches here + * once per block, model and tool it actually touches, and a deployed workflow + * with no acting user has no group for the funnel to consult at all. */ +/** permission-group-enforced: mcp_tools.use — gates tool invocation during a run, not an operation */ +/** permission-group-enforced: custom_tools.use — gates tool invocation during a run, not an operation */ +/** permission-group-enforced: skills.use — gates skill loading during a run, not an operation */ export async function assertPermissionsAllowed(req: PermissionAssertion): Promise { const { userId, workspaceId, model, blockType, toolId, toolKind, ctx } = req @@ -818,17 +798,10 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis } if (toolKind && config) { - if (toolKind === 'mcp' && config.disableMcpTools) { - logger.warn('MCP tools blocked by permission group', { userId, workspaceId }) - throw new McpToolsNotAllowedError() - } - if (toolKind === 'custom' && config.disableCustomTools) { - logger.warn('Custom tools blocked by permission group', { userId, workspaceId }) - throw new CustomToolsNotAllowedError() - } - if (toolKind === 'skill' && config.disableSkills) { - logger.warn('Skills blocked by permission group', { userId, workspaceId }) - throw new SkillsNotAllowedError() + const gate = TOOL_KIND_GATES[toolKind] + if (gate.rule.deniedBy(config)) { + logger.warn(gate.blocked, { userId, workspaceId }) + throw new gate.error() } } } diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 3664fb82227..fa346f84466 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -41,11 +41,9 @@ import { assembleCustomBlockInputMapping, isCustomBlockType } from '@/blocks/cus import type { BlockOutput } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' import { + assertPermissionsAllowed, validateBlockType, - validateCustomToolsAllowed, - validateMcpToolsAllowed, validateModelProvider, - validateSkillsAllowed, } from '@/ee/access-control/utils/permission-check' import { AGENT, BlockType, DEFAULTS, stripCustomToolPrefix } from '@/executor/constants' import { memoryService } from '@/executor/handlers/agent/memory' @@ -361,7 +359,12 @@ export class AgentBlockHandler implements BlockHandler { const skillInputs = filteredInputs.skills ?? [] let skillMetadata: Array<{ name: string; description: string }> = [] if (skillInputs.length > 0 && ctx.workspaceId) { - await validateSkillsAllowed(ctx.userId, ctx.workspaceId, ctx) + await assertPermissionsAllowed({ + userId: ctx.userId, + workspaceId: ctx.workspaceId, + toolKind: 'skill', + ctx, + }) skillMetadata = await resolveSkillMetadata(skillInputs, ctx.workspaceId) if (skillMetadata.length > 0) { const skillNames = skillMetadata.map((s) => s.name) @@ -595,11 +598,21 @@ export class AgentBlockHandler implements BlockHandler { const hasCustomTools = tools.some((t) => t.type === 'custom-tool') if (hasMcpTools) { - await validateMcpToolsAllowed(ctx.userId, ctx.workspaceId, ctx) + await assertPermissionsAllowed({ + userId: ctx.userId, + workspaceId: ctx.workspaceId, + toolKind: 'mcp', + ctx, + }) } if (hasCustomTools) { - await validateCustomToolsAllowed(ctx.userId, ctx.workspaceId, ctx) + await assertPermissionsAllowed({ + userId: ctx.userId, + workspaceId: ctx.workspaceId, + toolKind: 'custom', + ctx, + }) } } diff --git a/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts b/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts index 0f6b5b2e990..a9d5e26d138 100644 --- a/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts +++ b/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts @@ -20,15 +20,12 @@ import { updateChatDeploymentRow, } from '@/lib/chat-deployments/queries' import { buildChatDeploymentUrl } from '@/lib/chat-deployments/urls' -import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { encryptSecret } from '@/lib/core/security/encryption' import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import { getWorkflowDeploymentSummary, performFullDeploy } from '@/lib/workflows/orchestration' -import { - ChatDeployAuthNotAllowedError, - validateChatDeployAuth, -} from '@/ee/access-control/utils/permission-check' +import { validateChatDeployAuth } from '@/ee/access-control/utils/permission-check' const logger = createLogger('UpdateChatDeployment') @@ -192,14 +189,7 @@ export const updateChatDeployment = defineAuthorizedWorkspaceUseCase({ * be re-saved by a title-only edit without a refusal. */ if (input.authType && input.authType !== existing.authType) { - try { - await validateChatDeployAuth(actingUserId, context.workspaceId, input.authType) - } catch (error) { - if (error instanceof ChatDeployAuthNotAllowedError) { - throw new ForbiddenOperationError('CHAT_AUTH_MODE_NOT_PERMITTED', error.message) - } - throw error - } + await validateChatDeployAuth(actingUserId, context.workspaceId, input.authType) } if (input.identifier && input.identifier !== existing.identifier) { diff --git a/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts b/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts index 995271bd7df..5ed60936a85 100644 --- a/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts +++ b/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts @@ -25,13 +25,10 @@ import { getLiveChatDeploymentForWorkflow, } from '@/lib/chat-deployments/queries' import { buildChatDeploymentUrl } from '@/lib/chat-deployments/urls' -import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { performChatDeploy, performChatUndeploy } from '@/lib/workflows/orchestration' -import { - ChatDeployAuthNotAllowedError, - validateChatDeployAuth, -} from '@/ee/access-control/utils/permission-check' +import { validateChatDeployAuth } from '@/ee/access-control/utils/permission-check' /** * The chat singleton of a workflow. @@ -121,18 +118,11 @@ async function assertAuthModePermitted( authType: ChatAuthType ): Promise { if (authType === context.chatDeployment?.authType) return - try { - await validateChatDeployAuth( - requirePrincipalSubjectUserId(principal), - context.workspaceId, - authType - ) - } catch (error) { - if (error instanceof ChatDeployAuthNotAllowedError) { - throw new ForbiddenOperationError('CHAT_AUTH_MODE_NOT_PERMITTED', error.message) - } - throw error - } + await validateChatDeployAuth( + requirePrincipalSubjectUserId(principal), + context.workspaceId, + authType + ) } /** diff --git a/apps/sim/lib/copilot/mcp-tools.test.ts b/apps/sim/lib/copilot/mcp-tools.test.ts index defa40f1507..31a8fa49505 100644 --- a/apps/sim/lib/copilot/mcp-tools.test.ts +++ b/apps/sim/lib/copilot/mcp-tools.test.ts @@ -3,20 +3,20 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { discoverServerTools, validateMcpToolsAllowed } = vi.hoisted(() => ({ +const { discoverServerTools, assertPermissionsAllowed } = vi.hoisted(() => ({ discoverServerTools: vi.fn(), - validateMcpToolsAllowed: vi.fn(), + assertPermissionsAllowed: vi.fn(), })) vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ validateMcpToolsAllowed })) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ assertPermissionsAllowed })) import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' describe('mothership MCP tool schemas', () => { beforeEach(() => { vi.clearAllMocks() - validateMcpToolsAllowed.mockResolvedValue(undefined) + assertPermissionsAllowed.mockResolvedValue(undefined) }) it('discovers tools only for explicitly tagged servers', async () => { diff --git a/apps/sim/lib/copilot/mcp-tools.ts b/apps/sim/lib/copilot/mcp-tools.ts index 24856365e08..f3fde00fa6b 100644 --- a/apps/sim/lib/copilot/mcp-tools.ts +++ b/apps/sim/lib/copilot/mcp-tools.ts @@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors' import type { ToolSchema } from '@/lib/copilot/chat/payload' import type { McpTool, McpToolSchema } from '@/lib/mcp/types' import { createMcpToolId } from '@/lib/mcp/utils' -import { validateMcpToolsAllowed } from '@/ee/access-control/utils/permission-check' +import { assertPermissionsAllowed } from '@/ee/access-control/utils/permission-check' import type { ToolInput } from '@/executor/handlers/agent/types' const logger = createLogger('CopilotMcpTools') @@ -78,7 +78,7 @@ export async function buildTaggedMcpToolSchemas( const uniqueServerIds = [...new Set(serverIds.filter(Boolean))] if (uniqueServerIds.length === 0) return [] - await validateMcpToolsAllowed(userId, workspaceId) + await assertPermissionsAllowed({ userId, workspaceId, toolKind: 'mcp' }) const discovered = await Promise.all( uniqueServerIds.map((serverId) => discoverServerTools(userId, workspaceId, serverId)) ) @@ -104,7 +104,7 @@ export async function buildSelectedMcpToolSchemas( ) if (selected.length === 0) return [] - await validateMcpToolsAllowed(userId, workspaceId) + await assertPermissionsAllowed({ userId, workspaceId, toolKind: 'mcp' }) const discoveredByServer = new Map>() const resolved = await Promise.all( selected.map(async (selection) => { diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts index cd5b1ec5cd1..87d0197e822 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts @@ -64,7 +64,6 @@ vi.mock('@/app/api/chat/utils', () => ({ })) vi.mock('@/ee/access-control/utils/permission-check', () => ({ - ChatDeployAuthNotAllowedError: class ChatDeployAuthNotAllowedError extends Error {}, validateChatDeployAuth: vi.fn(), })) diff --git a/apps/sim/lib/workflows/application/chat-deployments.ts b/apps/sim/lib/workflows/application/chat-deployments.ts index fa125332cfe..5f22714eb2d 100644 --- a/apps/sim/lib/workflows/application/chat-deployments.ts +++ b/apps/sim/lib/workflows/application/chat-deployments.ts @@ -14,17 +14,13 @@ import { getChatDeploymentIdOwningIdentifier, getLiveChatDeploymentForWorkflow, } from '@/lib/chat-deployments/queries' -import { ForbiddenOperationError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' import { performChatDeploy, performChatUndeploy } from '@/lib/workflows/orchestration' -import { - ChatDeployAuthNotAllowedError, - validateChatDeployAuth, -} from '@/ee/access-control/utils/permission-check' +import { validateChatDeployAuth } from '@/ee/access-control/utils/permission-check' type ChatAuthType = 'public' | 'password' | 'email' | 'sso' type ChatOutputConfig = { blockId: string; path: string } @@ -161,14 +157,7 @@ export const deployWorkflowChat = defineAuthorizedWorkflowUseCase({ const subjectUserId = requirePrincipalSubjectUserId(principal) if (authType !== existingDeployment?.authType) { - try { - await validateChatDeployAuth(subjectUserId, context.workspaceId, authType) - } catch (error) { - if (error instanceof ChatDeployAuthNotAllowedError) { - throw new ForbiddenOperationError('CHAT_AUTH_MODE_NOT_PERMITTED', error.message) - } - throw error - } + await validateChatDeployAuth(subjectUserId, context.workspaceId, authType) } const attribution = resolvePrincipalAttribution(principal, { diff --git a/apps/sim/lib/workspace-files/application/share-workspace-file.ts b/apps/sim/lib/workspace-files/application/share-workspace-file.ts index b1584f0f977..b854f42b43a 100644 --- a/apps/sim/lib/workspace-files/application/share-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/share-workspace-file.ts @@ -2,7 +2,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalExecutionActorUserId } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' -import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getShareForResource, @@ -13,10 +12,7 @@ import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-fil import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' -import { - PublicFileSharingNotAllowedError, - validatePublicFileSharing, -} from '@/ee/access-control/utils/permission-check' +import { validatePublicFileSharing } from '@/ee/access-control/utils/permission-check' const logger = createLogger('WorkspaceFileShare') @@ -87,13 +83,7 @@ export const updateWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ if (input.isActive) { const effectiveAuthType = input.authType ?? existingShare?.authType ?? 'public' - try { - await validatePublicFileSharing(userId, context.workspaceId, effectiveAuthType) - } catch (error) { - if (error instanceof PublicFileSharingNotAllowedError) - throw new ForbiddenOperationError('PUBLIC_SHARING_NOT_ALLOWED', error.message) - throw error - } + await validatePublicFileSharing(userId, context.workspaceId, effectiveAuthType) } let share: ShareRecord diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index ec4d03ee860..7cfc1e63ae5 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -111,9 +111,6 @@ vi.mock('@/lib/core/security/encryption', () => ({ vi.mock('@/ee/access-control/utils/permission-check', () => ({ assertPermissionsAllowed: mockAssertPermissionsAllowed, validateBlockType: vi.fn().mockResolvedValue(undefined), - validateMcpToolsAllowed: vi.fn().mockResolvedValue(undefined), - validateCustomToolsAllowed: vi.fn().mockResolvedValue(undefined), - validateSkillsAllowed: vi.fn().mockResolvedValue(undefined), validateModelProvider: vi.fn().mockResolvedValue(undefined), validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined), validatePublicApiAllowed: vi.fn().mockResolvedValue(undefined), From 481146df6d3e53aff22fff2df280e4907584cfba Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 11:11:10 -0700 Subject: [PATCH 027/179] fix(permission-groups): close the defects an adversarial review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `disableTableExport` withheld nothing. `tableOperations.downloadExport` was gated, but a second raw route handed back a presigned URL for a finished export behind workspace-read alone, and the workspace job listing named every colleague's `jobId`. List, take one, download. Both are gated now — the listing withholds as an empty list rather than an error, because the caller has no exports to act on and erroring the tray would report a failure where the honest answer is that there is nothing to show. `tables.create` and `tables.export` now subsume `hideTablesTab`, the way the knowledge rules already did. An operation declares exactly one capability, so a narrower one replacing a broader one lets the broader key through: a group that hid the whole Tables module could still create and export tables. `triggers.webhook` gated creation but not reactivation, which is the act the key names. Only `false → true`: deactivating stays open, or a policy change would strand a member with a live webhook they cannot turn off. Enrichment ran under the workspace's billing owner. For a system-triggered cell the billing attribution names the payer, and running a member's tool denylist against a bystander is wrong both ways — it fails cells nobody meant to govern, and skips the denylist for whoever actually triggered one. It now names the triggering user or nobody, which is the documented behavior for an actorless run and what CLAUDE.md requires. Two projection leaks: the CSV export filled its message column from `finalOutput`, which the detail view deletes for that same viewer, and `stripSpanCosts` cleared `cost` but not `tokens` — the same spend in another unit, recoverable by anyone who knows the model's rate. Organization admins are exempt from the member-directory capability. The response is the only source for the team-management page and its seat snapshot, so withholding it took away the page an admin would use to change the setting. Workspace API-key revocation is no longer gated. Withholding key management must not withhold key revocation — an admin unable to revoke a leaked credential turns a policy into a security hazard. The personal-key delete route was already ungated for that reason. Also: the logs list and detail resolve through the per-request memo with the organization id they already hold. The list is polled, its operation declares no capability, so this was a net-new workspace query and plan check on every request including for personal workspaces that can never be governed. --- apps/sim/app/api/logs/export/route.ts | 7 +- .../api/organizations/[id]/members/route.ts | 21 ++++-- .../api/organizations/[id]/roster/route.ts | 13 +++- .../[tableId]/export/download/route.test.ts | 64 ++++++++++++++++++- .../table/[tableId]/export/download/route.ts | 16 +++++ apps/sim/app/api/table/jobs/route.ts | 14 ++++ apps/sim/app/api/webhooks/[id]/route.ts | 25 ++++++++ .../app/api/workspaces/[id]/api-keys/route.ts | 12 ++-- .../background/workflow-column-execution.ts | 11 +++- .../access-control/utils/permission-check.ts | 22 +------ .../lib/logs/application/list-logs.test.ts | 4 +- apps/sim/lib/logs/application/list-logs.ts | 8 ++- .../logs/application/read-log-detail.test.ts | 4 +- .../lib/logs/application/read-log-detail.ts | 8 ++- apps/sim/lib/logs/execution/trace-store.ts | 8 ++- .../sim/lib/permission-groups/capabilities.ts | 33 ++++++++-- .../capability-assertions.ts | 14 +--- 17 files changed, 224 insertions(+), 60 deletions(-) diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index e0061c22282..0579cd63d66 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -172,7 +172,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let message: unknown = '' let tracesJson = '' try { - if (executionData.finalOutput) { + /** + * `finalOutput` is one of the payloads the log-detail projection + * deletes for this viewer, so exporting it here would hand back in + * bulk exactly what the detail view withholds one run at a time. + */ + if (executionData.finalOutput && !hideTraceSpans) { message = typeof executionData.finalOutput === 'string' ? executionData.finalOutput diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 5e7b7950e8d..858c33f74ae 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -67,8 +67,22 @@ export const GET = withRouteHandler( ) } - // permission-group-enforced: organization.member_directory — an organization-scoped read with no workspace or resource for the funnel to authorize - if (await isOrganizationCapabilityWithheld(organizationId, 'organization.member_directory')) { + const userRole = memberEntry[0].role + const hasAdminAccess = isOrgAdminRole(userRole) + + /** + * permission-group-enforced: organization.member_directory — an + * organization-scoped read with no workspace for the funnel to authorize. + * + * Admins and owners are exempt. This response is the only source for the + * team-management page and the seat-usage snapshot it renders, so + * withholding it from an admin would take away the page they would use to + * change the setting, and their seat management with it. + */ + if ( + !hasAdminAccess && + (await isOrganizationCapabilityWithheld(organizationId, 'organization.member_directory')) + ) { logger.warn('Organization member directory blocked by permission group', { organizationId, userId: session.user.id, @@ -79,9 +93,6 @@ export const GET = withRouteHandler( ) } - const userRole = memberEntry[0].role - const hasAdminAccess = isOrgAdminRole(userRole) - // Get organization members const memberPageQuery = db .select({ diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index 1cbc1275454..a7d7bba1868 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -53,8 +53,17 @@ export const GET = withRouteHandler( ) } - // permission-group-enforced: organization.member_directory — an organization-scoped read with no workspace or resource for the funnel to authorize - if (await isOrganizationCapabilityWithheld(organizationId, 'organization.member_directory')) { + /** + * permission-group-enforced: organization.member_directory — an + * organization-scoped read with no workspace for the funnel to authorize. + * + * Admins and owners are exempt, for the reason the members route records: + * this feeds the page an admin would use to change the setting. + */ + if ( + !isOrgAdminRole(callerMembership.role) && + (await isOrganizationCapabilityWithheld(organizationId, 'organization.member_directory')) + ) { logger.warn('Organization roster blocked by permission group', { organizationId, userId: session.user.id, diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts index b977f5ff9f8..001a2d83399 100644 --- a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts @@ -5,10 +5,20 @@ import { createTableDefinition, hybridAuthMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckAccess, mockGetTableJob, mockGeneratePresignedDownloadUrl } = vi.hoisted(() => ({ +const { + mockCheckAccess, + mockGetTableJob, + mockGeneratePresignedDownloadUrl, + mockGetUserPermissionConfig, +} = vi.hoisted(() => ({ mockCheckAccess: vi.fn(), mockGetTableJob: vi.fn(), mockGeneratePresignedDownloadUrl: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, })) vi.mock('@/lib/table/jobs/service', () => ({ getTableJob: mockGetTableJob })) @@ -24,6 +34,7 @@ vi.mock('@/app/api/table/utils', async () => { } }) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { GET } from '@/app/api/table/[tableId]/export/download/route' function makeRequest(query: Record, tableId = 'tbl_1') { @@ -103,3 +114,54 @@ describe('GET /api/table/[tableId]/export/download', () => { expect(response.status).toBe(400) }) }) + +/** + * The second door to a finished export. Gating only the job that produces one + * left this route handing the file to anyone who could name a `jobId`, and the + * workspace job listing names every colleague's. + */ +describe('tables.export capability', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + }) + mockCheckAccess.mockResolvedValue({ + ok: true, + table: createTableDefinition({ id: 'tbl_1', workspaceId: 'workspace-1' }), + }) + mockGetTableJob.mockResolvedValue({ + type: 'export', + status: 'ready', + payload: { resultKey: 'exports/tbl_1.csv' }, + }) + mockGeneratePresignedDownloadUrl.mockResolvedValue('https://example.test/signed') + mockGetUserPermissionConfig.mockResolvedValue(null) + }) + + it('refuses when the group withholds table export', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableExport: true, + }) + + const response = await makeRequest(validQuery) + + expect(response.status).toBe(403) + expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled() + }) + + it('refuses a group that withholds the Tables module outright', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTablesTab: true, + }) + + expect((await makeRequest(validQuery)).status).toBe(403) + }) + + it('allows an ungoverned caller', async () => { + expect((await makeRequest(validQuery)).status).toBe(200) + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.ts b/apps/sim/app/api/table/[tableId]/export/download/route.ts index 988b774a262..ead14962b75 100644 --- a/apps/sim/app/api/table/[tableId]/export/download/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/download/route.ts @@ -5,10 +5,15 @@ import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityDeniedBy, + capabilityRefusal, +} from '@/lib/permission-groups/capability-assertions' import { getTableJob } from '@/lib/table/jobs/service' import type { TableExportJobPayload } from '@/lib/table/types' import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' import { accessError, checkAccess } from '@/app/api/table/utils' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('TableExportDownload') @@ -45,6 +50,17 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } + /** + * permission-group-enforced: tables.export — the second door to a finished + * export. Gating only the job that produces one leaves this route handing the + * file to anyone who can name a `jobId`, and the workspace job listing names + * every colleague's. + */ + const permissionConfig = await getUserPermissionConfig(authResult.userId, workspaceId) + if (capabilityDeniedBy('tables.export', permissionConfig)) { + return NextResponse.json({ error: capabilityRefusal('tables.export') }, { status: 403 }) + } + const job = await getTableJob(tableId, jobId) if (!job || job.type !== 'export') { return NextResponse.json({ error: 'Export job not found' }, { status: 404 }) diff --git a/apps/sim/app/api/table/jobs/route.ts b/apps/sim/app/api/table/jobs/route.ts index dbe38d3e489..76bd2d39444 100644 --- a/apps/sim/app/api/table/jobs/route.ts +++ b/apps/sim/app/api/table/jobs/route.ts @@ -5,8 +5,10 @@ import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' import { listWorkspaceExportJobs } from '@/lib/table/jobs/service' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('TableJobsAPI') @@ -36,6 +38,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + /** + * permission-group-enforced: tables.export — this listing is exports and + * nothing else, and each row names a `jobId` that resolves to a finished + * export file. Withheld as an empty list rather than a refusal: the caller has + * no exports they may act on, and erroring the tray would report a failure + * where the honest answer is that there is nothing to show. + */ + const permissionConfig = await getUserPermissionConfig(authResult.userId, workspaceId) + if (capabilityDeniedBy('tables.export', permissionConfig)) { + return NextResponse.json({ success: true, data: { jobs: [] } }) + } + const jobs = await listWorkspaceExportJobs(workspaceId) logger.info(`[${requestId}] Listed ${jobs.length} export jobs`, { workspaceId }) return NextResponse.json({ success: true, data: { jobs } }) diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 92506cc8627..695fa65b367 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -19,8 +19,13 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityDeniedBy, + capabilityRefusal, +} from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('WebhookAPI') @@ -139,6 +144,26 @@ export const PATCH = withRouteHandler( const setClause: Partial = {} if (isActive !== undefined && isActive !== webhooks[0].webhook.isActive) { + /** + * permission-group-enforced: triggers.webhook — reactivating is the act + * the key names, "prevent making a workflow reachable from an inbound + * webhook", so gating creation alone left it reachable by flipping a + * dormant webhook back on. Only this direction: deactivating must stay + * open, or a policy change would strand a member with a live webhook + * they cannot turn off. + */ + if (isActive) { + const permissionConfig = await getUserPermissionConfig( + userId, + webhooks[0].workflow.workspaceId ?? '' + ) + if (capabilityDeniedBy('triggers.webhook', permissionConfig)) { + return NextResponse.json( + { error: capabilityRefusal('triggers.webhook') }, + { status: 403 } + ) + } + } setClause.isActive = isActive } if (failedCount !== undefined && failedCount !== webhooks[0].webhook.failedCount) { diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index 6137138f489..c0923b3a89d 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -192,11 +192,13 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - // permission-group-enforced: api_keys.manage — raw handler with inline queries, which the authorization funnel never sees - if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'api_keys.manage')) { - return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) - } - + /** + * Deliberately not capability-gated, unlike the read and the mint above. + * Withholding key *management* must never withhold key *revocation*: a + * workspace admin whose group hides the API Keys tab would otherwise be + * unable to revoke a leaked credential, turning a policy into a security + * hazard. The personal-key delete route is ungated for the same reason. + */ const parsed = await parseRequest(deleteWorkspaceApiKeysContract, request, context) if (!parsed.success) return parsed.response const { keys } = parsed.data.body diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index a57e7277d39..f54683a886f 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -586,7 +586,16 @@ async function runWorkflowAndWriteTerminal( tableId, rowId, workspaceId, - userId: enrichmentBillingAttribution.actorUserId, + /** + * The person who asked, not who pays. For a system-triggered cell + * the billing attribution names the workspace's billing owner, and + * running a member's tool denylist against a bystander is wrong in + * both directions: it fails cells nobody meant to govern, and it + * skips the denylist for the person who actually triggered one. + * Absent means no per-tool gate applies, which is the documented + * behavior for an actorless run. + */ + userId: payload.triggeredByUserId ?? undefined, signal: attemptSignal, resolvedSecretTraceRegistry: enrichmentRegistry, }) diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index ccb0fa5106a..0e369781581 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -17,11 +17,9 @@ import { } from '@/lib/permission-groups/block-access' import { CAPABILITY_RULES, - capabilityRefusalMessage, - type PermissionGroupCapability, + refuseCapability, type StaticCapabilityRule, } from '@/lib/permission-groups/capabilities' -import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { @@ -104,24 +102,6 @@ export class PublicApiNotAllowedError extends Error { } } -/** - * Refuses with the sentence and detail code the authorization funnel raises for - * the same capability. - * - * The gates below decide from a request value the funnel never sees, so they - * cannot ride on an operation's declared capability — but the refusal a caller - * reads, and the code a client branches on, still come from - * {@link CAPABILITY_RULES} rather than being spelled out here. - */ -function refuseCapability(capability: PermissionGroupCapability): never { - const rule = CAPABILITY_RULES[capability] - throw new PermissionGroupCapabilityError( - capability, - rule.detailCode, - capabilityRefusalMessage(rule.describe) - ) -} - /** * Merges the env allowlist into a permission config. * diff --git a/apps/sim/lib/logs/application/list-logs.test.ts b/apps/sim/lib/logs/application/list-logs.test.ts index 3019f8d4903..5c46c3669c1 100644 --- a/apps/sim/lib/logs/application/list-logs.test.ts +++ b/apps/sim/lib/logs/application/list-logs.test.ts @@ -26,8 +26,8 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - getUserPermissionConfig: mocks.getUserPermissionConfig, +vi.mock('@/lib/permission-groups/config-scope.server', () => ({ + resolvePermissionGroupConfig: mocks.getUserPermissionConfig, })) import { listLogsUseCase } from '@/lib/logs/application/list-logs' diff --git a/apps/sim/lib/logs/application/list-logs.ts b/apps/sim/lib/logs/application/list-logs.ts index 1eb9c7c34f9..7e1fc160f63 100644 --- a/apps/sim/lib/logs/application/list-logs.ts +++ b/apps/sim/lib/logs/application/list-logs.ts @@ -9,8 +9,8 @@ import { import { logOperations } from '@/lib/logs/application/operations' import { type ListLogsParams, readLogs } from '@/lib/logs/list-logs' import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const authorizedListLogsUseCase = defineAuthorizedWorkspaceUseCase({ operation: logOperations.list, @@ -26,7 +26,11 @@ const authorizedListLogsUseCase = defineAuthorizedWorkspaceUseCase({ */ const viewerUserId = resolvePrincipalSubjectUserId(principal) const permissionConfig = viewerUserId - ? await getUserPermissionConfig(viewerUserId, context.workspaceId) + ? await resolvePermissionGroupConfig( + viewerUserId, + context.workspaceId, + context.workspaceOrganizationId + ) : null return readLogs({ diff --git a/apps/sim/lib/logs/application/read-log-detail.test.ts b/apps/sim/lib/logs/application/read-log-detail.test.ts index 07255b1e179..f7880ac1bfe 100644 --- a/apps/sim/lib/logs/application/read-log-detail.test.ts +++ b/apps/sim/lib/logs/application/read-log-detail.test.ts @@ -22,8 +22,8 @@ vi.mock('@/lib/workspaces/application/workspace-context', () => ({ resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspace, })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - getUserPermissionConfig: mocks.getUserPermissionConfig, +vi.mock('@/lib/permission-groups/config-scope.server', () => ({ + resolvePermissionGroupConfig: mocks.getUserPermissionConfig, })) vi.mock('@sim/platform-authz/workspace', () => ({ diff --git a/apps/sim/lib/logs/application/read-log-detail.ts b/apps/sim/lib/logs/application/read-log-detail.ts index 2fc1da756f9..6a25970031b 100644 --- a/apps/sim/lib/logs/application/read-log-detail.ts +++ b/apps/sim/lib/logs/application/read-log-detail.ts @@ -12,11 +12,11 @@ import { import { logOperations } from '@/lib/logs/application/operations' import { readLogDetail } from '@/lib/logs/fetch-log-detail' import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { type ActiveWorkspaceApplicationContext, resolveActiveWorkspaceApplicationContext, } from '@/lib/workspaces/application/workspace-context' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' export interface ReadLogDetailInput { workspaceId: string @@ -95,7 +95,11 @@ const authorizedReadLogDetailUseCase = defineAuthorizedWorkspaceUseCase({ * visibility to admins asked for. */ const permissionConfig = viewerUserId - ? await getUserPermissionConfig(viewerUserId, context.workspaceId) + ? await resolvePermissionGroupConfig( + viewerUserId, + context.workspaceId, + context.workspaceOrganizationId + ) : null const detail = await readLogDetail({ diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index ff229f87345..e17eaceda8f 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -109,8 +109,14 @@ export function stripSpanCosts(spans: unknown): void { if (!Array.isArray(spans)) return for (const span of spans) { if (!span || typeof span !== 'object') continue - const record = span as { cost?: unknown; children?: unknown } + const record = span as { cost?: unknown; tokens?: unknown; children?: unknown } if ('cost' in record) record.cost = undefined + /** + * Tokens as well as dollars: a span's token counts are the spend in another + * unit, so clearing only `cost` left the amount recoverable by anyone who + * knows the model's rate. + */ + if ('tokens' in record) record.tokens = undefined if (Array.isArray(record.children)) stripSpanCosts(record.children) } } diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts index 18fe2ba89c8..3dddcab3473 100644 --- a/apps/sim/lib/permission-groups/capabilities.ts +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -1,4 +1,5 @@ import type { ForbiddenDetailCode } from '@/lib/core/application/forbidden' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' import type { FILE_SHARE_AUTH_TYPES, PermissionGroupConfig, @@ -96,6 +97,23 @@ export interface ParameterizedCapabilityRule extends CapabilityRuleBase { export type CapabilityRule = StaticCapabilityRule | ParameterizedCapabilityRule +/** + * The one sentence every capability refusal uses, and the error that carries it. + * + * Shared so the funnel, a raw route gating inline, and a parameterized rule + * asserted from a use case cannot word the same refusal three ways. Accepts any + * capability, static or parameterized, because a parameterized one is refused + * from a call site rather than by the funnel and still has to read identically. + */ +export function refuseCapability(capability: PermissionGroupCapability): never { + const rule = CAPABILITY_RULES[capability] + throw new PermissionGroupCapabilityError( + capability, + rule.detailCode, + `${rule.describe} is not available under your organization's permission group` + ) +} + function authModeDeniedBy(allowed: ShareAuthMode[] | null, mode: string): boolean { return allowed !== null && !allowed.some((allowedMode) => allowedMode === mode) } @@ -317,19 +335,26 @@ export const CAPABILITY_RULES = { deniedBy: (config, connectorType) => allowlistDenies(config.allowedKnowledgeConnectors, connectorType), }, + /** + * Also reads `hideTablesTab`, for the reason `knowledge.create` does: an + * operation declares exactly one capability, so the narrower one replacing the + * broader one on `tables.create` would otherwise let a group that withholds + * the whole module still create tables through the API. + */ 'tables.create': { kind: 'static', - configKeys: ['disableTableCreation'], + configKeys: ['disableTableCreation', 'hideTablesTab'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', describe: 'Creating a table', - deniedBy: (config) => config.disableTableCreation, + deniedBy: (config) => config.disableTableCreation || config.hideTablesTab, }, + /** Subsumes `hideTablesTab` for the same reason as `tables.create`. */ 'tables.export': { kind: 'static', - configKeys: ['disableTableExport'], + configKeys: ['disableTableExport', 'hideTablesTab'], detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', describe: 'Exporting a table', - deniedBy: (config) => config.disableTableExport, + deniedBy: (config) => config.disableTableExport || config.hideTablesTab, }, 'files.bulk_download': { kind: 'static', diff --git a/apps/sim/lib/permission-groups/capability-assertions.ts b/apps/sim/lib/permission-groups/capability-assertions.ts index d50e8be29e6..aaad86a5b02 100644 --- a/apps/sim/lib/permission-groups/capability-assertions.ts +++ b/apps/sim/lib/permission-groups/capability-assertions.ts @@ -1,8 +1,8 @@ import { CAPABILITY_RULES, + refuseCapability, type StaticPermissionGroupCapability, } from '@/lib/permission-groups/capabilities' -import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { getUserPermissionConfigForOrganization } from '@/ee/access-control/utils/permission-check' @@ -39,14 +39,6 @@ export function capabilityRefusal(capability: StaticPermissionGroupCapability): return `${CAPABILITY_RULES[capability].describe} is not available under your organization's permission group` } -function refuse(capability: StaticPermissionGroupCapability): never { - throw new PermissionGroupCapabilityError( - capability, - CAPABILITY_RULES[capability].detailCode, - capabilityRefusal(capability) - ) -} - /** * Throws when `userId`'s group in `workspaceId` withholds `capability`. * @@ -62,7 +54,7 @@ export async function assertWorkspaceCapability( organizationId?: string | null ): Promise { const config = await resolvePermissionGroupConfig(userId, workspaceId, organizationId) - if (capabilityDeniedBy(capability, config)) refuse(capability) + if (capabilityDeniedBy(capability, config)) refuseCapability(capability) } /** @@ -78,7 +70,7 @@ export async function assertOrganizationCapability( capability: StaticPermissionGroupCapability ): Promise { const config = await getUserPermissionConfigForOrganization(organizationId) - if (capabilityDeniedBy(capability, config)) refuse(capability) + if (capabilityDeniedBy(capability, config)) refuseCapability(capability) } /** From 5f190fb67f1ae7389e28b050710d7eb36ca510db Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 11:32:54 -0700 Subject: [PATCH 028/179] refactor(permission-groups): route every config read through the request memo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighteen sites resolved a permission-group config by calling `getUserPermissionConfig` directly, so a request that authorized several operations paid one lookup per site instead of sharing the per-request memo the funnel already establishes. Capability gates now go through the canonical assertion helpers, so the decision reads CAPABILITY_RULES rather than a config the call site then tests itself. Projection sites — the copilot catalogs, the VFS, the integration allowlists — still read the config, but obtain it from `resolvePermissionGroupConfig` so they share the same memo. `lib/workspaces/policy.ts` uses `isOrganizationCapabilityWithheld`: no workspace exists yet, so the org-scoped form is the one that applies. Also merges a stacked TSDoc pair on `assertConnectorTypeAllowed`, where the first block sat orphaned above the second. --- apps/sim/app/api/logs/export/route.ts | 8 ++++++-- .../app/api/table/[tableId]/export-async/route.ts | 6 ++---- .../api/table/[tableId]/export/download/route.ts | 6 ++---- apps/sim/app/api/table/[tableId]/export/route.ts | 13 ++++++------- apps/sim/app/api/table/jobs/route.ts | 6 ++---- apps/sim/app/api/webhooks/[id]/route.ts | 10 +++++----- apps/sim/lib/copilot/chat/payload.ts | 6 ++++-- apps/sim/lib/copilot/chat/process-contents.ts | 4 ++-- .../lib/copilot/tools/handlers/integration-tools.ts | 4 ++-- .../tools/server/blocks/get-blocks-metadata-tool.ts | 4 ++-- .../tools/server/blocks/get-trigger-blocks.ts | 4 ++-- .../copilot/tools/server/user/get-credentials.ts | 6 ++++-- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 10 ++++------ apps/sim/lib/integrations/principal-scope.server.ts | 8 +++++--- apps/sim/lib/knowledge/application/connectors.ts | 7 +++---- .../application/apply-workflow-operations.ts | 4 ++-- .../lib/workflows/persistence/block-access-guard.ts | 8 ++++++-- apps/sim/lib/workspaces/policy.ts | 6 ++---- 18 files changed, 61 insertions(+), 59 deletions(-) diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index 0579cd63d66..aeb972735f8 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -16,8 +16,8 @@ import { capabilityDeniedBy, capabilityRefusal, } from '@/lib/permission-groups/capability-assertions' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('LogsExportAPI') const LOG_EXPORT_PAGE_SIZE = 100 @@ -110,7 +110,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { * queries directly and predates that boundary; migrating it is worth doing, * and is not a reason to leave the export ungoverned meanwhile. */ - const permissionConfig = await getUserPermissionConfig(userId, params.workspaceId) + const permissionConfig = await resolvePermissionGroupConfig( + userId, + params.workspaceId, + undefined + ) if (capabilityDeniedBy('logs.export', permissionConfig)) { return NextResponse.json({ error: capabilityRefusal('logs.export') }, { status: 403 }) } diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts index d86b7f342e3..bd87c067c02 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.ts @@ -10,15 +10,14 @@ import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - capabilityDeniedBy, capabilityRefusal, + isWorkspaceCapabilityWithheld, } from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' import type { TableExportJobPayload } from '@/lib/table/types' import { accessError, checkAccess } from '@/app/api/table/utils' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('TableExportAsync') @@ -57,8 +56,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } // permission-group-enforced: tables.export — raw route that queries directly and predates the operation boundary - const permissionConfig = await getUserPermissionConfig(authResult.userId, workspaceId) - if (capabilityDeniedBy('tables.export', permissionConfig)) { + if (await isWorkspaceCapabilityWithheld(authResult.userId, workspaceId, 'tables.export')) { return NextResponse.json({ error: capabilityRefusal('tables.export') }, { status: 403 }) } diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.ts b/apps/sim/app/api/table/[tableId]/export/download/route.ts index ead14962b75..d8ed1ecdeb0 100644 --- a/apps/sim/app/api/table/[tableId]/export/download/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/download/route.ts @@ -6,14 +6,13 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - capabilityDeniedBy, capabilityRefusal, + isWorkspaceCapabilityWithheld, } from '@/lib/permission-groups/capability-assertions' import { getTableJob } from '@/lib/table/jobs/service' import type { TableExportJobPayload } from '@/lib/table/types' import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' import { accessError, checkAccess } from '@/app/api/table/utils' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('TableExportDownload') @@ -56,8 +55,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou * file to anyone who can name a `jobId`, and the workspace job listing names * every colleague's. */ - const permissionConfig = await getUserPermissionConfig(authResult.userId, workspaceId) - if (capabilityDeniedBy('tables.export', permissionConfig)) { + if (await isWorkspaceCapabilityWithheld(authResult.userId, workspaceId, 'tables.export')) { return NextResponse.json({ error: capabilityRefusal('tables.export') }, { status: 403 }) } diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index 1c1a160ab43..23a8cae8ebd 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -6,14 +6,13 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - capabilityDeniedBy, capabilityRefusal, + isWorkspaceCapabilityWithheld, } from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { sanitizeExportFilename } from '@/lib/table/export-format' import { createTableExportStream, exportContentType } from '@/lib/table/export-stream' import { accessError, checkAccess } from '@/app/api/table/utils' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' interface RouteParams { params: Promise<{ tableId: string }> @@ -47,11 +46,11 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou const { table } = access // permission-group-enforced: tables.export — raw route that queries directly and predates the operation boundary - if (table.workspaceId) { - const config = await getUserPermissionConfig(userId, table.workspaceId) - if (capabilityDeniedBy('tables.export', config)) { - return NextResponse.json({ error: capabilityRefusal('tables.export') }, { status: 403 }) - } + if ( + table.workspaceId && + (await isWorkspaceCapabilityWithheld(userId, table.workspaceId, 'tables.export')) + ) { + return NextResponse.json({ error: capabilityRefusal('tables.export') }, { status: 403 }) } // Audit before streaming: rows leave incrementally, so a mid-stream failure still exfiltrates partial data. diff --git a/apps/sim/app/api/table/jobs/route.ts b/apps/sim/app/api/table/jobs/route.ts index 76bd2d39444..5d81b1ed68a 100644 --- a/apps/sim/app/api/table/jobs/route.ts +++ b/apps/sim/app/api/table/jobs/route.ts @@ -5,10 +5,9 @@ import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { listWorkspaceExportJobs } from '@/lib/table/jobs/service' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('TableJobsAPI') @@ -45,8 +44,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { * no exports they may act on, and erroring the tray would report a failure * where the honest answer is that there is nothing to show. */ - const permissionConfig = await getUserPermissionConfig(authResult.userId, workspaceId) - if (capabilityDeniedBy('tables.export', permissionConfig)) { + if (await isWorkspaceCapabilityWithheld(authResult.userId, workspaceId, 'tables.export')) { return NextResponse.json({ success: true, data: { jobs: [] } }) } diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 695fa65b367..2a607e1eaf5 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -20,12 +20,11 @@ import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - capabilityDeniedBy, capabilityRefusal, + isWorkspaceCapabilityWithheld, } from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' const logger = createLogger('WebhookAPI') @@ -153,11 +152,12 @@ export const PATCH = withRouteHandler( * they cannot turn off. */ if (isActive) { - const permissionConfig = await getUserPermissionConfig( + const withheld = await isWorkspaceCapabilityWithheld( userId, - webhooks[0].workflow.workspaceId ?? '' + webhooks[0].workflow.workspaceId ?? '', + 'triggers.webhook' ) - if (capabilityDeniedBy('triggers.webhook', permissionConfig)) { + if (withheld) { return NextResponse.json( { error: capabilityRefusal('triggers.webhook') }, { status: 403 } diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index bf888de5a7b..795cc1f7f36 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -160,8 +160,10 @@ export async function buildIntegrationToolSchemas( // what the entry caches: a user-tool schema per exposed integration tool. let permissionConfig: IntegrationGateConfig | null = null if (workspaceId) { - const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check') - permissionConfig = await getUserPermissionConfig(userId, workspaceId) + const { resolvePermissionGroupConfig } = await import( + '@/lib/permission-groups/config-scope.server' + ) + permissionConfig = await resolvePermissionGroupConfig(userId, workspaceId, undefined) } const cacheKey = getIntegrationToolSchemaCacheKey( userId, diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index b719abbe917..ff4f0b55d28 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -37,6 +37,7 @@ import type { TraceSpan } from '@/lib/logs/types' import { mcpService } from '@/lib/mcp/service' import { createMcpToolId } from '@/lib/mcp/utils' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { getColumnId } from '@/lib/table/column-keys' import { getRowsByIds } from '@/lib/table/rows/service' @@ -47,7 +48,6 @@ import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders } from '@/lib/workflows/utils' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { escapeRegExp } from '@/executor/constants' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { BrowserTextSelection, ChatContext, TerminalTextSelection } from '@/stores/panel' @@ -600,7 +600,7 @@ async function processBlockMetadata( ): Promise { try { const [permissionConfig, visibility] = await Promise.all([ - userId && workspaceId ? getUserPermissionConfig(userId, workspaceId) : null, + userId && workspaceId ? resolvePermissionGroupConfig(userId, workspaceId, undefined) : null, userId ? getBlockVisibilityForCopilot(userId, workspaceId) : null, ]) const allowedIntegrations = intersectIntegrationAllowlists( diff --git a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts b/apps/sim/lib/copilot/tools/handlers/integration-tools.ts index 6192fd377e3..35a3e66ab23 100644 --- a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts +++ b/apps/sim/lib/copilot/tools/handlers/integration-tools.ts @@ -1,7 +1,7 @@ import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { projectIntegrationToolsForViewer } from '@/lib/copilot/integration-tool-projection' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { stripVersionSuffix } from '@/tools/utils' export async function executeListIntegrationTools( @@ -17,7 +17,7 @@ export async function executeListIntegrationTools( // gated (preview / kill-switched) integrations stay undiscoverable. const vis = await getBlockVisibilityForCopilot(context.userId, context.workspaceId) const permissionConfig = context.workspaceId - ? await getUserPermissionConfig(context.userId, context.workspaceId) + ? await resolvePermissionGroupConfig(context.userId, context.workspaceId, undefined) : null const { tools: all } = projectIntegrationToolsForViewer(vis, permissionConfig) const service = stripVersionSuffix(raw.toLowerCase()) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 5cdd43af72a..81f9e12b3f7 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -18,6 +18,7 @@ import { getAllowedIntegrationsFromEnv, isHosted } from '@/lib/core/config/env-f import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { collectDeniedOperationIds, @@ -29,7 +30,6 @@ import { import { getBlock } from '@/blocks/registry' import { AuthMode, type BlockConfig, type SubBlockConfig } from '@/blocks/types' import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' /** * The block shape this tool reports, projected by the shared catalog projection @@ -193,7 +193,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< const permissionConfig = context?.userId && context?.workspaceId - ? await getUserPermissionConfig(context.userId, context.workspaceId) + ? await resolvePermissionGroupConfig(context.userId, context.workspaceId, undefined) : null const allowedIntegrations = intersectIntegrationAllowlists( permissionConfig?.allowedIntegrations ?? null, diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts index d27bd205015..11a0c6d562a 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts @@ -4,10 +4,10 @@ import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { getAllBlocks } from '@/blocks/registry' import { overlayVisibility } from '@/blocks/visibility/context' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' export const GetTriggerBlocksInput = z.object({}) export const GetTriggerBlocksResult = z.object({ @@ -27,7 +27,7 @@ export const getTriggerBlocksServerTool: BaseServerTool< const permissionConfig = context?.userId && context?.workspaceId - ? await getUserPermissionConfig(context.userId, context.workspaceId) + ? await resolvePermissionGroupConfig(context.userId, context.workspaceId, undefined) : null const allowedIntegrations = intersectIntegrationAllowlists( permissionConfig?.allowedIntegrations ?? null, diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts index 059be3c0e13..9b5398201c5 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts @@ -17,10 +17,10 @@ import { credentialProviderMatchesService, getAllOAuthServices, } from '@/lib/oauth' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { overlayVisibility } from '@/blocks/visibility/context' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' interface GetCredentialsParams { workflowId?: string @@ -80,7 +80,9 @@ export const getCredentialsServerTool: BaseServerTool .limit(1) const userEmail = userRecord.length > 0 ? userRecord[0]?.email : null - const permissionConfig = workspaceId ? await getUserPermissionConfig(userId, workspaceId) : null + const permissionConfig = workspaceId + ? await resolvePermissionGroupConfig(userId, workspaceId, undefined) + : null const configuredAllowedIntegrations = intersectIntegrationAllowlists( permissionConfig?.allowedIntegrations ?? null, getAllowedIntegrationsFromEnv() diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 83722e4330a..e37bed18b95 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -142,6 +142,7 @@ import { } from '@/lib/knowledge/application/knowledge-bases' import { validateMermaidSource } from '@/lib/mermaid/validate' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' import { intersectIntegrationAllowlists, @@ -203,10 +204,7 @@ import { BLOCK_REGISTRY } from '@/blocks/registry-maps' import type { BlockConfig, BlockIcon } from '@/blocks/types' import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' -import { - getUserPermissionConfig, - resolveVerifiedUserAccessControlContext, -} from '@/ee/access-control/utils/permission-check' +import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check' import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz' import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage' import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' @@ -941,7 +939,7 @@ export class WorkspaceVFS { const blockVisibility = overlayVisibility() const permissionConfigPromise = timed( 'permissions', - getUserPermissionConfig(userId, workspaceId) + resolvePermissionGroupConfig(userId, workspaceId, undefined) ) const sandboxEntitlementPromise = timed( 'sandbox_entitlement', @@ -3164,7 +3162,7 @@ export class WorkspaceVFS { private async materializeEnvironment( workspaceId: string, userId: string, - permissionConfigPromise: ReturnType, + permissionConfigPromise: ReturnType, blockVisibility: BlockVisibilityState | null, secretMountPolicy?: SecretMountPolicy ): Promise<{ diff --git a/apps/sim/lib/integrations/principal-scope.server.ts b/apps/sim/lib/integrations/principal-scope.server.ts index f7d0a6b993d..1b28703a876 100644 --- a/apps/sim/lib/integrations/principal-scope.server.ts +++ b/apps/sim/lib/integrations/principal-scope.server.ts @@ -1,7 +1,7 @@ import type { Principal } from '@sim/auth/principal' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' /** * The workspace integration gate, shared by every catalog that projects @@ -15,7 +15,7 @@ import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-ch * first time either changed, and the two endpoints describe the same * integrations. * - * Server-only: `getUserPermissionConfig` reads the database. + * Server-only: `resolvePermissionGroupConfig` reads the database. */ /** @@ -47,7 +47,9 @@ export async function allowedIntegrationTypes( workspaceId: string ): Promise | null> { const userId = principalUserId(principal) - const permissionConfig = userId ? await getUserPermissionConfig(userId, workspaceId) : null + const permissionConfig = userId + ? await resolvePermissionGroupConfig(userId, workspaceId, undefined) + : null const integrations = intersectIntegrationAllowlists( permissionConfig?.allowedIntegrations ?? null, getAllowedIntegrationsFromEnv() diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index d87ec80762c..c96bf96ecbb 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -44,7 +44,7 @@ import type { } from '@/lib/knowledge/orchestration/shared' import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' interface KnowledgeConnectorApplicationInput { assertedWorkspaceId?: string @@ -119,8 +119,7 @@ const CONNECTOR_ALLOWLIST_RULE = CAPABILITY_RULES['knowledge.connectors'] * * No-op when no permission group governs the caller, which is what keeps * non-enterprise and ungoverned organizations unaffected. - */ -/** + * * A permission group is a membership of users, so an actorless caller — a * schedule, or a webhook with no external subject — resolves no group and * passes through, exactly as the authorization funnel treats one. Requiring a @@ -133,7 +132,7 @@ async function assertConnectorTypeAllowed( connectorType: string ): Promise { if (!userId) return - const config = await getUserPermissionConfig(userId, workspaceId) + const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) if (!config || !CONNECTOR_ALLOWLIST_RULE.deniedBy(config, connectorType)) return throw new ForbiddenOperationError( diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.ts index ff5a34aebb9..f070935fc10 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.ts @@ -9,6 +9,7 @@ import { ForbiddenOperationError, principalAuditSource } from '@/lib/core/applic import { getBlockVisibility } from '@/lib/core/config/block-visibility' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { @@ -54,7 +55,6 @@ import { import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' import { withBlockVisibility } from '@/blocks/visibility/server-context' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation' @@ -274,7 +274,7 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ const baseGraph = await resolveBaseGraph(principal, input, context) const [permissionConfig, blockVisibility] = await Promise.all([ - getUserPermissionConfig(subjectUserId, context.workspaceId), + resolvePermissionGroupConfig(subjectUserId, context.workspaceId, undefined), getBlockVisibility({ userId: subjectUserId, orgId: context.workspaceOrganizationId }), ]) diff --git a/apps/sim/lib/workflows/persistence/block-access-guard.ts b/apps/sim/lib/workflows/persistence/block-access-guard.ts index 7f5e9d7fa29..7d94aeb4b7c 100644 --- a/apps/sim/lib/workflows/persistence/block-access-guard.ts +++ b/apps/sim/lib/workflows/persistence/block-access-guard.ts @@ -2,8 +2,8 @@ import { isBlockTypeAccessControlExempt, resolveAccessControlBlockType, } from '@/lib/permission-groups/block-access' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { toAllowedIntegrationTypes } from '@/lib/permission-groups/integration-allowlist' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { BlockType } from '@/executor/constants' /** @@ -39,7 +39,11 @@ export async function findWithheldBlockType(params: { workspaceId: string blocks: Iterable<{ type?: string }> }): Promise { - const permissionConfig = await getUserPermissionConfig(params.userId, params.workspaceId) + const permissionConfig = await resolvePermissionGroupConfig( + params.userId, + params.workspaceId, + undefined + ) const allowed = toAllowedIntegrationTypes(permissionConfig?.allowedIntegrations ?? null) /** diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 985c9027c65..3d56c7d0038 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -14,12 +14,11 @@ import { getPlanType, isEnterprise, isMaxTier, isPro, isTeam } from '@/lib/billi import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/env-flags' import type { DbOrTx } from '@/lib/db/types' -import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' +import { isOrganizationCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { CONTACT_OWNER_TO_UPGRADE_REASON, UPGRADE_TO_INVITE_REASON, } from '@/lib/workspaces/policy-constants' -import { getUserPermissionConfigForOrganization } from '@/ee/access-control/utils/permission-check' const logger = createLogger('WorkspacePolicy') @@ -366,8 +365,7 @@ export async function getWorkspaceCreationPolicy({ * so exempting it would leave the gate answering only the case it is not for. */ // permission-group-enforced: workspace.create — no workspace exists yet, so the workspace-scoped funnel has nothing to resolve a group against - const config = await getUserPermissionConfigForOrganization(governingOrganizationId) - if (capabilityDeniedBy('workspace.create', config)) { + if (await isOrganizationCapabilityWithheld(governingOrganizationId, 'workspace.create')) { return { canCreate: false, workspaceMode: From e8b044a430c753fa73782bbebfb25f8168de1315 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 11:33:51 -0700 Subject: [PATCH 029/179] test(permission-groups): cover the inbox, workflow-MCP, and Chat capability gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three capability gates shipped without a test. Each now asserts both directions — refused when the group withholds the capability, and allowed both when a group governs the user but withholds nothing and when no group governs at all, which is every personal workspace and non-enterprise org. Extracts the config-scope mock the eight existing permission-group tests each hand-rolled into permissionGroupScopeMock in @sim/testing. The shared version exports withPermissionGroupScope as a real passthrough, which a route-level test needs: withRouteHandler wraps every handler in it, so a factory exporting only resolvePermissionGroupConfig turns the gate under test into an unrelated 500. --- .../api/mcp/workflow-servers/route.test.ts | 110 +++++++++++++++ .../api/workspaces/[id]/inbox/route.test.ts | 133 +++++++++++++++++- .../catalog/application/operations.test.ts | 10 +- apps/sim/lib/copilot/chat/post.test.ts | 119 ++++++++++++++++ .../workspace-authorization.test.ts | 46 +++--- .../application/credential-crud.test.ts | 18 ++- .../lib/logs/application/list-logs.test.ts | 14 +- .../logs/application/read-log-detail.test.ts | 18 ++- .../lib/mcp/application/operations.test.ts | 14 +- .../lib/skills/application/operations.test.ts | 12 +- .../sim/lib/table/application/exports.test.ts | 14 +- packages/testing/src/mocks/index.ts | 5 + .../src/mocks/permission-group-scope.mock.ts | 54 +++++++ 13 files changed, 497 insertions(+), 70 deletions(-) create mode 100644 apps/sim/app/api/mcp/workflow-servers/route.test.ts create mode 100644 packages/testing/src/mocks/permission-group-scope.mock.ts diff --git a/apps/sim/app/api/mcp/workflow-servers/route.test.ts b/apps/sim/app/api/mcp/workflow-servers/route.test.ts new file mode 100644 index 00000000000..c05aed0fbf6 --- /dev/null +++ b/apps/sim/app/api/mcp/workflow-servers/route.test.ts @@ -0,0 +1,110 @@ +/** + * @vitest-environment node + */ +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetDbChainMock, +} from '@sim/testing' +import type { NextRequest } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPerformCreate } = vi.hoisted(() => ({ mockPerformCreate: vi.fn() })) + +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +vi.mock('@/lib/mcp/middleware', () => ({ + readMcpJsonBodyWithLimit: (request: NextRequest) => request.json(), + mcpBodyReadErrorResponse: () => null, + withMcpAuth: + () => + ( + handler: ( + request: NextRequest, + context: { + userId: string + userName: string + userEmail: string + workspaceId: string + requestId: string + } + ) => Promise + ) => + (request: NextRequest) => + handler(request, { + userId: 'user-1', + userName: 'Test User', + userEmail: 'test@example.com', + workspaceId: 'workspace-1', + requestId: 'request-1', + }), +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateWorkflowMcpServer: mockPerformCreate, +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { POST } from '@/app/api/mcp/workflow-servers/route' + +function createRequest() { + return new Request('http://localhost:3000/api/mcp/workflow-servers?workspaceId=workspace-1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Deploy bot', workflowIds: ['workflow-1'] }), + }) as NextRequest +} + +describe('workflow MCP servers POST route — deploy.mcp capability gate', () => { + afterAll(() => { + resetDbChainMock() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockPerformCreate.mockResolvedValue({ + success: true, + server: { id: 'server-1', name: 'Deploy bot' }, + addedTools: [], + }) + }) + + it('refuses to create a workflow MCP server when the group withholds deploy.mcp', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployMcp: true, + }) + + const response = await POST(createRequest(), { params: Promise.resolve({}) }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ + error: "MCP server deployment is not available under your organization's permission group", + }) + expect(mockPerformCreate).not.toHaveBeenCalled() + }) + + it('creates the server when a group governs the user but withholds nothing', async () => { + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + const response = await POST(createRequest(), { params: Promise.resolve({}) }) + + expect(response.status).toBe(201) + expect(mockPerformCreate).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'workspace-1', name: 'Deploy bot' }) + ) + }) + + /** A personal workspace, or any non-enterprise organization, is governed by no group. */ + it('creates the server when no permission group governs the user', async () => { + resolveGroupConfigMock.mockResolvedValue(null) + + const response = await POST(createRequest(), { params: Promise.resolve({}) }) + + expect(response.status).toBe(201) + expect(mockPerformCreate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts index a3eebf6272c..60b5d1d0f3e 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts @@ -6,6 +6,8 @@ import { createMockRequest, dbChainMock, dbChainMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, queueTableRows, resetDbChainMock, schemaMock, @@ -17,6 +19,8 @@ const { mockGetUserEntityPermissions, mockHasWorkspaceInboxAccess } = vi.hoisted mockHasWorkspaceInboxAccess: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/billing/core/subscription', () => ({ @@ -33,7 +37,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) -import { PATCH } from '@/app/api/workspaces/[id]/inbox/route' +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GET, PATCH } from '@/app/api/workspaces/[id]/inbox/route' const context = { params: Promise.resolve({ id: 'workspace-1' }) } @@ -44,6 +51,7 @@ describe('Inbox config secret policy', () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') mockHasWorkspaceInboxAccess.mockResolvedValue(true) + resolveGroupConfigMock.mockResolvedValue(null) }) it('updates policy without requiring an inbox lifecycle mutation', async () => { @@ -85,3 +93,126 @@ describe('Inbox config secret policy', () => { ) }) }) + +const REFUSAL = "The inbox is not available under your organization's permission group" + +function patchRequest() { + return createMockRequest( + 'PATCH', + { secretScope: 'all' }, + undefined, + 'http://localhost:3000/api/workspaces/workspace-1/inbox' + ) +} + +function getRequest() { + return createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost:3000/api/workspaces/workspace-1/inbox' + ) +} + +/** The current inbox config row plus the empty task-status rollup the GET handler joins onto it. */ +function queueInboxReadRows() { + queueTableRows(schemaMock.workspace, [ + { + inboxEnabled: true, + inboxAddress: 'tasks@example.com', + inboxProviderId: 'provider-1', + inboxSecretScope: 'all', + inboxMountedSecrets: [], + }, + ]) + queueTableRows(schemaMock.mothershipInboxTask, []) +} + +describe('Inbox inbox.use capability gate', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockHasWorkspaceInboxAccess.mockResolvedValue(true) + }) + + describe('when the group withholds inbox.use', () => { + beforeEach(() => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideInboxTab: true, + }) + }) + + it('refuses to read the inbox config', async () => { + queueInboxReadRows() + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + }) + + it('refuses to update the inbox config, leaving the row untouched', async () => { + queueInboxReadRows() + + const response = await PATCH(patchRequest(), context) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) + }) + + describe('when a group governs the user but withholds nothing', () => { + beforeEach(() => { + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + }) + + it('reads the inbox config', async () => { + queueInboxReadRows() + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + enabled: true, + address: 'tasks@example.com', + }) + }) + + it('updates the inbox config', async () => { + queueInboxReadRows() + + const response = await PATCH(patchRequest(), context) + + expect(response.status).toBe(200) + expect(dbChainMockFns.set).toHaveBeenCalled() + }) + }) + + /** A personal workspace, or any non-enterprise organization, is governed by no group. */ + describe('when no permission group governs the user', () => { + beforeEach(() => { + resolveGroupConfigMock.mockResolvedValue(null) + }) + + it('reads the inbox config', async () => { + queueInboxReadRows() + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(200) + }) + + it('updates the inbox config', async () => { + queueInboxReadRows() + + const response = await PATCH(patchRequest(), context) + + expect(response.status).toBe(200) + expect(dbChainMockFns.set).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/catalog/application/operations.test.ts b/apps/sim/lib/catalog/application/operations.test.ts index b295ab48786..7b1ce4d4421 100644 --- a/apps/sim/lib/catalog/application/operations.test.ts +++ b/apps/sim/lib/catalog/application/operations.test.ts @@ -1,21 +1,21 @@ /** * @vitest-environment node */ +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), - resolvePermissionGroupConfig: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: () => true, resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) -vi.mock('@/lib/permission-groups/config-scope.server', () => ({ - resolvePermissionGroupConfig: mocks.resolvePermissionGroupConfig, -})) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) import { catalogOperations } from '@/lib/catalog/application/operations' import type { WorkspaceOperation } from '@/lib/core/application' @@ -88,7 +88,7 @@ describe('catalog operations under a group that hides knowledge bases', () => { beforeEach(() => { vi.clearAllMocks() mocks.resolvePermission.mockResolvedValue('admin') - mocks.resolvePermissionGroupConfig.mockResolvedValue({ + resolveGroupConfigMock.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, hideKnowledgeBaseTab: true, }) diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index f7f71a24733..041c05240ab 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -5,6 +5,8 @@ import { authMockFns, environmentUtilsMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, permissionsMock, permissionsMockFns, resetDbChainMock, @@ -59,6 +61,8 @@ const { releaseChatSendClaim: vi.fn(), })) +const resolvePermissionGroupConfig = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + const getSession = authMockFns.mockGetSession const billingAttribution = { actorUserId: 'user-1', @@ -129,12 +133,15 @@ vi.mock('@/lib/copilot/resources/persistence', () => ({ persistChatResources, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + vi.mock('@/lib/copilot/chat-status', () => ({ chatPubSub: { publishStatusChanged: mockPublishStatusChanged, }, })) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { handleUnifiedChatPost } from './post' describe('handleUnifiedChatPost', () => { @@ -155,6 +162,7 @@ describe('handleUnifiedChatPost', () => { storeChatSendResult.mockResolvedValue(true) releaseChatSendClaim.mockResolvedValue(undefined) getSession.mockResolvedValue({ user: { id: 'user-1' } }) + resolvePermissionGroupConfig.mockResolvedValue(null) resolveWorkflowIdForUser.mockResolvedValue({ status: 'resolved', workflowId: 'wf-1', @@ -900,3 +908,114 @@ describe('handleUnifiedChatPost', () => { }) }) }) + +describe('handleUnifiedChatPost copilot.use capability gate', () => { + const REFUSAL = "Chat is not available under your organization's permission group" + + function chatRequest(body: Record = {}) { + return new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ message: 'Hello', workspaceId: 'ws-1', ...body }), + }) + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + getSession.mockResolvedValue({ user: { id: 'user-1' } }) + atomicallyClaimChatSend.mockResolvedValue({ + claimed: true, + normalizedKey: 'chat-send:user-message:msg-1:userId=user-1', + storageMethod: 'database', + claimToken: 'claim-1', + }) + storeChatSendResult.mockResolvedValue(true) + releaseChatSendClaim.mockResolvedValue(undefined) + resolveWorkflowIdForUser.mockResolvedValue({ + status: 'resolved', + workflowId: 'wf-1', + workspaceId: 'ws-1', + workflowName: 'Workflow One', + }) + getUserEntityPermissions.mockResolvedValue('write') + resolveBillingAttribution.mockResolvedValue(billingAttribution) + getEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: {}, + workspaceEncrypted: {}, + personalDecrypted: {}, + workspaceDecrypted: {}, + conflicts: [], + decryptionFailures: [], + }) + generateWorkspaceSnapshot.mockResolvedValue({ markdown: '', snapshot: { workflows: [] } }) + processContextsServer.mockResolvedValue([]) + resolveActiveResourceContext.mockResolvedValue(null) + buildCopilotRequestPayload.mockImplementation(async (params: Record) => params) + createSSEStream.mockReturnValue(new ReadableStream()) + acquirePendingChatStream.mockResolvedValue(true) + getPendingChatStreamId.mockResolvedValue(null) + releasePendingChatStream.mockResolvedValue(undefined) + resolveOrCreateChat.mockResolvedValue({ + chatId: 'chat-1', + chat: { id: 'chat-1' }, + conversationHistory: [], + isNew: true, + }) + }) + + /** + * Refused before the send is claimed or a chat exists, so a refused request + * leaves nothing behind for a resume stream to replay. + */ + it('refuses the send when the group withholds copilot.use', async () => { + resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCopilot: true, + }) + + const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true })) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + expect(atomicallyClaimChatSend).not.toHaveBeenCalled() + expect(resolveOrCreateChat).not.toHaveBeenCalled() + expect(createSSEStream).not.toHaveBeenCalled() + }) + + it('streams the send when a group governs the user but withholds nothing', async () => { + resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true })) + + expect(response.status).toBe(200) + expect(createSSEStream).toHaveBeenCalledTimes(1) + }) + + /** A personal workspace, or any non-enterprise organization, is governed by no group. */ + it('streams the send when no permission group governs the user', async () => { + resolvePermissionGroupConfig.mockResolvedValue(null) + + const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true })) + + expect(response.status).toBe(200) + expect(createSSEStream).toHaveBeenCalledTimes(1) + }) + + /** A request naming no workspace is governed by no group, so the gate never asks. */ + it('does not consult a permission group when the request names no workspace', async () => { + resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCopilot: true, + }) + + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ message: 'Hello', workflowId: 'wf-1', createNewChat: true }), + }) + ) + + expect(response.status).toBe(200) + expect(resolvePermissionGroupConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index 7b32402e206..b304b774742 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -7,13 +7,15 @@ import type { SessionPrincipal, WorkspaceApiKeyPrincipal, } from '@sim/auth/principal' +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), - resolvePermissionGroupConfig: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: (actual: string, required: string) => { const rank = { read: 1, write: 2, admin: 3 } as const @@ -22,9 +24,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) -vi.mock('@/lib/permission-groups/config-scope.server', () => ({ - resolvePermissionGroupConfig: mocks.resolvePermissionGroupConfig, -})) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) import { authorizeWorkspaceOperation, @@ -313,11 +313,11 @@ describe('authorizeWorkspaceOperation permission-group capability', () => { beforeEach(() => { vi.clearAllMocks() mocks.resolvePermission.mockResolvedValue('admin') - mocks.resolvePermissionGroupConfig.mockResolvedValue(null) + resolveGroupConfigMock.mockResolvedValue(null) }) it('refuses a session whose group withholds the capability', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) await expect( authorizeWorkspaceOperation(principal, capabilityOperation, context) @@ -325,7 +325,7 @@ describe('authorizeWorkspaceOperation permission-group capability', () => { }) it('names the capability and a code a caller can branch on', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) const error = await authorizeWorkspaceOperation(principal, capabilityOperation, context).catch( (thrown: unknown) => thrown @@ -339,7 +339,7 @@ describe('authorizeWorkspaceOperation permission-group capability', () => { }) it('refuses a personal API key the same way', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) await expect( authorizeWorkspaceOperation(personalKeyPrincipal, capabilityOperation, context) @@ -353,7 +353,7 @@ describe('authorizeWorkspaceOperation permission-group capability', () => { * and not what an admin ticking it intends. */ it('does not apply to an executor run, even one carrying a user subject', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) await expect( authorizeWorkspaceOperation( @@ -394,7 +394,7 @@ describe('authorizeWorkspaceOperation permission-group capability', () => { * Copilot acts as the person, so it must not reach what the person may not. */ it('does apply to a Copilot delegation, which acts as the person', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) await expect( authorizeWorkspaceOperation( @@ -419,12 +419,12 @@ describe('authorizeWorkspaceOperation permission-group capability', () => { * capability-gating key creation, not by guessing a user here. */ it('does not apply to a workspace API key, which has no user', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) await expect( authorizeWorkspaceOperation(scopedWorkspaceKeyPrincipal, capabilityOperation, context) ).resolves.toBeUndefined() - expect(mocks.resolvePermissionGroupConfig).not.toHaveBeenCalled() + expect(resolveGroupConfigMock).not.toHaveBeenCalled() }) /** @@ -433,7 +433,7 @@ describe('authorizeWorkspaceOperation permission-group capability', () => { * executor. */ it('does not apply to an actorless deployment run', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) await expect( authorizeWorkspaceOperation( @@ -443,11 +443,11 @@ describe('authorizeWorkspaceOperation permission-group capability', () => { executorAuthorization ) ).resolves.toBeUndefined() - expect(mocks.resolvePermissionGroupConfig).not.toHaveBeenCalled() + expect(resolveGroupConfigMock).not.toHaveBeenCalled() }) it('allows the operation when the group permits the capability', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) await expect( authorizeWorkspaceOperation(principal, capabilityOperation, context) @@ -467,17 +467,17 @@ describe('authorizeWorkspaceOperation permission-group capability', () => { workspaceOrganizationId: null, }) ).resolves.toBeUndefined() - expect(mocks.resolvePermissionGroupConfig).not.toHaveBeenCalled() + expect(resolveGroupConfigMock).not.toHaveBeenCalled() }) it('refuses on role before capability, so a non-member learns nothing about the group', async () => { mocks.resolvePermission.mockResolvedValue(null) - mocks.resolvePermissionGroupConfig.mockResolvedValue(withholdingConfig()) + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) await expect( authorizeWorkspaceOperation(principal, capabilityOperation, context) ).rejects.toBeInstanceOf(NoWorkspaceAccessError) - expect(mocks.resolvePermissionGroupConfig).not.toHaveBeenCalled() + expect(resolveGroupConfigMock).not.toHaveBeenCalled() }) }) @@ -525,11 +525,11 @@ describe('authorizeWorkspaceOperation personal API key policy', () => { beforeEach(() => { vi.clearAllMocks() mocks.resolvePermission.mockResolvedValue('admin') - mocks.resolvePermissionGroupConfig.mockResolvedValue(null) + resolveGroupConfigMock.mockResolvedValue(null) }) it('refuses when the permission group withholds personal keys', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue({ + resolveGroupConfigMock.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, disablePersonalApiKeys: true, }) @@ -546,11 +546,11 @@ describe('authorizeWorkspaceOperation personal API key policy', () => { allowPersonalApiKeys: false, }) ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) - expect(mocks.resolvePermissionGroupConfig).not.toHaveBeenCalled() + expect(resolveGroupConfigMock).not.toHaveBeenCalled() }) it('allows when both layers permit', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) await expect( authorizeWorkspaceOperation(personalKeyPrincipal, personalKeyOperation, context) @@ -558,7 +558,7 @@ describe('authorizeWorkspaceOperation personal API key policy', () => { }) it('leaves a session principal alone', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue({ + resolveGroupConfigMock.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, disablePersonalApiKeys: true, }) diff --git a/apps/sim/lib/credentials/application/credential-crud.test.ts b/apps/sim/lib/credentials/application/credential-crud.test.ts index 01dcc4b7867..120d72d5b78 100644 --- a/apps/sim/lib/credentials/application/credential-crud.test.ts +++ b/apps/sim/lib/credentials/application/credential-crud.test.ts @@ -1,7 +1,12 @@ /** * @vitest-environment node */ -import { auditMock, auditMockFns } from '@sim/testing' +import { + auditMock, + auditMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -12,9 +17,10 @@ const mocks = vi.hoisted(() => ({ getActor: vi.fn(), updateRecord: vi.fn(), createRecord: vi.fn(), - resolvePermissionGroupConfig: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/workspaces/application/workspace-context', () => ({ loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, @@ -37,9 +43,7 @@ vi.mock('@/lib/credentials/orchestration', () => ({ createCredentialRecord: mocks.createRecord, isProviderOutageCode: () => false, })) -vi.mock('@/lib/permission-groups/config-scope.server', () => ({ - resolvePermissionGroupConfig: mocks.resolvePermissionGroupConfig, -})) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) vi.mock('@/lib/credentials/oauth', () => ({ syncWorkspaceOAuthCredentialsForUser: vi.fn() })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: vi.fn() })) @@ -321,7 +325,7 @@ describe('personal-credential capability', () => { vi.clearAllMocks() mocks.loadWorkspace.mockResolvedValue(governedWorkspace) mocks.resolvePermission.mockResolvedValue('admin') - mocks.resolvePermissionGroupConfig.mockResolvedValue({ + resolveGroupConfigMock.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, disablePersonalCredentials: true, }) @@ -384,7 +388,7 @@ describe('personal-credential capability', () => { }) it('creates the personal secret when no group withholds it', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue(null) + resolveGroupConfigMock.mockResolvedValue(null) const created = createdCredential('env_personal') mocks.createRecord.mockResolvedValue({ success: true, created: true, credential: created }) mocks.getActor.mockResolvedValue({ diff --git a/apps/sim/lib/logs/application/list-logs.test.ts b/apps/sim/lib/logs/application/list-logs.test.ts index 5c46c3669c1..4e9781ccaa2 100644 --- a/apps/sim/lib/logs/application/list-logs.test.ts +++ b/apps/sim/lib/logs/application/list-logs.test.ts @@ -3,15 +3,17 @@ */ import type { Principal } from '@sim/auth/principal' +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ readLogs: vi.fn(), resolveWorkspace: vi.fn(), resolvePermission: vi.fn(), - getUserPermissionConfig: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@/lib/logs/list-logs', () => ({ readLogs: mocks.readLogs, })) @@ -26,9 +28,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) -vi.mock('@/lib/permission-groups/config-scope.server', () => ({ - resolvePermissionGroupConfig: mocks.getUserPermissionConfig, -})) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) import { listLogsUseCase } from '@/lib/logs/application/list-logs' @@ -46,7 +46,7 @@ describe('listLogsUseCase', () => { }) mocks.resolvePermission.mockResolvedValue('admin') mocks.readLogs.mockResolvedValue({ data: [], nextCursor: null }) - mocks.getUserPermissionConfig.mockResolvedValue(null) + resolveGroupConfigMock.mockResolvedValue(null) }) /** @@ -54,7 +54,7 @@ describe('listLogsUseCase', () => { * nothing, so the same key has to reach both queries. */ it('tells the list query to withhold spend when the group does', async () => { - mocks.getUserPermissionConfig.mockResolvedValue({ hideCostInfo: true }) + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) await listLogsUseCase.execute({ principal: SESSION, input: INPUT }) @@ -81,7 +81,7 @@ describe('listLogsUseCase', () => { input: INPUT, }) - expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + expect(resolveGroupConfigMock).not.toHaveBeenCalled() expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ hideCostInfo: false })) }) }) diff --git a/apps/sim/lib/logs/application/read-log-detail.test.ts b/apps/sim/lib/logs/application/read-log-detail.test.ts index f7880ac1bfe..ef5289c1eef 100644 --- a/apps/sim/lib/logs/application/read-log-detail.test.ts +++ b/apps/sim/lib/logs/application/read-log-detail.test.ts @@ -4,16 +4,22 @@ import type { Principal } from '@sim/auth/principal' import { workflowExecutionLogs } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ readLogDetail: vi.fn(), resolveWorkspace: vi.fn(), resolvePermission: vi.fn(), - getUserPermissionConfig: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@/lib/logs/fetch-log-detail', () => ({ readLogDetail: mocks.readLogDetail, })) @@ -22,9 +28,7 @@ vi.mock('@/lib/workspaces/application/workspace-context', () => ({ resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspace, })) -vi.mock('@/lib/permission-groups/config-scope.server', () => ({ - resolvePermissionGroupConfig: mocks.getUserPermissionConfig, -})) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: (held: string | null, required: string) => @@ -98,7 +102,7 @@ describe('readLogDetailUseCase', () => { }) mocks.readLogDetail.mockResolvedValue({ id: 'log-1', executionId: EXECUTION_ID }) mocks.resolvePermission.mockResolvedValue('admin') - mocks.getUserPermissionConfig.mockResolvedValue(null) + resolveGroupConfigMock.mockResolvedValue(null) }) afterAll(resetDbChainMock) @@ -136,7 +140,7 @@ describe('readLogDetailUseCase', () => { */ it('tells the loader to withhold spend when the group does', async () => { queueLogRow() - mocks.getUserPermissionConfig.mockResolvedValue({ hideCostInfo: true }) + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) await readLogDetailUseCase.execute({ principal: HUMAN_PRINCIPAL, diff --git a/apps/sim/lib/mcp/application/operations.test.ts b/apps/sim/lib/mcp/application/operations.test.ts index 1780fc657c5..3826c839466 100644 --- a/apps/sim/lib/mcp/application/operations.test.ts +++ b/apps/sim/lib/mcp/application/operations.test.ts @@ -1,21 +1,21 @@ /** * @vitest-environment node */ +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), - resolvePermissionGroupConfig: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: () => true, resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) -vi.mock('@/lib/permission-groups/config-scope.server', () => ({ - resolvePermissionGroupConfig: mocks.resolvePermissionGroupConfig, -})) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) import type { WorkspaceOperation } from '@/lib/core/application' import { authorizeWorkspaceOperation, PermissionGroupCapabilityError } from '@/lib/core/application' @@ -148,7 +148,7 @@ describe('MCP operations under a withholding permission group', () => { }) it('refuses every mcp_servers operation when the group blocks MCP tools', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue({ + resolveGroupConfigMock.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, disableMcpTools: true, }) @@ -165,7 +165,7 @@ describe('MCP operations under a withholding permission group', () => { }) it('refuses every workflow-deployment operation when the group hides MCP deployment', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue({ + resolveGroupConfigMock.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, hideDeployMcp: true, }) @@ -182,7 +182,7 @@ describe('MCP operations under a withholding permission group', () => { }) it('allows the same operations when the group withholds neither', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) for (const operation of [ ...sessionReachable('mcp_tools.use'), diff --git a/apps/sim/lib/skills/application/operations.test.ts b/apps/sim/lib/skills/application/operations.test.ts index f5868115379..a9152130d80 100644 --- a/apps/sim/lib/skills/application/operations.test.ts +++ b/apps/sim/lib/skills/application/operations.test.ts @@ -2,21 +2,21 @@ * @vitest-environment node */ import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), - resolvePermissionGroupConfig: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: () => true, resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) -vi.mock('@/lib/permission-groups/config-scope.server', () => ({ - resolvePermissionGroupConfig: mocks.resolvePermissionGroupConfig, -})) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) import type { WorkspaceOperation } from '@/lib/core/application' import { authorizeWorkspaceOperation, PermissionGroupCapabilityError } from '@/lib/core/application' @@ -137,7 +137,7 @@ describe('skill operations under a group that blocks skills', () => { }) it('refuses authoring and editor grants, not only loading', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue({ + resolveGroupConfigMock.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, disableSkills: true, }) @@ -151,7 +151,7 @@ describe('skill operations under a group that blocks skills', () => { }) it('allows them all when the group withholds nothing', async () => { - mocks.resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) for (const operation of Object.values(skillOperations)) { await expect( diff --git a/apps/sim/lib/table/application/exports.test.ts b/apps/sim/lib/table/application/exports.test.ts index 247bccca4d5..10d48cbf25c 100644 --- a/apps/sim/lib/table/application/exports.test.ts +++ b/apps/sim/lib/table/application/exports.test.ts @@ -3,12 +3,12 @@ */ import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table/types' const mocks = vi.hoisted(() => ({ cancel: vi.fn(), - resolveGroupConfig: vi.fn(), resolveWorkspaceContext: vi.fn(), create: vi.fn(), getTable: vi.fn(), @@ -16,6 +16,8 @@ const mocks = vi.hoisted(() => ({ resolveContext: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@sim/audit', () => ({ AuditAction: { TABLE_EXPORTED: 'table.exported' }, AuditResourceType: { TABLE: 'table' }, @@ -25,9 +27,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: () => true, resolveEffectiveWorkspacePermission: vi.fn(), })) -vi.mock('@/lib/permission-groups/config-scope.server', () => ({ - resolvePermissionGroupConfig: mocks.resolveGroupConfig, -})) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) vi.mock('@/lib/table', () => ({ getTableById: mocks.getTable })) vi.mock('@/lib/table/application/context', () => ({ resolveActiveTableContext: mocks.resolveContext, @@ -111,7 +111,7 @@ describe('table export application use cases', () => { mocks.create.mockResolvedValue(record) mocks.require.mockResolvedValue(record) mocks.cancel.mockResolvedValue({ ...record, status: 'canceled' }) - mocks.resolveGroupConfig.mockResolvedValue(null) + resolveGroupConfigMock.mockResolvedValue(null) mocks.resolveWorkspaceContext.mockImplementation(async (workspaceId: string) => ({ workspaceId, workspaceOrganizationId: null, @@ -192,7 +192,7 @@ describe('table export application use cases', () => { allowPersonalApiKeys: true, billedAccountUserId: 'billing-owner-1', })) - mocks.resolveGroupConfig.mockResolvedValue({ + resolveGroupConfigMock.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, disableTableExport: true, }) @@ -219,7 +219,7 @@ describe('table export application use cases', () => { }) it('generates an export when the group withholds nothing', async () => { - mocks.resolveGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) await expect( createTableExportUseCase.execute({ diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 2616c0cee1b..4f579ec9532 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -123,6 +123,11 @@ export { OauthStepTimeoutErrorMock, } from './mcp-oauth.mock' // Permission mocks +export { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from './permission-group-scope.mock' export { permissionsMock, permissionsMockFns } from './permissions.mock' // PostHog server mocks (for @/lib/posthog/server) export { posthogServerMock, posthogServerMockFns } from './posthog-server.mock' diff --git a/packages/testing/src/mocks/permission-group-scope.mock.ts b/packages/testing/src/mocks/permission-group-scope.mock.ts new file mode 100644 index 00000000000..48c290c6b9b --- /dev/null +++ b/packages/testing/src/mocks/permission-group-scope.mock.ts @@ -0,0 +1,54 @@ +import { vi } from 'vitest' + +/** + * Controllable mock functions for `@/lib/permission-groups/config-scope.server`. + * + * `mockResolvePermissionGroupConfig` is the one seam every capability gate reads + * — `assertWorkspaceCapability`, `isWorkspaceCapabilityWithheld`, and the + * authorization funnel all resolve the governing config through it. Mock this + * rather than `@/ee/access-control/utils/permission-check`, which sits a layer + * below the memo and is not what the gates call. + * + * Resolve `null` for the ungoverned case (a personal workspace, or any + * non-enterprise organization) and a spread of `DEFAULT_PERMISSION_GROUP_CONFIG` + * for a group that governs the user. + * + * @example + * ```ts + * import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' + * + * vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + * + * permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + * ...DEFAULT_PERMISSION_GROUP_CONFIG, + * hideInboxTab: true, + * }) + * ``` + */ +export const permissionGroupScopeMockFns = { + mockResolvePermissionGroupConfig: vi.fn(), +} + +/** + * Static mock module for `@/lib/permission-groups/config-scope.server`. + * + * `withPermissionGroupScope` is a real passthrough rather than a `vi.fn()` + * because `withRouteHandler` calls it to wrap every route handler. A factory + * that exports only `resolvePermissionGroupConfig` leaves it `undefined`, and + * the route then fails with a 500 that looks nothing like the gate under test. + * + * @example + * ```ts + * vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + * ``` + */ +export const permissionGroupScopeMock = { + resolvePermissionGroupConfig: permissionGroupScopeMockFns.mockResolvePermissionGroupConfig, + withPermissionGroupScope: (run: () => R): R => run(), +} + +/** Restores the ungoverned default — no group governs the user. */ +export function resetPermissionGroupScopeMock(): void { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockReset() + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(null) +} From d8dfb354d16676091ecf665fc2282bf0e8716232 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 11:38:07 -0700 Subject: [PATCH 030/179] docs(permission-groups): tell admins what they are actually revoking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve `hide*` keys are now server-enforced, but their admin copy still described the cosmetic behavior they used to have. A hint reading "Hide the Tables module from the sidebar" tells an admin they are tidying a nav bar while they are revoking a module — and the same string is read a second time by `getActivePermissionGroupRestrictions` as the prose for an active restriction, where "hide" is simply false. Rewrite every misleading label and hint to name the access withheld, and re-file the categories: `Sidebar`, `Settings Tabs`, `Deploy Tabs` and `Workflow Panel` named UI chrome that no longer decides anything. Also deduplicate the model and block-type gates that `assertPermissionsAllowed` copied verbatim from `validateModelProvider` / `validateBlockType`, drop four dead exports from the access-control hooks, and correct the two permission-group skills against the current code. --- .../skills/add-permission-group-item/SKILL.md | 31 +++- .../validate-permission-group-item/SKILL.md | 14 +- .../components/group-detail.tsx | 6 +- .../access-control/hooks/permission-groups.ts | 8 +- .../access-control/utils/permission-check.ts | 150 +++++++++--------- apps/sim/lib/permission-groups/features.ts | 24 +-- apps/sim/lib/permission-groups/fields.ts | 94 ++++++----- 7 files changed, 181 insertions(+), 146 deletions(-) diff --git a/.agents/skills/add-permission-group-item/SKILL.md b/.agents/skills/add-permission-group-item/SKILL.md index 1026783f877..85d08102459 100644 --- a/.agents/skills/add-permission-group-item/SKILL.md +++ b/.agents/skills/add-permission-group-item/SKILL.md @@ -17,7 +17,8 @@ Read these completely before editing. Do not infer their shape from this documen - `apps/sim/lib/permission-groups/fields.ts` — the registry, the three field builders, the tolerant parser - `apps/sim/lib/permission-groups/capabilities.ts` — `CAPABILITY_IDS`, `CAPABILITY_RULES`, the static/parameterized split - `apps/sim/lib/permission-groups/capability-assertions.ts` — the only sanctioned way to ask whether a group withholds something -- `apps/sim/lib/core/application/workspace-operation.ts` — the required `capability` field +- `apps/sim/lib/permission-groups/config-scope.server.ts` — `resolvePermissionGroupConfig`, the per-request memo every assertion resolves through +- `apps/sim/lib/core/application/workspace-operation.ts` — `capability` is a **required** field on `defineWorkspaceOperation`, typed `StaticPermissionGroupCapability | 'none'` - `apps/sim/lib/core/application/workspace-authorization.ts` — where the funnel enforces, and who passes through - `scripts/check-permission-group-enforcement.ts` — the audit you have to satisfy @@ -51,11 +52,13 @@ Append one entry to `PERMISSION_GROUP_FIELDS`. **Append, never insert.** disableWidgetSharing: booleanRestriction('capability', { id: 'disable-widget-sharing', label: 'Widget Sharing', - category: 'Features', + category: 'Collaboration', hint: 'Prevent sharing a widget outside the workspace.', }), ``` +The object in the second argument is the field's `feature` property, typed `PlatformFeatureMeta`. `PLATFORM_FEATURES` spreads it and appends `configKey`, so `id`, `label`, `category` and `hint` are exactly what the editor renders. + Declaration order here is the key order of `PermissionGroupConfig`, of both zod schemas, and of every config JSON that crosses the API boundary. The group editor in `apps/sim/ee/access-control/components/group-detail.tsx` runs its dirty check by comparing stringified configs, so moving an existing key makes every open editor read as having unsaved changes. The registry already carries a TSDoc note on `disablePersonalApiKeys` saying exactly this — extend the tail, do not tidy the middle. Three things to get right in the entry itself: @@ -64,7 +67,9 @@ Three things to get right in the entry itself: **The admin checkbox is inverted.** `group-detail.tsx` renders `checked={!editingConfig[feature.configKey]}` — ticked means *allowed*. A key named `allowX` would render backwards. Name it `hideX` or `disableX`. -**The category must be in `PLATFORM_CATEGORY_ORDER`.** That constant lives in `apps/sim/lib/permission-groups/features.ts`. An unlisted category still renders, but at the end, after every ordered section. +**The hint must describe revoked access, not a hidden surface.** Every `enforcement: 'capability'` key refuses at the API. A hint reading "Hide the Tables module from the sidebar" tells an admin they are tidying a nav bar when they are revoking a module — and the same string is read a second time by `getActivePermissionGroupRestrictions` as the prose explaining an *active* restriction, where "hide" is simply false. Write what the member can no longer do: "Revoke the Tables module. Members cannot read or write any table." That wording drift is not hypothetical — twelve keys carried "hide from the sidebar" hints for a release after they started returning 403. + +**The category must be in `PLATFORM_CATEGORY_ORDER`.** That constant lives in `apps/sim/lib/permission-groups/features.ts` and currently reads `Modules`, `Knowledge Base`, `Tables`, `Files`, `Deployment`, `Tools`, `Logs`, `Collaboration`, `Credentials & Access`. An unlisted category still renders, but at the end, after every ordered section. The names describe what a group withholds, not where a link used to be hidden — do not reintroduce a surface-shaped section like "Sidebar" or "Settings Tabs". Note that `PLATFORM_FEATURES` — the array the editor renders — is *derived* from the registry in `features.ts`, not hand-listed. A boolean key cannot reach the config without reaching the editor, which is deliberate: an unrendered key is one an admin can neither set nor see. @@ -113,7 +118,7 @@ A static rule: }, ``` -`configKeys` is what the audit reads to prove your key is enforced — it must list every key `deniedBy` actually reads. `describe` is substituted into `capabilityRefusalMessage`, which produces `" is not available under your organization's permission group"`, so write it as a noun phrase that fits. +`configKeys` is what the audit reads to prove your key is enforced — it must list every key `deniedBy` actually reads. `describe` is the subject of one shared sentence, `" is not available under your organization's permission group"`, so write it as a singular noun or gerund phrase that agrees with the verb. Two functions build that sentence and there is no third: `refuseCapability(capability)` in `capabilities.ts` throws it as a `PermissionGroupCapabilityError`, and `capabilityRefusal(capability)` in `capability-assertions.ts` returns it as a string for a raw route rendering its own response body. Never write the sentence out at a call site. Use `'PERMISSION_GROUP_CAPABILITY_BLOCKED'` for `detailCode` unless a caller can act differently on this specific refusal. The set in `apps/sim/lib/core/application/forbidden.ts` is closed **over remedies, not over causes** — a new code is warranted only when the remedy differs from "ask an organization admin". Adding one also requires an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS` (a compile-time gate) and publishes a new value in the generated OpenAPI 403 description. @@ -158,7 +163,14 @@ export const shareWidget = defineWorkspaceOperation({ If the domain wraps `defineWorkspaceOperation` in a same-file factory, the audit resolves the capability through it — either fixed in the factory body or taken as a positional second argument. `apps/sim/lib/table/application/operations.ts` shows both, and deliberately gives the positional form **no default**: a default would let a new operation inherit `tables.use` without anyone deciding it should, which is the unreviewed omission the whole gate exists to prevent. -**Parameterized, or no operation to hang it on** — assert from inside the use case through `capability-assertions.ts`, and annotate the call site: +**Static, but no operation to hang it on** — a raw route, or an organization-level action — call `assertWorkspaceCapability` / `assertOrganizationCapability` from `capability-assertions.ts` directly, and annotate the call site: + +```ts + // permission-group-enforced: logs.export — raw streaming route, no workspace operation to declare it on + await assertWorkspaceCapability(userId, workspaceId, 'logs.export', organizationId) +``` + +**Parameterized** — the rule needs a request value, so no helper in `capability-assertions.ts` fits (they are all typed `StaticPermissionGroupCapability`). Write a small module-local wrapper that reads the rule and refuses, and annotate the call site. `assertConnectorTypeAllowed` in `apps/sim/lib/knowledge/application/connectors.ts` is the shape: ```ts // permission-group-enforced: knowledge.connectors — needs the request's connector id, which the funnel never sees @@ -169,7 +181,7 @@ If the domain wraps `defineWorkspaceOperation` in a same-file factory, the audit ) ``` -Always route the decision through `CAPABILITY_RULES` (via `assertWorkspaceCapability`, `assertOrganizationCapability`, `capabilityDeniedBy`, or the `isWorkspaceCapabilityWithheld` / `isOrganizationCapabilityWithheld` non-throwing pair). Never spell the config key out at the call site: a renamed key would silently stop denying anything, and the refusal wording would drift from the funnel's. +Always route the decision through `CAPABILITY_RULES` (via `assertWorkspaceCapability`, `assertOrganizationCapability`, `capabilityDeniedBy`, the `isWorkspaceCapabilityWithheld` / `isOrganizationCapabilityWithheld` non-throwing pair, or a direct `CAPABILITY_RULES[''].deniedBy(config, value)` for a parameterized rule) and raise it with `refuseCapability`. Never spell the config key out at the call site, and never write the refusal sentence out: a renamed key silently stops denying anything, and a hand-written message drifts from the funnel's for the same refusal. Use `assertOrganizationCapability` for an action that names an organization rather than a workspace — creating a workspace, reading the member directory. It resolves the organization's *default* group, because a non-default group targets specific workspaces and has nothing to say about an action no workspace scopes. @@ -205,7 +217,9 @@ Read the audit's success line, not just its exit code: ✓ permission-group enforcement: 287 operations declare a capability, 35 capabilities all enforced ``` -While any operation is still unannotated the audit runs in **count-down mode** and exits 0 with a `(N to go)` line — and in that mode it *suppresses* the "capability declared but nothing enforces it" finding. If your run prints a count-down, your new capability being unreachable will not fail the build. Check the `pending enforcement:` line for your capability id by name. +The counts should have grown by your operation and your capability. The audit is now all-or-nothing — it either prints that line or fails with findings; there is no longer a count-down or migration mode that exits 0 with work outstanding. It does have a self-check that refuses to report success when `CAPABILITY_IDS`, `CAPABILITY_RULES` or `PERMISSION_GROUP_FIELDS` parse to nothing, and it fails when the rule count and the capability count disagree. If either fires, the script's regexes stopped matching a rename — fix the parsers rather than leaving it green. + +What the audit proves is *reachability*: your capability is named somewhere and your key is read by some rule. It cannot tell whether the rule's logic is right or whether every operation reaching the behavior declares it. Do not treat a green run as proof the gate fires. ## Traps @@ -240,7 +254,8 @@ These are the ones that actually bite. Each has a reason; understand the reason - [ ] Kind and `enforcement` chosen deliberately; `ui-only` justified in writing if used - [ ] Entry **appended** to `PERMISSION_GROUP_FIELDS`, permissive default, restriction-phrased name -- [ ] Category present in `PLATFORM_CATEGORY_ORDER` +- [ ] Category present in `PLATFORM_CATEGORY_ORDER`, named after what is withheld rather than a surface +- [ ] `hint` says what access is revoked, never "hide" — it is also the prose for an active restriction - [ ] Non-boolean key has a `featureExtras` picker that refuses empty and collapses "all" to `null` - [ ] Capability id in `CAPABILITY_IDS`, rule in `CAPABILITY_RULES`, `configKeys` lists every key `deniedBy` reads - [ ] A narrower capability replacing a broader one also reads the broader key diff --git a/.agents/skills/validate-permission-group-item/SKILL.md b/.agents/skills/validate-permission-group-item/SKILL.md index 7769b79a31c..867662c6bea 100644 --- a/.agents/skills/validate-permission-group-item/SKILL.md +++ b/.agents/skills/validate-permission-group-item/SKILL.md @@ -17,6 +17,7 @@ Twelve keys once shipped with an admin checkbox, a hint describing what they res - `apps/sim/lib/permission-groups/fields.ts` — the registry every config surface derives from - `apps/sim/lib/permission-groups/capabilities.ts` — `CAPABILITY_IDS`, `CAPABILITY_RULES` - `apps/sim/lib/permission-groups/capability-assertions.ts` — the canonical assertion API +- `apps/sim/lib/permission-groups/config-scope.server.ts` — `resolvePermissionGroupConfig`, the per-request memo every assertion resolves through - `apps/sim/lib/core/application/workspace-authorization.ts` — the funnel, and who bypasses it - `scripts/check-permission-group-enforcement.ts` — what the audit does and does not prove @@ -28,6 +29,7 @@ Find the key in `PERMISSION_GROUP_FIELDS`. Record its builder (`booleanRestricti - **Named as a restriction?** `hideX` / `disableX` / `allowedX` / `deniedX`. The admin checkbox renders `checked={!editingConfig[feature.configKey]}` — ticked means allowed — so a positively-named boolean renders backwards. - **Position stable?** Declaration order is the wire order of `PermissionGroupConfig`, both zod schemas, and every config JSON crossing the API boundary. If `git log -p` shows the key was ever *moved* rather than appended, that shipped as a dirty-check regression in the group editor. - **Phrasing present and accurate?** An allowlist's `{ limited, empty }` and a denylist's string are read by `getActivePermissionGroupRestrictions` in `features.ts` and surface to users through the Copilot context and the group roster. Check the `empty` string genuinely says "none allowed" and not "unrestricted". +- **Does the boolean's `hint` tell the truth?** This is the highest-value read in Step 1. A key with `enforcement: 'capability'` refuses at the API, so a hint saying it hides a tab, a panel, a module "from the sidebar", or a nav item is a **lie** an admin acts on — they believe they are tidying chrome while they are revoking access. The same string is read a second time as the prose for an *active* restriction, where "hide" is simply false. It must name what a member can no longer do. Twelve keys carried that wording for a release after they started 403-ing; treat any surviving "Hide the …" hint on a `'capability'` key as a finding, not a nit. Check `label` and `category` the same way — a section headed "Sidebar" or "Settings Tabs" makes the same claim structurally. ## Step 2: Schemas, type, defaults, parser @@ -64,7 +66,7 @@ Then check the things the audit cannot: - **`kind` is right.** A rule whose decision needs a request value must be `'parameterized'`. A parameterized rule can never be declared on an operation — `defineWorkspaceOperation` throws at definition time — so if you find one named on an operation, that code does not run in production; something else is wrong. - **A narrower capability subsumes the broader one it replaced.** An operation carries exactly one capability. If this capability was split off a more general one, its rule must also read the general key. The precedent is `knowledge.create` and `knowledge.upload`, which both read `hideKnowledgeBaseTab` alongside their own key — without that, a group withholding the entire Knowledge Base module could still create one through the API. Check `git log` for a re-pointed `capability:` field and verify the narrower rule grew the broader key in the same commit. - **`detailCode` matches the remedy.** `FORBIDDEN_DETAIL_CODES` is closed over *remedies*, not causes. A distinct code is warranted only when a caller would act differently; otherwise `PERMISSION_GROUP_CAPABILITY_BLOCKED` is correct. Any code in use must have an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS`, which is a compile-time gate and also publishes the OpenAPI 403 text. -- **`describe` reads correctly in the sentence.** `capabilityRefusalMessage` produces `" is not available under your organization's permission group"`. +- **`describe` reads correctly in the sentence.** Two functions build it and there is no third: `refuseCapability(capability)` in `capabilities.ts` throws `" is not available under your organization's permission group"` as a `PermissionGroupCapabilityError`, and `capabilityRefusal(capability)` in `capability-assertions.ts` returns the same string for a raw route rendering its own body. `describe` must be a singular noun or gerund phrase that agrees with "is". Any call site that writes the sentence out itself is a drift finding. ## Step 5: Prove the enforcement — do not assume it @@ -78,7 +80,7 @@ grep -rn "permission-group-enforced: " apps/sim Classify what you find into exactly one of: 1. **Declared on operations.** `capability: ''` on one or more `defineWorkspaceOperation` calls. The funnel enforces in `requireCurrentHumanAccess` → `requireCapability`. Verify the set of operations is *complete*: enumerate every route and tool that reaches the same behavior and check each one's operation declares it. One route declaring `capability: 'none'` for the same behavior is the hole. -2. **Asserted at a call site**, with a `// permission-group-enforced: ` annotation. Verify the assertion goes through `capability-assertions.ts` or a `CAPABILITY_RULES` entry rather than spelling the config key out inline — a call site reading `config.disableX` directly stops denying anything the moment the key is renamed, and its wording drifts from the funnel's. Some older helpers in `apps/sim/ee/access-control/utils/permission-check.ts` (`validatePublicFileSharing`, `validateChatDeployAuth`) still read config keys directly; note that as a finding rather than a blocker, and cite `assertConnectorTypeAllowed` in `apps/sim/lib/knowledge/application/connectors.ts` as the pattern they should converge on. +2. **Asserted at a call site**, with a `// permission-group-enforced: ` annotation. Verify the assertion goes through `capability-assertions.ts` or a `CAPABILITY_RULES` entry rather than spelling the config key out inline — a call site reading `config.disableX` directly stops denying anything the moment the key is renamed, and its wording drifts from the funnel's. Then check the second half, which is easy to miss because the decision looks right: does it *raise* through `refuseCapability`, or does it build its own `ForbiddenOperationError` with a hand-written message? `validatePublicFileSharing` and `validateChatDeployAuth` in `apps/sim/ee/access-control/utils/permission-check.ts` read the rule and call `refuseCapability` — that is the pattern. `assertConnectorTypeAllowed` in `apps/sim/lib/knowledge/application/connectors.ts` reads the rule but writes its own sentence; the decision is sound, the wording is a standing drift finding, not a new one. 3. **Executor-gated.** Read by `assertPermissionsAllowed` in `permission-check.ts`, per block / tool / model. Verify the branch exists and throws a real error, and that the id it compares against is the same vocabulary the admin UI writes — `deniedTools` holds block `tools.access` ids verbatim, version suffix included. 4. **Nothing.** Report it as a defect, with the sentence "an organization that sets this believes it applied a restriction that does not exist". @@ -100,10 +102,10 @@ cd apps/sim && bun run type-check cd apps/sim && bunx vitest run lib/permission-groups ``` -Read the audit's output, not just its exit code. Two ways it can pass without proving what you want: +Read the audit's output, not just its exit code. It is all-or-nothing — it either prints one success line or fails with findings; there is no count-down or migration mode that exits 0 with work outstanding, so do not go looking for a `pending enforcement:` list. What it can still do is pass without proving what you want: -- **Count-down mode.** While any operation is still unannotated it prints `(N to go)` and exits 0 — and in that mode it *suppresses* the "capability declared but nothing enforces it" finding entirely. Check the `pending enforcement:` line for your capability by name. A capability listed there is unenforced and the build is green anyway. - **Vacuous parse.** The audit reads source text with regexes. It has a self-check that refuses to report success when `CAPABILITY_IDS`, `CAPABILITY_RULES`, or `PERMISSION_GROUP_FIELDS` parse to nothing, and it cross-checks that the rule count equals the capability count. If either of those errors fires, the audit is broken, not the code — fix the parsers rather than leaving it passing. +- **A capability declared on an operation nothing routes to.** Assertion C is satisfied by the declaration alone. An operation that no route, tool, or use case actually invokes still counts as reaching the capability. The audit proves *reachability*, not correctness: it proves a capability is named somewhere and a key is read by some rule. It cannot tell whether the rule's logic is right, whether every relevant operation declares it, or whether an annotated call site actually calls anything. Step 5 is what covers that, and no amount of green CI substitutes for it. @@ -124,4 +126,6 @@ For each item audited, state: 2. **The refusal** — file, line, the error thrown, and what a caller sees (status, `detailCode`, message). Or: *nothing refuses*. 3. **Proof** — the test that fails when the gate is removed, or the statement that no such test exists. 4. **Coverage gaps** — routes, tools, or surfaces reaching the same behavior without the gate. -5. **Findings**, ordered: unenforced key > incomplete operation coverage > fail-open coercion > allowlist three-state confusion > missing admin UI > missing test > cosmetic. +5. **Findings**, ordered: unenforced key > incomplete operation coverage > fail-open coercion > allowlist three-state confusion > **admin copy that misstates the enforcement** > missing admin UI > missing test > cosmetic. + +A hint that says "hide" for a key that 403s is not cosmetic. It is the one defect an admin acts on directly: they tick it believing they hid a link, and members lose the module. Rank it with the enforcement findings, not the polish. diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 559fce0e015..23357534e6a 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -1439,7 +1439,7 @@ export function GroupDetail({ const filteredProvidersAllAllowed = filteredProviders.every((id) => isProviderAllowed(id)) const coreBlocksAllAllowed = filteredCoreBlocks.every((b) => isIntegrationAllowed(b.type)) const toolBlocksAllAllowed = filteredToolBlocks.every((b) => isIntegrationAllowed(b.type)) - const platformAllVisible = filteredPlatformFeatures.every((f) => !editingConfig[f.configKey]) + const platformAllAllowed = filteredPlatformFeatures.every((f) => !editingConfig[f.configKey]) return ( <> @@ -1777,13 +1777,13 @@ export function GroupDetail({ setEditingConfig((prev) => ({ ...prev, ...Object.fromEntries( - filteredPlatformFeatures.map((f) => [f.configKey, platformAllVisible]) + filteredPlatformFeatures.map((f) => [f.configKey, platformAllAllowed]) ), })) } disabled={filteredPlatformFeatures.length === 0} > - {platformAllVisible ? 'Deselect All' : 'Select All'} + {platformAllAllowed ? 'Deselect All' : 'Select All'} {platformCategorySections.length === 0 && ( diff --git a/apps/sim/ee/access-control/hooks/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts index 0f1b7f68ce9..8912120cd11 100644 --- a/apps/sim/ee/access-control/hooks/permission-groups.ts +++ b/apps/sim/ee/access-control/hooks/permission-groups.ts @@ -108,7 +108,7 @@ export function useUserPermissionConfig(workspaceId?: string) { }) } -export interface CreatePermissionGroupData { +interface CreatePermissionGroupData { organizationId: string name: string description?: string @@ -135,7 +135,7 @@ export function useCreatePermissionGroup() { }) } -export interface UpdatePermissionGroupData { +interface UpdatePermissionGroupData { id: string organizationId: string name?: string @@ -164,7 +164,7 @@ export function useUpdatePermissionGroup() { }) } -export interface DeletePermissionGroupParams { +interface DeletePermissionGroupParams { permissionGroupId: string organizationId: string } @@ -204,7 +204,7 @@ export function useRemovePermissionGroupMember() { }) } -export interface BulkAddMembersData { +interface BulkAddMembersData { organizationId: string permissionGroupId: string userIds?: string[] diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 0e369781581..28cc5b89080 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -451,58 +451,52 @@ function isModelDenied(config: PermissionGroupConfig, model: string): boolean { return config.deniedModels.some((denied) => denied.toLowerCase() === normalized) } -export async function validateModelProvider( - userId: string | undefined, - workspaceId: string | undefined, - model: string, - ctx?: ExecutionContext -): Promise { - if (!userId || !workspaceId) { - return - } - - const config = await getPermissionConfig(userId, workspaceId, ctx) - - if (!config) { - return - } +/** Identifies the caller in a log line; never used for a decision. */ +interface PermissionSubject { + userId: string | undefined + workspaceId: string | undefined +} +/** + * Refuses `model` when the config withholds its provider or names it outright. + * + * Takes a loaded config rather than loading one, so the single-gate entry point + * and {@link assertPermissionsAllowed} share one copy of the decision. Two + * copies is how an allowlist stops matching in one of them. + */ +function assertModelAllowed( + config: PermissionGroupConfig, + model: string, + subject: PermissionSubject +): void { if (config.allowedModelProviders !== null) { const providerId = getProviderFromModel(model) if (!config.allowedModelProviders.includes(providerId)) { - logger.warn('Model provider blocked by permission group', { - userId, - workspaceId, - model, - providerId, - }) + logger.warn('Model provider blocked by permission group', { ...subject, model, providerId }) throw new ProviderNotAllowedError(providerId, model) } } if (isModelDenied(config, model)) { - logger.warn('Model blocked by permission group', { userId, workspaceId, model }) + logger.warn('Model blocked by permission group', { ...subject, model }) throw new ModelNotAllowedError(model) } } -export async function validateBlockType( - userId: string | undefined, - workspaceId: string | undefined, +/** + * Refuses `blockType` when the config's integration allowlist does not name it. + * + * Shared with {@link assertPermissionsAllowed} for the reason + * {@link assertModelAllowed} is. Callers screen out exempt block types first — + * the exemption also decides whether they need a config at all. + */ +function assertBlockTypeAllowed( + config: PermissionGroupConfig, blockType: string, - ctx?: ExecutionContext -): Promise { - if (isBlockTypeAccessControlExempt(blockType)) { - return - } - - const config = - userId && workspaceId - ? await getPermissionConfig(userId, workspaceId, ctx) - : mergeEnvAllowlist(null) - - if (!config || config.allowedIntegrations === null) { + subject: PermissionSubject +): void { + if (config.allowedIntegrations === null) { return } @@ -521,7 +515,7 @@ export async function validateBlockType( blockedByEnv ? 'Integration blocked by env allowlist' : 'Integration blocked by permission group', - { userId, workspaceId, blockType } + { ...subject, blockType } ) throw new IntegrationNotAllowedError( blockType, @@ -530,6 +524,46 @@ export async function validateBlockType( } } +export async function validateModelProvider( + userId: string | undefined, + workspaceId: string | undefined, + model: string, + ctx?: ExecutionContext +): Promise { + if (!userId || !workspaceId) { + return + } + + const config = await getPermissionConfig(userId, workspaceId, ctx) + if (!config) { + return + } + + assertModelAllowed(config, model, { userId, workspaceId }) +} + +export async function validateBlockType( + userId: string | undefined, + workspaceId: string | undefined, + blockType: string, + ctx?: ExecutionContext +): Promise { + if (isBlockTypeAccessControlExempt(blockType)) { + return + } + + const config = + userId && workspaceId + ? await getPermissionConfig(userId, workspaceId, ctx) + : mergeEnvAllowlist(null) + + if (!config) { + return + } + + assertBlockTypeAllowed(config, blockType, { userId, workspaceId }) +} + const INVITATIONS_RULE = CAPABILITY_RULES['invitations.send'] /** @@ -685,44 +719,14 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis ? await getPermissionConfig(userId, workspaceId, ctx) : mergeEnvAllowlist(null) - if (model && config) { - if (config.allowedModelProviders !== null) { - const providerId = getProviderFromModel(model) - if (!config.allowedModelProviders.includes(providerId)) { - logger.warn('Model provider blocked by permission group', { - userId, - workspaceId, - model, - providerId, - }) - throw new ProviderNotAllowedError(providerId, model) - } - } + const subject = { userId, workspaceId } - if (isModelDenied(config, model)) { - logger.warn('Model blocked by permission group', { userId, workspaceId, model }) - throw new ModelNotAllowedError(model) - } + if (model && config) { + assertModelAllowed(config, model, subject) } - if (blockType && !blockTypeExempt) { - if (config && config.allowedIntegrations !== null) { - const allowlistType = resolveAccessControlBlockType(blockType).toLowerCase() - if (!config.allowedIntegrations.includes(allowlistType)) { - const envAllowlist = getAllowedIntegrationsFromEnv() - const blockedByEnv = envAllowlist !== null && !envAllowlist.includes(allowlistType) - logger.warn( - blockedByEnv - ? 'Integration blocked by env allowlist' - : 'Integration blocked by permission group', - { userId, workspaceId, blockType } - ) - throw new IntegrationNotAllowedError( - blockType, - blockedByEnv ? 'blocked by server ALLOWED_INTEGRATIONS policy' : undefined - ) - } - } + if (blockType && !blockTypeExempt && config) { + assertBlockTypeAllowed(config, blockType, subject) } if (toolId && !createToolAccessGate(config?.deniedTools)(toolId)) { diff --git a/apps/sim/lib/permission-groups/features.ts b/apps/sim/lib/permission-groups/features.ts index d2cbe3c152b..c7602af67e9 100644 --- a/apps/sim/lib/permission-groups/features.ts +++ b/apps/sim/lib/permission-groups/features.ts @@ -21,18 +21,24 @@ export interface ActivePermissionGroupRestriction { description: string } -/** Render order for the platform-feature category sections; unlisted ones follow. */ +/** + * Render order for the platform-feature category sections; unlisted ones follow. + * + * Named after what a group withholds, not after where the key's cosmetic + * ancestor used to hide a link. Every key here is server-enforced, so a section + * headed "Sidebar" or "Settings Tabs" would tell an admin they were tidying a + * nav bar while they were in fact revoking an API. + */ export const PLATFORM_CATEGORY_ORDER: readonly string[] = [ - 'Sidebar', - 'Deploy Tabs', - 'Chat', - 'Collaboration', - 'Workflow Panel', + 'Modules', + 'Knowledge Base', + 'Tables', + 'Files', + 'Deployment', 'Tools', - 'Features', - 'Settings Tabs', 'Logs', - 'Files', + 'Collaboration', + 'Credentials & Access', ] as const const FIELD_ENTRIES = Object.entries(PERMISSION_GROUP_FIELDS) as Array< diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index b1ce2fed375..c6e3df0f961 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -39,10 +39,16 @@ interface PlatformFeatureMeta { readonly label: string readonly category: string /** - * What setting the key withholds, one sentence. Read twice — as the editor's - * hint and as the prose `getActivePermissionGroupRestrictions` reports for an - * active restriction — so it states the restriction rather than what remains - * permitted, which reads backwards in the second place. + * What the key withholds, in one or two short sentences. Read twice — as the + * editor's hint and as the prose `getActivePermissionGroupRestrictions` + * reports for an active restriction — so it states the restriction rather + * than what remains permitted, which reads backwards in the second place. + * + * It must describe the *access* withheld, never a surface hidden. Every key + * with `enforcement: 'capability'` refuses at the API, so a hint that says + * "hide from the sidebar" tells an admin they are tidying a nav bar when they + * are revoking a module. Twelve keys shipped worded that way while they were + * genuinely cosmetic; the wording outlived the behavior. */ readonly hint: string } @@ -215,55 +221,55 @@ export const PERMISSION_GROUP_FIELDS = { id: 'hide-trace-spans', label: 'Trace Spans', category: 'Logs', - hint: 'Hide per-block trace spans in logs.', + hint: 'Withhold per-block trace spans from logs and from the API.', }), hideKnowledgeBaseTab: booleanRestriction('capability', { id: 'hide-knowledge-base', label: 'Knowledge Base', - category: 'Sidebar', - hint: 'Hide the Knowledge Base module from the sidebar.', + category: 'Knowledge Base', + hint: 'Revoke the Knowledge Base module. Members cannot open, search, or query any knowledge base.', }), hideTablesTab: booleanRestriction('capability', { id: 'hide-tables', label: 'Tables', - category: 'Sidebar', - hint: 'Hide the Tables module from the sidebar.', + category: 'Tables', + hint: 'Revoke the Tables module. Members cannot read or write any table.', }), hideCopilot: booleanRestriction('capability', { id: 'hide-copilot', label: 'Chat', - category: 'Workflow Panel', - hint: 'Hide the Chat panel so users cannot build or edit with natural language.', + category: 'Modules', + hint: 'Revoke Chat. Members cannot ask Sim to build or edit anything.', }), hideIntegrationsTab: booleanRestriction('capability', { id: 'hide-integrations', label: 'Integrations', - category: 'Settings Tabs', - hint: 'Hide the Integrations settings tab (OAuth connections).', + category: 'Credentials & Access', + hint: 'Revoke integration connections. Members cannot view, add, or remove an OAuth connection.', }), hideSecretsTab: booleanRestriction('capability', { id: 'hide-secrets', label: 'Secrets', - category: 'Settings Tabs', - hint: 'Hide the Secrets (environment variables) settings tab.', + category: 'Credentials & Access', + hint: 'Revoke secrets. Members cannot read, add, or change a workspace environment variable.', }), hideApiKeysTab: booleanRestriction('capability', { id: 'hide-api-keys', label: 'API Keys', - category: 'Settings Tabs', - hint: 'Hide the API Keys settings tab.', + category: 'Credentials & Access', + hint: 'Revoke workspace API keys. Members cannot list, create, or revoke one.', }), hideInboxTab: booleanRestriction('capability', { id: 'hide-inbox', label: 'Sim Mailer', - category: 'Features', - hint: 'Hide the Sim Mailer inbox.', + category: 'Modules', + hint: 'Revoke the Sim Mailer inbox. Members cannot read or send mail.', }), hideFilesTab: booleanRestriction('capability', { id: 'hide-files', label: 'Files', - category: 'Settings Tabs', - hint: 'Hide the Files settings tab.', + category: 'Files', + hint: 'Revoke the Files module. Members cannot list, upload, or download workspace files.', }), disableMcpTools: booleanRestriction('capability', { id: 'disable-mcp', @@ -287,19 +293,19 @@ export const PERMISSION_GROUP_FIELDS = { id: 'disable-invitations', label: 'Invitations', category: 'Collaboration', - hint: 'Prevent users from inviting others to workspaces.', + hint: 'Prevent inviting anyone to a workspace or to the organization.', }), disablePublicApi: booleanRestriction('capability', { id: 'disable-public-api', label: 'Public API', - category: 'Features', - hint: 'Disable public API access to deployed workflows.', + category: 'Deployment', + hint: 'Revoke public API access. Calls to a deployed workflow are refused.', }), disablePublicFileSharing: booleanRestriction('capability', { id: 'disable-public-file-sharing', label: 'Public Sharing', category: 'Files', - hint: 'Disable public file-share links.', + hint: 'Revoke public file sharing. Members cannot create a share link.', }), allowedFileShareAuthTypes: allowlist(shareAuthType, 'capability', { limited: @@ -308,21 +314,21 @@ export const PERMISSION_GROUP_FIELDS = { }), hideDeployApi: booleanRestriction('capability', { id: 'hide-deploy-api', - label: 'API', - category: 'Deploy Tabs', - hint: 'Hide the API deployment option.', + label: 'API Deployment', + category: 'Deployment', + hint: 'Prevent deploying a workflow as an API endpoint.', }), hideDeployMcp: booleanRestriction('capability', { id: 'hide-deploy-mcp', - label: 'MCP', - category: 'Deploy Tabs', - hint: 'Hide the MCP server deployment option.', + label: 'MCP Server', + category: 'Deployment', + hint: 'Prevent exposing a workflow as an MCP server.', }), hideDeployChatbot: booleanRestriction('capability', { id: 'hide-deploy-chatbot', - label: 'Deployment', - category: 'Chat', - hint: 'Hide the chat deployment option.', + label: 'Chat Deployment', + category: 'Deployment', + hint: 'Prevent publishing a workflow as a chat.', }), allowedChatDeployAuthTypes: allowlist(shareAuthType, 'capability', { limited: @@ -337,7 +343,7 @@ export const PERMISSION_GROUP_FIELDS = { disablePersonalApiKeys: booleanRestriction('capability', { id: 'disable-personal-api-keys', label: 'Personal API Keys', - category: 'Settings Tabs', + category: 'Credentials & Access', hint: 'Prevent members from using a personal API key against this workspace.', }), disableLogExport: booleanRestriction('capability', { @@ -350,18 +356,18 @@ export const PERMISSION_GROUP_FIELDS = { id: 'hide-cost-info', label: 'Execution Cost', category: 'Logs', - hint: 'Hide per-execution cost and token spend in logs.', + hint: 'Withhold execution cost. Logs and exports omit cost and token spend.', }), disableKnowledgeBaseCreation: booleanRestriction('capability', { id: 'disable-knowledge-base-creation', label: 'Knowledge Base Creation', - category: 'Sidebar', + category: 'Knowledge Base', hint: 'Prevent creating knowledge bases, leaving existing ones queryable.', }), disableKnowledgeBaseFileUpload: booleanRestriction('capability', { id: 'disable-knowledge-base-upload', label: 'Knowledge Base Uploads', - category: 'Sidebar', + category: 'Knowledge Base', hint: 'Prevent uploading local documents, leaving sanctioned connectors as the only source.', }), allowedKnowledgeConnectors: allowlist(z.string(), 'capability', { @@ -371,13 +377,13 @@ export const PERMISSION_GROUP_FIELDS = { disableTableCreation: booleanRestriction('capability', { id: 'disable-table-creation', label: 'Table Creation', - category: 'Sidebar', + category: 'Tables', hint: 'Prevent creating tables, leaving existing ones usable.', }), disableTableExport: booleanRestriction('capability', { id: 'disable-table-export', label: 'Table Export', - category: 'Sidebar', + category: 'Tables', hint: 'Prevent downloading a whole table as CSV or JSON.', }), disableBulkFileDownload: booleanRestriction('capability', { @@ -389,7 +395,7 @@ export const PERMISSION_GROUP_FIELDS = { disablePersonalCredentials: booleanRestriction('capability', { id: 'disable-personal-credentials', label: 'Personal Credentials', - category: 'Settings Tabs', + category: 'Credentials & Access', hint: 'Prevent connecting personal credentials, leaving only workspace-shared ones.', }), disableWorkspaceCreation: booleanRestriction('capability', { @@ -402,18 +408,18 @@ export const PERMISSION_GROUP_FIELDS = { id: 'hide-org-member-directory', label: 'Member Directory', category: 'Collaboration', - hint: 'Hide the names and email addresses of other organization members.', + hint: 'Withhold the member directory. Members cannot see the names or email addresses of other members.', }), disableCliAccess: booleanRestriction('capability', { id: 'disable-cli-access', label: 'CLI Access', - category: 'Features', + category: 'Credentials & Access', hint: 'Prevent approving a CLI login, which mints a key for the public API.', }), disableWebhookTriggers: booleanRestriction('capability', { id: 'disable-webhook-triggers', label: 'Webhook Triggers', - category: 'Deploy Tabs', + category: 'Deployment', hint: 'Prevent making a workflow reachable from an inbound webhook.', }), disableToolAutoApproval: booleanRestriction('capability', { From a7cebebfef1efc9023f39eff635489c3fe811026 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 11:39:34 -0700 Subject: [PATCH 031/179] refactor(permission-groups): remove indirection and close the audit's silent-drop hole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes what no longer earns its keep in the permission-groups module and the application-operation boundary, and hardens the enforcement audit against parses that go quiet. Removals, each proven unused by a whole-worktree grep: - `assertOrganizationCapability` — no caller anywhere. - `authModeDeniedBy` and the `ShareAuthMode` alias — the same predicate `allowlistDenies` already expressed, spelled with different words. - `capabilityRefusal` was defined twice, in two files each documented as "the one sentence every capability refusal uses". It now has one definition beside `CAPABILITY_RULES`, which `refuseCapability` also uses. - The `schema` member on every field entry: `readSchema` already carried each key's wire type, and the config type now derives from it. - Re-export shims for `PrincipalKind` and `PermissionGroupCapabilityError`; the barrel names their real modules. - The duplicated restriction shape in `queries.ts`, now the exported type. The audit no longer drops operations in silence. A `defineWorkspaceOperation` call whose id it cannot read — a const reference, or a wrapper written as an arrow const rather than a `function` — is a finding rather than an absence, and a file that calls the builder and yields nothing is reported too. Both forms previously took the count from 287 to less with a passing tick. `resolvePermissionGroupConfig` keeps its key: `organizationId` is a function of `workspaceId`, so adding it would split the cache and double the queries. Said so in TSDoc and pinned it with a test. --- .../authorized-workspace-use-case.ts | 32 +++--- apps/sim/lib/core/application/index.ts | 4 +- .../application/workspace-authorization.ts | 3 - .../core/application/workspace-operation.ts | 2 - .../sim/lib/permission-groups/capabilities.ts | 38 ++++--- .../capability-assertions.ts | 44 +++----- .../config-scope.server.test.ts | 67 +++++++++++ .../permission-groups/config-scope.server.ts | 12 ++ apps/sim/lib/permission-groups/fields.ts | 11 +- .../sim/lib/permission-groups/model-access.ts | 2 +- apps/sim/lib/permission-groups/queries.ts | 9 +- package.json | 3 +- ...check-permission-group-enforcement.test.ts | 105 ++++++++++++++++++ scripts/check-permission-group-enforcement.ts | 67 ++++++++++- 14 files changed, 309 insertions(+), 90 deletions(-) create mode 100644 apps/sim/lib/permission-groups/config-scope.server.test.ts create mode 100644 scripts/check-permission-group-enforcement.test.ts diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts index 6cf9c176bc6..4d0bbedad0b 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -127,25 +127,21 @@ export function defineAuthorizedWorkspaceUseCase< R, >(definition: AuthorizedWorkspaceUseCaseDefinition): OperationUseCase { const resourceAuthorization = (() => { - if ('resourcePolicy' in definition.operation && definition.operation.resourcePolicy) { - const authorizeResource = definition.authorizeResource - if (!authorizeResource) { - throw new Error( - `Operation ${definition.operation.id} requires resource policy authorization` - ) - } - const resourcePolicy = definition.operation.resourcePolicy as ResourcePolicyForOperation - return (executionContext: AuthorizedWorkspaceUseCaseContext) => - authorizeResource({ - ...executionContext, - resourcePolicy, - } as AuthorizedWorkspaceResourceUseCaseContext) + const { authorizeResource, operation } = definition + const resourcePolicy = ('resourcePolicy' in operation ? operation.resourcePolicy : undefined) as + | ResourcePolicyForOperation + | undefined + + if (resourcePolicy && !authorizeResource) { + throw new Error(`Operation ${operation.id} requires resource policy authorization`) } - const authorizeResource = definition.authorizeResource - return authorizeResource - ? (executionContext: AuthorizedWorkspaceUseCaseContext) => - authorizeResource(executionContext as AuthorizedWorkspaceResourceUseCaseContext) - : undefined + if (!authorizeResource) return undefined + + return (executionContext: AuthorizedWorkspaceUseCaseContext) => + authorizeResource({ + ...executionContext, + resourcePolicy, + } as AuthorizedWorkspaceResourceUseCaseContext) })() /** diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index 144b10d9785..7e7f160d3ef 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -20,6 +20,7 @@ export { assertOperationPrincipal, defineOperation, type OperationUseCase, + type PrincipalKind, type PrincipalScopedOperation, type UndelegatedPrincipalKind, } from '@/lib/core/application/operation' @@ -34,7 +35,6 @@ export { DelegatedWorkspaceAuthorizationError, InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, - PermissionGroupCapabilityError, PersonalApiKeysDisabledError, PrincipalKindAuthorizationError, requireAllowedWorkspacePrincipal, @@ -44,6 +44,6 @@ export { export { defineWorkspaceOperation, type PrincipalForOperation, - type PrincipalKind, type WorkspaceOperation, } from '@/lib/core/application/workspace-operation' +export { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index af86e875648..0e5be2643c4 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -19,7 +19,6 @@ import { assertWorkspaceCapability, capabilityDeniedBy, } from '@/lib/permission-groups/capability-assertions' -import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' export interface WorkspaceAuthorizationContext { @@ -59,8 +58,6 @@ export class NoWorkspaceAccessError extends OrchestrationError { } } -export { PermissionGroupCapabilityError } - export class PersonalApiKeysDisabledError extends ForbiddenOperationError { constructor() { super('PERSONAL_API_KEYS_DISABLED', 'Personal API keys are not allowed for this workspace') diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts index 10a6774700d..30e14a6aec9 100644 --- a/apps/sim/lib/core/application/workspace-operation.ts +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -12,8 +12,6 @@ import { type WorkspaceApiKeyPolicy = R extends 'admin' ? 'deny' : 'allow' | 'deny' -export type { PrincipalKind } - type WorkspaceOperationPrincipal = Extract type NonDelegatedPrincipalForOperation< diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts index 3dddcab3473..c6e67edae20 100644 --- a/apps/sim/lib/permission-groups/capabilities.ts +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -1,13 +1,10 @@ import type { ForbiddenDetailCode } from '@/lib/core/application/forbidden' import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' import type { - FILE_SHARE_AUTH_TYPES, PermissionGroupConfig, PermissionGroupConfigKey, } from '@/lib/permission-groups/fields' -type ShareAuthMode = (typeof FILE_SHARE_AUTH_TYPES)[number] - /** * Every capability a permission group can withhold. * @@ -98,26 +95,31 @@ export interface ParameterizedCapabilityRule extends CapabilityRuleBase { export type CapabilityRule = StaticCapabilityRule | ParameterizedCapabilityRule /** - * The one sentence every capability refusal uses, and the error that carries it. + * The one sentence every capability refusal uses, wherever it is raised. * * Shared so the funnel, a raw route gating inline, and a parameterized rule - * asserted from a use case cannot word the same refusal three ways. Accepts any - * capability, static or parameterized, because a parameterized one is refused - * from a call site rather than by the funnel and still has to read identically. + * asserted from a use case cannot word the same refusal three ways. Each rule's + * `describe` is written to read as this sentence's subject. + */ +export function capabilityRefusal(capability: PermissionGroupCapability): string { + return `${CAPABILITY_RULES[capability].describe} is not available under your organization's permission group` +} + +/** + * Throws {@link capabilityRefusal} as the error the surfaces project. + * + * Accepts any capability, static or parameterized, because a parameterized one + * is refused from a call site rather than by the funnel and still has to read + * identically. */ export function refuseCapability(capability: PermissionGroupCapability): never { - const rule = CAPABILITY_RULES[capability] throw new PermissionGroupCapabilityError( capability, - rule.detailCode, - `${rule.describe} is not available under your organization's permission group` + CAPABILITY_RULES[capability].detailCode, + capabilityRefusal(capability) ) } -function authModeDeniedBy(allowed: ShareAuthMode[] | null, mode: string): boolean { - return allowed !== null && !allowed.some((allowedMode) => allowedMode === mode) -} - /** An allowlist denies a member when it is set and does not name it; `null` names everything. */ function allowlistDenies(allowed: readonly string[] | null, member: string): boolean { return allowed !== null && !allowed.includes(member) @@ -216,7 +218,7 @@ export const CAPABILITY_RULES = { configKeys: ['allowedChatDeployAuthTypes'], detailCode: 'CHAT_AUTH_MODE_NOT_PERMITTED', describe: 'This chat authentication mode', - deniedBy: (config, mode) => authModeDeniedBy(config.allowedChatDeployAuthTypes, mode), + deniedBy: (config, mode) => allowlistDenies(config.allowedChatDeployAuthTypes, mode), }, 'file_share.publish': { kind: 'static', @@ -230,7 +232,7 @@ export const CAPABILITY_RULES = { configKeys: ['allowedFileShareAuthTypes'], detailCode: 'PUBLIC_SHARING_NOT_ALLOWED', describe: 'This file-share authentication mode', - deniedBy: (config, mode) => authModeDeniedBy(config.allowedFileShareAuthTypes, mode), + deniedBy: (config, mode) => allowlistDenies(config.allowedFileShareAuthTypes, mode), }, 'public_api.use': { kind: 'static', @@ -313,7 +315,7 @@ export const CAPABILITY_RULES = { describe: 'Creating a knowledge base', deniedBy: (config) => config.disableKnowledgeBaseCreation || config.hideKnowledgeBaseTab, }, - /** Subsumes `knowledge.use` for the same reason as {@link CAPABILITY_RULES}'s `knowledge.create`. */ + /** Subsumes `knowledge.use` for the same reason as `knowledge.create`. */ 'knowledge.upload': { kind: 'static', configKeys: ['disableKnowledgeBaseFileUpload', 'hideKnowledgeBaseTab'], @@ -348,7 +350,7 @@ export const CAPABILITY_RULES = { describe: 'Creating a table', deniedBy: (config) => config.disableTableCreation || config.hideTablesTab, }, - /** Subsumes `hideTablesTab` for the same reason as `tables.create`. */ + /** Subsumes `tables.use` for the same reason as `tables.create`. */ 'tables.export': { kind: 'static', configKeys: ['disableTableExport', 'hideTablesTab'], diff --git a/apps/sim/lib/permission-groups/capability-assertions.ts b/apps/sim/lib/permission-groups/capability-assertions.ts index aaad86a5b02..fab2287471b 100644 --- a/apps/sim/lib/permission-groups/capability-assertions.ts +++ b/apps/sim/lib/permission-groups/capability-assertions.ts @@ -7,6 +7,13 @@ import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-sco import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { getUserPermissionConfigForOrganization } from '@/ee/access-control/utils/permission-check' +/** + * Re-exported so a caller that gates inline reaches the refusal sentence and the + * assertions through one module; {@link CAPABILITY_RULES} remains its only + * definition. + */ +export { capabilityRefusal } from '@/lib/permission-groups/capabilities' + /** * The one way to ask whether a permission group withholds a capability. * @@ -28,17 +35,6 @@ export function capabilityDeniedBy( return rule.kind === 'static' && rule.deniedBy(config) } -/** - * The one sentence every capability refusal uses, wherever it is raised. - * - * Shared so a raw route that gates inline cannot drift from what the - * authorization funnel tells a caller refused for the same reason. Each rule's - * `describe` is written to read as this sentence's subject. - */ -export function capabilityRefusal(capability: StaticPermissionGroupCapability): string { - return `${CAPABILITY_RULES[capability].describe} is not available under your organization's permission group` -} - /** * Throws when `userId`'s group in `workspaceId` withholds `capability`. * @@ -57,22 +53,6 @@ export async function assertWorkspaceCapability( if (capabilityDeniedBy(capability, config)) refuseCapability(capability) } -/** - * The same refusal for an action that names an organization rather than a - * workspace — creating one, or reading its member directory. - * - * Resolves the organization's default group, which is what governs a member for - * an action no workspace scopes; a non-default group targets specific - * workspaces and has nothing to say here. - */ -export async function assertOrganizationCapability( - organizationId: string, - capability: StaticPermissionGroupCapability -): Promise { - const config = await getUserPermissionConfigForOrganization(organizationId) - if (capabilityDeniedBy(capability, config)) refuseCapability(capability) -} - /** * Whether the capability is withheld, without throwing. * @@ -91,7 +71,15 @@ export async function isWorkspaceCapabilityWithheld( ) } -/** The organization-scoped counterpart of {@link isWorkspaceCapabilityWithheld}. */ +/** + * The organization-scoped counterpart of {@link isWorkspaceCapabilityWithheld}. + * + * Outside the per-request memo on purpose. That memo is keyed by user and + * workspace, and this decision is keyed by organization alone, so sharing it + * would need a second key vocabulary in the store. No request asks an + * organization-scoped capability twice — each of the four call sites gates one + * listing — so the memo would never be hit. Key it if that changes. + */ export async function isOrganizationCapabilityWithheld( organizationId: string, capability: StaticPermissionGroupCapability diff --git a/apps/sim/lib/permission-groups/config-scope.server.test.ts b/apps/sim/lib/permission-groups/config-scope.server.test.ts new file mode 100644 index 00000000000..fef7c741d3e --- /dev/null +++ b/apps/sim/lib/permission-groups/config-scope.server.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserPermissionConfig, mockResolveVerifiedContext } = vi.hoisted(() => ({ + mockGetUserPermissionConfig: vi.fn(), + mockResolveVerifiedContext: vi.fn(), +})) + +vi.mock('react', () => ({ cache: (fn: F) => fn })) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, + resolveVerifiedUserAccessControlContext: mockResolveVerifiedContext, +})) + +import { + resolvePermissionGroupConfig, + withPermissionGroupScope, +} from '@/lib/permission-groups/config-scope.server' + +const CONFIG = { hideTablesTab: true } + +describe('resolvePermissionGroupConfig scope memo', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetUserPermissionConfig.mockResolvedValue(CONFIG) + mockResolveVerifiedContext.mockResolvedValue({ config: CONFIG }) + }) + + /** + * The key omits `organizationId` because a caller may only pass the + * organization of the workspace it names, so the two arms resolve the same + * group. Adding it to the key would split the cache and query twice. + */ + it('shares one query between the looked-up and the already-loaded form', async () => { + const [first, second] = await withPermissionGroupScope(() => + Promise.all([ + resolvePermissionGroupConfig('user-1', 'workspace-1', undefined), + resolvePermissionGroupConfig('user-1', 'workspace-1', 'org-1'), + ]) + ) + + expect(first).toBe(second) + expect( + mockGetUserPermissionConfig.mock.calls.length + mockResolveVerifiedContext.mock.calls.length + ).toBe(1) + }) + + it('resolves a different user or workspace separately', async () => { + await withPermissionGroupScope(async () => { + await resolvePermissionGroupConfig('user-1', 'workspace-1', 'org-1') + await resolvePermissionGroupConfig('user-2', 'workspace-1', 'org-1') + await resolvePermissionGroupConfig('user-1', 'workspace-2', 'org-1') + }) + + expect(mockResolveVerifiedContext).toHaveBeenCalledTimes(3) + }) + + it('still answers outside a scope, without memoizing', async () => { + await resolvePermissionGroupConfig('user-1', 'workspace-1', 'org-1') + await resolvePermissionGroupConfig('user-1', 'workspace-1', 'org-1') + + expect(mockResolveVerifiedContext).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/permission-groups/config-scope.server.ts b/apps/sim/lib/permission-groups/config-scope.server.ts index 9840bc9ea30..0f0899934b5 100644 --- a/apps/sim/lib/permission-groups/config-scope.server.ts +++ b/apps/sim/lib/permission-groups/config-scope.server.ts @@ -68,6 +68,18 @@ const resolveCached = cache( * Pass `undefined` for `organizationId` when the caller has not already loaded * the workspace — a raw route, typically. The resolver looks it up, and both * forms share this memo, so a request that mixes them still queries once. + * + * `organizationId` is deliberately NOT part of the key, and adding it would + * split the cache and double the queries for no gain. Both arms end in + * `resolveUserAccessControlContextForOrganization(userId, workspaceId, org)`; + * they differ only in where `org` came from, and a caller may only pass the + * organization of the very workspace it names — it is a value it loaded off + * that workspace, not an independent argument. So `organizationId` is a + * function of `workspaceId`, and the key already carries it. A caller that + * passed some *other* organization would be resolving the wrong group with or + * without this memo, and would fail open (no group in that organization targets + * this workspace, so nothing restricts); that is a call-site invariant, which + * is why the parameter is documented as "already loaded" rather than free. */ export function resolvePermissionGroupConfig( userId: string, diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index b1ce2fed375..633cfcaf0c8 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -31,7 +31,7 @@ const shareAuthType = z.enum(FILE_SHARE_AUTH_TYPES) * they share, and `check:permission-group-enforcement` is what keeps it honest * in both directions. */ -export type PermissionGroupEnforcement = 'capability' | 'executor' | 'ui-only' +type PermissionGroupEnforcement = 'capability' | 'executor' | 'ui-only' /** The admin-editor descriptor for a boolean key, rendered from the registry. */ interface PlatformFeatureMeta { @@ -77,7 +77,6 @@ function tolerantArray interface BooleanRestrictionField { readonly kind: 'boolean-restriction' - readonly schema: z.ZodBoolean readonly writeSchema: z.ZodOptional readonly readSchema: z.ZodBoolean readonly tolerantSchema: z.ZodType @@ -88,7 +87,6 @@ interface BooleanRestrictionField { interface AllowlistField { readonly kind: 'allowlist' - readonly schema: z.ZodNullable> readonly writeSchema: z.ZodOptional>> readonly readSchema: z.ZodNullable> readonly tolerantSchema: z.ZodType[] | null> @@ -99,7 +97,6 @@ interface AllowlistField { interface DenylistField { readonly kind: 'denylist' - readonly schema: z.ZodArray readonly writeSchema: z.ZodOptional> readonly readSchema: z.ZodDefault> readonly tolerantSchema: z.ZodType[]> @@ -117,7 +114,6 @@ interface DenylistField { */ type PermissionGroupField = { readonly kind: 'boolean-restriction' | 'allowlist' | 'denylist' - readonly schema: z.ZodType readonly writeSchema: z.ZodType readonly readSchema: z.ZodType readonly tolerantSchema: z.ZodType @@ -132,7 +128,6 @@ function booleanRestriction( const schema = z.boolean() return { kind: 'boolean-restriction', - schema, writeSchema: schema.optional(), readSchema: schema, tolerantSchema: schema.catch(false), @@ -150,7 +145,6 @@ function allowlist( const schema = z.array(item).nullable() return { kind: 'allowlist', - schema, writeSchema: schema.optional(), readSchema: schema, tolerantSchema: tolerantArray(item, null), @@ -168,7 +162,6 @@ function denylist( const schema = z.array(item) return { kind: 'denylist', - schema, writeSchema: schema.optional(), readSchema: schema.default([]), tolerantSchema: tolerantArray(item, [] as never[]), @@ -428,7 +421,7 @@ export type PermissionGroupFields = typeof PERMISSION_GROUP_FIELDS export type PermissionGroupConfigKey = keyof PermissionGroupFields type DerivedPermissionGroupConfig = { - [K in PermissionGroupConfigKey]: z.infer + [K in PermissionGroupConfigKey]: z.infer } /** diff --git a/apps/sim/lib/permission-groups/model-access.ts b/apps/sim/lib/permission-groups/model-access.ts index b677f96f15b..b78f98fd4e2 100644 --- a/apps/sim/lib/permission-groups/model-access.ts +++ b/apps/sim/lib/permission-groups/model-access.ts @@ -1,4 +1,4 @@ -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { findProviderFromModel } from '@/providers/utils' /** Decides whether the caller's permission group allows a concrete model id. */ diff --git a/apps/sim/lib/permission-groups/queries.ts b/apps/sim/lib/permission-groups/queries.ts index 06bc3e96eb9..f61ef57e394 100644 --- a/apps/sim/lib/permission-groups/queries.ts +++ b/apps/sim/lib/permission-groups/queries.ts @@ -6,8 +6,11 @@ import { workspace, } from '@sim/db/schema' import { asc, count, desc, eq, inArray } from 'drizzle-orm' -import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import { + type ActivePermissionGroupRestriction, + getActivePermissionGroupRestrictions, +} from '@/lib/permission-groups/features' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' /** A workspace reference (id + display name). */ export interface OrgWorkspaceRef { @@ -38,7 +41,7 @@ export interface PermissionGroupRosterEntry { isDefault: boolean memberCount: number workspaces: OrgWorkspaceRef[] - activeRestrictions: Array<{ key: string; description: string }> + activeRestrictions: ActivePermissionGroupRestriction[] } /** diff --git a/package.json b/package.json index 24e04da883a..379f4ca2d83 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,14 @@ "dev:sockets": "cd apps/realtime && bun run dev", "dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev\" \"cd apps/realtime && bun run dev\"", "dev:full:capped": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev:capped\" \"cd apps/realtime && bun run dev\"", - "test": "bun run test:setup && bun run test:npm-package-versions && bun run test:icon-path-precision && bun run test:tool-registry-boundary && bun run test:tool-request-boundary && bun run test:actorless-executor-operations && bun run test:migrations-safety && bun run test:generators && turbo run test", + "test": "bun run test:setup && bun run test:npm-package-versions && bun run test:icon-path-precision && bun run test:tool-registry-boundary && bun run test:tool-request-boundary && bun run test:actorless-executor-operations && bun run test:permission-group-enforcement && bun run test:migrations-safety && bun run test:generators && turbo run test", "test:setup": "bun run --cwd packages/sim-setup test", "test:npm-package-versions": "bunx vitest run scripts/bump-npm-package-versions.test.ts", "test:icon-path-precision": "bunx vitest run scripts/check-icon-path-precision.test.ts", "test:tool-registry-boundary": "bunx vitest run scripts/check-tool-registry-boundary.test.ts", "test:tool-request-boundary": "bunx vitest run scripts/check-tool-request-boundary.test.ts", "test:actorless-executor-operations": "bunx vitest run scripts/check-actorless-executor-operations.test.ts", + "test:permission-group-enforcement": "bunx vitest run scripts/check-permission-group-enforcement.test.ts", "test:migrations-safety": "bunx vitest run scripts/check-migrations-safety.test.ts", "test:generators": "bunx vitest run scripts/generate-v2-cli-api.test.ts scripts/generate-cli-docs.test.ts scripts/generate-docs.test.ts", "format": "turbo run format", diff --git a/scripts/check-permission-group-enforcement.test.ts b/scripts/check-permission-group-enforcement.test.ts new file mode 100644 index 00000000000..d9512299073 --- /dev/null +++ b/scripts/check-permission-group-enforcement.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { + parseCapabilityIds, + parseFieldEnforcement, + parseOperationCapabilities, +} from './check-permission-group-enforcement' + +describe('operation capability parsing', () => { + it('reads a direct declaration', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + export const tableOperations = { + create: defineWorkspaceOperation({ + id: 'tables.create', + minimumRole: 'write', + capability: 'tables.create', + }), + } as const + `) + + expect(declarations).toEqual([ + expect.objectContaining({ id: 'tables.create', capability: 'tables.create' }), + ]) + expect(unreadable).toEqual([]) + }) + + it('resolves call sites of a function factory without reporting the factory itself', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + function tableOperation(id: string, capability: string) { + return defineWorkspaceOperation({ id, minimumRole: 'write', capability }) + } + + export const listRows = tableOperation('tables.rows.list', 'tables.use') + export const readRow = tableOperation('tables.rows.read', 'tables.use') + `) + + expect(declarations.map((declaration) => declaration.id)).toEqual([ + 'tables.rows.list', + 'tables.rows.read', + ]) + expect(declarations.every((declaration) => declaration.capability === 'tables.use')).toBe(true) + expect(unreadable).toEqual([]) + }) + + /** + * The two silent-drop forms. Each used to vanish from the count with the audit + * still printing a tick; both are now findings. + */ + it('reports a declaration whose id is a const reference', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + const TABLE_CREATE_ID = 'tables.create' + + export const create = defineWorkspaceOperation({ + id: TABLE_CREATE_ID, + minimumRole: 'write', + capability: 'tables.create', + }) + `) + + expect(declarations).toEqual([]) + expect(unreadable).toHaveLength(1) + }) + + it('reports a wrapper written as an arrow const rather than a function', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + const tableOperation = (id: string, capability: string) => + defineWorkspaceOperation({ id, minimumRole: 'write', capability }) + + export const listRows = tableOperation('tables.rows.list', 'tables.use') + `) + + expect(declarations).toEqual([]) + expect(unreadable).toHaveLength(1) + }) +}) + +describe('registry parsing', () => { + it('reads capability ids in declaration order', () => { + expect( + parseCapabilityIds(` + export const CAPABILITY_IDS = ['tables.use', 'files.use'] as const + `) + ).toEqual(['tables.use', 'files.use']) + }) + + /** Keys are matched at the registry's own two-space indentation. */ + it('reads each config key declared enforcement', () => { + const enforcement = parseFieldEnforcement( + [ + 'export const PERMISSION_GROUP_FIELDS = {', + " allowedIntegrations: allowlist(z.string(), 'executor', {", + " limited: 'x',", + " empty: 'y',", + ' }),', + " hideTablesTab: booleanRestriction('capability', {", + " id: 'hide-tables',", + " hint: 'Hide the Tables module from the sidebar.',", + ' }),', + '} satisfies Record', + ].join('\n') + ) + + expect(enforcement.get('allowedIntegrations')).toBe('executor') + expect(enforcement.get('hideTablesTab')).toBe('capability') + }) +}) diff --git a/scripts/check-permission-group-enforcement.ts b/scripts/check-permission-group-enforcement.ts index 38ed87bbfec..b48a871993e 100644 --- a/scripts/check-permission-group-enforcement.ts +++ b/scripts/check-permission-group-enforcement.ts @@ -42,6 +42,14 @@ import { fileURLToPath } from 'node:url' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') +/** + * Where a `defineWorkspaceOperation` can live. Every operation declared today is + * under one of these, and `.tsx` is excluded because an operation is policy data + * that no component declares. Both are deliberate rather than incidental: an + * operation landing in `background`, `tools`, `triggers`, `connectors` or + * `enrichments`, or in a `.tsx`, would be invisible here — so widen this list + * (and `walk`) at the same time as moving one, not afterwards. + */ const SCAN_ROOTS = ['apps/sim/lib', 'apps/sim/app', 'apps/sim/ee', 'apps/sim/executor'] const CAPABILITIES_FILE = 'apps/sim/lib/permission-groups/capabilities.ts' const FIELDS_FILE = 'apps/sim/lib/permission-groups/fields.ts' @@ -134,13 +142,29 @@ interface OperationDeclaration { capability: string | undefined } +interface ParsedOperations { + declarations: OperationDeclaration[] + /** + * Lines of `defineWorkspaceOperation` calls this parser could not read an id + * from, and which no recognized factory accounts for. + * + * A call whose `id` is a const reference, or one minted by a wrapper written + * as an arrow const rather than a `function`, used to be dropped in silence — + * the operation simply stopped being counted, and the audit still printed a + * tick. Reported instead, because an audit that quietly stops watching a + * domain is the failure it exists to prevent. + */ + unreadable: number[] +} + /** * Every `defineWorkspaceOperation` in a module and the capability it declares, * resolved through a same-file factory when a domain wraps the builder (the * table operations take only an id and a capability). */ -export function parseOperationCapabilities(source: string): OperationDeclaration[] { +export function parseOperationCapabilities(source: string): ParsedOperations { const declarations: OperationDeclaration[] = [] + const unreadable: number[] = [] const lineAt = (index: number) => source.slice(0, index).split('\n').length /** @@ -150,22 +174,31 @@ export function parseOperationCapabilities(source: string): OperationDeclaration * Both are legible at the call site, so both are read here. */ const factoryCapabilities = new Map() + const factoryRanges: Array<[number, number]> = [] const factoryPattern = /(?:^|\n)\s*(?:export\s+)?function\s+([A-Za-z0-9_$]+)\s*[<(]/g for (let match = factoryPattern.exec(source); match; match = factoryPattern.exec(source)) { const bodyIndex = source.indexOf('{', match.index + match[0].length - 1) if (bodyIndex === -1) continue const body = balancedGroup(source, bodyIndex) if (!body.includes('defineWorkspaceOperation')) continue + factoryRanges.push([bodyIndex, bodyIndex + body.length]) const fixed = /capability\s*:\s*'([a-z0-9_.]+)'/.exec(body)?.[1] if (fixed) factoryCapabilities.set(match[1], fixed) else if (/capability\s*[,:}]/.test(body)) factoryCapabilities.set(match[1], 'positional') } + /** A call inside a recognized factory takes its id from a parameter; its call sites are read below. */ + const insideFactory = (index: number) => + factoryRanges.some(([start, end]) => index >= start && index < end) + const directPattern = /defineWorkspaceOperation\s*\(/g for (let match = directPattern.exec(source); match; match = directPattern.exec(source)) { const call = balancedGroup(source, source.indexOf('(', match.index)) const id = /id\s*:\s*'([^']+)'/.exec(call)?.[1] - if (!id) continue + if (!id) { + if (!insideFactory(match.index)) unreadable.push(lineAt(match.index)) + continue + } declarations.push({ id, line: lineAt(match.index), @@ -187,7 +220,7 @@ export function parseOperationCapabilities(source: string): OperationDeclaration } } - return declarations + return { declarations, unreadable } } /** Capabilities declared enforced at a call site the funnel cannot reach. */ @@ -264,9 +297,33 @@ function main(): void { } } - if (!source.includes('defineWorkspaceOperation')) continue + if (!source.includes('defineWorkspaceOperation(')) continue + + const { declarations, unreadable } = parseOperationCapabilities(source) + + for (const line of unreadable) { + findings.push({ + file: relativePath, + line, + message: + 'defineWorkspaceOperation call this audit cannot read an id from — a const-reference id, or a wrapper written as an arrow const rather than a `function`. Teach parseOperationCapabilities the form rather than letting the operation drop out of the count in silence', + }) + } + + /** + * A file that calls the builder and yields nothing means the parsers no + * longer understand it. Per-file rather than a count floor: a floor rots on + * every legitimate addition and invites bumping the number. + */ + if (declarations.length === 0 && unreadable.length === 0) { + findings.push({ + file: relativePath, + message: + 'calls defineWorkspaceOperation but this audit parsed no operation from it — the declaration form changed and every operation in this file is now unchecked', + }) + } - for (const declaration of parseOperationCapabilities(source)) { + for (const declaration of declarations) { declaredOperations++ if (declaration.capability === undefined) { findings.push({ From 681986c8bdbe70da7ae92d27cd11ea1837e743c7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 11:42:29 -0700 Subject: [PATCH 032/179] refactor(permission-groups): drop the types.ts re-export shim CLAUDE.md forbids re-exporting from a non-barrel file. types.ts re-exported six names that live in fields.ts, so importers had two paths to the same symbol. Repoint them at the source. What was left held no types: two DB constraint-name maps, now constraints.ts, and permissionGroupConfigSchema, which joins the other derivations in fields.ts. types.test.ts covers the derived parser, so it becomes fields.test.ts. --- .../[groupId]/members/bulk/route.ts | 2 +- .../[groupId]/members/route.ts | 2 +- .../[id]/permission-groups/[groupId]/route.ts | 4 +-- .../[id]/permission-groups/route.ts | 4 +-- .../components/group-detail.tsx | 2 +- .../access-control/hooks/permission-groups.ts | 2 +- .../access-control/utils/permission-check.ts | 6 ++-- apps/sim/executor/types.ts | 2 +- apps/sim/hooks/use-permission-config.ts | 8 ++--- .../api/contracts/permission-groups.test.ts | 2 +- .../lib/api/contracts/permission-groups.ts | 6 ++-- .../copilot/integration-tool-projection.ts | 2 +- .../application/credential-crud.test.ts | 2 +- apps/sim/lib/permission-groups/constraints.ts | 8 +++++ .../lib/permission-groups/features.test.ts | 2 +- .../{types.test.ts => fields.test.ts} | 2 +- apps/sim/lib/permission-groups/fields.ts | 8 +++++ apps/sim/lib/permission-groups/types.ts | 34 ------------------- .../platform-context-use-cases.test.ts | 2 +- apps/sim/lib/workflows/editing/builders.ts | 2 +- apps/sim/lib/workflows/editing/engine.ts | 2 +- .../lib/workflows/editing/operations.test.ts | 2 +- apps/sim/lib/workflows/editing/types.ts | 2 +- apps/sim/lib/workflows/editing/validation.ts | 2 +- 24 files changed, 47 insertions(+), 63 deletions(-) create mode 100644 apps/sim/lib/permission-groups/constraints.ts rename apps/sim/lib/permission-groups/{types.test.ts => fields.test.ts} (99%) delete mode 100644 apps/sim/lib/permission-groups/types.ts diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts index c17f862602b..484fc15ee3a 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts @@ -10,7 +10,7 @@ import { bulkAddPermissionGroupMembersContract } from '@/lib/api/contracts/permi import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/types' +import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { acquirePermissionGroupOrgLock, authorizeOrgAccessControl, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts index 3f47477d403..b958a5791dd 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts @@ -10,7 +10,7 @@ import { addPermissionGroupMemberContract } from '@/lib/api/contracts/permission import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/types' +import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { isOrganizationMember } from '@/lib/workspaces/permissions/utils' import { type AllMembersConflict, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts index d54d44fa803..572d129ddfe 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts @@ -10,11 +10,11 @@ import { updatePermissionGroupContract } from '@/lib/api/contracts/permission-gr import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { PERMISSION_GROUP_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { - PERMISSION_GROUP_CONSTRAINTS, type PermissionGroupConfig, parsePermissionGroupConfig, -} from '@/lib/permission-groups/types' +} from '@/lib/permission-groups/fields' import { type AllMembersConflict, acquirePermissionGroupOrgLock, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts index 1029a5b62f3..c53dc9ae8d6 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts @@ -15,12 +15,12 @@ import { createPermissionGroupContract } from '@/lib/api/contracts/permission-gr import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { PERMISSION_GROUP_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { DEFAULT_PERMISSION_GROUP_CONFIG, - PERMISSION_GROUP_CONSTRAINTS, type PermissionGroupConfig, parsePermissionGroupConfig, -} from '@/lib/permission-groups/types' +} from '@/lib/permission-groups/fields' import { type AllMembersConflict, acquirePermissionGroupOrgLock, diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 23357534e6a..f906f8d384b 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -32,7 +32,7 @@ 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 type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { groupSearchParam, diff --git a/apps/sim/ee/access-control/hooks/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts index 8912120cd11..91e8f2b61e9 100644 --- a/apps/sim/ee/access-control/hooks/permission-groups.ts +++ b/apps/sim/ee/access-control/hooks/permission-groups.ts @@ -17,7 +17,7 @@ import { type UserPermissionConfig, updatePermissionGroupContract, } from '@/lib/api/contracts' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' export const PERMISSION_GROUP_MEMBERS_STALE_TIME = 30 * 1000 export const PERMISSION_GROUPS_STALE_TIME = 60 * 1000 diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 28cc5b89080..6c398c57104 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -20,13 +20,13 @@ import { refuseCapability, type StaticCapabilityRule, } from '@/lib/permission-groups/capabilities' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, parsePermissionGroupConfig, -} from '@/lib/permission-groups/types' +} from '@/lib/permission-groups/fields' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import type { ExecutionContext } from '@/executor/types' import { getProviderFromModel } from '@/providers/utils' diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 8aa9e7cd6ce..f66d7785d0d 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -1,7 +1,7 @@ import type { WorkflowExecutionAuthority, WorkflowExecutionPrincipal } from '@sim/auth/principal' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { TraceSpan } from '@/lib/logs/types' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import type { BlockOutput } from '@/blocks/types' import type { ChildWorkflowContext, diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index da494ece028..3bfdd1d2f69 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -15,13 +15,13 @@ import { resolveIntegrationAvailabilityStateForVisibility, } from '@/lib/integrations/availability' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import { createModelAccessGate } from '@/lib/permission-groups/model-access' -import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, -} from '@/lib/permission-groups/types' +} from '@/lib/permission-groups/fields' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { createModelAccessGate } from '@/lib/permission-groups/model-access' +import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { overlayVisibility } from '@/blocks/visibility/context' diff --git a/apps/sim/lib/api/contracts/permission-groups.test.ts b/apps/sim/lib/api/contracts/permission-groups.test.ts index e3460557529..6359763d60f 100644 --- a/apps/sim/lib/api/contracts/permission-groups.test.ts +++ b/apps/sim/lib/api/contracts/permission-groups.test.ts @@ -10,7 +10,7 @@ import { import { DEFAULT_PERMISSION_GROUP_CONFIG, parsePermissionGroupConfig, -} from '@/lib/permission-groups/types' +} from '@/lib/permission-groups/fields' describe('createPermissionGroupBodySchema', () => { it('accepts a name-only body (scope is resolved and validated server-side)', () => { diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index fc1dc0c49ab..f77704d8040 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -1,8 +1,10 @@ import { z } from 'zod' import { organizationIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { permissionGroupReadShape } from '@/lib/permission-groups/fields' -import { permissionGroupConfigSchema } from '@/lib/permission-groups/types' +import { + permissionGroupConfigSchema, + permissionGroupReadShape, +} from '@/lib/permission-groups/fields' /** * The wire shape of a resolved config: every key present, in registry order. diff --git a/apps/sim/lib/copilot/integration-tool-projection.ts b/apps/sim/lib/copilot/integration-tool-projection.ts index ba96dead1de..69111c6e6bf 100644 --- a/apps/sim/lib/copilot/integration-tool-projection.ts +++ b/apps/sim/lib/copilot/integration-tool-projection.ts @@ -6,6 +6,7 @@ import { import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { intersectIntegrationAllowlists, toAllowedIntegrationTypes, @@ -17,7 +18,6 @@ import { type IsToolAllowed, NO_DENIED_OPERATIONS, } from '@/lib/permission-groups/operation-access' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { BLOCK_REGISTRY } from '@/blocks/registry-maps' /** The slice of a permission group the integration gate reads. */ diff --git a/apps/sim/lib/credentials/application/credential-crud.test.ts b/apps/sim/lib/credentials/application/credential-crud.test.ts index 120d72d5b78..2766c5dcc02 100644 --- a/apps/sim/lib/credentials/application/credential-crud.test.ts +++ b/apps/sim/lib/credentials/application/credential-crud.test.ts @@ -55,7 +55,7 @@ import { updateWorkspaceCredentialUseCase, } from '@/lib/credentials/application/credential-crud' import { credentialOperations } from '@/lib/credentials/application/operations' -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' const WORKSPACE_ID = 'workspace-1' const OTHER_WORKSPACE_ID = 'workspace-2' diff --git a/apps/sim/lib/permission-groups/constraints.ts b/apps/sim/lib/permission-groups/constraints.ts new file mode 100644 index 00000000000..f87bde2ec06 --- /dev/null +++ b/apps/sim/lib/permission-groups/constraints.ts @@ -0,0 +1,8 @@ +export const PERMISSION_GROUP_CONSTRAINTS = { + organizationName: 'permission_group_organization_name_unique', + organizationDefault: 'permission_group_organization_default_unique', +} as const + +export const PERMISSION_GROUP_MEMBER_CONSTRAINTS = { + groupUser: 'permission_group_member_group_user_unique', +} as const diff --git a/apps/sim/lib/permission-groups/features.test.ts b/apps/sim/lib/permission-groups/features.test.ts index c7496830cd3..906ff6c963b 100644 --- a/apps/sim/lib/permission-groups/features.test.ts +++ b/apps/sim/lib/permission-groups/features.test.ts @@ -9,7 +9,7 @@ import { import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, -} from '@/lib/permission-groups/types' +} from '@/lib/permission-groups/fields' describe('getActivePermissionGroupRestrictions', () => { it('returns no restrictions for an absent or unrestricted config', () => { diff --git a/apps/sim/lib/permission-groups/types.test.ts b/apps/sim/lib/permission-groups/fields.test.ts similarity index 99% rename from apps/sim/lib/permission-groups/types.test.ts rename to apps/sim/lib/permission-groups/fields.test.ts index fef28f88d2f..5126780fc4f 100644 --- a/apps/sim/lib/permission-groups/types.test.ts +++ b/apps/sim/lib/permission-groups/fields.test.ts @@ -9,7 +9,7 @@ import { type PermissionGroupConfig, parsePermissionGroupConfig, permissionGroupConfigSchema, -} from '@/lib/permission-groups/types' +} from '@/lib/permission-groups/fields' /** * The coercion corpus, pinned against the hand-written parser before it is diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index 0e11ceb3647..c8e16719942 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -465,6 +465,14 @@ function collectFieldProperty

( /** The PATCH shape: every key optional, so a partial config is a legal write. */ export const permissionGroupWriteShape = collectFieldProperty('writeSchema') +/** + * The config a create or update body may carry: every key optional, so a caller + * patches only what it means to change. The route merges the result over the + * group's stored config, which is what heals a row written before a key + * existed. + */ +export const permissionGroupConfigSchema = z.object(permissionGroupWriteShape) + /** The wire shape: every key present, denylists defaulted. */ export const permissionGroupReadShape = collectFieldProperty('readSchema') diff --git a/apps/sim/lib/permission-groups/types.ts b/apps/sim/lib/permission-groups/types.ts deleted file mode 100644 index 939dd624da2..00000000000 --- a/apps/sim/lib/permission-groups/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from 'zod' -import { - DEFAULT_PERMISSION_GROUP_CONFIG, - FILE_SHARE_AUTH_TYPES, - type PermissionGroupConfig, - type PermissionGroupConfigKey, - parsePermissionGroupConfig, - permissionGroupWriteShape, -} from '@/lib/permission-groups/fields' - -export { - DEFAULT_PERMISSION_GROUP_CONFIG, - FILE_SHARE_AUTH_TYPES, - type PermissionGroupConfig, - type PermissionGroupConfigKey, - parsePermissionGroupConfig, -} - -export const PERMISSION_GROUP_CONSTRAINTS = { - organizationName: 'permission_group_organization_name_unique', - organizationDefault: 'permission_group_organization_default_unique', -} as const - -export const PERMISSION_GROUP_MEMBER_CONSTRAINTS = { - groupUser: 'permission_group_member_group_user_unique', -} as const - -/** - * The config a create or update body may carry: every key optional, so a caller - * patches only what it means to change. The route merges the result over the - * group's stored config, which is what heals a row written before a key - * existed. - */ -export const permissionGroupConfigSchema = z.object(permissionGroupWriteShape) 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 index 64319782d72..9469e87f828 100644 --- 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 @@ -38,7 +38,7 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ resolveVerifiedUserAccessControlContext: mocks.resolveVerifiedUserAccessControlContext, })) -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { readAccountBilling } from '@/lib/platform-context/application/read-account-billing' import { readEnterpriseContext } from '@/lib/platform-context/application/read-enterprise-context' diff --git a/apps/sim/lib/workflows/editing/builders.ts b/apps/sim/lib/workflows/editing/builders.ts index 970fb9605a9..2218ea5915d 100644 --- a/apps/sim/lib/workflows/editing/builders.ts +++ b/apps/sim/lib/workflows/editing/builders.ts @@ -8,6 +8,7 @@ import { } from '@sim/workflow-types/workflow' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { createModelAccessGate } from '@/lib/permission-groups/model-access' import { createToolAccessGate, @@ -15,7 +16,6 @@ import { MODEL_SUBBLOCK_ID, OPERATION_SUBBLOCK_ID, } from '@/lib/permission-groups/operation-access' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { isRetryEligibleBlock } from '@/lib/workflows/blocks/retry-eligibility' import { diff --git a/apps/sim/lib/workflows/editing/engine.ts b/apps/sim/lib/workflows/editing/engine.ts index b58642d30ad..68754056277 100644 --- a/apps/sim/lib/workflows/editing/engine.ts +++ b/apps/sim/lib/workflows/editing/engine.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { isValidKey } from '@/lib/workflows/sanitization/key-validation' import { validateEdges } from '@/stores/workflows/workflow/edge-validation' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' diff --git a/apps/sim/lib/workflows/editing/operations.test.ts b/apps/sim/lib/workflows/editing/operations.test.ts index c049bc5e34a..c9f8511bc60 100644 --- a/apps/sim/lib/workflows/editing/operations.test.ts +++ b/apps/sim/lib/workflows/editing/operations.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { applyOperationsToWorkflowState } from './engine' diff --git a/apps/sim/lib/workflows/editing/types.ts b/apps/sim/lib/workflows/editing/types.ts index c2350f009f2..a775b85e137 100644 --- a/apps/sim/lib/workflows/editing/types.ts +++ b/apps/sim/lib/workflows/editing/types.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' /** Selector subblock types that can be validated */ export const SELECTOR_TYPES = new Set([ diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index 34cd3b58d8b..4294f15041e 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -7,7 +7,7 @@ import { isBlockTypeAccessControlExempt, resolveAccessControlBlockType, } from '@/lib/permission-groups/block-access' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { validateSelectorIds } from '@/lib/workflows/editing/selector-validator' import { getSkillById } from '@/lib/workflows/skills/operations' From 6ea2ed191fcf696e9ff12ba45be003f5b4321158 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 11:45:35 -0700 Subject: [PATCH 033/179] fix(permission-groups): enforce tables.use on the raw internal table routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hideTablesTab` declares `booleanRestriction('capability', …)`, and `tables.use` is declared on the 20 table operations that govern `/api/v2/tables/**` and the Copilot table tools. Sixteen internal routes under `/api/table/**` never became operations: they authorize with `checkAccess`, which did `getUserEntityPermissions` + `permissionSatisfies` and nothing else, then called the table service directly. A member of a group denied Tables could still add a TTL column, import, export, delete, restore, and read every row through them; the only other guard was a client-side redirect. `checkAccess` is the choke point all sixteen share, so the gate goes there — one place to forget rather than sixteen, and a route added later inherits it. Three more routes name a workspace rather than a table (`import-csv`, the root `import-async`, `restore`) and assert inline through the same shared refusal response. Capability runs strictly after the 404 and the role check, matching `authorizeWorkspaceOperation`: a role failure conceals whether the table exists, and refusing on capability first would tell a non-member which modules the organization withholds. The denial variant of `AccessResult` carries the capability so `accessError` can raise the shared refusal sentence and `PERMISSION_GROUP_CAPABILITY_BLOCKED` rather than the role message it is not. --- .../app/api/table/[tableId]/restore/route.ts | 11 +- .../sim/app/api/table/capability-gate.test.ts | 179 ++++++++++++++++++ apps/sim/app/api/table/import-async/route.ts | 8 +- apps/sim/app/api/table/import-csv/route.ts | 7 + apps/sim/app/api/table/utils.ts | 76 +++++++- 5 files changed, 274 insertions(+), 7 deletions(-) create mode 100644 apps/sim/app/api/table/capability-gate.test.ts diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts index c01a25af73c..a9dda129c3e 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -4,10 +4,11 @@ import { tableIdParamsSchema } from '@/lib/api/contracts/tables' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { getTableById } from '@/lib/table' import { performRestoreTable } from '@/lib/table/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { orchestrationOutcomeErrorResponse } from '@/app/api/table/utils' +import { capabilityRefusalResponse, orchestrationOutcomeErrorResponse } from '@/app/api/table/utils' const logger = createLogger('RestoreTableAPI') @@ -32,6 +33,14 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } + // permission-group-enforced: tables.use — raw route that queries directly and predates the operation boundary + if ( + table.workspaceId && + (await isWorkspaceCapabilityWithheld(auth.userId, table.workspaceId, 'tables.use')) + ) { + return capabilityRefusalResponse('tables.use') + } + const result = await performRestoreTable({ tableId, userId: auth.userId, requestId }) if (!result.success) { return orchestrationOutcomeErrorResponse(result, 'Failed to restore table') diff --git a/apps/sim/app/api/table/capability-gate.test.ts b/apps/sim/app/api/table/capability-gate.test.ts new file mode 100644 index 00000000000..271f434c974 --- /dev/null +++ b/apps/sim/app/api/table/capability-gate.test.ts @@ -0,0 +1,179 @@ +/** + * @vitest-environment node + * + * Every raw route under `/api/table/**` authorizes through `checkAccess`, which + * predates the operation boundary and so is never reached by the authorization + * funnel that applies `tables.use` to `tableOperations`. These pin the gate + * `checkAccess` now carries, on a write path and a read path, against the real + * `checkAccess` and `accessError` rather than a mock of them. + * + * The write path is column creation on purpose: a TTL column is the only column + * configuration that causes rows to be deleted on a schedule, so a member of a + * group denied Tables driving this route is the worst of the sixteen. + */ +import { + hybridAuthMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetTableById, mockGetUserEntityPermissions, mockAddTableColumn, mockListTableViews } = + vi.hoisted(() => ({ + mockGetTableById: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockAddTableColumn: vi.fn(), + mockListTableViews: vi.fn(), + })) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/table', () => ({ + addTableColumn: mockAddTableColumn, + buildFilterClause: vi.fn(), + createTableView: vi.fn(), + deleteColumn: vi.fn(), + getTableById: mockGetTableById, + listTableViews: mockListTableViews, + TableQueryValidationError: class TableQueryValidationError extends Error {}, + TableViewValidationError: class TableViewValidationError extends Error {}, +})) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: vi.fn() })) +vi.mock('@/lib/table/orchestration', () => ({ performUpdateTableColumn: vi.fn() })) +vi.mock('@/lib/table/wire', () => ({ normalizeColumn: (column: unknown) => column })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) +vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceOrganizationId: vi.fn() })) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { POST } from '@/app/api/table/[tableId]/columns/route' +import { GET } from '@/app/api/table/[tableId]/views/route' + +const USER_ID = 'user-1' +const TABLE_ID = '22222222-2222-4222-8222-222222222222' +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' + +const TABLE = { + id: TABLE_ID, + name: 'expenses', + workspaceId: WORKSPACE_ID, + rowCount: 0, + schema: { columns: [] }, +} + +/** A TTL column — the configuration that deletes rows on a schedule. */ +function addTtlColumn() { + return POST( + new NextRequest(`http://localhost/api/table/${TABLE_ID}/columns`, { + method: 'POST', + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + column: { name: 'expires_at', type: 'ttl' }, + }), + headers: { 'content-type': 'application/json' }, + }), + { params: Promise.resolve({ tableId: TABLE_ID }) } + ) +} + +function listViews() { + return GET( + new NextRequest(`http://localhost/api/table/${TABLE_ID}/views?workspaceId=${WORKSPACE_ID}`), + { params: Promise.resolve({ tableId: TABLE_ID }) } + ) +} + +describe('tables.use gate on the raw /api/table routes', () => { + beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: USER_ID, + authType: 'session', + }) + mockGetTableById.mockResolvedValue(TABLE) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockAddTableColumn.mockResolvedValue({ schema: { columns: [{ name: 'expires_at' }] } }) + mockListTableViews.mockResolvedValue([]) + }) + + describe('when the group withholds Tables', () => { + beforeEach(() => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTablesTab: true, + }) + }) + + it('refuses adding a TTL column, and never writes the schema', async () => { + const response = await addTtlColumn() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "The Tables module is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + expect(mockAddTableColumn).not.toHaveBeenCalled() + }) + + it('refuses the read path, and never reads the views', async () => { + const response = await listViews() + + expect(response.status).toBe(403) + expect((await response.json()).details).toEqual({ + code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }) + expect(mockListTableViews).not.toHaveBeenCalled() + }) + + it('still conceals a table the caller cannot reach, rather than naming the capability', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + + const response = await listViews() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Access denied' }) + }) + + it('still 404s a table that does not exist, rather than naming the capability', async () => { + mockGetTableById.mockResolvedValue(null) + + const response = await listViews() + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Table not found' }) + }) + }) + + describe('when no group withholds Tables', () => { + it('lets the TTL column through', async () => { + const response = await addTtlColumn() + + expect(response.status).toBe(200) + expect(mockAddTableColumn).toHaveBeenCalledTimes(1) + }) + + it('lets the read path through', async () => { + const response = await listViews() + + expect(response.status).toBe(200) + expect(mockListTableViews).toHaveBeenCalledTimes(1) + }) + + it('lets a governed group that withholds something else through', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideKnowledgeBaseTab: true, + }) + + const response = await addTtlColumn() + + expect(response.status).toBe(200) + expect(mockAddTableColumn).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index 04039178db7..ecb94687a8c 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -9,6 +9,7 @@ import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { createTable, @@ -22,7 +23,7 @@ import { import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { orchestrationErrorResponse } from '@/app/api/table/utils' +import { capabilityRefusalResponse, orchestrationErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableImportAsync') @@ -46,6 +47,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (permission !== 'write' && permission !== 'admin') { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + + // permission-group-enforced: tables.use — raw route that queries directly and predates the operation boundary + if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'tables.use')) { + return capabilityRefusalResponse('tables.use') + } // The fileKey is client-supplied — ensure it points at this workspace's storage prefix so a // caller can't import another workspace's uploaded object. if (!fileKey.startsWith(`workspace/${workspaceId}/`)) { diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index d6cf5fb6440..a4266de1c77 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -9,11 +9,13 @@ import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { CSV_SYNC_MAX_FILE_SIZE_BYTES } from '@/lib/table' import { performCreateTableFromCsv } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { + capabilityRefusalResponse, csvProxyBodyCapResponse, multipartErrorResponse, orchestrationOutcomeErrorResponse, @@ -71,6 +73,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + // permission-group-enforced: tables.use — raw route that queries directly and predates the operation boundary + if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'tables.use')) { + return capabilityRefusalResponse('tables.use') + } + let folderId: string | null = null if (fields.folderId) { const folderIdResult = csvImportFormSchema.shape.folderId.safeParse(fields.folderId) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index b9a2a855349..5add676d157 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -10,6 +10,11 @@ import { statusForOrchestrationError, } from '@/lib/core/orchestration/types' import type { MultipartError } from '@/lib/core/utils/multipart' +import type { StaticPermissionGroupCapability } from '@/lib/permission-groups/capabilities' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import type { ColumnDefinition, Filter, TableDefinition, TablePredicate } from '@/lib/table' import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table' import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' @@ -222,7 +227,15 @@ interface TableAccessDenied { export type TableAccessCheck = TableAccessResult | TableAccessDenied -export type AccessResult = { ok: true; table: TableDefinition } | { ok: false; status: 404 | 403 } +/** + * A denial carries `capability` when the caller's permission group withheld the + * Tables module, so {@link accessError} can say so rather than reporting the + * role failure it is not. Optional because the other two denials — the table + * does not exist, the role is too low — have no capability to name. + */ +export type AccessResult = + | { ok: true; table: TableDefinition } + | { ok: false; status: 404 | 403; capability?: StaticPermissionGroupCapability } interface ApiErrorResponse { error: string @@ -269,7 +282,23 @@ async function checkTableWriteAccess(tableId: string, userId: string): Promise { + return NextResponse.json( + { + error: capabilityRefusal(capability), + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }, + { status: 403 } + ) } export function accessError( - result: { ok: false; status: 404 | 403 }, + result: Extract, requestId: string, context?: string ): NextResponse { + if (result.capability) { + logger.warn( + `[${requestId}] ${capabilityRefusal(result.capability)}${context ? `: ${context}` : ''}` + ) + return capabilityRefusalResponse(result.capability) + } + const message = result.status === 404 ? 'Table not found' : 'Access denied' logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`) return NextResponse.json({ error: message }, { status: result.status }) From 99483a643a778da091625f442c0f48cfa50213e8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 11:48:46 -0700 Subject: [PATCH 034/179] test(permission-groups): make every operation fixture declare its capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capability` is a required field, but `apps/sim/tsconfig.json` excludes test files and `check-permission-group-enforcement.ts` walks past them, so 22 of the 26 `defineWorkspaceOperation` fixtures across these seven suites omitted it and nothing complained. Each now names the capability it is actually modelling — `files.use` and `integrations.manage` where the fixture stands for a real file or credential operation, `'none'` where the operation is scaffolding for an assertion about roles, principal kinds, audit, or resource policy. The guard in `defineWorkspaceOperation` is kept and tightened rather than removed as unreachable. It previously skipped an `undefined` capability instead of refusing it, so a capability-less operation defined cleanly and then threw `Cannot read properties of undefined` from `capabilityDeniedBy` — but only for a caller whose organization has a permission group, meaning it passed every personal workspace and every non-enterprise test and failed exactly in the tenants that bought the feature. It now refuses at definition time, its TSDoc says why it survives a required field, and two tests hold it to that. --- .../application/application-adapter.test.ts | 4 ++ .../authorized-workspace-use-case.test.ts | 4 ++ .../workspace-authorization.test.ts | 3 ++ .../application/workspace-operation.test.ts | 42 +++++++++++++++++++ .../core/application/workspace-operation.ts | 22 +++++++++- .../authorized-credential-use-case.test.ts | 2 + .../application/operations.test.ts | 1 + .../resolve-workspace-file-reference.test.ts | 1 + 8 files changed, 78 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/copilot/application/application-adapter.test.ts b/apps/sim/lib/copilot/application/application-adapter.test.ts index 0ef223344bf..add82de2876 100644 --- a/apps/sim/lib/copilot/application/application-adapter.test.ts +++ b/apps/sim/lib/copilot/application/application-adapter.test.ts @@ -18,6 +18,7 @@ const operation = defineWorkspaceOperation({ workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['copilot'], + capability: 'files.use', }) const delegation = { @@ -87,6 +88,7 @@ describe('Copilot application adapter', () => { workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['copilot'], + capability: 'files.use', }) const sameIdDifferentPolicy = defineWorkspaceOperation({ id: operation.id, @@ -94,6 +96,7 @@ describe('Copilot application adapter', () => { workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['copilot'], + capability: 'files.use', }) expect(() => @@ -121,6 +124,7 @@ describe('Copilot application adapter', () => { workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['executor'], + capability: 'files.use', }) const execute = vi.fn() const executeCopilotUseCase = createCopilotApplicationAdapter< diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts index bd9b0655d11..5ead0766352 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts @@ -41,6 +41,7 @@ const operation = defineWorkspaceOperation({ minimumRole: 'write', workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'none', }) const delegatedOperation = defineWorkspaceOperation({ @@ -49,6 +50,7 @@ const delegatedOperation = defineWorkspaceOperation({ workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['executor'], + capability: 'none', }) const workspaceKeyOperation = defineWorkspaceOperation({ @@ -56,6 +58,7 @@ const workspaceKeyOperation = defineWorkspaceOperation({ minimumRole: 'read', workspaceApiKey: 'allow', principalKinds: ['workspace_api_key'], + capability: 'none', }) const resourcePolicyOperation = defineWorkspaceOperation({ @@ -67,6 +70,7 @@ const resourcePolicyOperation = defineWorkspaceOperation({ resourceType: 'credential_group', action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, }, + capability: 'none', }) interface TestInput { diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index b304b774742..566f7abdf2f 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -44,6 +44,7 @@ const writeOperation = defineWorkspaceOperation({ minimumRole: 'write', workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'none', }) const principal: SessionPrincipal = { @@ -57,6 +58,7 @@ const workspaceKeyOperation = defineWorkspaceOperation({ minimumRole: 'write', workspaceApiKey: 'allow', principalKinds: ['workspace_api_key'], + capability: 'none', }) const workspaceKeyPrincipal: WorkspaceApiKeyPrincipal = { @@ -71,6 +73,7 @@ const executorOperation = defineWorkspaceOperation({ workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['executor'], + capability: 'none', }) function executorPrincipal( diff --git a/apps/sim/lib/core/application/workspace-operation.test.ts b/apps/sim/lib/core/application/workspace-operation.test.ts index 2e99e65eec1..9bd6ce5e996 100644 --- a/apps/sim/lib/core/application/workspace-operation.test.ts +++ b/apps/sim/lib/core/application/workspace-operation.test.ts @@ -13,6 +13,7 @@ describe('defineWorkspaceOperation delegated service policy', () => { workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['copilot', 'executor'], + capability: 'none', }) expect(operation.delegatedServices).toEqual(['copilot', 'executor']) @@ -26,6 +27,7 @@ describe('defineWorkspaceOperation delegated service policy', () => { minimumRole: 'read', workspaceApiKey: 'deny', principalKinds: ['delegated'], + capability: 'none', } as never) ).toThrow('Operation test.missing_service_policy has inconsistent delegated service policy') }) @@ -38,6 +40,7 @@ describe('defineWorkspaceOperation delegated service policy', () => { workspaceApiKey: 'deny', principalKinds: ['session'], delegatedServices: ['copilot'], + capability: 'none', } as never) ).toThrow('Operation test.unused_service_policy has inconsistent delegated service policy') }) @@ -50,6 +53,7 @@ describe('defineWorkspaceOperation delegated service policy', () => { workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['copilot', 'copilot'], + capability: 'none', } as never) ).toThrow('Operation test.duplicate_service_policy declares duplicate delegated services') }) @@ -65,6 +69,7 @@ describe('defineWorkspaceOperation delegated service policy', () => { resourceType: 'credential_group', action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, }, + capability: 'none', }) expect(operation.resourcePolicy).toEqual({ @@ -86,7 +91,44 @@ describe('defineWorkspaceOperation delegated service policy', () => { resourceType: 'credential_group', action: 'credentials.invalid', }, + capability: 'none', } as never) ).toThrow('Action credentials.invalid does not apply to resource policy type credential_group') }) }) + +/** + * The one construction site the static checks cannot see. + * + * `apps/sim/tsconfig.json` excludes test files, and + * `check-permission-group-enforcement.ts` walks past them, so a fixture can + * omit `capability` and nothing complains — which is how every fixture in this + * file used to be written. These assert the runtime guard that stands in for + * the type here, so deleting it as unreachable turns this suite red. + */ +describe('defineWorkspaceOperation capability policy', () => { + it('refuses an operation that declares no capability', () => { + expect(() => + defineWorkspaceOperation({ + id: 'test.no_capability', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + } as never) + ).toThrow( + "Operation test.no_capability declares no capability; name one, or 'none' with a reason" + ) + }) + + it('refuses a capability the registry does not define', () => { + expect(() => + defineWorkspaceOperation({ + id: 'test.unknown_capability', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + capability: 'tables.definitely_not_a_capability', + } as never) + ).toThrow('Operation test.unknown_capability names unknown capability') + }) +}) diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts index 30e14a6aec9..cebc7472512 100644 --- a/apps/sim/lib/core/application/workspace-operation.ts +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -122,7 +122,27 @@ export function defineWorkspaceOperation< if (operation.resourcePolicy) requireResourcePolicyBinding(operation.resourcePolicy) - if (operation.capability !== undefined && operation.capability !== 'none') { + /** + * `capability` is required, so refusing an absent one reads as unreachable. + * It is not. `apps/sim/tsconfig.json` excludes test files from type-checking, + * and `check-permission-group-enforcement.ts` walks past them too, so a test + * fixture is the one construction site no static check reads — and fixtures + * are where an operation is written from memory rather than from the + * surrounding domain. + * + * Left to reach authorization, an absent capability does not deny; it throws + * `Cannot read properties of undefined` from inside `capabilityDeniedBy`, and + * only for a caller whose organization has a permission group. It would pass + * every personal workspace and every non-enterprise test, then fail in the + * tenants that bought the feature. Named here instead, at definition time. + */ + if (operation.capability === undefined) { + throw new Error( + `Operation ${operation.id} declares no capability; name one, or 'none' with a reason` + ) + } + + if (operation.capability !== 'none') { const rule = CAPABILITY_RULES[operation.capability] if (!rule) { throw new Error(`Operation ${operation.id} names unknown capability ${operation.capability}`) diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts index 65a3b7a9133..ea01ec344ed 100644 --- a/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts @@ -31,6 +31,7 @@ const memberOperation = defineCredentialOperation( minimumRole: 'read', workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'integrations.manage', }), 'member' ) @@ -40,6 +41,7 @@ const adminOperation = defineCredentialOperation( minimumRole: 'read', workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'integrations.manage', }), 'admin' ) diff --git a/apps/sim/lib/credentials/application/operations.test.ts b/apps/sim/lib/credentials/application/operations.test.ts index 77737952366..975e3b14668 100644 --- a/apps/sim/lib/credentials/application/operations.test.ts +++ b/apps/sim/lib/credentials/application/operations.test.ts @@ -44,6 +44,7 @@ describe('credential operations', () => { minimumRole: 'read', workspaceApiKey: 'allow', principalKinds: ['workspace_api_key'], + capability: 'integrations.manage', }) expect(() => defineCredentialOperation(workspaceKeyOperation, 'admin')).toThrow( diff --git a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts index cd69a123118..2601f6f55d3 100644 --- a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts @@ -90,6 +90,7 @@ describe('workspace file reference application service', () => { minimumRole: 'write', workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'files.use', }) await expect( From a53e5b3d4cbb26c2606d03c46aa77e9032a5f09b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 11:50:26 -0700 Subject: [PATCH 035/179] refactor(permission-groups): share the memo, the refusal sentence, and the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route validatePublicFileSharing, validateChatDeployAuth, validatePublicApiAllowed and validateInvitationsAllowed through resolvePermissionGroupConfig so a request that authorizes an operation and then asserts one of these resolves the group once. Outside a scope the resolver degrades to a direct call, so the executor and job paths are unaffected. Raise the knowledge-connector allowlist refusal through refuseCapability instead of a hand-written copy of the shared sentence. PermissionGroupCapabilityError is a ForbiddenOperationError carrying the same detail code, so the status and error contract are unchanged. Make resolveUserAccessControlContext module-private — it had no consumer outside its own file, every caller elsewhere already holds the organization id — and move its context-shape coverage onto the verified variant that callers actually use, keeping the workspace-lookup behavior covered through getUserPermissionConfig. Export the permission-group request type aliases from the contract and consume them in the hook, replacing four hand-written wire types. --- .../access-control/hooks/permission-groups.ts | 55 +++++++------- .../utils/permission-check.test.ts | 72 +++++++++++++++---- .../access-control/utils/permission-check.ts | 20 ++++-- .../lib/api/contracts/permission-groups.ts | 10 +++ .../lib/knowledge/application/connectors.ts | 13 ++-- 5 files changed, 116 insertions(+), 54 deletions(-) diff --git a/apps/sim/ee/access-control/hooks/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts index 91e8f2b61e9..beeb49746e3 100644 --- a/apps/sim/ee/access-control/hooks/permission-groups.ts +++ b/apps/sim/ee/access-control/hooks/permission-groups.ts @@ -3,7 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { + type BulkAddPermissionGroupMembersBody, bulkAddPermissionGroupMembersContract, + type CreatePermissionGroupBody, createPermissionGroupContract, deletePermissionGroupContract, getUserPermissionConfigContract, @@ -13,11 +15,12 @@ import { type PermissionGroup, type PermissionGroupMember, type PermissionGroupWorkspaceRef, + type RemovePermissionGroupMemberQuery, removePermissionGroupMemberContract, + type UpdatePermissionGroupBody, type UserPermissionConfig, updatePermissionGroupContract, } from '@/lib/api/contracts' -import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' export const PERMISSION_GROUP_MEMBERS_STALE_TIME = 30 * 1000 export const PERMISSION_GROUPS_STALE_TIME = 60 * 1000 @@ -108,20 +111,14 @@ export function useUserPermissionConfig(workspaceId?: string) { }) } -interface CreatePermissionGroupData { - organizationId: string - name: string - description?: string - config?: Partial - isDefault?: boolean - workspaceIds?: string[] -} +/** The create body, plus the organization the route params name. */ +type CreatePermissionGroupVariables = CreatePermissionGroupBody & { organizationId: string } export function useCreatePermissionGroup() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ organizationId, ...data }: CreatePermissionGroupData) => { + mutationFn: async ({ organizationId, ...data }: CreatePermissionGroupVariables) => { return requestJson(createPermissionGroupContract, { params: { id: organizationId }, body: data, @@ -135,21 +132,17 @@ export function useCreatePermissionGroup() { }) } -interface UpdatePermissionGroupData { +/** The update body, plus the group and organization the route params name. */ +type UpdatePermissionGroupVariables = UpdatePermissionGroupBody & { id: string organizationId: string - name?: string - description?: string | null - config?: Partial - isDefault?: boolean - workspaceIds?: string[] } export function useUpdatePermissionGroup() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ id, organizationId, ...data }: UpdatePermissionGroupData) => { + mutationFn: async ({ id, organizationId, ...data }: UpdatePermissionGroupVariables) => { return requestJson(updatePermissionGroupContract, { params: { id: organizationId, groupId: id }, body: data, @@ -164,7 +157,8 @@ export function useUpdatePermissionGroup() { }) } -interface DeletePermissionGroupParams { +/** Route params only — the delete carries no wire payload of its own. */ +interface DeletePermissionGroupVariables { permissionGroupId: string organizationId: string } @@ -173,7 +167,7 @@ export function useDeletePermissionGroup() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ permissionGroupId, organizationId }: DeletePermissionGroupParams) => { + mutationFn: async ({ permissionGroupId, organizationId }: DeletePermissionGroupVariables) => { return requestJson(deletePermissionGroupContract, { params: { id: organizationId, groupId: permissionGroupId }, }) @@ -184,15 +178,17 @@ export function useDeletePermissionGroup() { }) } +/** The remove query (`memberId`), plus the group and organization it targets. */ +type RemovePermissionGroupMemberVariables = RemovePermissionGroupMemberQuery & { + organizationId: string + permissionGroupId: string +} + export function useRemovePermissionGroupMember() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (data: { - organizationId: string - permissionGroupId: string - memberId: string - }) => { + mutationFn: async (data: RemovePermissionGroupMemberVariables) => { return requestJson(removePermissionGroupMemberContract, { params: { id: data.organizationId, groupId: data.permissionGroupId }, query: { memberId: data.memberId }, @@ -204,18 +200,21 @@ export function useRemovePermissionGroupMember() { }) } -interface BulkAddMembersData { +/** The bulk-add body, plus the group and organization the route params name. */ +type BulkAddPermissionGroupMembersVariables = BulkAddPermissionGroupMembersBody & { organizationId: string permissionGroupId: string - userIds?: string[] - addAllOrganizationMembers?: boolean } export function useBulkAddPermissionGroupMembers() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ organizationId, permissionGroupId, ...data }: BulkAddMembersData) => { + mutationFn: async ({ + organizationId, + permissionGroupId, + ...data + }: BulkAddPermissionGroupMembersVariables) => { return requestJson(bulkAddPermissionGroupMembersContract, { params: { id: organizationId, groupId: permissionGroupId }, body: data, 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 75be191fb27..8109eeb822b 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -37,6 +37,7 @@ vi.mock('@/providers/utils', () => ({ })) import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' +import { withPermissionGroupScope } from '@/lib/permission-groups/config-scope.server' import { assertPermissionsAllowed, CustomToolsNotAllowedError, @@ -45,7 +46,6 @@ import { McpToolsNotAllowedError, ModelNotAllowedError, ProviderNotAllowedError, - resolveUserAccessControlContext, resolveVerifiedUserAccessControlContext, SkillsNotAllowedError, ToolNotAllowedError, @@ -191,27 +191,37 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { }) }) -describe('resolveUserAccessControlContext', () => { +describe('access control context resolution', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) }) - it('describes a personal workspace without changing the config-only result', async () => { + it('loads the workspace to find its organization, archived workspaces included', 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() + expect(mockGetWorkspaceWithOwner).toHaveBeenCalledWith('workspace-1', { + includeArchived: true, + }) + expect(mockIsOrganizationOnEnterprisePlan).not.toHaveBeenCalled() }) - it('returns the explicit governing group and its effective config', async () => { + it('resolves the group through the organization the workspace lookup returned', async () => { setEnterpriseOrgWorkspace() + queueGroupResolution([ + { id: 'g', config: { disableMcpTools: true }, isMember: true, hasMembers: true }, + ]) + + await expect(getUserPermissionConfig('user-123', 'workspace-1')).resolves.toMatchObject({ + disableMcpTools: true, + }) + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-1') + }) + + it('returns the explicit governing group and its effective config', async () => { + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) queueGroupResolution([ { id: 'group-explicit', @@ -222,7 +232,9 @@ describe('resolveUserAccessControlContext', () => { }, ]) - await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({ + await expect( + resolveVerifiedUserAccessControlContext('user-123', 'workspace-1', 'org-1') + ).resolves.toEqual({ organizationId: 'org-1', entitled: true, permissionGroup: { @@ -234,8 +246,19 @@ describe('resolveUserAccessControlContext', () => { }) }) + it('describes a personal workspace as unentitled and ungoverned', async () => { + await expect( + resolveVerifiedUserAccessControlContext('user-123', 'workspace-1', null) + ).resolves.toEqual({ + organizationId: null, + entitled: false, + permissionGroup: null, + config: null, + }) + }) + it('identifies an all-members governing group', async () => { - setEnterpriseOrgWorkspace() + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) queueGroupResolution([ { id: 'group-all-members', @@ -246,7 +269,11 @@ describe('resolveUserAccessControlContext', () => { }, ]) - const context = await resolveUserAccessControlContext('user-123', 'workspace-1') + const context = await resolveVerifiedUserAccessControlContext( + 'user-123', + 'workspace-1', + 'org-1' + ) expect(context.permissionGroup).toEqual({ id: 'group-all-members', @@ -287,7 +314,7 @@ describe('resolveUserAccessControlContext', () => { }) it('identifies the default group and preserves the environment allowlist', async () => { - setEnterpriseOrgWorkspace() + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack']) queueGroupResolution( [], @@ -300,7 +327,11 @@ describe('resolveUserAccessControlContext', () => { ] ) - const context = await resolveUserAccessControlContext('user-123', 'workspace-1') + const context = await resolveVerifiedUserAccessControlContext( + 'user-123', + 'workspace-1', + 'org-1' + ) expect(context.permissionGroup).toEqual({ id: 'group-default', @@ -625,6 +656,17 @@ describe('validatePublicFileSharing', () => { queueGroupResolution([{ config: { allowedFileShareAuthTypes: ['password'] } }]) await validatePublicFileSharing('user-123', 'workspace-1') }) + + it('resolves the group once per request scope, not once per assertion', async () => { + queueGroupResolution([{ config: { allowedFileShareAuthTypes: null } }]) + + await withPermissionGroupScope(async () => { + await validatePublicFileSharing('user-123', 'workspace-1', 'password') + await validatePublicFileSharing('user-123', 'workspace-1', 'email') + }) + + expect(mockGetWorkspaceWithOwner).toHaveBeenCalledTimes(1) + }) }) describe('validateChatDeployAuth', () => { diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 6c398c57104..57baab4a510 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -20,6 +20,7 @@ import { refuseCapability, type StaticCapabilityRule, } from '@/lib/permission-groups/capabilities' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, @@ -302,7 +303,16 @@ export async function resolveVerifiedUserAccessControlContext( return resolveUserAccessControlContextForOrganization(userId, workspaceId, organizationId) } -export async function resolveUserAccessControlContext( +/** + * The unverified counterpart of {@link resolveVerifiedUserAccessControlContext}: + * it loads the workspace itself to learn the owning organization. + * + * Module-private on purpose — every caller outside this file has already + * access-checked the workspace and so holds the organization id, and exporting a + * resolver that looks it up again invites a second query on a path that does not + * need one. {@link getUserPermissionConfig} is the one caller. + */ +async function resolveUserAccessControlContext( userId: string, workspaceId: string ): Promise { @@ -342,7 +352,7 @@ export async function validatePublicFileSharing( workspaceId: string, authType?: ShareAuthType ): Promise { - const config = await getUserPermissionConfig(userId, workspaceId) + const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) if (!config) { return } @@ -374,7 +384,7 @@ export async function validateChatDeployAuth( workspaceId: string, authType: ShareAuthType ): Promise { - const config = await getUserPermissionConfig(userId, workspaceId) + const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) if (!config) { return } @@ -592,7 +602,7 @@ export async function validateInvitationsAllowed( typeof scope === 'string' ? { workspaceId: scope, organizationId: undefined } : scope if (workspaceId) { - const config = await getUserPermissionConfig(userId, workspaceId) + const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) if (config && INVITATIONS_RULE.deniedBy(config)) { logger.warn('Invitations blocked by permission group', { userId, workspaceId }) throw new InvitationsNotAllowedError() @@ -631,7 +641,7 @@ export async function validatePublicApiAllowed( return } - const config = await getUserPermissionConfig(userId, workspaceId) + const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) if (!config) { return diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index f77704d8040..6d80ee68d18 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -24,12 +24,14 @@ export const addPermissionGroupMemberBodySchema = z.object({ export const permissionGroupParamsSchema = z.object({ id: organizationIdSchema, }) +export type PermissionGroupParams = z.input /** Route params for a single permission group (`id` = organizationId, `groupId` = permission group id). */ export const permissionGroupDetailParamsSchema = z.object({ id: organizationIdSchema, groupId: z.string().min(1), }) +export type PermissionGroupDetailParams = z.input /** A workspace a permission group targets (id + display name). */ export const permissionGroupWorkspaceRefSchema = z.object({ @@ -139,6 +141,7 @@ export const createPermissionGroupBodySchema = z workspaceIds: workspaceIdsSchema.optional(), }) .superRefine(refineWorkspaceScope) +export type CreatePermissionGroupBody = z.input export const updatePermissionGroupBodySchema = z .object({ @@ -149,15 +152,22 @@ export const updatePermissionGroupBodySchema = z workspaceIds: workspaceIdsSchema.optional(), }) .superRefine(refineWorkspaceScope) +export type UpdatePermissionGroupBody = z.input export const removePermissionGroupMemberQuerySchema = z.object({ memberId: z.string().min(1), }) +export type RemovePermissionGroupMemberQuery = z.input< + typeof removePermissionGroupMemberQuerySchema +> export const bulkAddPermissionGroupMembersBodySchema = z.object({ userIds: z.array(z.string()).optional(), addAllOrganizationMembers: z.boolean().optional(), }) +export type BulkAddPermissionGroupMembersBody = z.input< + typeof bulkAddPermissionGroupMembersBodySchema +> const successResponseSchema = z.object({ success: z.literal(true), diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index c96bf96ecbb..0dbe5b4ad98 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -5,7 +5,6 @@ import { document, knowledgeConnector, knowledgeConnectorSyncLog } from '@sim/db import { and, asc, count, desc, eq, inArray, isNull } from 'drizzle-orm' import { decryptApiKey } from '@/lib/api-key/crypto' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' -import { ForbiddenOperationError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { @@ -43,7 +42,7 @@ import type { KnowledgeOrchestrationResult, } from '@/lib/knowledge/orchestration/shared' import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' +import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' interface KnowledgeConnectorApplicationInput { @@ -125,6 +124,11 @@ const CONNECTOR_ALLOWLIST_RULE = CAPABILITY_RULES['knowledge.connectors'] * passes through, exactly as the authorization funnel treats one. Requiring a * subject here would turn every scheduled connector sync into a 500 rather than * a refusal anyone could act on. + * + * Refused through {@link refuseCapability} so the sentence reads exactly like + * every other capability refusal. The error it throws is a + * `ForbiddenOperationError` carrying this rule's own detail code, so the status + * and error contract are the ones this already raised. */ async function assertConnectorTypeAllowed( userId: string | undefined, @@ -135,10 +139,7 @@ async function assertConnectorTypeAllowed( const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) if (!config || !CONNECTOR_ALLOWLIST_RULE.deniedBy(config, connectorType)) return - throw new ForbiddenOperationError( - CONNECTOR_ALLOWLIST_RULE.detailCode, - `The ${connectorType} connector is not available under your organization's permission group` - ) + refuseCapability('knowledge.connectors') } function requireSuccessfulOutcome( From 6eab5a45bdabba11aa30ac225c5f4783f4a3f6eb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 11:51:50 -0700 Subject: [PATCH 036/179] test(knowledge): assert the shared connector refusal sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refusal now comes from refuseCapability, so it reads like every other capability refusal and no longer names the connector — matching its two sibling parameterized capabilities, which already say 'This chat authentication mode' and 'This file-share authentication mode'. --- apps/sim/lib/knowledge/application/connectors.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index c931c5c9513..6e474fdf5e2 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -89,6 +89,7 @@ import { updateKnowledgeConnector, updateKnowledgeConnectorDocuments, } from '@/lib/knowledge/application/connectors' +import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' const crossWorkspaceContext = { @@ -705,7 +706,7 @@ describe('knowledge connector application use cases', () => { createKnowledgeConnector.execute({ principal: delegatedPrincipal, input: createInput }) ).rejects.toMatchObject({ code: 'forbidden', - message: expect.stringContaining('confluence'), + message: capabilityRefusal('knowledge.connectors'), }) expect(mocks.createConnector).not.toHaveBeenCalled() From 4254b17a2660bc1de86a4194191802c214a3da7d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 12:02:51 -0700 Subject: [PATCH 037/179] fix(permission-groups): enforce declared capabilities on the v1 public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `app/api/v1/middleware.ts` authorizes in its own middleware rather than through `authorizeWorkspaceOperation`, so none of the capabilities the funnel applies reached it. It asserted exactly one — `personal_api_key.use` — and nothing else. A member of a group that withholds Tables was correctly refused on `/api/v2/tables/**` and on every internal `/api/table/**` route, and could still list, create and write tables through `/api/v1/tables/**` with a personal API key; the same held for Knowledge Base and Files, and for deploying a workflow through `/api/v1/workflows/[id]/deploy`. That left `hideTablesTab` and its siblings bypassable by the very credential the branch exists to gate. The declaration is threaded through the middleware rather than hand-asserted per handler: `validateWorkspaceAccess` takes a required `V1RouteCapability`, and the two shared resolvers routes compose it through — `resolveKnowledgeBase` and `resolveV1DeploymentWorkflow` — take one too. Required, and `'none'` spelled out rather than omitted, for the same reason `capability` is required on `defineWorkspaceOperation`: an absent declaration cannot be told apart from an unreviewed one. Every value is the capability the route's v2 or internal counterpart already declares; v1 gets no mapping of its own. Three invariants are preserved. The check runs strictly after the key-scope and workspace-role checks, matching `authorizeWorkspaceOperation`, so a capability refusal never reaches a non-member who would learn from it that the workspace exists and which modules the organization withholds. A workspace API key passes through ungated — it has no user and so no group, and its `rateLimit.userId` is the key's *creator*, a bystander whose group must not govern every caller of a shared credential. And no execution route is gated: `/api/v1` contains none, and the deployment routes gate `deploy.api` exactly as `workflows.deploy` and `workflows.versions.activate` already do, which changes who may deploy without touching a workflow already running. --- apps/sim/app/api/v1/audit-logs/[id]/route.ts | 6 + apps/sim/app/api/v1/audit-logs/route.ts | 8 + apps/sim/app/api/v1/capability-gate.test.ts | 289 ++++++++++++++++++ apps/sim/app/api/v1/copilot/chat/route.ts | 5 + apps/sim/app/api/v1/files/[fileId]/route.ts | 18 +- apps/sim/app/api/v1/files/route.ts | 10 +- .../[id]/documents/[documentId]/route.ts | 4 +- .../api/v1/knowledge/[id]/documents/route.ts | 9 +- apps/sim/app/api/v1/knowledge/[id]/route.ts | 18 +- apps/sim/app/api/v1/knowledge/route.ts | 15 +- apps/sim/app/api/v1/knowledge/search/route.ts | 7 +- apps/sim/app/api/v1/knowledge/utils.ts | 24 +- apps/sim/app/api/v1/logs/[id]/route.ts | 2 +- .../v1/logs/executions/[executionId]/route.ts | 7 +- apps/sim/app/api/v1/logs/route.ts | 8 +- apps/sim/app/api/v1/middleware.ts | 104 ++++++- apps/sim/app/api/v1/tables/route.ts | 3 +- .../v1/workflows/[id]/deploy/route.test.ts | 1 + .../app/api/v1/workflows/[id]/deploy/route.ts | 4 +- .../app/api/v1/workflows/[id]/export/route.ts | 7 +- .../v1/workflows/[id]/rollback/route.test.ts | 1 + .../api/v1/workflows/[id]/rollback/route.ts | 2 +- apps/sim/app/api/v1/workflows/[id]/route.ts | 3 +- .../app/api/v1/workflows/import/route.test.ts | 1 + apps/sim/app/api/v1/workflows/import/route.ts | 8 +- apps/sim/app/api/v1/workflows/route.ts | 2 +- apps/sim/app/api/v1/workflows/utils.ts | 28 +- 27 files changed, 555 insertions(+), 39 deletions(-) create mode 100644 apps/sim/app/api/v1/capability-gate.test.ts diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts index 965d619cee8..bea7a9b0272 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -31,6 +31,12 @@ const logger = createLogger('V1AuditLogDetailAPI') export const revalidate = 0 +/** + * GET /api/v1/audit-logs/[id] — Read one audit log entry. + * + * permission-group-exempt: none — same as the list. `audit_logs.read_detail` is + * an organization-admin operation and declares no capability. + */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateId().slice(0, 8) diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index 36c12d1019d..36dfb466b08 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -46,6 +46,14 @@ const logger = createLogger('V1AuditLogsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 +/** + * GET /api/v1/audit-logs — List an organization's audit log. + * + * permission-group-exempt: none — the counterpart `audit_logs.list` is an + * organization-admin operation with no `capability` field at all, because the + * surface is already restricted to organization admins and owners, who sit + * above every permission group. There is nothing here for a group to withhold. + */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) diff --git a/apps/sim/app/api/v1/capability-gate.test.ts b/apps/sim/app/api/v1/capability-gate.test.ts new file mode 100644 index 00000000000..abbaa3457c3 --- /dev/null +++ b/apps/sim/app/api/v1/capability-gate.test.ts @@ -0,0 +1,289 @@ +/** + * @vitest-environment node + * + * The v1 public API authorizes in `app/api/v1/middleware.ts` rather than + * through `authorizeWorkspaceOperation`, so none of the capabilities the funnel + * applies to the v2 and internal surfaces reached it. A member of a group that + * withholds Tables was refused on `/api/v2/tables/**` and on every internal + * `/api/table/**` route, and could still do the same work through + * `/api/v1/tables/**` with a personal API key. + * + * These run the real middleware against the real routes — only the credential, + * the rate bucket, the workspace role and the governing group config are + * mocked — so they fail if the capability a route declares is dropped, is + * checked before the role check, or starts applying to a workspace API key. + */ +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticateV1Request, + mockGetUserEntityPermissions, + mockGetWorkspaceBillingSettings, + mockListTables, + mockListKnowledgeBases, + mockListWorkspaceFiles, + mockListPublicWorkflowLogs, + mockGetDeploymentWorkflowTarget, + mockPerformFullDeploy, +} = vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceBillingSettings: vi.fn(), + mockListTables: vi.fn(), + mockListKnowledgeBases: vi.fn(), + mockListWorkspaceFiles: vi.fn(), + mockListPublicWorkflowLogs: vi.fn(), + mockGetDeploymentWorkflowTarget: vi.fn(), + mockPerformFullDeploy: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@/app/api/v1/auth', () => ({ authenticateV1Request: mockAuthenticateV1Request })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, + getWorkspaceBilledAccountUserId: vi.fn(async () => 'billed-user'), +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: vi.fn(async () => null), +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitWithSubscription() { + return Promise.resolve({ allowed: true, remaining: 100, resetAt: new Date() }) + } + }, + getRateLimit: () => ({ maxTokens: 200 }), +})) +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + buildRateLimitHeaders: () => ({}), + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: () => null, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: {}, + AuditResourceType: {}, + recordAudit: vi.fn(), +})) +vi.mock('@/lib/table', () => ({ + listTables: mockListTables, + createTable: vi.fn(), + getWorkspaceTableLimits: vi.fn(async () => ({ maxTables: 10 })), + TableConflictError: class TableConflictError extends Error {}, +})) +vi.mock('@/lib/table/wire', () => ({ normalizeColumn: (column: unknown) => column })) +vi.mock('@/lib/knowledge/service', () => ({ + listWorkspaceAndLegacyKnowledgeBases: mockListKnowledgeBases, + getKnowledgeBaseById: vi.fn(), +})) +vi.mock('@/lib/knowledge/orchestration', () => ({ performCreateKnowledgeBase: vi.fn() })) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + listWorkspaceFiles: mockListWorkspaceFiles, + getWorkspaceFile: vi.fn(), + uploadWorkspaceFile: vi.fn(), + FileConflictError: class FileConflictError extends Error {}, +})) +vi.mock('@/lib/logs/public-queries', () => ({ + listPublicWorkflowLogs: mockListPublicWorkflowLogs, + decodePublicLogCursor: vi.fn(), +})) +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionDataForDisplay: vi.fn(), +})) +vi.mock('@/app/api/v1/logs/meta', () => ({ + getUserLimits: vi.fn(async () => ({})), + createApiResponse: (body: unknown) => ({ body, headers: {} }), +})) +vi.mock('@/lib/workflows/deployments/queries', () => ({ + getDeploymentWorkflowTarget: mockGetDeploymentWorkflowTarget, +})) +vi.mock('@/lib/workflows/orchestration', () => ({ + performFullDeploy: mockPerformFullDeploy, + performFullUndeploy: vi.fn(), +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GET as getFiles } from '@/app/api/v1/files/route' +import { GET as getKnowledge } from '@/app/api/v1/knowledge/route' +import { GET as getLogs } from '@/app/api/v1/logs/route' +import { GET as getTables } from '@/app/api/v1/tables/route' +import { POST as deployWorkflow } from '@/app/api/v1/workflows/[id]/deploy/route' + +const USER_ID = 'user-1' +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' +const WORKFLOW_ID = 'wf-1' + +function personalKey() { + return { + authenticated: true, + userId: USER_ID, + keyType: 'personal' as const, + principal: { kind: 'personal_api_key' as const, userId: USER_ID, keyId: 'key-1' }, + } +} + +/** + * A workspace key still reports a `userId` — the key's creator — so a gate keyed + * on the presence of a user rather than on `keyType` would silently apply a + * bystander's group to every caller of a shared credential. + */ +function workspaceKey() { + return { + authenticated: true, + userId: 'key-creator', + workspaceId: WORKSPACE_ID, + keyType: 'workspace' as const, + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-2', + }, + } +} + +function governedBy(overrides: Partial) { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + ...overrides, + }) +} + +function get(path: string) { + return new NextRequest(`http://localhost${path}`, { + method: 'GET', + headers: { 'x-api-key': 'sim_test' }, + }) +} + +function deployRequest() { + return [ + new NextRequest(`http://localhost/api/v1/workflows/${WORKFLOW_ID}/deploy`, { + method: 'POST', + headers: { 'x-api-key': 'sim_test', 'content-type': 'application/json' }, + body: JSON.stringify({}), + }), + { params: Promise.resolve({ id: WORKFLOW_ID }) }, + ] as const +} + +const REFUSAL = /is not available under your organization's permission group/ + +beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mockAuthenticateV1Request.mockResolvedValue(personalKey()) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true }) + mockListTables.mockResolvedValue([]) + mockListKnowledgeBases.mockResolvedValue([]) + mockListWorkspaceFiles.mockResolvedValue([]) + mockListPublicWorkflowLogs.mockResolvedValue({ data: [], nextCursor: null }) + mockGetDeploymentWorkflowTarget.mockResolvedValue({ + workflow: { id: WORKFLOW_ID, name: 'wf', isDeployed: false }, + workspaceId: WORKSPACE_ID, + }) +}) + +describe('v1 permission-group capability gate', () => { + describe('refuses a personal key whose group withholds the module', () => { + it('tables — GET /api/v1/tables declares tables.use', async () => { + governedBy({ hideTablesTab: true }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toMatch(REFUSAL) + expect(body.details).toEqual({ code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }) + expect(mockListTables).not.toHaveBeenCalled() + }) + + it('knowledge — GET /api/v1/knowledge declares knowledge.use', async () => { + governedBy({ hideKnowledgeBaseTab: true }) + + const response = await getKnowledge(get(`/api/v1/knowledge?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toMatch(REFUSAL) + expect(mockListKnowledgeBases).not.toHaveBeenCalled() + }) + + it('files — GET /api/v1/files declares files.use', async () => { + governedBy({ hideFilesTab: true }) + + const response = await getFiles(get(`/api/v1/files?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toMatch(REFUSAL) + expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('workflows — POST /api/v1/workflows/[id]/deploy declares deploy.api', async () => { + governedBy({ hideDeployApi: true }) + + const response = await deployWorkflow(...deployRequest()) + + /** The deployment routes mask every access failure as 404, so the status + * is the 404 they already return; what the gate changes is that the + * deploy never happens. */ + expect(response.status).toBe(404) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + }) + + describe('exceptions that must keep working', () => { + it('a workspace API key passes through ungated — it has no user, so no group', async () => { + mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) + governedBy({ hideTablesTab: true }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(mockListTables).toHaveBeenCalledWith(WORKSPACE_ID) + }) + + it('a route declaring none is unaffected by a group that withholds everything', async () => { + governedBy({ + hideTablesTab: true, + hideKnowledgeBaseTab: true, + hideFilesTab: true, + hideDeployApi: true, + }) + + const response = await getLogs(get(`/api/v1/logs?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(mockListPublicWorkflowLogs).toHaveBeenCalled() + }) + + it('an ungoverned workspace resolves no config and is never refused', async () => { + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + }) + }) + + it('refuses on role before capability, so a non-member learns nothing about the group', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + governedBy({ hideTablesTab: true }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toBe('Access denied') + expect(body.details).toBeUndefined() + }) +}) diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts index ac3759d96fc..970cbab2a81 100644 --- a/apps/sim/app/api/v1/copilot/chat/route.ts +++ b/apps/sim/app/api/v1/copilot/chat/route.ts @@ -6,6 +6,11 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' * * Deprecated: the v1 headless copilot chat API has been removed. The endpoint * returns 410 Gone for all callers. + * + * permission-group-exempt: none — the route authenticates nobody and reaches no + * resource, so there is no workspace in which to resolve a group. The + * counterpart `chat.send` declares `copilot.use`; when this surface returns + * anything but 410 again, it declares that capability. */ export const POST = withRouteHandler(async () => NextResponse.json( diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index eb767ac5f3c..2367b6ee467 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -26,7 +26,15 @@ interface FileRouteParams { params: Promise<{ fileId: string }> } -/** GET /api/v1/files/[fileId] — Download file content. */ +/** + * GET /api/v1/files/[fileId] — Download file content. + * + * permission-group-exempt: none is declared here because this handler already + * runs through the application funnel — `downloadWorkspaceFileStream` is the + * `files.download` operation, which declares `files.use` and has it applied by + * `authorizeWorkspaceOperation` for the personal-API-key principal. Adding a + * middleware gate would check the same capability twice. + */ export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { const requestId = generateRequestId() @@ -106,7 +114,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil const { fileId } = parsed.data.params const { workspaceId } = parsed.data.query - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + 'files.use', + 'write' + ) if (accessError) return accessError const fileRecord = await getWorkspaceFile(workspaceId, fileId) diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index 36adcb392e4..ccd823fa1b2 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -58,7 +58,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { workspaceId } = parsed.data.query - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'files.use') if (accessError) return accessError const files = await listWorkspaceFiles(workspaceId) @@ -146,7 +146,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + 'files.use', + 'write' + ) if (accessError) return accessError const buffer = await readFileToBufferWithLimit(file, { diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts index 33ac4611ffe..b7a363fc11c 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts @@ -41,7 +41,8 @@ export const GET = withRouteHandler( knowledgeBaseId, parsed.data.query.workspaceId, userId, - rateLimit + rateLimit, + 'knowledge.use' ) if (result instanceof NextResponse) return result @@ -133,6 +134,7 @@ export const DELETE = withRouteHandler( parsed.data.query.workspaceId, userId, rateLimit, + 'knowledge.use', 'write' ) if (result instanceof NextResponse) return result diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts index 2a5ddb92f2c..e04b3370cbc 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -54,7 +54,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume parsed.data.query const { id: knowledgeBaseId } = parsed.data.params - const result = await resolveKnowledgeBase(knowledgeBaseId, workspaceId, userId, rateLimit) + const result = await resolveKnowledgeBase( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'knowledge.use' + ) if (result instanceof NextResponse) return result const documentsResult = await getDocuments( @@ -159,6 +165,7 @@ export const POST = withRouteHandler( workspaceId, userId, rateLimit, + 'knowledge.upload', 'write' ) if (result instanceof NextResponse) return result diff --git a/apps/sim/app/api/v1/knowledge/[id]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/route.ts index 373d0951636..3dda06cd9fd 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/route.ts @@ -41,7 +41,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Knowle if (!parsed.success) return parsed.response const { id } = parsed.data.params - const result = await resolveKnowledgeBase(id, parsed.data.query.workspaceId, userId, rateLimit) + const result = await resolveKnowledgeBase( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'knowledge.use' + ) if (result instanceof NextResponse) return result return NextResponse.json({ @@ -70,7 +76,14 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle const { id } = parsed.data.params const { workspaceId, name, description, chunkingConfig } = parsed.data.body - const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, 'write') + const result = await resolveKnowledgeBase( + id, + workspaceId, + userId, + rateLimit, + 'knowledge.use', + 'write' + ) if (result instanceof NextResponse) return result const outcome = await performUpdateKnowledgeBase({ @@ -120,6 +133,7 @@ export const DELETE = withRouteHandler( parsed.data.query.workspaceId, userId, rateLimit, + 'knowledge.use', 'write' ) if (result instanceof NextResponse) return result diff --git a/apps/sim/app/api/v1/knowledge/route.ts b/apps/sim/app/api/v1/knowledge/route.ts index 9593cc8466e..95edbaa332f 100644 --- a/apps/sim/app/api/v1/knowledge/route.ts +++ b/apps/sim/app/api/v1/knowledge/route.ts @@ -40,7 +40,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { workspaceId } = parsed.data.query - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + 'knowledge.use' + ) if (accessError) return accessError /** Read only after `validateWorkspaceAccess` authorized this caller; same list the @@ -78,7 +83,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { workspaceId, name, description, chunkingConfig } = parsed.data.body - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + 'knowledge.create', + 'write' + ) if (accessError) return accessError const outcome = await performCreateKnowledgeBase({ diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index a54a4685b6b..7f331dbe0a4 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -48,7 +48,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { workspaceId, topK, query, tagFilters, searchMode } = parsed.data.body - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + 'knowledge.use' + ) if (accessError) return accessError const hasBillableQuery = Boolean(query?.trim()) diff --git a/apps/sim/app/api/v1/knowledge/utils.ts b/apps/sim/app/api/v1/knowledge/utils.ts index d87524610cd..9995f7a3144 100644 --- a/apps/sim/app/api/v1/knowledge/utils.ts +++ b/apps/sim/app/api/v1/knowledge/utils.ts @@ -3,22 +3,38 @@ import { NextResponse } from 'next/server' import { validationErrorResponseFromError } from '@/lib/api/server' import { getKnowledgeBaseById } from '@/lib/knowledge/service' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' -import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware' +import { + type RateLimitResult, + type V1RouteCapability, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' const logger = createLogger('V1KnowledgeAPI') /** - * Fetches a KB by ID, validates it exists, belongs to the workspace, - * and the user has permission. Returns the KB or a NextResponse error. + * Fetches a KB by ID, validates it exists, belongs to the workspace, the user + * has permission, and the caller's permission group grants `capability`. + * Returns the KB or a NextResponse error. + * + * `capability` is required rather than defaulted to `knowledge.use` because + * uploading a document is withheld separately (`knowledge.upload`), and a + * default would let a route added later inherit a gate nobody chose for it. */ export async function resolveKnowledgeBase( id: string, workspaceId: string, userId: string, rateLimit: RateLimitResult, + capability: V1RouteCapability, level: 'read' | 'write' = 'read' ): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, level) + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + capability, + level + ) if (accessError) return accessError const kb = await getKnowledgeBaseById(id) diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts index 12066f2f9cc..02e9bf09cfa 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -41,7 +41,7 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Log not found' }, { status: 404 }) } - const accessError = await validateWorkspaceAccess(rateLimit, userId, log.workspaceId) + const accessError = await validateWorkspaceAccess(rateLimit, userId, log.workspaceId, 'none') if (accessError) { return NextResponse.json({ error: 'Log not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts index 5bb92f2363f..76e37063493 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts @@ -46,7 +46,12 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) } - const accessError = await validateWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId) + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workflowLog.workspaceId, + 'none' + ) if (accessError) { return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index 390e7707300..52f3620ba5a 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -42,7 +42,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const params = parsed.data.query - const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + params.workspaceId, + 'none', + 'read' + ) if (accessError) return accessError logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, { diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 5eee6057386..c5590a7fac2 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -7,10 +7,18 @@ import { getValidationErrorMessage, isZodError, validationErrorResponse } from ' import { buildRateLimitHeaders, recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' import { PERSONAL_KEY_DENIED, WORKSPACE_KEY_SCOPE_DENIED } from '@/lib/api-key/policy-messages' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import type { ForbiddenDetailCode } from '@/lib/core/application/forbidden' import type { SubscriptionPlan } from '@/lib/core/rate-limiter' import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter' import { generateRequestId } from '@/lib/core/utils/request' -import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { + CAPABILITY_RULES, + type StaticPermissionGroupCapability, +} from '@/lib/permission-groups/capabilities' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBilledAccountUserId, @@ -227,6 +235,66 @@ export interface WorkspaceAccessError { status: number code: 'FORBIDDEN' message: string + /** + * The detail code a client can branch on, present only when a permission + * group withheld a capability. Read from {@link CAPABILITY_RULES} rather than + * spelled out, so v1 renders the same code as the funnel and the raw internal + * routes for the same refusal. + */ + details?: { code: ForbiddenDetailCode } +} + +/** + * The permission-group capability a v1 route requires, or `'none'` when no + * group governs it. + * + * Required rather than optional wherever it is threaded, and `'none'` spelled + * out rather than omitted, for the same reason `capability` is required on + * `defineWorkspaceOperation`: an absent declaration cannot be told apart from an + * unreviewed one, and unreviewed omission is how twelve config keys shipped + * enforcing nothing. Each route's value is the one its v2 or internal + * counterpart already declares — v1 does not get a mapping of its own. + */ +export type V1RouteCapability = StaticPermissionGroupCapability | 'none' + +/** + * The permission-group gate for a v1 route, as a structured failure. + * + * Only a personal key carries capabilities. A workspace key authorizes as the + * workspace and has no user, so there is no group to resolve — and its + * `rateLimit.userId` is the key's *creator*, a bystander whose group must not + * govern every caller of a shared credential. That is the same reasoning the + * `workspace_api_key` branch of `authorizeWorkspaceOperation` applies; the + * escape is closed at the door instead, because minting a workspace key is + * itself capability-gated. + * + * No `permission-group-enforced:` annotation, because this gate names no + * capability of its own: it applies whichever one the route declares, and every + * one of those is already reachable through the operation its v2 or internal + * counterpart declares. + * + * Never call this before the workspace role check. A capability refusal handed + * to a non-member would confirm the workspace exists and disclose which modules + * the organization withholds; the role failure conceals both. + */ +export async function resolveCapabilityRefusal( + rateLimit: RateLimitResult, + userId: string, + workspaceId: string, + capability: V1RouteCapability +): Promise { + if (capability === 'none') return null + if (rateLimit.keyType !== 'personal') return null + + if (!(await isWorkspaceCapabilityWithheld(userId, workspaceId, capability))) return null + + logger.warn('v1 request blocked by permission group', { workspaceId, userId, capability }) + return { + status: 403, + code: 'FORBIDDEN', + message: capabilityRefusal(capability), + details: { code: CAPABILITY_RULES[capability].detailCode }, + } } /** @@ -288,13 +356,18 @@ export async function resolveWorkspaceScope( } /** - * Core workspace-access check (scope + the user's workspace permission level), - * shared by v1 and v2. Returns a structured failure or null on success. + * Core workspace-access check: key scope, then the user's workspace permission + * level, then the permission-group capability the route declares. Returns a + * structured failure or null on success. + * + * Capability comes last, matching `authorizeWorkspaceOperation` — see + * {@link resolveCapabilityRefusal} for why the ordering is load-bearing. */ export async function resolveWorkspaceAccess( rateLimit: RateLimitResult, userId: string, workspaceId: string, + capability: V1RouteCapability, level: PermissionType = 'read' ): Promise { const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) @@ -304,18 +377,34 @@ export async function resolveWorkspaceAccess( if (!permissionSatisfies(permission, level)) { return { status: 403, code: 'FORBIDDEN', message: 'Access denied' } } - return null + + return resolveCapabilityRefusal(rateLimit, userId, workspaceId, capability) } /** * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body. + * + * Scope only — it deliberately gates no module capability, because it runs + * before the route's role check. A route using it authorizes its resource + * through a domain helper afterwards (the table routes call `checkAccess`, + * which applies `tables.use` itself), so the capability is declared there. */ export async function checkWorkspaceScope( rateLimit: RateLimitResult, requestedWorkspaceId: string ): Promise { const failure = await resolveWorkspaceScope(rateLimit, requestedWorkspaceId) - return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null + return failure ? workspaceAccessErrorResponse(failure) : null +} + +/** Renders a {@link WorkspaceAccessError} as the v1 `{ error, details? }` body. */ +function workspaceAccessErrorResponse(failure: WorkspaceAccessError): NextResponse { + return NextResponse.json( + failure.details + ? { error: failure.message, details: failure.details } + : { error: failure.message }, + { status: failure.status } + ) } /** @@ -341,10 +430,11 @@ export async function validateWorkspaceAccess( rateLimit: RateLimitResult, userId: string, workspaceId: string, + capability: V1RouteCapability, level: PermissionType = 'read' ): Promise { - const failure = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, level) - return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null + const failure = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, capability, level) + return failure ? workspaceAccessErrorResponse(failure) : null } /** diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index ecd742efb29..5817317f4af 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -50,7 +50,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { workspaceId } = parsed.data.query - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'tables.use') if (accessError) return accessError const tables = await listTables(workspaceId) @@ -115,6 +115,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { rateLimit, userId, params.workspaceId, + 'tables.create', 'write' ) if (accessError) return accessError diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts index 7ec6df59634..b9130239b1f 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts @@ -133,6 +133,7 @@ describe('POST /api/v1/workflows/[id]/deploy', () => { expect.objectContaining({ allowed: true }), 'user-1', 'ws-1', + 'deploy.api', 'admin' ) expect(mockPerformFullDeploy).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts index 304d69f63d8..7e0a550aee5 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -53,7 +53,7 @@ export const POST = withRouteHandler( return v1ValidationErrorResponse(body.error) } - const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id, 'deploy.api') if (!target.ok) return target.response const { workflow, workspaceId } = target @@ -126,7 +126,7 @@ export const DELETE = withRouteHandler( const { id } = parsed.data.params - const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id, 'deploy.api') if (!target.ok) return target.response const { workflow, workspaceId } = target diff --git a/apps/sim/app/api/v1/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/workflows/[id]/export/route.ts index 6f2c2310169..af48925e638 100644 --- a/apps/sim/app/api/v1/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/export/route.ts @@ -57,7 +57,12 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - const accessError = await validateWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId, + 'none' + ) if (accessError) { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts index 2327f71325b..b0b69757b0a 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts @@ -229,6 +229,7 @@ describe('POST /api/v1/workflows/[id]/rollback', () => { expect.objectContaining({ allowed: true }), 'user-1', 'ws-1', + 'deploy.api', 'admin' ) expect(mockPerformActivateVersion).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index d015ba4b024..ce88815b2cb 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -52,7 +52,7 @@ export const POST = withRouteHandler( return v1ValidationErrorResponse(body.error) } - const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id, 'deploy.api') if (!target.ok) return target.response const { workflow, workspaceId } = target diff --git a/apps/sim/app/api/v1/workflows/[id]/route.ts b/apps/sim/app/api/v1/workflows/[id]/route.ts index 653b833e591..1e01b403a3b 100644 --- a/apps/sim/app/api/v1/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/route.ts @@ -50,7 +50,8 @@ export const GET = withRouteHandler( const accessError = await validateWorkspaceAccess( rateLimit, userId, - workflowData.workspaceId! + workflowData.workspaceId!, + 'none' ) if (accessError) { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) diff --git a/apps/sim/app/api/v1/workflows/import/route.test.ts b/apps/sim/app/api/v1/workflows/import/route.test.ts index ab3492b0fc0..ef3ef7a6fab 100644 --- a/apps/sim/app/api/v1/workflows/import/route.test.ts +++ b/apps/sim/app/api/v1/workflows/import/route.test.ts @@ -214,6 +214,7 @@ describe('POST /api/v1/workflows/import', () => { expect.anything(), 'user-1', WORKSPACE_ID, + 'none', 'write' ) expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts index 762be48852c..fc380ec3400 100644 --- a/apps/sim/app/api/v1/workflows/import/route.ts +++ b/apps/sim/app/api/v1/workflows/import/route.ts @@ -62,7 +62,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { folderId, }) - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + 'none', + 'write' + ) if (accessError) return accessError const result = await importWorkflowIntoWorkspace({ diff --git a/apps/sim/app/api/v1/workflows/route.ts b/apps/sim/app/api/v1/workflows/route.ts index 3b24eefc55d..7814a85ba8a 100644 --- a/apps/sim/app/api/v1/workflows/route.ts +++ b/apps/sim/app/api/v1/workflows/route.ts @@ -69,7 +69,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, }) - const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId) + const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId, 'none') if (accessError) return accessError const conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)] diff --git a/apps/sim/app/api/v1/workflows/utils.ts b/apps/sim/app/api/v1/workflows/utils.ts index f2cb6d059a9..c387f47e510 100644 --- a/apps/sim/app/api/v1/workflows/utils.ts +++ b/apps/sim/app/api/v1/workflows/utils.ts @@ -3,7 +3,11 @@ import { type DeploymentWorkflowTarget, getDeploymentWorkflowTarget, } from '@/lib/workflows/deployments/queries' -import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware' +import { + type RateLimitResult, + type V1RouteCapability, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' function workflowNotFoundResponse(): NextResponse { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) @@ -11,21 +15,33 @@ function workflowNotFoundResponse(): NextResponse { /** * Resolves the target workflow for a v1 deployment mutation: loads the active - * record and verifies the caller's admin permission on its workspace. Access - * failures are masked as 404, matching the v1 workflow read surface so - * unauthorized callers cannot probe workflow existence. + * record, verifies the caller's admin permission on its workspace, then applies + * the permission-group capability the caller declares. Access failures are + * masked as 404, matching the v1 workflow read surface so unauthorized callers + * cannot probe workflow existence. + * + * `capability` is required rather than defaulted to `deploy.api`: the deployment + * routes do not all withhold the same thing, and a default would let a route + * added later inherit a gate nobody chose for it. */ export async function resolveV1DeploymentWorkflow( rateLimit: RateLimitResult, userId: string, - workflowId: string + workflowId: string, + capability: V1RouteCapability ): Promise<({ ok: true } & DeploymentWorkflowTarget) | { ok: false; response: NextResponse }> { const target = await getDeploymentWorkflowTarget(workflowId) if (!target) { return { ok: false, response: workflowNotFoundResponse() } } - const accessError = await validateWorkspaceAccess(rateLimit, userId, target.workspaceId, 'admin') + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + target.workspaceId, + capability, + 'admin' + ) if (accessError) { return { ok: false, response: workflowNotFoundResponse() } } From c4c4a11e028781f87b3114d8cf7a55cb4da8187e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 12:10:38 -0700 Subject: [PATCH 038/179] refactor(permission-groups): keep the authorization funnel's module graph light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capability-assertions.ts` and `config-scope.server.ts` sit under `@/lib/core/application`, which ~24 domain `operations.ts` modules import, and both reached into `ee/access-control/utils/permission-check.ts`. That module also holds the model, block and tool gates, so every authorization decision transitively loaded the provider registry, the block registry and the billing barrel — and through them `lib/workflows/**` and `lib/uploads/**`. Nothing reported the edge. The only symptom was two knowledge use-case suites failing on a partial mock of `@/lib/uploads/utils/validation`, a module they never meant to load. Move the resolution layer — `mergeEnvAllowlist`, group resolution, and the config readers — into `lib/permission-groups/resolve.server.ts`, where the funnel can reach it without the gates. `permission-check.ts` re-exports them, so the surfaces that read every validator from one module are unchanged, and its enterprise-plan check, `isHosted`/`isAccessControlEnabled` short-circuit and env-allowlist merge keep their exact semantics and ordering. This also breaks the import cycle the two modules had formed. `check:application-graph` fails the build if the edge returns, walking runtime `import`/`export … from` specifiers from the funnel's roots. It joins `check:audits` by living in the `check:*` namespace. --- .../app/api/cli/auth/approve/route.test.ts | 2 +- .../api/copilot/tool-permission/route.test.ts | 2 +- apps/sim/app/api/logs/export/route.test.ts | 2 +- .../organizations/[id]/members/route.test.ts | 2 +- .../organizations/[id]/roster/route.test.ts | 2 +- .../[tableId]/export-async/route.test.ts | 2 +- .../[tableId]/export/download/route.test.ts | 2 +- .../api/table/[tableId]/export/route.test.ts | 2 +- apps/sim/app/api/webhooks/route.test.ts | 2 +- .../settings/[section]/page.test.tsx | 2 +- .../utils/permission-check.test.ts | 2 +- .../access-control/utils/permission-check.ts | 290 ++---------------- apps/sim/lib/copilot/chat/payload.test.ts | 2 +- .../lib/copilot/chat/process-contents.test.ts | 2 +- .../lib/copilot/request/lifecycle/run.test.ts | 2 +- .../get-blocks-metadata-projection.test.ts | 2 +- .../blocks/get-blocks-metadata-tool.test.ts | 2 +- .../server/blocks/get-trigger-blocks.test.ts | 2 +- .../tools/server/user/get-credentials.test.ts | 2 +- .../application/provider-catalog.test.ts | 2 +- .../knowledge/application/connectors.test.ts | 2 +- .../capability-assertions.ts | 2 +- .../config-scope.server.test.ts | 2 +- .../permission-groups/config-scope.server.ts | 2 +- .../lib/permission-groups/resolve.server.ts | 288 +++++++++++++++++ .../platform-context-use-cases.test.ts | 2 +- .../sim/lib/table/application/imports.test.ts | 2 +- .../apply-workflow-operations.test.ts | 2 +- .../operations/import-workflow.test.ts | 2 +- .../persistence/block-access-guard.test.ts | 2 +- .../save-workflow-normalized-state.test.ts | 2 +- .../download-workspace-file-items.test.ts | 2 +- apps/sim/lib/workspaces/policy.test.ts | 2 +- apps/sim/tools/index.test.ts | 5 +- package.json | 4 +- scripts/check-application-graph.test.ts | 66 ++++ scripts/check-application-graph.ts | 170 ++++++++++ 37 files changed, 584 insertions(+), 301 deletions(-) create mode 100644 apps/sim/lib/permission-groups/resolve.server.ts create mode 100644 scripts/check-application-graph.test.ts create mode 100644 scripts/check-application-graph.ts diff --git a/apps/sim/app/api/cli/auth/approve/route.test.ts b/apps/sim/app/api/cli/auth/approve/route.test.ts index d0d39aff4e0..a421c922216 100644 --- a/apps/sim/app/api/cli/auth/approve/route.test.ts +++ b/apps/sim/app/api/cli/auth/approve/route.test.ts @@ -25,7 +25,7 @@ const { mockGetUserOrganization: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, getUserPermissionConfigForOrganization: mockGetOrgPermissionConfig, resolveVerifiedUserAccessControlContext: mockResolveVerifiedContext, diff --git a/apps/sim/app/api/copilot/tool-permission/route.test.ts b/apps/sim/app/api/copilot/tool-permission/route.test.ts index 471c67ccea8..f0508c3042c 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.test.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.test.ts @@ -51,7 +51,7 @@ vi.mock('@/lib/core/config/env-flags', () => ({ isCopilotToolPermissionsEnabled: true, })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig, })) diff --git a/apps/sim/app/api/logs/export/route.test.ts b/apps/sim/app/api/logs/export/route.test.ts index 0fd04dbf897..51670cae491 100644 --- a/apps/sim/app/api/logs/export/route.test.ts +++ b/apps/sim/app/api/logs/export/route.test.ts @@ -25,7 +25,7 @@ const { mockGetUserPermissionConfig: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) diff --git a/apps/sim/app/api/organizations/[id]/members/route.test.ts b/apps/sim/app/api/organizations/[id]/members/route.test.ts index 68638a3a74e..5eab80f930b 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.test.ts @@ -27,7 +27,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ isOrgAdminRole: (role: string | null | undefined) => role === 'owner' || role === 'admin', })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, getUserPermissionConfigForOrganization: mockGetOrgPermissionConfig, resolveVerifiedUserAccessControlContext: mockResolveVerifiedContext, diff --git a/apps/sim/app/api/organizations/[id]/roster/route.test.ts b/apps/sim/app/api/organizations/[id]/roster/route.test.ts index 9ccc4c5f04c..45b2ad9437a 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.test.ts @@ -29,7 +29,7 @@ const { mockResolveVerifiedContext: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, getUserPermissionConfigForOrganization: mockGetOrgPermissionConfig, resolveVerifiedUserAccessControlContext: mockResolveVerifiedContext, diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts index 110486a7cb5..503c9c4bde6 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts @@ -17,7 +17,7 @@ const { mockGetUserPermissionConfig: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts index 001a2d83399..a7b6a27206b 100644 --- a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts @@ -17,7 +17,7 @@ const { mockGetUserPermissionConfig: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) diff --git a/apps/sim/app/api/table/[tableId]/export/route.test.ts b/apps/sim/app/api/table/[tableId]/export/route.test.ts index 11aec7c6c32..69f50d077fc 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.test.ts @@ -11,7 +11,7 @@ const { mockCheckAccess, mockQueryRows, mockGetUserPermissionConfig } = vi.hoist mockGetUserPermissionConfig: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) diff --git a/apps/sim/app/api/webhooks/route.test.ts b/apps/sim/app/api/webhooks/route.test.ts index 1c2a85d3572..ce69d22dbcd 100644 --- a/apps/sim/app/api/webhooks/route.test.ts +++ b/apps/sim/app/api/webhooks/route.test.ts @@ -29,7 +29,7 @@ vi.mock('@sim/platform-authz/workflow', () => ({ }, })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.getUserPermissionConfig, })) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx index 205c416042e..071b8418313 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -132,7 +132,7 @@ vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({ getSettingsSectionMeta: vi.fn(() => null), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ resolveWorkspaceGroup: mockResolveWorkspaceGroup, })) 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 8109eeb822b..dfcd0be4be5 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -19,7 +19,7 @@ const { mockIsOrganizationOnEnterprisePlan, mockGetWorkspaceWithOwner, mockGetPr mockGetProviderFromModel: vi.fn<(model: string) => string>(), })) -vi.mock('@/lib/billing', () => ({ +vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, })) diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 57baab4a510..98973c439d8 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -1,13 +1,7 @@ -import { db } from '@sim/db' -import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, asc, eq, sql } from 'drizzle-orm' import type { ShareAuthType } from '@/lib/api/contracts/public-shares' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing' import { getAllowedIntegrationsFromEnv, - isAccessControlEnabled, - isHosted, isInvitationsDisabled, isPublicApiDisabled, } from '@/lib/core/config/env-flags' @@ -21,17 +15,32 @@ import { type StaticCapabilityRule, } from '@/lib/permission-groups/capabilities' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' -import { - DEFAULT_PERMISSION_GROUP_CONFIG, - type PermissionGroupConfig, - parsePermissionGroupConfig, -} from '@/lib/permission-groups/fields' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { createToolAccessGate } from '@/lib/permission-groups/operation-access' -import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' +import { + getUserPermissionConfig, + getUserPermissionConfigForOrganization, + mergeEnvAllowlist, +} from '@/lib/permission-groups/resolve.server' import type { ExecutionContext } from '@/executor/types' import { getProviderFromModel } from '@/providers/utils' +/** + * The permission-group resolution layer lives in `@/lib/permission-groups` + * because ~24 domain `operations.ts` modules reach it through the authorization + * funnel, and none of them may pull in this module's provider, block-registry + * and billing imports. Re-exported here so the surfaces that already read the + * validators from one place keep doing so. + */ +export { + getUserPermissionConfig, + getUserPermissionConfigForOrganization, + type ResolvedPermissionGroup, + resolveVerifiedUserAccessControlContext, + resolveWorkspaceGroup, + type UserAccessControlContext, +} from '@/lib/permission-groups/resolve.server' + const logger = createLogger('PermissionCheck') export class ProviderNotAllowedError extends Error { @@ -103,238 +112,6 @@ export class PublicApiNotAllowedError extends Error { } } -/** - * Merges the env allowlist into a permission config. - * - * Returns null only when neither layer restricts anything. Otherwise the group's - * own allowlist is intersected with the env one by - * {@link intersectIntegrationAllowlists}, which case-folds both sides — callers - * compare against a lowercased block type, and a stored config reaches here - * straight off the wire, where the contract permits any casing. - */ -function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGroupConfig | null { - const envAllowlist = getAllowedIntegrationsFromEnv() - if (config === null && envAllowlist === null) return null - - const base = config ?? DEFAULT_PERMISSION_GROUP_CONFIG - return { - ...base, - allowedIntegrations: intersectIntegrationAllowlists(base.allowedIntegrations, envAllowlist), - } -} - -/** - * The permission group that governs a user in a given context, with its parsed - * config. Shared by the executor path and the `/api/permission-groups/user` - * route so resolution never drifts between the two. - */ -export interface ResolvedPermissionGroup { - permissionGroupId: string - groupName: string - 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 -): Promise { - const [defaultGroup] = await db - .select({ - id: permissionGroup.id, - name: permissionGroup.name, - config: permissionGroup.config, - }) - .from(permissionGroup) - .where( - and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.isDefault, true)) - ) - .limit(1) - - if (!defaultGroup) { - return null - } - - return { - permissionGroupId: defaultGroup.id, - groupName: defaultGroup.name, - resolution: 'default', - config: parsePermissionGroupConfig(defaultGroup.config), - } -} - -/** - * Resolve the group governing `userId` in `workspaceId` (which belongs to - * `organizationId`). One effective group per workspace, by precedence: - * 1. a non-default group targeting this workspace that `userId` is an explicit - * member of, else - * 2. a non-default group targeting this workspace that has no explicit members - * — governs all members of the workspace, including external members, else - * 3. the organization's default group (also governs external members), else - * 4. `null` (unrestricted). - * - * Assignment-time conflict checks keep this unambiguous: at most one all-members - * group per workspace, and a user is an explicit member of at most one group per - * workspace. If an overlap nonetheless exists, the oldest group wins — rows are - * ordered by `created_at` (then `id`). - * - * Callers gate on enterprise entitlement before invoking this and merge the env - * allowlist afterwards. - */ -export async function resolveWorkspaceGroup( - userId: string, - organizationId: string, - workspaceId: string -): Promise { - const rows = await db - .select({ - id: permissionGroup.id, - name: permissionGroup.name, - config: permissionGroup.config, - isMember: sql`exists ( - select 1 from ${permissionGroupMember} - where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id} - and ${permissionGroupMember.userId} = ${userId} - )`, - hasMembers: sql`exists ( - select 1 from ${permissionGroupMember} - where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id} - )`, - }) - .from(permissionGroup) - .innerJoin( - permissionGroupWorkspace, - and( - eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id), - eq(permissionGroupWorkspace.workspaceId, workspaceId) - ) - ) - .where( - and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.isDefault, false)) - ) - .orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id)) - - const 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), - } - } - - return resolveDefaultGroup(organizationId) -} - -/** - * Resolve the effective permission-group config for a user in the context of a - * specific workspace. The workspace is mapped to its organization and the - * governing group is resolved with specific-over-all precedence. - * - * Returns `null` (after env merge) when the workspace has no organization, the - * organization isn't on an enterprise plan, or no group governs the user. - * - * The env-level integration allowlist is always merged last so self-hosted - * deployments can constrain integrations without touching the DB. - */ -async function resolveUserAccessControlContextForOrganization( - userId: string, - workspaceId: string, - organizationId: string | null -): Promise { - if (!organizationId) return inactiveUserAccessControlContext(null) - - const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId) - if (!isEnterprise) { - return inactiveUserAccessControlContext(organizationId) - } - - 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), - } -} - -/** - * 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) -} - -/** - * The unverified counterpart of {@link resolveVerifiedUserAccessControlContext}: - * it loads the workspace itself to learn the owning organization. - * - * Module-private on purpose — every caller outside this file has already - * access-checked the workspace and so holds the organization id, and exporting a - * resolver that looks it up again invites a second query on a path that does not - * need one. {@link getUserPermissionConfig} is the one caller. - */ -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 -} - /** * Refuses a public file share the caller's permission group withholds — the * master switch, and then — when `authType` is given — the auth mode the share @@ -398,29 +175,6 @@ export async function validateChatDeployAuth( } } -/** - * Org-addressed variant of {@link getUserPermissionConfig}. Use when only the - * organization is known (e.g. organization-level invitations). Non-default - * groups target specific workspaces and never gate organization-level actions, - * so this resolves the organization's default group — which governs everyone not - * covered by a workspace group. - */ -export async function getUserPermissionConfigForOrganization( - organizationId: string -): Promise { - if (!isHosted && !isAccessControlEnabled) { - return mergeEnvAllowlist(null) - } - - const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId) - if (!isEnterprise) { - return mergeEnvAllowlist(null) - } - - const resolved = await resolveDefaultGroup(organizationId) - return mergeEnvAllowlist(resolved?.config ?? null) -} - /** * Cache-aware wrapper around `getUserPermissionConfig`. When an * `ExecutionContext` is provided, the resolved config is memoized on the diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 9b6274051ec..4f7f3b7de8f 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -134,7 +134,7 @@ vi.mock('@/lib/integrations/availability.server', () => ({ isOAuthServiceDeploymentAvailable: mockIsOAuthServiceDeploymentAvailable, })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 3e29c8b4046..cc0482b272b 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -45,7 +45,7 @@ const { vi.mock('@/blocks/registry', () => ({ getBlock, getBlockRegistry })) vi.mock('@/lib/copilot/block-visibility', () => ({ getBlockVisibilityForCopilot })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ getUserPermissionConfig })) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig })) vi.mock('@/lib/integrations/availability.server', () => ({ isIntegrationDeploymentAvailableForVisibility: isIntegrationDeploymentAvailable, })) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 36f74ee661c..5c71cc27327 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -114,7 +114,7 @@ vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ addChatAutoAllowedTool: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts index 3af8978ef76..e0e2a5688ca 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts @@ -20,7 +20,7 @@ const mocks = vi.hoisted(() => ({ isDeploymentAvailable: vi.fn(() => true), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.getUserPermissionConfig, })) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts index ef9572fe4cc..3fb868b0731 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts @@ -9,7 +9,7 @@ const { mockGetUserPermissionConfig, mockIsIntegrationDeploymentAvailable } = vi mockIsIntegrationDeploymentAvailable: vi.fn(() => true), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.test.ts index 8f699d66183..f93858ab953 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.test.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.test.ts @@ -21,7 +21,7 @@ vi.mock('@/blocks/registry', () => ({ getBlock: mockGetBlock, })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts index cabf986de60..83da859ade1 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts @@ -79,7 +79,7 @@ vi.mock('@/lib/core/config/env-flags', () => ({ getAllowedIntegrationsFromEnv: vi.fn(() => null), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: getUserPermissionConfigMock, })) diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index f485a80465d..ab2fc8bbab4 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -20,7 +20,7 @@ vi.mock('@/lib/core/config/env-flags', () => ({ getAllowedIntegrationsFromEnv: mocks.getAllowedIntegrationsFromEnv, })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.getUserPermissionConfig, })) diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index 6e474fdf5e2..189c0b3ede1 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -68,7 +68,7 @@ vi.mock('@/lib/oauth/credential-service', () => ({ refreshAccessTokenIfNeeded: mocks.refreshToken, })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.getUserPermissionConfig, })) diff --git a/apps/sim/lib/permission-groups/capability-assertions.ts b/apps/sim/lib/permission-groups/capability-assertions.ts index fab2287471b..dbeb6f7ad17 100644 --- a/apps/sim/lib/permission-groups/capability-assertions.ts +++ b/apps/sim/lib/permission-groups/capability-assertions.ts @@ -5,7 +5,7 @@ import { } from '@/lib/permission-groups/capabilities' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' -import { getUserPermissionConfigForOrganization } from '@/ee/access-control/utils/permission-check' +import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server' /** * Re-exported so a caller that gates inline reaches the refusal sentence and the diff --git a/apps/sim/lib/permission-groups/config-scope.server.test.ts b/apps/sim/lib/permission-groups/config-scope.server.test.ts index fef7c741d3e..2ff3403dbed 100644 --- a/apps/sim/lib/permission-groups/config-scope.server.test.ts +++ b/apps/sim/lib/permission-groups/config-scope.server.test.ts @@ -10,7 +10,7 @@ const { mockGetUserPermissionConfig, mockResolveVerifiedContext } = vi.hoisted(( vi.mock('react', () => ({ cache: (fn: F) => fn })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, resolveVerifiedUserAccessControlContext: mockResolveVerifiedContext, })) diff --git a/apps/sim/lib/permission-groups/config-scope.server.ts b/apps/sim/lib/permission-groups/config-scope.server.ts index 0f0899934b5..dae112bf08c 100644 --- a/apps/sim/lib/permission-groups/config-scope.server.ts +++ b/apps/sim/lib/permission-groups/config-scope.server.ts @@ -3,7 +3,7 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { getUserPermissionConfig, resolveVerifiedUserAccessControlContext, -} from '@/ee/access-control/utils/permission-check' +} from '@/lib/permission-groups/resolve.server' type ConfigKey = `${string}:${string}` type ConfigStore = Map> diff --git a/apps/sim/lib/permission-groups/resolve.server.ts b/apps/sim/lib/permission-groups/resolve.server.ts new file mode 100644 index 00000000000..e2af7963d76 --- /dev/null +++ b/apps/sim/lib/permission-groups/resolve.server.ts @@ -0,0 +1,288 @@ +/** + * Resolves the permission group governing a user, and its config. + * + * Lives here rather than in `ee/access-control` because the authorization + * funnel reads it: `capability-assertions.ts` and `config-scope.server.ts` sit + * under `@/lib/core/application`, which ~24 domain `operations.ts` modules + * import. `ee/access-control/utils/permission-check.ts` also holds the model, + * block and tool gates, and those reach the provider registry, the block + * registry and the billing barrel — a graph no authorization decision should + * load. Splitting resolution out is what keeps the funnel light; + * `scripts/check-application-graph.ts` fails the build if the edge returns. + * + * `permission-check.ts` re-exports these, so the surfaces that read every + * validator from one module are unaffected. + */ +import { db } from '@sim/db' +import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema' +import { and, asc, eq, sql } from 'drizzle-orm' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { + getAllowedIntegrationsFromEnv, + isAccessControlEnabled, + isHosted, +} from '@/lib/core/config/env-flags' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + type PermissionGroupConfig, + parsePermissionGroupConfig, +} from '@/lib/permission-groups/fields' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' + +/** + * Merges the env allowlist into a permission config. + * + * Returns null only when neither layer restricts anything. Otherwise the group's + * own allowlist is intersected with the env one by + * {@link intersectIntegrationAllowlists}, which case-folds both sides — callers + * compare against a lowercased block type, and a stored config reaches here + * straight off the wire, where the contract permits any casing. + */ +export function mergeEnvAllowlist( + config: PermissionGroupConfig | null +): PermissionGroupConfig | null { + const envAllowlist = getAllowedIntegrationsFromEnv() + if (config === null && envAllowlist === null) return null + + const base = config ?? DEFAULT_PERMISSION_GROUP_CONFIG + return { + ...base, + allowedIntegrations: intersectIntegrationAllowlists(base.allowedIntegrations, envAllowlist), + } +} + +/** + * The permission group that governs a user in a given context, with its parsed + * config. Shared by the executor path and the `/api/permission-groups/user` + * route so resolution never drifts between the two. + */ +export interface ResolvedPermissionGroup { + permissionGroupId: string + groupName: string + 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 +): Promise { + const [defaultGroup] = await db + .select({ + id: permissionGroup.id, + name: permissionGroup.name, + config: permissionGroup.config, + }) + .from(permissionGroup) + .where( + and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.isDefault, true)) + ) + .limit(1) + + if (!defaultGroup) { + return null + } + + return { + permissionGroupId: defaultGroup.id, + groupName: defaultGroup.name, + resolution: 'default', + config: parsePermissionGroupConfig(defaultGroup.config), + } +} + +/** + * Resolve the group governing `userId` in `workspaceId` (which belongs to + * `organizationId`). One effective group per workspace, by precedence: + * 1. a non-default group targeting this workspace that `userId` is an explicit + * member of, else + * 2. a non-default group targeting this workspace that has no explicit members + * — governs all members of the workspace, including external members, else + * 3. the organization's default group (also governs external members), else + * 4. `null` (unrestricted). + * + * Assignment-time conflict checks keep this unambiguous: at most one all-members + * group per workspace, and a user is an explicit member of at most one group per + * workspace. If an overlap nonetheless exists, the oldest group wins — rows are + * ordered by `created_at` (then `id`). + * + * Callers gate on enterprise entitlement before invoking this and merge the env + * allowlist afterwards. + */ +export async function resolveWorkspaceGroup( + userId: string, + organizationId: string, + workspaceId: string +): Promise { + const rows = await db + .select({ + id: permissionGroup.id, + name: permissionGroup.name, + config: permissionGroup.config, + isMember: sql`exists ( + select 1 from ${permissionGroupMember} + where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id} + and ${permissionGroupMember.userId} = ${userId} + )`, + hasMembers: sql`exists ( + select 1 from ${permissionGroupMember} + where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id} + )`, + }) + .from(permissionGroup) + .innerJoin( + permissionGroupWorkspace, + and( + eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id), + eq(permissionGroupWorkspace.workspaceId, workspaceId) + ) + ) + .where( + and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.isDefault, false)) + ) + .orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id)) + + const 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), + } + } + + return resolveDefaultGroup(organizationId) +} + +/** + * Resolve the effective permission-group config for a user in the context of a + * specific workspace. The workspace is mapped to its organization and the + * governing group is resolved with specific-over-all precedence. + * + * Returns `null` (after env merge) when the workspace has no organization, the + * organization isn't on an enterprise plan, or no group governs the user. + * + * The env-level integration allowlist is always merged last so self-hosted + * deployments can constrain integrations without touching the DB. + */ +async function resolveUserAccessControlContextForOrganization( + userId: string, + workspaceId: string, + organizationId: string | null +): Promise { + if (!organizationId) return inactiveUserAccessControlContext(null) + + const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId) + if (!isEnterprise) { + return inactiveUserAccessControlContext(organizationId) + } + + 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), + } +} + +/** + * 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) +} + +/** + * The unverified counterpart of {@link resolveVerifiedUserAccessControlContext}: + * it loads the workspace itself to learn the owning organization. + * + * Module-private on purpose — every caller outside this file has already + * access-checked the workspace and so holds the organization id, and exporting a + * resolver that looks it up again invites a second query on a path that does not + * need one. {@link getUserPermissionConfig} is the one caller. + */ +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 +} + +/** + * Org-addressed variant of {@link getUserPermissionConfig}. Use when only the + * organization is known (e.g. organization-level invitations). Non-default + * groups target specific workspaces and never gate organization-level actions, + * so this resolves the organization's default group — which governs everyone not + * covered by a workspace group. + */ +export async function getUserPermissionConfigForOrganization( + organizationId: string +): Promise { + if (!isHosted && !isAccessControlEnabled) { + return mergeEnvAllowlist(null) + } + + const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId) + if (!isEnterprise) { + return mergeEnvAllowlist(null) + } + + const resolved = await resolveDefaultGroup(organizationId) + return mergeEnvAllowlist(resolved?.config ?? null) +} 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 index 9469e87f828..44976130c67 100644 --- 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 @@ -34,7 +34,7 @@ vi.mock('@/lib/workspaces/host-context', () => ({ getWorkspaceHostContextForViewer: mocks.getWorkspaceHostContextForViewer, })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ resolveVerifiedUserAccessControlContext: mocks.resolveVerifiedUserAccessControlContext, })) diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts index 3512087ba4b..53fbf893a6c 100644 --- a/apps/sim/lib/table/application/imports.test.ts +++ b/apps/sim/lib/table/application/imports.test.ts @@ -25,7 +25,7 @@ const mocks = vi.hoisted(() => ({ getUserPermissionConfig: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.getUserPermissionConfig, })) diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts index a9acf0dc61a..83203422e03 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -85,7 +85,7 @@ vi.mock('@/lib/billing/core/subscription', () => ({ hasWorkspaceSandboxAccess: mocks.sandboxAccess, })) vi.mock('@/lib/core/config/block-visibility', () => ({ getBlockVisibility: mocks.blockVisibility })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.permissionConfig, })) vi.mock('@/blocks/visibility/server-context', () => ({ diff --git a/apps/sim/lib/workflows/operations/import-workflow.test.ts b/apps/sim/lib/workflows/operations/import-workflow.test.ts index e5539f3fa23..857b22c9438 100644 --- a/apps/sim/lib/workflows/operations/import-workflow.test.ts +++ b/apps/sim/lib/workflows/operations/import-workflow.test.ts @@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({ extractAndPersistCustomTools: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.getUserPermissionConfig, })) vi.mock('@/lib/workflows/orchestration', () => ({ diff --git a/apps/sim/lib/workflows/persistence/block-access-guard.test.ts b/apps/sim/lib/workflows/persistence/block-access-guard.test.ts index 239ccc3d902..4e61d7bd556 100644 --- a/apps/sim/lib/workflows/persistence/block-access-guard.test.ts +++ b/apps/sim/lib/workflows/persistence/block-access-guard.test.ts @@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({ getUserPermissionConfig: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.getUserPermissionConfig, })) diff --git a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts index 28d8e8dd54d..5818e7ef6b0 100644 --- a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts @@ -15,7 +15,7 @@ const mocks = vi.hoisted(() => ({ getUserPermissionConfig: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.getUserPermissionConfig, })) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts index aff4731b45b..28702436eef 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts @@ -29,7 +29,7 @@ const { mockGetUserPermissionConfig: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 2fae8784fd8..096ec5686b3 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -26,7 +26,7 @@ const { mockGetUserPermissionConfigForOrganization: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfigForOrganization: mockGetUserPermissionConfigForOrganization, })) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 1abd4cefead..4f8e01c62ce 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -114,7 +114,6 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ validateModelProvider: vi.fn().mockResolvedValue(undefined), validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined), validatePublicApiAllowed: vi.fn().mockResolvedValue(undefined), - getUserPermissionConfig: vi.fn().mockResolvedValue(null), ProviderNotAllowedError: class ProviderNotAllowedError extends Error {}, IntegrationNotAllowedError: class IntegrationNotAllowedError extends Error {}, McpToolsNotAllowedError: class McpToolsNotAllowedError extends Error {}, @@ -124,6 +123,10 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ PublicApiNotAllowedError: class PublicApiNotAllowedError extends Error {}, })) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: vi.fn().mockResolvedValue(null), +})) + vi.mock('@/lib/billing/core/usage-log', () => ({})) vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) diff --git a/package.json b/package.json index 379f4ca2d83..31069253baa 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "dev:sockets": "cd apps/realtime && bun run dev", "dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev\" \"cd apps/realtime && bun run dev\"", "dev:full:capped": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev:capped\" \"cd apps/realtime && bun run dev\"", - "test": "bun run test:setup && bun run test:npm-package-versions && bun run test:icon-path-precision && bun run test:tool-registry-boundary && bun run test:tool-request-boundary && bun run test:actorless-executor-operations && bun run test:permission-group-enforcement && bun run test:migrations-safety && bun run test:generators && turbo run test", + "test": "bun run test:setup && bun run test:npm-package-versions && bun run test:icon-path-precision && bun run test:tool-registry-boundary && bun run test:tool-request-boundary && bun run test:actorless-executor-operations && bun run test:permission-group-enforcement && bun run test:application-graph && bun run test:migrations-safety && bun run test:generators && turbo run test", "test:setup": "bun run --cwd packages/sim-setup test", "test:npm-package-versions": "bunx vitest run scripts/bump-npm-package-versions.test.ts", "test:icon-path-precision": "bunx vitest run scripts/check-icon-path-precision.test.ts", @@ -22,6 +22,7 @@ "test:tool-request-boundary": "bunx vitest run scripts/check-tool-request-boundary.test.ts", "test:actorless-executor-operations": "bunx vitest run scripts/check-actorless-executor-operations.test.ts", "test:permission-group-enforcement": "bunx vitest run scripts/check-permission-group-enforcement.test.ts", + "test:application-graph": "bunx vitest run scripts/check-application-graph.test.ts", "test:migrations-safety": "bunx vitest run scripts/check-migrations-safety.test.ts", "test:generators": "bunx vitest run scripts/generate-v2-cli-api.test.ts scripts/generate-cli-docs.test.ts scripts/generate-docs.test.ts", "format": "turbo run format", @@ -48,6 +49,7 @@ "check:tool-request-boundary": "bun run scripts/check-tool-request-boundary.ts", "check:actorless-executor-operations": "bun run scripts/check-actorless-executor-operations.ts", "check:permission-group-enforcement": "bun run scripts/check-permission-group-enforcement.ts", + "check:application-graph": "bun run scripts/check-application-graph.ts", "check:tool-registry-boundary": "bun run scripts/check-tool-registry-boundary.ts --check", "check:trigger-block-cycle": "bun run scripts/check-trigger-block-cycle.ts", "check:import-specifiers": "bun run scripts/check-import-specifiers.ts", diff --git a/scripts/check-application-graph.test.ts b/scripts/check-application-graph.test.ts new file mode 100644 index 00000000000..f891e2cda9c --- /dev/null +++ b/scripts/check-application-graph.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + FORBIDDEN_PREFIXES, + findViolations, + GUARDED_ROOTS, + resolveSpecifier, + runtimeSpecifiers, +} from './check-application-graph' + +describe('runtimeSpecifiers', () => { + it('collects import and re-export specifiers', () => { + expect( + runtimeSpecifiers( + "import { a } from '@/lib/a'\nexport { b } from '@/lib/b'\nimport '@/lib/c'\n" + ) + ).toEqual(['@/lib/a', '@/lib/b']) + }) + + it('ignores type-only statements, which the compiler erases', () => { + expect( + runtimeSpecifiers( + "import type { A } from '@/lib/a'\nimport type B from '@/lib/b'\nexport type { C } from '@/lib/c'\n" + ) + ).toEqual([]) + }) + + it('keeps an inline type import, which still emits a runtime load', () => { + expect(runtimeSpecifiers("import { type A, b } from '@/lib/a'\n")).toEqual(['@/lib/a']) + }) +}) + +describe('resolveSpecifier', () => { + it('resolves an @/ specifier against apps/sim', () => { + expect(resolveSpecifier('@/lib/permission-groups/capabilities', __filename)).toMatch( + /apps\/sim\/lib\/permission-groups\/capabilities\.ts$/ + ) + }) + + it('returns null for a bare package specifier', () => { + expect(resolveSpecifier('drizzle-orm', __filename)).toBeNull() + }) +}) + +describe('the guarded roots', () => { + it('reaches no forbidden module tree at runtime', () => { + for (const root of GUARDED_ROOTS) { + expect({ root, violations: findViolations(root) }).toEqual({ root, violations: [] }) + } + }) + + it('reports the shortest chain when a forbidden module is reachable', () => { + /** + * Walked from a module that legitimately imports the provider registry, so + * the walker is proven able to fail. Without this the suite above would + * still pass if `findViolations` silently stopped finding anything. + */ + const violations = findViolations('lib/permission-groups/model-access.ts') + expect(violations).toHaveLength(1) + expect(violations[0].forbidden).toBe('providers/utils.ts') + expect(violations[0].reason).toBe(FORBIDDEN_PREFIXES['providers/']) + expect(violations[0].path).toEqual([ + 'lib/permission-groups/model-access.ts', + 'providers/utils.ts', + ]) + }) +}) diff --git a/scripts/check-application-graph.ts b/scripts/check-application-graph.ts new file mode 100644 index 00000000000..0791a47337e --- /dev/null +++ b/scripts/check-application-graph.ts @@ -0,0 +1,170 @@ +#!/usr/bin/env bun +/** + * Asserts the authorization funnel's module graph stays light. + * + * `@/lib/core/application` is imported by ~every domain `operations.ts`, and + * `lib/permission-groups/capabilities.ts` sits below it, so anything either one + * reaches at runtime is loaded by every surface that authorizes anything — + * routes, jobs, the realtime prune graph and every use-case unit test. The + * provider registry, the block/tool registries, the executor and the + * uploads/workflow graph are all far heavier than an authorization decision, + * and none of them has anything to say about one. + * + * The edge this guards against is invisible without a check: adding one import + * to a permission-group helper once widened this graph as far as + * `lib/uploads/utils/file-utils.ts`, and the only symptom was two unrelated + * knowledge tests failing on a partial mock of a module they never meant to + * load. + * + * Walks runtime `import`/`export … from` specifiers only. `import type` is + * erased by the compiler and costs nothing at runtime, so a type-only edge into + * a forbidden module is allowed and deliberately not reported. + */ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { dirname, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(SCRIPT_DIR, '..') +const APP_ROOT = resolve(REPO_ROOT, 'apps/sim') + +/** Entry points whose graph every authorization decision pays for. */ +export const GUARDED_ROOTS = [ + 'lib/core/application/index.ts', + 'lib/permission-groups/capabilities.ts', + 'lib/permission-groups/capability-assertions.ts', + 'lib/permission-groups/config-scope.server.ts', +] as const + +/** + * Path prefixes no guarded root may reach at runtime, with the reason a reviewer + * needs to understand the failure without re-deriving this file. + */ +export const FORBIDDEN_PREFIXES: Record = { + 'providers/': 'the LLM provider registry — an authorization decision never picks a model', + 'blocks/': 'the block registry — it pulls every block definition into the graph', + 'tools/': 'the executable tool registry — see the tool-registry-boundary skill', + 'executor/': 'the workflow execution engine', + 'lib/uploads/': 'the uploads graph, which reaches file parsing and archive handling', + 'lib/workflows/': 'the workflow graph, which reaches the editor and serializer', +} + +/** + * Matches a runtime `import … from '…'` or `export … from '…'`. + * + * The negative lookahead drops `import type {` and `import type X`, which the + * compiler erases; `import { type A }` still counts, because that statement + * emits a runtime require for the module. + */ +const IMPORT_PATTERN = + /(?:^|\n)\s*(?:import|export)\s+(?!type[\s{])[\s\S]*?\s*from\s*['"]([^'"]+)['"]/g + +/** Resolves an `@/`- or relative specifier to a file under `apps/sim`, or null. */ +export function resolveSpecifier(specifier: string, fromFile: string): string | null { + const base = specifier.startsWith('@/') + ? resolve(APP_ROOT, specifier.slice(2)) + : specifier.startsWith('.') + ? resolve(dirname(fromFile), specifier) + : null + if (base === null) return null + + for (const candidate of [`${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate + } + return null +} + +/** The runtime specifiers `source` imports, in source order. */ +export function runtimeSpecifiers(source: string): string[] { + return [...source.matchAll(IMPORT_PATTERN)].map((match) => match[1]) +} + +export interface GraphViolation { + root: string + forbidden: string + reason: string + path: string[] +} + +/** + * Breadth-first walk from `root`, reporting the shortest import chain into each + * forbidden prefix. Breadth-first on purpose: the shortest chain is the one a + * reader can act on, and it names the single edge worth deleting. + */ +export function findViolations(root: string): GraphViolation[] { + const start = resolve(APP_ROOT, root) + const violations: GraphViolation[] = [] + const reported = new Set() + const seen = new Set([start]) + const queue: Array<[string, string[]]> = [[start, [start]]] + + while (queue.length > 0) { + const [file, path] = queue.shift() as [string, string[]] + let source: string + try { + source = readFileSync(file, 'utf8') + } catch { + continue + } + + for (const specifier of runtimeSpecifiers(source)) { + const next = resolveSpecifier(specifier, file) + if (next === null || seen.has(next)) continue + + const rel = relative(APP_ROOT, next) + const prefix = Object.keys(FORBIDDEN_PREFIXES).find((candidate) => rel.startsWith(candidate)) + if (prefix !== undefined) { + if (!reported.has(prefix)) { + reported.add(prefix) + violations.push({ + root, + forbidden: rel, + reason: FORBIDDEN_PREFIXES[prefix], + path: [...path, next].map((entry) => relative(APP_ROOT, entry)), + }) + } + continue + } + + seen.add(next) + queue.push([next, [...path, next]]) + } + } + + return violations +} + +function main(): void { + const violations: GraphViolation[] = [] + for (const root of GUARDED_ROOTS) { + if (!existsSync(resolve(APP_ROOT, root))) { + console.error( + `Application-graph audit could not find its own root '${root}'.\n` + + 'The module was renamed or moved. Update GUARDED_ROOTS rather than leaving this\n' + + 'audit passing over a file that no longer exists.\n' + ) + process.exit(1) + } + violations.push(...findViolations(root)) + } + + if (violations.length > 0) { + console.error('❌ The authorization funnel reaches modules it must not load at runtime:\n') + for (const violation of violations) { + console.error(` ${violation.forbidden} — ${violation.reason}`) + console.error(` ${violation.path.join('\n -> ')}\n`) + } + console.error( + 'Move the code that needs the heavy module out of the funnel, or import it only as a\n' + + "type. Do not add the module to FORBIDDEN_PREFIXES' exceptions.\n" + ) + process.exit(1) + } + + console.log( + `✅ Application graph clean: ${GUARDED_ROOTS.length} roots reach none of ` + + `${Object.keys(FORBIDDEN_PREFIXES).length} forbidden module trees` + ) +} + +if (import.meta.main) main() From cb412fcb3576db91c36db18c6e744778ff50d470 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 12:21:57 -0700 Subject: [PATCH 039/179] refactor(permission-groups): keep the universal route wrapper's graph light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `withRouteHandler` wraps every API route in the app, so anything it reaches at runtime is loaded by every route and every route test. It imported `withPermissionGroupScope` from `config-scope.server.ts`, which imports the resolver at module scope, which reaches `lib/billing/types` — so every route eagerly loaded the billing graph to get an AsyncLocalStorage wrapper it may never use. The visible symptom was `app/api/chat/[identifier]/otp/route.test.ts` failing with `z.coerce.number is not a function` on its own partial `zod` mock. Splits the import-free scope wrapper into `request-scope.server.ts`, leaving `config-scope.server.ts` as the resolver the gate call sites already import heavy things to use. The guarded require of `node:async_hooks`, the React `cache()` fallback outside a scope, promise-caching, negative caching, and the `userId:workspaceId` key semantics are unchanged. Extends `check-application-graph.ts` so the roots each carry their own forbidden list: the route wrapper is now a guarded root, and additionally may not reach `lib/billing/`, the permission-group resolver, auth, copilot or knowledge. Those stay allowed for the funnel roots, where `resolve.server.ts` legitimately reads a subscription to decide enterprise gating. --- .../utils/permission-check.test.ts | 2 +- apps/sim/lib/core/utils/with-route-handler.ts | 2 +- .../config-scope.server.test.ts | 6 +- .../permission-groups/config-scope.server.ts | 39 +-------- .../permission-groups/request-scope.server.ts | 64 +++++++++++++++ .../src/mocks/permission-group-scope.mock.ts | 9 +-- scripts/check-application-graph.test.ts | 19 ++++- scripts/check-application-graph.ts | 80 ++++++++++++++----- 8 files changed, 154 insertions(+), 67 deletions(-) create mode 100644 apps/sim/lib/permission-groups/request-scope.server.ts 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 dfcd0be4be5..b14c3922260 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -37,7 +37,7 @@ vi.mock('@/providers/utils', () => ({ })) import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' -import { withPermissionGroupScope } from '@/lib/permission-groups/config-scope.server' +import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server' import { assertPermissionsAllowed, CustomToolsNotAllowedError, diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index c3d90ae26f5..00bf7781160 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -5,7 +5,7 @@ import { NextResponse } from 'next/server' import { getRateLimitHeaders } from '@/lib/api/server/rate-limit-context' import { HttpError } from '@/lib/core/utils/http-error' import { generateRequestId } from '@/lib/core/utils/request' -import { withPermissionGroupScope } from '@/lib/permission-groups/config-scope.server' +import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server' const logger = createLogger('RouteHandler') diff --git a/apps/sim/lib/permission-groups/config-scope.server.test.ts b/apps/sim/lib/permission-groups/config-scope.server.test.ts index 2ff3403dbed..7f4f473e885 100644 --- a/apps/sim/lib/permission-groups/config-scope.server.test.ts +++ b/apps/sim/lib/permission-groups/config-scope.server.test.ts @@ -15,10 +15,8 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ resolveVerifiedUserAccessControlContext: mockResolveVerifiedContext, })) -import { - resolvePermissionGroupConfig, - withPermissionGroupScope, -} from '@/lib/permission-groups/config-scope.server' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server' const CONFIG = { hideTablesTab: true } diff --git a/apps/sim/lib/permission-groups/config-scope.server.ts b/apps/sim/lib/permission-groups/config-scope.server.ts index dae112bf08c..ba9021faba9 100644 --- a/apps/sim/lib/permission-groups/config-scope.server.ts +++ b/apps/sim/lib/permission-groups/config-scope.server.ts @@ -1,43 +1,12 @@ import { cache } from 'react' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' +import type { PermissionGroupConfigKey } from '@/lib/permission-groups/request-scope.server' +import { getPermissionGroupConfigStore } from '@/lib/permission-groups/request-scope.server' import { getUserPermissionConfig, resolveVerifiedUserAccessControlContext, } from '@/lib/permission-groups/resolve.server' -type ConfigKey = `${string}:${string}` -type ConfigStore = Map> - -interface Storage { - getStore(): T | undefined - run(store: T, fn: () => R): R -} - -let storage: Storage - -if (typeof globalThis.process !== 'undefined' && globalThis.process.versions?.node) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { AsyncLocalStorage } = require('node:async_hooks') as typeof import('node:async_hooks') - storage = new AsyncLocalStorage() -} else { - storage = { - getStore: () => undefined, - run: (_store: ConfigStore, fn: () => R) => fn(), - } -} - -/** - * Establishes one permission-group memo for everything a request or job does. - * - * A request that authorizes several operations — a bulk mutation, a route that - * runs two use cases — would otherwise resolve the same group once per - * operation. Nesting this inside the request context means every route handler - * gets it without threading a parameter through 266 operations. - */ -export function withPermissionGroupScope(run: () => R): R { - return storage.run(new Map(), run) -} - /** * Memoized for a React server request, so an RSC render that resolves the same * viewer twice still makes one query. Both paths delegate here, so the scope and @@ -86,10 +55,10 @@ export function resolvePermissionGroupConfig( workspaceId: string, organizationId: string | null | undefined ): Promise { - const store = storage.getStore() + const store = getPermissionGroupConfigStore() if (!store) return resolveCached(userId, workspaceId, organizationId) - const key: ConfigKey = `${userId}:${workspaceId}` + const key: PermissionGroupConfigKey = `${userId}:${workspaceId}` const existing = store.get(key) if (existing) return existing diff --git a/apps/sim/lib/permission-groups/request-scope.server.ts b/apps/sim/lib/permission-groups/request-scope.server.ts new file mode 100644 index 00000000000..b28e3acd507 --- /dev/null +++ b/apps/sim/lib/permission-groups/request-scope.server.ts @@ -0,0 +1,64 @@ +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' + +/** + * A resolution key, `userId:workspaceId`. `organizationId` is deliberately not + * part of it — see `resolvePermissionGroupConfig` for why. + */ +export type PermissionGroupConfigKey = `${string}:${string}` + +/** + * The per-scope memo: a resolution key to the in-flight resolution for it. + * + * Holds the promise rather than the resolved value so N concurrent + * authorizations share one query instead of racing to start several. + */ +export type PermissionGroupConfigStore = Map< + PermissionGroupConfigKey, + Promise +> + +interface Storage { + getStore(): T | undefined + run(store: T, fn: () => R): R +} + +/** + * AsyncLocalStorage is only available in Node.js. Parts of this graph reach the + * Edge runtime, so fall back to a no-op that simply runs the callback — the + * resolver then degrades to its React `cache()` memo, which is slower but never + * wrong. + */ +let storage: Storage + +if (typeof globalThis.process !== 'undefined' && globalThis.process.versions?.node) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { AsyncLocalStorage } = require('node:async_hooks') as typeof import('node:async_hooks') + storage = new AsyncLocalStorage() +} else { + storage = { + getStore: () => undefined, + run: (_store: PermissionGroupConfigStore, fn: () => R) => fn(), + } +} + +/** + * Establishes one permission-group memo for everything a request or job does. + * + * A request that authorizes several operations — a bulk mutation, a route that + * runs two use cases — would otherwise resolve the same group once per + * operation. Nesting this inside the request context means every route handler + * gets it without threading a parameter through 266 operations. + * + * This module is deliberately free of runtime imports. `withRouteHandler` wraps + * every route in the app, so anything reachable from here is loaded by every + * route and every route test; the resolver that fills the store lives in + * `config-scope.server.ts`, which only the gate call sites import. + */ +export function withPermissionGroupScope(run: () => R): R { + return storage.run(new Map(), run) +} + +/** The memo for the current scope, or undefined when running outside one. */ +export function getPermissionGroupConfigStore(): PermissionGroupConfigStore | undefined { + return storage.getStore() +} diff --git a/packages/testing/src/mocks/permission-group-scope.mock.ts b/packages/testing/src/mocks/permission-group-scope.mock.ts index 48c290c6b9b..01f332298cf 100644 --- a/packages/testing/src/mocks/permission-group-scope.mock.ts +++ b/packages/testing/src/mocks/permission-group-scope.mock.ts @@ -32,10 +32,10 @@ export const permissionGroupScopeMockFns = { /** * Static mock module for `@/lib/permission-groups/config-scope.server`. * - * `withPermissionGroupScope` is a real passthrough rather than a `vi.fn()` - * because `withRouteHandler` calls it to wrap every route handler. A factory - * that exports only `resolvePermissionGroupConfig` leaves it `undefined`, and - * the route then fails with a 500 that looks nothing like the gate under test. + * Only the resolver lives there. `withPermissionGroupScope` — which + * `withRouteHandler` calls to wrap every route handler — lives in the + * import-free `@/lib/permission-groups/request-scope.server`, so mocking this + * module leaves the real scope wrapper in place and there is nothing to stub. * * @example * ```ts @@ -44,7 +44,6 @@ export const permissionGroupScopeMockFns = { */ export const permissionGroupScopeMock = { resolvePermissionGroupConfig: permissionGroupScopeMockFns.mockResolvePermissionGroupConfig, - withPermissionGroupScope: (run: () => R): R => run(), } /** Restores the ungoverned default — no group governs the user. */ diff --git a/scripts/check-application-graph.test.ts b/scripts/check-application-graph.test.ts index f891e2cda9c..a535a5e300b 100644 --- a/scripts/check-application-graph.test.ts +++ b/scripts/check-application-graph.test.ts @@ -42,9 +42,19 @@ describe('resolveSpecifier', () => { }) describe('the guarded roots', () => { + it('guards the universal route wrapper against the billing graph', () => { + const wrapper = GUARDED_ROOTS.find( + (guarded) => guarded.root === 'lib/core/utils/with-route-handler.ts' + ) + expect(wrapper?.forbidden['lib/billing/']).toBeTruthy() + }) + it('reaches no forbidden module tree at runtime', () => { - for (const root of GUARDED_ROOTS) { - expect({ root, violations: findViolations(root) }).toEqual({ root, violations: [] }) + for (const guarded of GUARDED_ROOTS) { + expect({ root: guarded.root, violations: findViolations(guarded) }).toEqual({ + root: guarded.root, + violations: [], + }) } }) @@ -54,7 +64,10 @@ describe('the guarded roots', () => { * the walker is proven able to fail. Without this the suite above would * still pass if `findViolations` silently stopped finding anything. */ - const violations = findViolations('lib/permission-groups/model-access.ts') + const violations = findViolations({ + root: 'lib/permission-groups/model-access.ts', + forbidden: FORBIDDEN_PREFIXES, + }) expect(violations).toHaveLength(1) expect(violations[0].forbidden).toBe('providers/utils.ts') expect(violations[0].reason).toBe(FORBIDDEN_PREFIXES['providers/']) diff --git a/scripts/check-application-graph.ts b/scripts/check-application-graph.ts index 0791a47337e..d56dd085b1f 100644 --- a/scripts/check-application-graph.ts +++ b/scripts/check-application-graph.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun /** - * Asserts the authorization funnel's module graph stays light. + * Asserts the authorization funnel's and the route wrapper's module graphs stay + * light. * * `@/lib/core/application` is imported by ~every domain `operations.ts`, and * `lib/permission-groups/capabilities.ts` sits below it, so anything either one @@ -10,11 +11,17 @@ * uploads/workflow graph are all far heavier than an authorization decision, * and none of them has anything to say about one. * + * `lib/core/utils/with-route-handler.ts` is guarded on the same principle with a + * wider list: it wraps every API route, so its graph is loaded by every route + * and every route test. + * * The edge this guards against is invisible without a check: adding one import * to a permission-group helper once widened this graph as far as * `lib/uploads/utils/file-utils.ts`, and the only symptom was two unrelated * knowledge tests failing on a partial mock of a module they never meant to - * load. + * load. Later, one import in the route wrapper pulled the whole billing graph + * into every route test, and the only symptom was an unrelated OTP-route test + * failing on its own partial `zod` mock. * * Walks runtime `import`/`export … from` specifiers only. `import type` is * erased by the compiler and costs nothing at runtime, so a type-only edge into @@ -28,14 +35,6 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const REPO_ROOT = resolve(SCRIPT_DIR, '..') const APP_ROOT = resolve(REPO_ROOT, 'apps/sim') -/** Entry points whose graph every authorization decision pays for. */ -export const GUARDED_ROOTS = [ - 'lib/core/application/index.ts', - 'lib/permission-groups/capabilities.ts', - 'lib/permission-groups/capability-assertions.ts', - 'lib/permission-groups/config-scope.server.ts', -] as const - /** * Path prefixes no guarded root may reach at runtime, with the reason a reviewer * needs to understand the failure without re-deriving this file. @@ -49,6 +48,50 @@ export const FORBIDDEN_PREFIXES: Record = { 'lib/workflows/': 'the workflow graph, which reaches the editor and serializer', } +/** + * `withRouteHandler` wraps every API route in the app, so its graph is the one + * graph *every* route and every route test pays for — a strictly wider blast + * radius than the authorization funnel's. It is a request-lifecycle wrapper: it + * stamps a request id, records timings, maps typed errors to statuses, and opens + * the permission-group memo. It decides nothing about billing, identity, or any + * domain, so it has no business loading those graphs. + * + * These sit on top of FORBIDDEN_PREFIXES for this root only. They are NOT + * app-wide bans — `lib/permission-groups/resolve.server.ts` legitimately reads + * the subscription to decide whether an organization is on an enterprise plan, + * which is why `lib/billing/` stays allowed for the funnel roots below. + */ +const ROUTE_WRAPPER_FORBIDDEN_PREFIXES: Record = { + 'lib/billing/': 'the billing graph — the route wrapper makes no plan or subscription decision', + 'lib/permission-groups/resolve.server': + 'the permission-group resolver — the wrapper only opens the memo scope; the resolver ' + + 'belongs to the gate call sites, and it is what dragged billing in', + 'lib/auth': 'the auth graph — the wrapper wraps handlers that authenticate, it does not', + 'lib/copilot/': 'the copilot graph', + 'lib/knowledge/': 'the knowledge-base graph', +} + +export interface GuardedRoot { + /** Path under `apps/sim`. */ + root: string + forbidden: Record +} + +/** + * Entry points whose graph every authorization decision — or, for the route + * wrapper, every request — pays for. + */ +export const GUARDED_ROOTS: readonly GuardedRoot[] = [ + { root: 'lib/core/application/index.ts', forbidden: FORBIDDEN_PREFIXES }, + { root: 'lib/permission-groups/capabilities.ts', forbidden: FORBIDDEN_PREFIXES }, + { root: 'lib/permission-groups/capability-assertions.ts', forbidden: FORBIDDEN_PREFIXES }, + { root: 'lib/permission-groups/config-scope.server.ts', forbidden: FORBIDDEN_PREFIXES }, + { + root: 'lib/core/utils/with-route-handler.ts', + forbidden: { ...FORBIDDEN_PREFIXES, ...ROUTE_WRAPPER_FORBIDDEN_PREFIXES }, + }, +] + /** * Matches a runtime `import … from '…'` or `export … from '…'`. * @@ -91,7 +134,7 @@ export interface GraphViolation { * forbidden prefix. Breadth-first on purpose: the shortest chain is the one a * reader can act on, and it names the single edge worth deleting. */ -export function findViolations(root: string): GraphViolation[] { +export function findViolations({ root, forbidden }: GuardedRoot): GraphViolation[] { const start = resolve(APP_ROOT, root) const violations: GraphViolation[] = [] const reported = new Set() @@ -112,14 +155,14 @@ export function findViolations(root: string): GraphViolation[] { if (next === null || seen.has(next)) continue const rel = relative(APP_ROOT, next) - const prefix = Object.keys(FORBIDDEN_PREFIXES).find((candidate) => rel.startsWith(candidate)) + const prefix = Object.keys(forbidden).find((candidate) => rel.startsWith(candidate)) if (prefix !== undefined) { if (!reported.has(prefix)) { reported.add(prefix) violations.push({ root, forbidden: rel, - reason: FORBIDDEN_PREFIXES[prefix], + reason: forbidden[prefix], path: [...path, next].map((entry) => relative(APP_ROOT, entry)), }) } @@ -136,16 +179,16 @@ export function findViolations(root: string): GraphViolation[] { function main(): void { const violations: GraphViolation[] = [] - for (const root of GUARDED_ROOTS) { - if (!existsSync(resolve(APP_ROOT, root))) { + for (const guarded of GUARDED_ROOTS) { + if (!existsSync(resolve(APP_ROOT, guarded.root))) { console.error( - `Application-graph audit could not find its own root '${root}'.\n` + + `Application-graph audit could not find its own root '${guarded.root}'.\n` + 'The module was renamed or moved. Update GUARDED_ROOTS rather than leaving this\n' + 'audit passing over a file that no longer exists.\n' ) process.exit(1) } - violations.push(...findViolations(root)) + violations.push(...findViolations(guarded)) } if (violations.length > 0) { @@ -161,9 +204,10 @@ function main(): void { process.exit(1) } + const trees = new Set(GUARDED_ROOTS.flatMap((guarded) => Object.keys(guarded.forbidden))) console.log( `✅ Application graph clean: ${GUARDED_ROOTS.length} roots reach none of ` + - `${Object.keys(FORBIDDEN_PREFIXES).length} forbidden module trees` + `${trees.size} forbidden module trees` ) } From 35807efe591ee4ebb6f023f79d2e73b11adfb188 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 12:22:52 -0700 Subject: [PATCH 040/179] fix(permission-groups): keep a workspace API key out of its creator's group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checkAccess` gained a `tables.use` gate for the raw `/api/table/**` routes, which authenticate with `checkSessionOrInternalAuth` and so only ever serve a real person. `/api/v1/tables/[tableId]/**` shares the same helper and passes `rateLimit.userId`, which for a WORKSPACE key is the key's creator — so a shared credential started failing whenever the bystander who minted it sat in a group that withholds Tables. `checkAccess` now takes a required `TableAccessPrincipal` discriminated union instead of a bare user id. A caller cannot reach the gated behavior by passing a string, and the only way to skip the gate is to name the `workspace_api_key` kind explicitly. v1 builds the principal through `tableAccessPrincipal`, which reads `keyType` through the one shared `capabilityGovernedUserId` helper that `resolveCapabilityRefusal` now uses too. The internal routes keep the gate. Separately, `logs.trace_spans` and `logs.cost` are projections rather than gates, so the v1 logs routes correctly declare `capability: 'none'` and still have to withhold the fields themselves — which they did not, handing a governed member trace spans, block payloads, the final output and every cost figure via `?details=full&includeTraceSpans=true`. The flags and the field-stripping now live in `lib/logs/log-projection.ts`, which `readLogDetail`'s caller resolves through as well, so there is one copy of the redaction rule. --- .../api/table/[tableId]/cancel-runs/route.ts | 2 +- .../app/api/table/[tableId]/columns/route.ts | 10 +- .../api/table/[tableId]/columns/run/route.ts | 2 +- .../api/table/[tableId]/delete-async/route.ts | 2 +- .../api/table/[tableId]/dispatches/route.ts | 2 +- .../table/[tableId]/events/stream/route.ts | 2 +- .../api/table/[tableId]/export-async/route.ts | 2 +- .../table/[tableId]/export/download/route.ts | 2 +- .../app/api/table/[tableId]/export/route.ts | 2 +- .../api/table/[tableId]/import-async/route.ts | 2 +- .../app/api/table/[tableId]/import/route.ts | 6 +- .../api/table/[tableId]/job/cancel/route.ts | 2 +- .../app/api/table/[tableId]/metadata/route.ts | 2 +- apps/sim/app/api/table/[tableId]/route.ts | 18 +- .../api/table/[tableId]/rows/find/route.ts | 6 +- .../table/[tableId]/views/[viewId]/route.ts | 12 +- .../app/api/table/[tableId]/views/route.ts | 4 +- apps/sim/app/api/table/utils.ts | 60 +++- apps/sim/app/api/v1/logs/[id]/route.ts | 39 ++- .../executions/[executionId]/route.test.ts | 2 + .../v1/logs/executions/[executionId]/route.ts | 10 +- apps/sim/app/api/v1/logs/projection.test.ts | 294 ++++++++++++++++++ apps/sim/app/api/v1/logs/route.ts | 28 +- apps/sim/app/api/v1/middleware.ts | 36 ++- .../api/v1/tables/[tableId]/columns/route.ts | 7 +- .../app/api/v1/tables/[tableId]/route.test.ts | 6 +- apps/sim/app/api/v1/tables/[tableId]/route.ts | 6 +- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 10 +- .../app/api/v1/tables/[tableId]/rows/route.ts | 32 +- .../v1/tables/[tableId]/rows/upsert/route.ts | 4 +- .../app/api/v1/tables/capability-gate.test.ts | 194 ++++++++++++ .../lib/logs/application/read-log-detail.ts | 30 +- apps/sim/lib/logs/fetch-log-detail.ts | 4 +- apps/sim/lib/logs/log-projection.ts | 79 +++++ 34 files changed, 828 insertions(+), 91 deletions(-) create mode 100644 apps/sim/app/api/v1/logs/projection.test.ts create mode 100644 apps/sim/app/api/v1/tables/capability-gate.test.ts create mode 100644 apps/sim/lib/logs/log-projection.ts diff --git a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts index 73a51011fc4..ecd2ef8e611 100644 --- a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts +++ b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts @@ -40,7 +40,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro // legacy Filter the runners/persisted payloads still compile. const filter = toLegacyFilter(wireFilter) - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 2b2aa60c131..dff45ad6728 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -44,7 +44,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum if (!validation.success) return validation.response const validated = validation.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -104,7 +104,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu if (!validation.success) return validation.response const validated = validation.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -161,7 +161,11 @@ export const DELETE = withRouteHandler( if (!validation.success) return validation.response const validated = validation.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'write' + ) if (!result.ok) return accessError(result, requestId, tableId) const { table } = result diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts index e9140d22a83..dd6dffd890d 100644 --- a/apps/sim/app/api/table/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -45,7 +45,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro // Dual-grammar wire: downgrade a predicate to the legacy Filter the // dispatcher and scheduled runs still compile. const filter = toLegacyFilter(wireFilter) - const access = await checkAccess(tableId, auth.userId, 'write') + const access = await checkAccess(tableId, { kind: 'user', userId: auth.userId }, 'write') if (!access.ok) return accessError(access, requestId, tableId) // Validate the filter up front (the dispatcher reuses it) so a bad field fails fast. diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.ts index 83382ea2ddd..96587b83d34 100644 --- a/apps/sim/app/api/table/[tableId]/delete-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.ts @@ -61,7 +61,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro throw error } - const access = await checkAccess(tableId, userId, 'write') + const access = await checkAccess(tableId, { kind: 'user', userId }, 'write') if (!access.ok) return accessError(access, requestId, tableId) const { table } = access diff --git a/apps/sim/app/api/table/[tableId]/dispatches/route.ts b/apps/sim/app/api/table/[tableId]/dispatches/route.ts index a39d3409402..2043f2c4e4e 100644 --- a/apps/sim/app/api/table/[tableId]/dispatches/route.ts +++ b/apps/sim/app/api/table/[tableId]/dispatches/route.ts @@ -34,7 +34,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou if (!parsed.success) return parsed.response const { tableId } = parsed.data.params - const result = await checkAccess(tableId, authResult.userId, 'read') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'read') if (!result.ok) return accessError(result, requestId, tableId) const rows = await listActiveDispatches(tableId) diff --git a/apps/sim/app/api/table/[tableId]/events/stream/route.ts b/apps/sim/app/api/table/[tableId]/events/stream/route.ts index 2142e4a1d93..3ccc9c39261 100644 --- a/apps/sim/app/api/table/[tableId]/events/stream/route.ts +++ b/apps/sim/app/api/table/[tableId]/events/stream/route.ts @@ -32,7 +32,7 @@ export const GET = withRouteHandler(async (req: NextRequest, context: RouteConte return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const access = await checkAccess(tableId, auth.userId, 'read') + const access = await checkAccess(tableId, { kind: 'user', userId: auth.userId }, 'read') if (!access.ok) return accessError(access, requestId, tableId) return createEventStreamResponse({ diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts index bd87c067c02..9f25b4d5ecf 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.ts @@ -49,7 +49,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const { tableId } = parsed.data.params const { workspaceId, format } = parsed.data.body - const access = await checkAccess(tableId, authResult.userId, 'read') + const access = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'read') if (!access.ok) return accessError(access, requestId, tableId) if (access.table.workspaceId !== workspaceId) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.ts b/apps/sim/app/api/table/[tableId]/export/download/route.ts index d8ed1ecdeb0..f0f0f253fb3 100644 --- a/apps/sim/app/api/table/[tableId]/export/download/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/download/route.ts @@ -43,7 +43,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou const { tableId } = parsed.data.params const { workspaceId, jobId } = parsed.data.query - const access = await checkAccess(tableId, authResult.userId, 'read') + const access = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'read') if (!access.ok) return accessError(access, requestId, tableId) if (access.table.workspaceId !== workspaceId) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index 23a8cae8ebd..018fbf00279 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -41,7 +41,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou } const format = formatValidation.data - const access = await checkAccess(tableId, auth.userId, 'read') + const access = await checkAccess(tableId, { kind: 'user', userId: auth.userId }, 'read') if (!access.ok) return accessError(access, requestId, tableId) const { table } = access diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.ts b/apps/sim/app/api/table/[tableId]/import-async/route.ts index 900e97d3a4d..6f007f6bc8b 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.ts @@ -38,7 +38,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const { workspaceId, fileKey, fileName, mode, mapping, createColumns, timezone } = parsed.data.body - const access = await checkAccess(tableId, userId, 'write') + const access = await checkAccess(tableId, { kind: 'user', userId }, 'write') if (!access.ok) return accessError(access, requestId, tableId) const { table } = access diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index b7b1d106df7..8161813ea47 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -96,7 +96,11 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro ) } - const accessResult = await checkAccess(tableId, authResult.userId, 'write') + const accessResult = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'write' + ) if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult diff --git a/apps/sim/app/api/table/[tableId]/job/cancel/route.ts b/apps/sim/app/api/table/[tableId]/job/cancel/route.ts index 677c338f65b..bee06bba32d 100644 --- a/apps/sim/app/api/table/[tableId]/job/cancel/route.ts +++ b/apps/sim/app/api/table/[tableId]/job/cancel/route.ts @@ -39,7 +39,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const { tableId } = parsed.data.params const { workspaceId, jobId } = parsed.data.body - const access = await checkAccess(tableId, authResult.userId, 'write') + const access = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'write') if (!access.ok) return accessError(access, requestId, tableId) if (access.table.workspaceId !== workspaceId) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) diff --git a/apps/sim/app/api/table/[tableId]/metadata/route.ts b/apps/sim/app/api/table/[tableId]/metadata/route.ts index 6883b56038c..e455ec187e9 100644 --- a/apps/sim/app/api/table/[tableId]/metadata/route.ts +++ b/apps/sim/app/api/table/[tableId]/metadata/route.ts @@ -35,7 +35,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR const { tableId } = parsed.data.params const validated = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 2e0a6286181..e868b2e3f6d 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -115,7 +115,11 @@ export const PATCH = withRouteHandler( // `write` is the floor for either operation; a `locks` change additionally // requires `admin` (checked below), matching the workflow-lock precedent. - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'write' + ) if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -125,7 +129,11 @@ export const PATCH = withRouteHandler( } if (validated.locks !== undefined) { - const adminResult = await checkAccess(tableId, authResult.userId, 'admin') + const adminResult = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'admin' + ) if (!adminResult.ok) { return NextResponse.json( { error: 'Admin access required to change table locks' }, @@ -233,7 +241,11 @@ export const DELETE = withRouteHandler( workspaceId: searchParams.get('workspaceId'), }) - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'write' + ) if (!result.ok) return accessError(result, requestId, tableId) const { table } = result diff --git a/apps/sim/app/api/table/[tableId]/rows/find/route.ts b/apps/sim/app/api/table/[tableId]/rows/find/route.ts index f72c86633f6..de7b8034a0b 100644 --- a/apps/sim/app/api/table/[tableId]/rows/find/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/find/route.ts @@ -47,7 +47,11 @@ export const GET = withRouteHandler( const validated = findTableRowsQuerySchema.parse({ workspaceId, q, filter, sort }) - const accessResult = await checkAccess(tableId, authResult.userId, 'read') + const accessResult = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'read' + ) if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult diff --git a/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts index b6c4774c95d..034d8059296 100644 --- a/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts @@ -34,7 +34,11 @@ export const PATCH = withRouteHandler( const { tableId, viewId } = parsed.data.params const { workspaceId, name, config, configPatch, isDefault } = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'write' + ) if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { @@ -85,7 +89,11 @@ export const DELETE = withRouteHandler( const { tableId, viewId } = parsed.data.params const { workspaceId } = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'write' + ) if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/table/[tableId]/views/route.ts b/apps/sim/app/api/table/[tableId]/views/route.ts index 975267d8a84..b191b9bf55f 100644 --- a/apps/sim/app/api/table/[tableId]/views/route.ts +++ b/apps/sim/app/api/table/[tableId]/views/route.ts @@ -33,7 +33,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR const { tableId } = parsed.data.params const { workspaceId } = parsed.data.query - const result = await checkAccess(tableId, authResult.userId, 'read') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'read') if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { @@ -68,7 +68,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Table const { tableId } = parsed.data.params const { workspaceId, name, config } = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'write') if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 5add676d157..fcaf0f97492 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -280,6 +280,49 @@ async function checkTableWriteAccess(tableId: string, userId: string): Promise { const table = await getTableById(tableId) @@ -311,15 +359,21 @@ export async function checkAccess( return { ok: false, status: 404 } } - const permission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId) + const permission = await getUserEntityPermissions( + roleSubjectUserId(principal), + 'workspace', + table.workspaceId + ) if (!permissionSatisfies(permission, level)) { return { ok: false, status: 403 } } // permission-group-enforced: tables.use — raw routes that query directly and predate the operation boundary + const governedUserId = capabilityGovernedUserId(principal) if ( + governedUserId && table.workspaceId && - (await isWorkspaceCapabilityWithheld(userId, table.workspaceId, 'tables.use')) + (await isWorkspaceCapabilityWithheld(governedUserId, table.workspaceId, 'tables.use')) ) { return { ok: false, status: 403, capability: 'tables.use' } } diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts index 02e9bf09cfa..7f5644cd2d2 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -5,9 +5,15 @@ import { v1GetLogContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { + projectCostTotal, + projectExecutionData, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { + capabilityGovernedUserId, checkRateLimit, createRateLimitResponse, validateWorkspaceAccess, @@ -46,6 +52,16 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Log not found' }, { status: 404 }) } + /** + * `logs.trace_spans` and `logs.cost` are projections, not gates — this + * route declares `'none'` above and withholds the fields here instead, + * through the same helper the internal/v2 detail path uses. + */ + const projection = await resolveLogFieldProjection( + capabilityGovernedUserId(rateLimit), + log.workspaceId + ) + const workflowSummary = { id: log.workflowId, name: log.workflowName || 'Deleted Workflow', @@ -69,16 +85,19 @@ export const GET = withRouteHandler( totalDurationMs: log.totalDurationMs, files: log.files || undefined, workflow: workflowSummary, - executionData: (await materializeExecutionDataForDisplay( - log.executionData as Record | null, - { - workspaceId: log.workspaceId, - workflowId: log.workflowId, - executionId: log.executionId, - userId, - } - )) as any, - cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + executionData: projectExecutionData( + (await materializeExecutionDataForDisplay( + log.executionData as Record | null, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + userId, + } + )) as Record | null, + projection + ) as any, + cost: projectCostTotal(log.costTotal, projection), createdAt: log.createdAt.toISOString(), } diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts index 92ee767591e..7a10930557a 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/app/api/v1/middleware', () => ({ + capabilityGovernedUserId: (rateLimit: { keyType?: string; userId?: string }) => + rateLimit.keyType === 'personal' ? (rateLimit.userId ?? null) : null, checkRateLimit: mocks.checkRateLimit, createRateLimitResponse: () => NextResponse.json({ error: 'Rate limit' }, { status: 429 }), validateWorkspaceAccess: mocks.validateWorkspaceAccess, diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts index 76e37063493..22d1ce54283 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts @@ -3,10 +3,12 @@ import { type NextRequest, NextResponse } from 'next/server' import { v1GetExecutionContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { projectCostTotal, resolveLogFieldProjection } from '@/lib/logs/log-projection' import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { sanitizeExecutionSnapshotState } from '@/lib/logs/snapshot-sanitizer' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { + capabilityGovernedUserId, checkRateLimit, createRateLimitResponse, validateWorkspaceAccess, @@ -62,6 +64,12 @@ export const GET = withRouteHandler( * treatment the v2 run detail applies. A snapshot the sanitizer cannot walk projects * as `null`, which keeps the pre-existing "not found" outcome for an absent one. */ + /** `logs.cost` is a projection, not a gate — see `resolveLogFieldProjection`. */ + const projection = await resolveLogFieldProjection( + capabilityGovernedUserId(rateLimit), + workflowLog.workspaceId + ) + const workflowState = sanitizeExecutionSnapshotState(workflowLog.workflowState) if (!workflowState) { return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 }) @@ -78,7 +86,7 @@ export const GET = withRouteHandler( totalDurationMs: workflowLog.totalDurationMs, // Sourced from the cost_total projection of the usage_log ledger // (the deprecated cost jsonb column was dropped). - cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null, + cost: projectCostTotal(workflowLog.costTotal, projection), }, } diff --git a/apps/sim/app/api/v1/logs/projection.test.ts b/apps/sim/app/api/v1/logs/projection.test.ts new file mode 100644 index 00000000000..17c9adfd4ed --- /dev/null +++ b/apps/sim/app/api/v1/logs/projection.test.ts @@ -0,0 +1,294 @@ +/** + * @vitest-environment node + * + * `logs.trace_spans` and `logs.cost` are PROJECTIONS, not gates — a group + * withholds those fields from the response rather than refusing the read, which + * is why every v1 logs route correctly declares `capability: 'none'`. The + * internal/v2 detail path applies them in `readLogDetail`; the v1 routes built + * their own bodies and applied nothing, so `?details=full&includeTraceSpans=true` + * still handed a governed member the spans and the spend. + * + * These run the real routes against the real `resolveLogFieldProjection` — the + * same helper `readLogDetail` resolves its flags through — so they fail if + * either surface stops projecting. + */ +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticateV1Request, + mockGetUserEntityPermissions, + mockGetWorkspaceBillingSettings, + mockListPublicWorkflowLogs, + mockGetPublicWorkflowLog, + mockMaterialize, +} = vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceBillingSettings: vi.fn(), + mockListPublicWorkflowLogs: vi.fn(), + mockGetPublicWorkflowLog: vi.fn(), + mockMaterialize: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@/app/api/v1/auth', () => ({ authenticateV1Request: mockAuthenticateV1Request })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, + getWorkspaceBilledAccountUserId: vi.fn(async () => 'billed-user'), + getWorkspaceOrganizationId: vi.fn(async () => null), +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: vi.fn(async () => null), +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitWithSubscription() { + return Promise.resolve({ allowed: true, remaining: 100, resetAt: new Date() }) + } + }, + getRateLimit: () => ({ maxTokens: 200 }), +})) +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + buildRateLimitHeaders: () => ({}), + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: () => null, +})) +vi.mock('@/lib/logs/public-queries', () => ({ + listPublicWorkflowLogs: mockListPublicWorkflowLogs, + getPublicWorkflowLog: mockGetPublicWorkflowLog, + decodePublicLogCursor: vi.fn(), +})) +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionDataForDisplay: mockMaterialize, +})) +vi.mock('@/lib/logs/snapshot-sanitizer', () => ({ + sanitizeExecutionSnapshotState: (state: unknown) => state, +})) +vi.mock('@/app/api/v1/logs/meta', () => ({ + getUserLimits: vi.fn(async () => ({})), + createApiResponse: (body: unknown) => ({ body, headers: {} }), +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GET as getLogDetail } from '@/app/api/v1/logs/[id]/route' +import { GET as getExecution } from '@/app/api/v1/logs/executions/[executionId]/route' +import { GET as listLogs } from '@/app/api/v1/logs/route' + +const USER_ID = 'user-1' +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' +const LOG_ID = 'log-1' + +const EXECUTION_DATA = { + finalOutput: { answer: 'a customer address' }, + workflowInput: { question: 'who?' }, + blockInput: { prompt: 'who?' }, + blockExecutions: [{ blockId: 'b1', cost: { total: 0.2 }, tokens: { total: 90 } }], + traceSpans: [ + { + id: 's1', + name: 'agent', + cost: { total: 0.5 }, + tokens: { total: 120 }, + children: [{ id: 's2', name: 'tool', cost: { total: 0.1 } }], + }, + ], +} + +const LOG_ROW = { + id: LOG_ID, + workflowId: 'wf-1', + workspaceId: WORKSPACE_ID, + executionId: 'exec-1', + deploymentVersionId: null, + level: 'info', + trigger: 'api', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: new Date('2026-01-01T00:00:01.000Z'), + createdAt: new Date('2026-01-01T00:00:01.000Z'), + workflowState: { blocks: {} }, + totalDurationMs: 1000, + costTotal: '0.75', + files: null, + executionData: EXECUTION_DATA, + workflowName: 'wf', + workflowDescription: null, + workflowFolderId: null, + workflowUserId: USER_ID, + workflowWorkspaceId: WORKSPACE_ID, + workflowCreatedAt: new Date('2026-01-01T00:00:00.000Z'), + workflowUpdatedAt: new Date('2026-01-01T00:00:00.000Z'), +} + +function personalKey() { + return { + authenticated: true, + userId: USER_ID, + keyType: 'personal' as const, + principal: { kind: 'personal_api_key' as const, userId: USER_ID, keyId: 'key-1' }, + } +} + +/** A workspace key authorizes as the workspace: its creator's group is nobody's. */ +function workspaceKey() { + return { + authenticated: true, + userId: 'key-creator', + workspaceId: WORKSPACE_ID, + keyType: 'workspace' as const, + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-2' }, + } +} + +function governedBy(overrides: Partial) { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + ...overrides, + }) +} + +function apiRequest(path: string) { + return new NextRequest(`http://localhost${path}`, { + method: 'GET', + headers: { 'x-api-key': 'sim_test' }, + }) +} + +function listFull() { + return listLogs( + apiRequest( + `/api/v1/logs?workspaceId=${WORKSPACE_ID}&details=full&includeTraceSpans=true&includeFinalOutput=true` + ) + ) +} + +function readExecution() { + return getExecution(apiRequest('/api/v1/logs/executions/exec-1'), { + params: Promise.resolve({ executionId: 'exec-1' }), + }) +} + +function readDetail() { + return getLogDetail(apiRequest(`/api/v1/logs/${LOG_ID}`), { + params: Promise.resolve({ id: LOG_ID }), + }) +} + +beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mockAuthenticateV1Request.mockResolvedValue(personalKey()) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true }) + mockListPublicWorkflowLogs.mockResolvedValue({ data: [LOG_ROW], nextCursor: null }) + mockGetPublicWorkflowLog.mockResolvedValue(LOG_ROW) + mockMaterialize.mockImplementation(async () => structuredClone(EXECUTION_DATA)) +}) + +describe('GET /api/v1/logs?details=full', () => { + it('withholds trace spans and the final output when the group hides them', async () => { + governedBy({ hideTraceSpans: true }) + + const body = await (await listFull()).json() + const [log] = body.data + + expect(log).not.toHaveProperty('traceSpans') + expect(log).not.toHaveProperty('finalOutput') + }) + + it('withholds the run cost when the group hides cost', async () => { + governedBy({ hideCostInfo: true }) + + const body = await (await listFull()).json() + + expect(body.data[0].cost).toBeNull() + }) + + it('strips spend from the spans it still returns when only cost is hidden', async () => { + governedBy({ hideCostInfo: true }) + + const body = await (await listFull()).json() + const [span] = body.data[0].traceSpans + + expect(span.name).toBe('agent') + expect(span).not.toHaveProperty('cost') + expect(span).not.toHaveProperty('tokens') + expect(span.children[0]).not.toHaveProperty('cost') + }) + + it('returns both when no group withholds them', async () => { + const body = await (await listFull()).json() + const [log] = body.data + + expect(log.cost).toEqual({ total: 0.75 }) + expect(log.traceSpans[0].cost).toEqual({ total: 0.5 }) + expect(log.finalOutput).toEqual(EXECUTION_DATA.finalOutput) + }) + + it('withholds nothing from a workspace API key, whose creator has no say', async () => { + mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) + governedBy({ hideTraceSpans: true, hideCostInfo: true }) + + const body = await (await listFull()).json() + const [log] = body.data + + expect(log.cost).toEqual({ total: 0.75 }) + expect(log.traceSpans).toHaveLength(1) + }) +}) + +describe('GET /api/v1/logs/[id]', () => { + it('withholds the execution payloads when the group hides trace spans', async () => { + governedBy({ hideTraceSpans: true }) + + const body = await (await readDetail()).json() + + expect(body.data.executionData).not.toHaveProperty('traceSpans') + expect(body.data.executionData).not.toHaveProperty('blockExecutions') + expect(body.data.executionData).not.toHaveProperty('finalOutput') + expect(body.data.executionData).not.toHaveProperty('workflowInput') + expect(body.data.executionData).not.toHaveProperty('blockInput') + }) + + it('withholds the run cost and per-span spend when the group hides cost', async () => { + governedBy({ hideCostInfo: true }) + + const body = await (await readDetail()).json() + + expect(body.data.cost).toBeNull() + expect(body.data.executionData.traceSpans[0]).not.toHaveProperty('cost') + expect(body.data.executionData.blockExecutions[0]).not.toHaveProperty('tokens') + }) + + it('returns everything when no group withholds it', async () => { + const body = await (await readDetail()).json() + + expect(body.data.cost).toEqual({ total: 0.75 }) + expect(body.data.executionData.traceSpans[0].cost).toEqual({ total: 0.5 }) + expect(body.data.executionData.finalOutput).toEqual(EXECUTION_DATA.finalOutput) + }) +}) + +describe('GET /api/v1/logs/executions/[executionId]', () => { + it('withholds the run cost when the group hides cost', async () => { + governedBy({ hideCostInfo: true }) + + const body = await (await readExecution()).json() + + expect(body.executionMetadata.cost).toBeNull() + }) + + it('returns the run cost when no group withholds it', async () => { + const body = await (await readExecution()).json() + + expect(body.executionMetadata.cost).toEqual({ total: 0.75 }) + }) +}) diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index 52f3620ba5a..d04cf2e0153 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -6,9 +6,15 @@ import { parseRequest } from '@/lib/api/server' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { + projectCostTotal, + projectExecutionData, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import { decodePublicLogCursor, listPublicWorkflowLogs } from '@/lib/logs/public-queries' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { + capabilityGovernedUserId, checkRateLimit, createRateLimitResponse, v1ValidationErrorResponse, @@ -51,6 +57,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (accessError) return accessError + /** + * `logs.trace_spans` and `logs.cost` are projections, not gates, which is + * why this route declares `'none'` above and still has to withhold fields + * here. Same helper the internal/v2 detail path uses — a hidden tab + * withholds nothing from a caller reading the public API directly. + */ + const projection = await resolveLogFieldProjection( + capabilityGovernedUserId(rateLimit), + params.workspaceId + ) + logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, { userId, filters: { @@ -106,7 +123,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { startedAt: log.startedAt.toISOString(), endedAt: log.endedAt?.toISOString() || null, totalDurationMs: log.totalDurationMs, - cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + cost: projectCostTotal(log.costTotal, projection), files: log.files || null, } @@ -126,7 +143,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { const result = buildBase(log) if (log.executionData) { - const execData = (await materializeExecutionDataForDisplay( + const materialized = (await materializeExecutionDataForDisplay( log.executionData as Record | null, { workspaceId: log.workspaceId, @@ -134,11 +151,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { executionId: log.executionId, userId, } - )) as any - if (params.includeFinalOutput && execData.finalOutput) { + )) as Record | null + const execData = projectExecutionData(materialized, projection) as any + if (params.includeFinalOutput && execData?.finalOutput) { result.finalOutput = execData.finalOutput } - if (params.includeTraceSpans && execData.traceSpans) { + if (params.includeTraceSpans && execData?.traceSpans) { result.traceSpans = execData.traceSpans } } diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index c5590a7fac2..b0b1da23b0b 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -24,6 +24,7 @@ import { getWorkspaceBilledAccountUserId, getWorkspaceBillingSettings, } from '@/lib/workspaces/utils' +import type { TableAccessPrincipal } from '@/app/api/table/utils' import { authenticateV1Request } from '@/app/api/v1/auth' const logger = createLogger('V1Middleware') @@ -93,6 +94,39 @@ export function requireRateLimitUserId(rateLimit: RateLimitResult): string { return rateLimit.userId } +/** + * The user whose permission group governs this request, or `null` when none + * does. + * + * `rateLimit.userId` is present for BOTH key kinds, and for a workspace key it + * is the key's *creator* — a bystander who may not be the caller. Any gate keyed + * on the presence of a user id therefore applies that bystander's group to every + * caller of a shared credential. `keyType` is the authoritative signal, and this + * is the one place v1 reads it for that purpose, so + * {@link resolveCapabilityRefusal}, {@link tableAccessPrincipal} and the log + * field projection cannot drift. + */ +export function capabilityGovernedUserId(rateLimit: RateLimitResult): string | null { + return rateLimit.keyType === 'personal' ? (rateLimit.userId ?? null) : null +} + +/** + * The {@link TableAccessPrincipal} for a v1 table request. + * + * `/api/v1/tables/**` shares `checkAccess` with the raw internal table routes, + * which gate `tables.use` inside it. Those routes reject `x-api-key` outright, + * so every caller there is a person; v1's are API keys, and a workspace key must + * reach the table ungated. Built here rather than at each of the fourteen v1 + * handlers, so the decision stays in the same module as every other `keyType` + * policy. + */ +export function tableAccessPrincipal(rateLimit: RateLimitResult): TableAccessPrincipal { + const userId = requireRateLimitUserId(rateLimit) + return capabilityGovernedUserId(rateLimit) + ? { kind: 'user', userId } + : { kind: 'workspace_api_key', keyCreatorUserId: userId } +} + export function requireRateLimitPrincipal( rateLimit: RateLimitResult ): PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal { @@ -284,7 +318,7 @@ export async function resolveCapabilityRefusal( capability: V1RouteCapability ): Promise { if (capability === 'none') return null - if (rateLimit.keyType !== 'personal') return null + if (!capabilityGovernedUserId(rateLimit)) return null if (!(await isWorkspaceCapabilityWithheld(userId, workspaceId, capability))) return null diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index ae8c00affab..96c7ae38c1a 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -24,6 +24,7 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + tableAccessPrincipal, v1ValidationErrorResponse, v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' @@ -59,7 +60,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -125,7 +126,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -187,7 +188,7 @@ export const DELETE = withRouteHandler( const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.test.ts b/apps/sim/app/api/v1/tables/[tableId]/route.test.ts index 7a71898fb9f..b9206531317 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.test.ts @@ -29,6 +29,10 @@ const { vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, + tableAccessPrincipal: (rateLimit: { keyType?: string; userId?: string }) => + rateLimit.keyType === 'workspace' + ? { kind: 'workspace_api_key', keyCreatorUserId: rateLimit.userId } + : { kind: 'user', userId: rateLimit.userId }, checkWorkspaceScope: mockCheckWorkspaceScope, createRateLimitResponse: () => NextResponse.json({ error: 'Rate limited' }, { status: 429 }), })) @@ -80,7 +84,7 @@ function makeContext() { describe('DELETE /api/v1/tables/[tableId] — orchestration failure projection', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' }) + mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1', keyType: 'personal' }) mockCheckWorkspaceScope.mockResolvedValue(null) mockGetTableById.mockResolvedValue({ id: TABLE_ID, diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index 5d46bdf619b..ab172723442 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -17,6 +17,7 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + tableAccessPrincipal, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableDetailAPI') @@ -38,7 +39,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1GetTableContract, request, context, { validationErrorResponse: (error) => { const hasInvalidTableId = error.issues.some((issue) => issue.path.includes('tableId')) @@ -60,7 +60,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'read') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'read') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -133,7 +133,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 034fb8cd8f8..04b500897b8 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -30,6 +30,7 @@ import { checkWorkspaceScope, createRateLimitResponse, resolveWorkspaceRequestActor, + tableAccessPrincipal, v1ValidationErrorResponse, v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' @@ -53,7 +54,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1GetTableRowContract, request, context, { validationErrorResponse: () => NextResponse.json({ error: 'workspaceId query parameter is required' }, { status: 400 }), @@ -65,7 +65,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'read') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'read') if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { @@ -125,7 +125,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1UpdateTableRowContract, request, context, { validationErrorResponse: v1ValidationErrorResponse, }) @@ -140,7 +139,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`) } - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -219,7 +218,6 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1DeleteTableRowContract, request, context, { validationErrorResponse: () => NextResponse.json({ error: 'workspaceId query parameter is required' }, { status: 400 }), @@ -231,7 +229,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index 9297cc68181..ec8bf68249d 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -33,12 +33,18 @@ import { signalTableRowsChanged } from '@/lib/table/events' import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' import { queryRows } from '@/lib/table/rows/service' import { resolveFilterSelectValues } from '@/lib/table/select-values' -import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + orchestrationErrorResponse, + type TableAccessPrincipal, +} from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, resolveWorkspaceRequestActor, + tableAccessPrincipal, v1ValidationErrorResponse, v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' @@ -56,10 +62,10 @@ async function handleBatchInsert( requestId: string, tableId: string, validated: V1BatchInsertTableRowsBody, - userId: string, + principal: TableAccessPrincipal, actorUserId: string ): Promise { - const accessResult = await checkAccess(tableId, userId, 'write') + const accessResult = await checkAccess(tableId, principal, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult @@ -127,7 +133,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1ListTableRowsContract, request, context, { validationErrorResponse: (error) => { const hasJsonError = error.issues.some( @@ -147,7 +152,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError - const accessResult = await checkAccess(tableId, userId, 'read') + const accessResult = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'read') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult @@ -224,7 +229,6 @@ export const POST = withRouteHandler( return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1CreateTableRowContract, request, context, { validationErrorResponse: v1ValidationErrorResponse, }) @@ -244,7 +248,13 @@ export const POST = withRouteHandler( `Unable to resolve system actor for workspace ${batchValidated.workspaceId}` ) } - return handleBatchInsert(requestId, tableId, batchValidated, userId, actorUserId) + return handleBatchInsert( + requestId, + tableId, + batchValidated, + tableAccessPrincipal(rateLimit), + actorUserId + ) } const validated = parsed.data.body @@ -256,7 +266,7 @@ export const POST = withRouteHandler( throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`) } - const accessResult = await checkAccess(tableId, userId, 'write') + const accessResult = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult @@ -325,7 +335,6 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1UpdateRowsByFilterContract, request, context, { validationErrorResponse: v1ValidationErrorResponse, }) @@ -340,7 +349,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`) } - const accessResult = await checkAccess(tableId, userId, 'write') + const accessResult = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult @@ -421,7 +430,6 @@ export const DELETE = withRouteHandler( return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1DeleteTableRowsContract, request, context, { validationErrorResponse: v1ValidationErrorResponse, }) @@ -432,7 +440,7 @@ export const DELETE = withRouteHandler( const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError - const accessResult = await checkAccess(tableId, userId, 'write') + const accessResult = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index 8ac2e5cbc6d..6e6c8f96d62 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -21,6 +21,7 @@ import { checkWorkspaceScope, createRateLimitResponse, resolveWorkspaceRequestActor, + tableAccessPrincipal, v1ValidationErrorResponse, v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' @@ -44,7 +45,6 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1UpsertTableRowContract, request, context, { validationErrorResponse: v1ValidationErrorResponse, }) @@ -59,7 +59,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`) } - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result diff --git a/apps/sim/app/api/v1/tables/capability-gate.test.ts b/apps/sim/app/api/v1/tables/capability-gate.test.ts new file mode 100644 index 00000000000..e814e7c5343 --- /dev/null +++ b/apps/sim/app/api/v1/tables/capability-gate.test.ts @@ -0,0 +1,194 @@ +/** + * @vitest-environment node + * + * `/api/v1/tables/[tableId]/**` shares `checkAccess` with the raw internal + * `/api/table/**` routes, and `checkAccess` gates `tables.use` inside itself. + * That gate is correct for the internal routes — `checkSessionOrInternalAuth` + * rejects `x-api-key`, so every caller there is a person. v1 authenticates with + * an API key, and a WORKSPACE key reports its creator's user id: gating on that + * id applies a bystander's permission group to every caller of a shared + * credential, which is exactly what `principal-scope.server.ts` and the + * `workspace_api_key` branch of `authorizeWorkspaceOperation` refuse to do. + * + * These run the real middleware and the real `checkAccess` against the real + * route — only the credential, the rate bucket, the workspace role, the table + * row and the governing group config are mocked. + */ +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticateV1Request, + mockGetUserEntityPermissions, + mockGetWorkspaceBillingSettings, + mockGetTableById, +} = vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceBillingSettings: vi.fn(), + mockGetTableById: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@/app/api/v1/auth', () => ({ authenticateV1Request: mockAuthenticateV1Request })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, + getWorkspaceBilledAccountUserId: vi.fn(async () => 'billed-user'), + getWorkspaceOrganizationId: vi.fn(async () => null), +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: vi.fn(async () => null), +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitWithSubscription() { + return Promise.resolve({ allowed: true, remaining: 100, resetAt: new Date() }) + } + }, + getRateLimit: () => ({ maxTokens: 200 }), +})) +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + buildRateLimitHeaders: () => ({}), + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: () => null, +})) +vi.mock('@/lib/table', () => ({ + getTableById: mockGetTableById, + buildFilterClause: vi.fn(), + TableQueryValidationError: class TableQueryValidationError extends Error {}, +})) +vi.mock('@/lib/table/orchestration', () => ({ performDeleteTable: vi.fn() })) +vi.mock('@/lib/table/wire', () => ({ normalizeColumn: (column: unknown) => column })) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GET as getTable } from '@/app/api/v1/tables/[tableId]/route' + +const MEMBER_ID = 'user-1' +const TABLE_ID = '22222222-2222-4222-8222-222222222222' +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' + +const TABLE = { + id: TABLE_ID, + name: 'expenses', + workspaceId: WORKSPACE_ID, + description: null, + rowCount: 0, + maxRows: 1000, + locks: null, + schema: { columns: [] }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} + +function personalKey() { + return { + authenticated: true, + userId: MEMBER_ID, + keyType: 'personal' as const, + principal: { kind: 'personal_api_key' as const, userId: MEMBER_ID, keyId: 'key-1' }, + } +} + +/** + * A workspace key still reports a `userId` — the key's CREATOR. A gate keyed on + * the presence of a user id rather than on the principal kind would apply that + * bystander's group to every caller of the shared credential. + */ +function workspaceKey() { + return { + authenticated: true, + userId: 'key-creator', + workspaceId: WORKSPACE_ID, + keyType: 'workspace' as const, + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-2', + }, + } +} + +function governedBy(overrides: Partial) { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + ...overrides, + }) +} + +function readTable() { + return getTable( + new NextRequest(`http://localhost/api/v1/tables/${TABLE_ID}?workspaceId=${WORKSPACE_ID}`, { + method: 'GET', + headers: { 'x-api-key': 'sim_test' }, + }), + { params: Promise.resolve({ tableId: TABLE_ID }) } + ) +} + +const REFUSAL = /is not available under your organization's permission group/ + +beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mockAuthenticateV1Request.mockResolvedValue(personalKey()) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true }) + mockGetTableById.mockResolvedValue(TABLE) +}) + +describe('tables.use on /api/v1/tables/[tableId]', () => { + it('lets a workspace API key through even when its CREATOR is denied Tables', async () => { + mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) + governedBy({ hideTablesTab: true }) + + const response = await readTable() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.table.id).toBe(TABLE_ID) + }) + + it('never resolves a group for a workspace API key at all', async () => { + mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) + governedBy({ hideTablesTab: true }) + + await readTable() + + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('still refuses a personal API key whose group withholds Tables', async () => { + governedBy({ hideTablesTab: true }) + + const response = await readTable() + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toMatch(REFUSAL) + expect(body.details).toEqual({ code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }) + }) + + it('lets a personal API key through when no group withholds Tables', async () => { + const response = await readTable() + + expect(response.status).toBe(200) + }) + + it('still refuses either key kind on role, before naming the capability', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + governedBy({ hideTablesTab: true }) + + const response = await readTable() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Access denied' }) + }) +}) diff --git a/apps/sim/lib/logs/application/read-log-detail.ts b/apps/sim/lib/logs/application/read-log-detail.ts index 6a25970031b..ddd56478b99 100644 --- a/apps/sim/lib/logs/application/read-log-detail.ts +++ b/apps/sim/lib/logs/application/read-log-detail.ts @@ -11,8 +11,7 @@ import { } from '@/lib/logs/application/authorization' import { logOperations } from '@/lib/logs/application/operations' import { readLogDetail } from '@/lib/logs/fetch-log-detail' -import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' -import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { resolveLogFieldProjection } from '@/lib/logs/log-projection' import { type ActiveWorkspaceApplicationContext, resolveActiveWorkspaceApplicationContext, @@ -84,23 +83,15 @@ const authorizedReadLogDetailUseCase = defineAuthorizedWorkspaceUseCase({ const viewerUserId = resolvePrincipalSubjectUserId(principal) /** - * permission-group-enforced: logs.trace_spans — a projection rather than a - * refusal: the log stays readable, its execution payloads do not. An - * actorless run has no group and reads its own workspace's logs whole. - * - * permission-group-enforced: logs.cost — the same projection, applied to - * spend: the run total, its itemized ledger and the per-block and per-span - * figures. Refusing the read instead would withhold the status and the - * error message too, which is not what an organization restricting spend - * visibility to admins asked for. + * A projection rather than a refusal: the log stays readable, its execution + * payloads and its spend do not. Resolved through the shared helper, which + * the v1 public API reads too — see {@link resolveLogFieldProjection}. */ - const permissionConfig = viewerUserId - ? await resolvePermissionGroupConfig( - viewerUserId, - context.workspaceId, - context.workspaceOrganizationId - ) - : null + const projection = await resolveLogFieldProjection( + viewerUserId, + context.workspaceId, + context.workspaceOrganizationId + ) const detail = await readLogDetail({ viewerUserId, @@ -108,8 +99,7 @@ const authorizedReadLogDetailUseCase = defineAuthorizedWorkspaceUseCase({ lookupColumn: input.lookupColumn, lookupValue: input.lookupValue, signal: input.signal, - hideTraceSpans: capabilityDeniedBy('logs.trace_spans', permissionConfig), - hideCostInfo: capabilityDeniedBy('logs.cost', permissionConfig), + ...projection, }) input.signal?.throwIfAborted() if (!detail) throw new OrchestrationError('not_found', 'Not found') diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts index 8edcd2c9ce2..4f0319f4d0c 100644 --- a/apps/sim/lib/logs/fetch-log-detail.ts +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -66,7 +66,9 @@ interface FetchLogDetailArgs { * Applied before child traces are hydrated, so a withheld view does not pay for * a cross-workspace join whose result it discards. */ -function withheldExecutionData(executionData: Record): Record { +export function withheldExecutionData( + executionData: Record +): Record { const { traceSpans: _traceSpans, blockExecutions: _blockExecutions, diff --git a/apps/sim/lib/logs/log-projection.ts b/apps/sim/lib/logs/log-projection.ts new file mode 100644 index 00000000000..b4f39c66e5d --- /dev/null +++ b/apps/sim/lib/logs/log-projection.ts @@ -0,0 +1,79 @@ +import { withheldExecutionData, withheldSpendData } from '@/lib/logs/fetch-log-detail' +import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' + +/** + * What a viewer's permission group withholds from a log response. + * + * `logs.trace_spans` and `logs.cost` are PROJECTIONS rather than gates: the log + * stays readable, some of its fields do not. That is why every logs route + * declares `capability: 'none'` — refusing the read would withhold the status + * and the error message too, which is not what an organization restricting + * execution detail or spend visibility asked for. + */ +export interface LogFieldProjection { + hideTraceSpans: boolean + hideCostInfo: boolean +} + +/** Nothing withheld — the shape a caller with no governing group gets. */ +export const NO_LOG_FIELD_PROJECTION: LogFieldProjection = { + hideTraceSpans: false, + hideCostInfo: false, +} + +/** + * The projection a viewer's permission group imposes on a workspace's logs. + * + * `viewerUserId` is `null` when no group governs the request — an actorless run + * (a schedule, or a webhook with no external subject) reading its own + * workspace's logs, and a workspace API key, which authorizes as the workspace + * and whose reported user id is only the key's creator. Both read whole. + * + * The one place the two capabilities are read, so the internal/v2 detail path + * and the v1 public API cannot drift: two copies of a redaction rule is how one + * of them stops redacting. + * + * permission-group-enforced: logs.trace_spans + * permission-group-enforced: logs.cost + */ +export async function resolveLogFieldProjection( + viewerUserId: string | null | undefined, + workspaceId: string, + organizationId?: string | null +): Promise { + if (!viewerUserId) return NO_LOG_FIELD_PROJECTION + + const config = await resolvePermissionGroupConfig(viewerUserId, workspaceId, organizationId) + return { + hideTraceSpans: capabilityDeniedBy('logs.trace_spans', config), + hideCostInfo: capabilityDeniedBy('logs.cost', config), + } +} + +/** + * Applies {@link LogFieldProjection} to a materialized execution payload. + * + * Both halves DELETE the withheld fields rather than leaving them for response + * validation to drop, because the log contracts are passthrough (and a span's + * own shape is a `catchall`), so a field left in place would survive the schema. + */ +export function projectExecutionData | null | undefined>( + executionData: T, + projection: LogFieldProjection +): T | Record { + if (!executionData) return executionData + const withoutPayloads = projection.hideTraceSpans + ? withheldExecutionData(executionData) + : executionData + return projection.hideCostInfo ? withheldSpendData(withoutPayloads) : withoutPayloads +} + +/** The run's cost total, or `null` when the group withholds spend. */ +export function projectCostTotal( + costTotal: unknown, + projection: LogFieldProjection +): { total: number } | null { + if (projection.hideCostInfo || costTotal == null) return null + return { total: Number(costTotal) } +} From d55446c63d3b44238d374001e21ef1e68f03a5d6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 12:37:49 -0700 Subject: [PATCH 041/179] fix(permission-groups): make the key-creator substitution structurally impossible in v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep across every remaining /api/v1 domain (knowledge, files, workflows, logs, audit-logs, copilot, tables) found no route deciding a permission-group capability from an API key's creator: every capability decision already funnels through `capabilityGovernedUserId`, either via `validateWorkspaceAccess` / `resolveKnowledgeBase` / `resolveV1DeploymentWorkflow`, via `tableAccessPrincipal`, or directly at the three log-projection call sites. What was left was the shape the bug takes rather than the bug. `resolveCapabilityRefusal` guarded on `capabilityGovernedUserId(rateLimit)` and then asserted against a *different* variable — the `userId` the role check uses. The two agreed, but nothing made them agree. It now takes no caller-supplied id at all and asserts against the guard's own return value, and `resolveWorkspaceScope`'s `personal_api_key.use` check reads the same helper instead of `rateLimit.userId`. `check:capability-subject` is what stops the next route. It asserts that no v1 file outside the middleware imports the permission-group modules, that every capability sink's subject argument came from `capabilityGovernedUserId`, and that it found at least one such call — so a refactor into a form it cannot read fails loudly instead of passing vacuously. It joins `check:audits` by name. Tests cover the two v1 capability paths the gate suite did not reach: the `personal_api_key.use` key-kind refusal and the `logs.cost` field projection, each asserting that a workspace key passes where its creator's group would deny and that a personal key is still refused. --- apps/sim/app/api/v1/capability-gate.test.ts | 81 +++++++ apps/sim/app/api/v1/middleware.ts | 18 +- package.json | 1 + scripts/check-capability-subject.ts | 247 ++++++++++++++++++++ 4 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 scripts/check-capability-subject.ts diff --git a/apps/sim/app/api/v1/capability-gate.test.ts b/apps/sim/app/api/v1/capability-gate.test.ts index abbaa3457c3..306a480d4d3 100644 --- a/apps/sim/app/api/v1/capability-gate.test.ts +++ b/apps/sim/app/api/v1/capability-gate.test.ts @@ -275,6 +275,87 @@ describe('v1 permission-group capability gate', () => { }) }) + /** + * `personal_api_key.use` refuses a *principal kind* rather than a module, so it + * is asserted in `resolveWorkspaceScope` — before, and separately from, the + * capability the route declares. A workspace key is not a personal key, and its + * creator's group must not decide whether it may be used. + */ + describe('personal_api_key.use — the key kind, not the module', () => { + it('refuses a personal key whose group disables personal API keys', async () => { + governedBy({ disablePersonalApiKeys: true }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toMatch(/personal API key/i) + expect(mockListTables).not.toHaveBeenCalled() + }) + + it('passes a workspace key through the same group that would deny its creator', async () => { + mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) + governedBy({ disablePersonalApiKeys: true }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(mockListTables).toHaveBeenCalledWith(WORKSPACE_ID) + }) + }) + + /** + * `logs.cost` and `logs.trace_spans` are projections rather than gates: the + * route declares `'none'` and withholds fields instead. The substitution shows + * up here as silently blanked data rather than a 403 — a shared workspace key + * would report `cost: null` on every run because one bystander's group hides + * spend. + */ + describe('log field projection follows the caller, not the key creator', () => { + const LOG_ROW = { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: WORKSPACE_ID, + executionId: 'exec-1', + deploymentVersionId: null, + level: 'info', + trigger: 'api', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: new Date('2026-01-01T00:00:01.000Z'), + totalDurationMs: 1000, + costTotal: '1.25', + files: null, + executionData: null, + workflowName: 'wf', + workflowDescription: null, + } + + beforeEach(() => { + mockListPublicWorkflowLogs.mockResolvedValue({ data: [LOG_ROW], nextCursor: null }) + }) + + it('withholds cost from a personal key whose group hides spend', async () => { + governedBy({ hideCostInfo: true }) + + const response = await getLogs(get(`/api/v1/logs?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data[0].cost).toBeNull() + }) + + it('still reports cost to a workspace key whose creator is in that group', async () => { + mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) + governedBy({ hideCostInfo: true }) + + const response = await getLogs(get(`/api/v1/logs?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data[0].cost).toEqual({ total: 1.25 }) + }) + }) + it('refuses on role before capability, so a non-member learns nothing about the group', async () => { mockGetUserEntityPermissions.mockResolvedValue(null) governedBy({ hideTablesTab: true }) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index b0b1da23b0b..188eded9b82 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -310,15 +310,22 @@ export type V1RouteCapability = StaticPermissionGroupCapability | 'none' * Never call this before the workspace role check. A capability refusal handed * to a non-member would confirm the workspace exists and disclose which modules * the organization withholds; the role failure conceals both. + * + * Takes no caller-supplied user id on purpose. It used to take the same `userId` + * the role check uses, guard on {@link capabilityGovernedUserId} and then assert + * against that *other* variable — the two agreed, but nothing made them agree, + * and a caller passing the key creator's id past a guard that said "personal" is + * the exact shape this bug has taken twice. The subject is now the guard's own + * return value, so there is only one id and no way to assert against another. */ export async function resolveCapabilityRefusal( rateLimit: RateLimitResult, - userId: string, workspaceId: string, capability: V1RouteCapability ): Promise { if (capability === 'none') return null - if (!capabilityGovernedUserId(rateLimit)) return null + const userId = capabilityGovernedUserId(rateLimit) + if (!userId) return null if (!(await isWorkspaceCapabilityWithheld(userId, workspaceId, capability))) return null @@ -370,9 +377,10 @@ export async function resolveWorkspaceScope( * the funnel applies has to be repeated here or the same key that v2 * refuses would still work against v1. */ - if (rateLimit.userId) { + const governedUserId = capabilityGovernedUserId(rateLimit) + if (governedUserId) { const withheld = await isWorkspaceCapabilityWithheld( - rateLimit.userId, + governedUserId, requestedWorkspaceId, 'personal_api_key.use' ) @@ -412,7 +420,7 @@ export async function resolveWorkspaceAccess( return { status: 403, code: 'FORBIDDEN', message: 'Access denied' } } - return resolveCapabilityRefusal(rateLimit, userId, workspaceId, capability) + return resolveCapabilityRefusal(rateLimit, workspaceId, capability) } /** diff --git a/package.json b/package.json index 31069253baa..9d76e280c9b 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "check:tool-request-boundary": "bun run scripts/check-tool-request-boundary.ts", "check:actorless-executor-operations": "bun run scripts/check-actorless-executor-operations.ts", "check:permission-group-enforcement": "bun run scripts/check-permission-group-enforcement.ts", + "check:capability-subject": "bun run scripts/check-capability-subject.ts", "check:application-graph": "bun run scripts/check-application-graph.ts", "check:tool-registry-boundary": "bun run scripts/check-tool-registry-boundary.ts --check", "check:trigger-block-cycle": "bun run scripts/check-trigger-block-cycle.ts", diff --git a/scripts/check-capability-subject.ts b/scripts/check-capability-subject.ts new file mode 100644 index 00000000000..99009ca5288 --- /dev/null +++ b/scripts/check-capability-subject.ts @@ -0,0 +1,247 @@ +#!/usr/bin/env bun +/** + * Keeps an API key's creator out of every permission-group decision the v1 + * public API makes. + * + * A capability belongs to a *person*, so it may only ever be evaluated against a + * user-bearing principal. `authenticateApiKeyFromHeader` reports a `userId` for + * BOTH key kinds, and for a workspace key that id is the key's *creator* — a + * bystander. Asking "does this user's group withhold Tables?" with the creator's + * id applies one employee's group to every caller of a shared credential, and + * CLAUDE.md forbids it verbatim: "Never substitute a billing owner, uploader, + * creator, or API-key owner for the acting principal." + * + * The bug has shipped twice and been fixed twice. Both times the fix was to read + * `keyType` instead of the presence of a user id, and both times nothing stopped + * the next route from reaching for `rateLimit.userId` again — the raw id sits + * one property access away from every handler, and it is the right value for the + * role check, the audit actor and the log line sitting beside the gate. This + * audit is the thing that stops it. + * + * It asserts, over `apps/sim/app/api/v1/**` (excluding `admin/`, the + * platform-admin surface, and test files): + * + * A `capabilityGovernedUserId` is still exported from the v1 middleware. + * Every other assertion is written in terms of it, so a rename that went + * unnoticed would turn this whole audit into a no-op that still passes. + * B no v1 file outside the middleware imports the permission-group modules + * directly. A capability decision made at a route reaches for whatever id + * is in scope; routing every one through the middleware is what puts it + * under assertion C. + * C every call to a capability sink passes a subject that came from + * `capabilityGovernedUserId` — the call expression inline, or a local bound + * to it. An id read off `rateLimit`/`auth` is the failure this exists for. + * D at least one governed sink call was actually found. The assertions are + * source-text matches, so a refactor into a form the parser cannot follow + * would otherwise be indistinguishable from a clean tree. + * + * Scope is v1 on purpose. It is the one surface that authorizes in a middleware + * of its own rather than through `authorizeWorkspaceOperation`, which decides + * from a `Principal` that has no user to substitute in the first place. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' + +const ROOT = resolve(import.meta.dir, '..') +const V1_ROOT = 'apps/sim/app/api/v1' +const MIDDLEWARE = `${V1_ROOT}/middleware.ts` +/** Out of scope: the platform-admin surface authenticates platform admins, not workspace keys. */ +const EXCLUDED_DIRECTORIES = ['admin'] +const GOVERNED = 'capabilityGovernedUserId' + +/** + * Modules that decide a permission-group capability. A v1 route importing one of + * these has stepped around the middleware and is deciding for itself. + */ +const CAPABILITY_MODULES = [ + '@/lib/permission-groups/capability-assertions', + '@/lib/permission-groups/capabilities', + '@/lib/permission-groups/resolve.server', + '@/lib/permission-groups/config-scope.server', +] + +/** + * Functions whose named argument IS the person a group governs, by argument + * index. Add a sink here when one is introduced; a helper that resolves a + * config or asserts a capability from a user id belongs on this list. + */ +const CAPABILITY_SINKS: Record = { + isWorkspaceCapabilityWithheld: 0, + assertWorkspaceCapability: 0, + resolvePermissionGroupConfig: 0, + getUserPermissionConfigForOrganization: 0, + resolveLogFieldProjection: 0, +} + +interface Finding { + file: string + line: number + message: string +} + +function walk(directory: string, into: string[]): void { + for (const entry of readdirSync(directory)) { + const full = join(directory, entry) + if (statSync(full).isDirectory()) { + if (!EXCLUDED_DIRECTORIES.includes(entry)) walk(full, into) + } else if (full.endsWith('.ts') && !full.endsWith('.test.ts')) { + into.push(full) + } + } +} + +/** Splits a call's argument list on top-level commas, ignoring nested groups and strings. */ +function splitArguments(argumentText: string): string[] { + const parts: string[] = [] + let depth = 0 + let quote: string | null = null + let current = '' + for (let index = 0; index < argumentText.length; index++) { + const char = argumentText[index] + if (quote) { + if (char === quote && argumentText[index - 1] !== '\\') quote = null + current += char + continue + } + if (char === "'" || char === '"' || char === '`') { + quote = char + current += char + continue + } + if (char === '(' || char === '[' || char === '{') depth++ + else if (char === ')' || char === ']' || char === '}') depth-- + if (char === ',' && depth === 0) { + parts.push(current.trim()) + current = '' + continue + } + current += char + } + if (current.trim()) parts.push(current.trim()) + return parts +} + +/** Text inside the `(...)` that starts at `openIndex`. */ +function callArguments(source: string, openIndex: number): string | null { + let depth = 0 + for (let index = openIndex; index < source.length; index++) { + const char = source[index] + if (char === '(') depth++ + else if (char === ')') { + depth-- + if (depth === 0) return source.slice(openIndex + 1, index) + } + } + return null +} + +function lineOf(source: string, index: number): number { + return source.slice(0, index).split('\n').length +} + +const files: string[] = [] +walk(join(ROOT, V1_ROOT), files) +const relativeFiles = files.map((file) => relative(ROOT, file)).sort() + +const findings: Finding[] = [] +let governedSinkCalls = 0 + +const middlewareSource = readFileSync(join(ROOT, MIDDLEWARE), 'utf8') +if (!new RegExp(`export function ${GOVERNED}\\s*\\(`).test(middlewareSource)) { + findings.push({ + file: MIDDLEWARE, + line: 1, + message: + `${MIDDLEWARE} no longer exports \`${GOVERNED}\`. Every assertion in this audit is ` + + 'written in terms of it; rename it here and in this script together, or the audit ' + + 'silently stops checking anything.', + }) +} + +for (const file of relativeFiles) { + const source = readFileSync(join(ROOT, file), 'utf8') + + if (file !== MIDDLEWARE) { + for (const module of CAPABILITY_MODULES) { + const index = source.indexOf(`from '${module}'`) + if (index === -1) continue + findings.push({ + file, + line: lineOf(source, index), + message: + `imports ${module} directly. A v1 capability decision goes through ` + + `${MIDDLEWARE}, which resolves its subject with \`${GOVERNED}\` — deciding here ` + + 'reaches for whatever id is in scope, and the id in scope is the key creator.', + }) + } + } + + /** Locals bound to the governed id, so a call may pass the variable rather than the call. */ + const governedLocals = new Set() + for (const match of source.matchAll( + new RegExp( + `(?:const|let)\\s+([A-Za-z_$][\\w$]*)\\s*(?::[^=]+)?=\\s*(?:await\\s+)?${GOVERNED}\\(`, + 'g' + ) + )) { + governedLocals.add(match[1]) + } + + for (const [sink, subjectIndex] of Object.entries(CAPABILITY_SINKS)) { + for (const match of source.matchAll(new RegExp(`\\b${sink}\\s*\\(`, 'g'))) { + const openIndex = match.index + match[0].length - 1 + /** A declaration or an import of the sink, not a call into it. */ + const preceding = source.slice(Math.max(0, match.index - 40), match.index) + if (/\b(function|import)\s*$|\bexport\s+(async\s+)?function\s*$/.test(preceding)) continue + + const argumentText = callArguments(source, openIndex) + if (argumentText === null) continue + const subject = splitArguments(argumentText)[subjectIndex] + if (subject === undefined) continue + + const governed = + subject.startsWith(`${GOVERNED}(`) || + subject.startsWith(`await ${GOVERNED}(`) || + governedLocals.has(subject) + if (governed) { + governedSinkCalls++ + continue + } + + findings.push({ + file, + line: lineOf(source, match.index), + message: + `${sink}(...) is asked about \`${subject}\`, which did not come from ` + + `\`${GOVERNED}\`. A workspace API key reports its creator's user id, so this ` + + "applies a bystander's permission group to every caller of a shared credential.", + }) + } + } +} + +if (governedSinkCalls === 0 && findings.length === 0) { + findings.push({ + file: V1_ROOT, + line: 1, + message: + `no call to a capability sink passed through \`${GOVERNED}\` anywhere under ${V1_ROOT}. ` + + 'Either v1 stopped gating capabilities, or it now gates them in a form this audit ' + + 'cannot read — both mean the audit is passing without checking anything.', + }) +} + +if (findings.length > 0) { + console.error( + `check:capability-subject — ${findings.length} finding${findings.length === 1 ? '' : 's'}:\n` + ) + for (const finding of findings) { + console.error(` ${finding.file}:${finding.line}\n ${finding.message}\n`) + } + process.exit(1) +} + +console.log( + `check:capability-subject — ${relativeFiles.length} v1 files, ${governedSinkCalls} capability ` + + `subject${governedSinkCalls === 1 ? '' : 's'} resolved through ${GOVERNED}.` +) From 413d851d701f17bda8664f0a8de5fc9d0b693bb4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 13:23:36 -0700 Subject: [PATCH 042/179] docs(skills): realign add-permission-group-item with the refactored code types.ts is gone (fields.ts + constraints.ts), group resolution moved out of ee/ into resolve.server.ts, the request scope split in two, and assertOrganizationCapability was deleted. Adds the definition-time capability guard and why it exists given tsconfig excludes tests, the satisfies rule, the Array.isArray parser guard, the executor/Copilot principal split, the v1 and table-route capability subjects, the log-projection distinction, and the two new audits. --- .../skills/add-permission-group-item/SKILL.md | 151 +++++++++++++----- 1 file changed, 114 insertions(+), 37 deletions(-) diff --git a/.agents/skills/add-permission-group-item/SKILL.md b/.agents/skills/add-permission-group-item/SKILL.md index 85d08102459..ce07df9e113 100644 --- a/.agents/skills/add-permission-group-item/SKILL.md +++ b/.agents/skills/add-permission-group-item/SKILL.md @@ -14,13 +14,15 @@ You are adding one governed item to the enterprise permission-group system: some Read these completely before editing. Do not infer their shape from this document. -- `apps/sim/lib/permission-groups/fields.ts` — the registry, the three field builders, the tolerant parser -- `apps/sim/lib/permission-groups/capabilities.ts` — `CAPABILITY_IDS`, `CAPABILITY_RULES`, the static/parameterized split -- `apps/sim/lib/permission-groups/capability-assertions.ts` — the only sanctioned way to ask whether a group withholds something +- `apps/sim/lib/permission-groups/fields.ts` — the registry, the three field builders, `permissionGroupConfigSchema`, the tolerant parser. There is **no `types.ts`**; it was folded into this file, and the two DB constraint maps live in `constraints.ts` +- `apps/sim/lib/permission-groups/capabilities.ts` — `CAPABILITY_IDS`, `CAPABILITY_RULES`, `capabilityRefusal`, `refuseCapability`, the static/parameterized split +- `apps/sim/lib/permission-groups/capability-assertions.ts` — the sanctioned way to ask whether a group withholds something (and it re-exports `capabilityRefusal`) +- `apps/sim/lib/permission-groups/resolve.server.ts` — group resolution: `resolveWorkspaceGroup`, `resolveVerifiedUserAccessControlContext`, `getUserPermissionConfig`, `getUserPermissionConfigForOrganization`, `mergeEnvAllowlist`. This moved out of `ee/`; `ee/access-control/utils/permission-check.ts` now only re-exports it and keeps the executor gates - `apps/sim/lib/permission-groups/config-scope.server.ts` — `resolvePermissionGroupConfig`, the per-request memo every assertion resolves through -- `apps/sim/lib/core/application/workspace-operation.ts` — `capability` is a **required** field on `defineWorkspaceOperation`, typed `StaticPermissionGroupCapability | 'none'` +- `apps/sim/lib/permission-groups/request-scope.server.ts` — the light half of the scope: `withPermissionGroupScope` and the store, deliberately free of runtime imports because `withRouteHandler` imports it +- `apps/sim/lib/core/application/workspace-operation.ts` — `capability` is a **required** field on `defineWorkspaceOperation`, typed `StaticPermissionGroupCapability | 'none'`, with a definition-time guard - `apps/sim/lib/core/application/workspace-authorization.ts` — where the funnel enforces, and who passes through -- `scripts/check-permission-group-enforcement.ts` — the audit you have to satisfy +- `scripts/check-permission-group-enforcement.ts`, `scripts/check-application-graph.ts`, `scripts/check-capability-subject.ts` — the three audits you have to satisfy, all inside `check:audits` ## Step 0: Decide what kind of thing it is @@ -40,10 +42,12 @@ Choose an allowlist when the safe posture is "only what the admin named" and the - `'capability'` — an operation declares a capability whose rule reads the key, so the authorization funnel refuses before the use case runs. This is the default answer for anything reachable through an application operation. - `'executor'` — read per block, tool, or model at execution time by `assertPermissionsAllowed` in `apps/sim/ee/access-control/utils/permission-check.ts`. It governs what a *run* may do, which no operation-level gate can express: one API call can execute fifty blocks. `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, and `deniedTools` are the four that live here. -- `'ui-only'` — the key hides a surface without withholding it, so a caller who skips the UI still reaches the API. **Almost never the right answer.** Choose it only when you can say, in the `enforcement` comment, why a determined caller reaching the data anyway is acceptable. Nothing currently ships as `ui-only`; if yours is the first, expect that to be questioned in review. +- `'ui-only'` — the key hides a surface without withholding it, so a caller who skips the UI still reaches the API. **Almost never the right answer.** Choose it only when you can say, in the `enforcement` comment, why a determined caller reaching the data anyway is acceptable. Nothing currently ships as `ui-only` — the union member exists and has no user; if yours is the first, expect that to be questioned in review. **Is the decision knowable from the config alone?** A rule that needs a value only the request carries — an auth mode, a connector id, a file id — is *parameterized*, and parameterized rules cannot be declared on an operation. See Step 3. +**Is it a gate at all, or a projection?** A key that withholds *fields from a response* rather than the response itself is a projection, not a gate. `hideTraceSpans` and `hideCostInfo` work this way: every logs route declares `capability: 'none'` and strips fields instead, because refusing the read would withhold the status and the error message too, which is not what an organization restricting execution detail or spend visibility asked for. The projections live in one place — `apps/sim/lib/logs/log-projection.ts`, which owns `resolveLogFieldProjection`, `projectExecutionData` and `projectCostTotal` and carries the `permission-group-enforced:` annotations for both capabilities. If your key is a projection, add it there rather than to an operation; two copies of a redaction rule is how one of them stops redacting. + ## Step 1: Add the field entry — at the end Append one entry to `PERMISSION_GROUP_FIELDS`. **Append, never insert.** @@ -59,15 +63,15 @@ Append one entry to `PERMISSION_GROUP_FIELDS`. **Append, never insert.** The object in the second argument is the field's `feature` property, typed `PlatformFeatureMeta`. `PLATFORM_FEATURES` spreads it and appends `configKey`, so `id`, `label`, `category` and `hint` are exactly what the editor renders. -Declaration order here is the key order of `PermissionGroupConfig`, of both zod schemas, and of every config JSON that crosses the API boundary. The group editor in `apps/sim/ee/access-control/components/group-detail.tsx` runs its dirty check by comparing stringified configs, so moving an existing key makes every open editor read as having unsaved changes. The registry already carries a TSDoc note on `disablePersonalApiKeys` saying exactly this — extend the tail, do not tidy the middle. +Declaration order here is the key order of `PermissionGroupConfig`, of both zod schemas, and of every config JSON that crosses the API boundary. `fields.test.ts` pins that order with a contract test comparing key order, so a moved key fails the suite. The group editor in `apps/sim/ee/access-control/components/group-detail.tsx` also runs its dirty check by comparing stringified configs, so moving an existing key makes every open editor read as having unsaved changes. The registry already carries a TSDoc note on `disablePersonalApiKeys` saying exactly this — extend the tail, do not tidy the middle. Three things to get right in the entry itself: **The default must be the permissive value.** Every config row already stored in the `permission_group.config` column predates your key. `parsePermissionGroupConfig` fills the gap from the field's default, and the create/update route merges a partial write over the stored config. If your default is the restrictive value, adding the key silently applies a new restriction to every existing group in every enterprise organization, with nothing in the admin UI having changed. This is why the boolean builder hardcodes `false`, the allowlist `null`, and the denylist `[]` — but it is also why a *new* key must be phrased so that the permissive value is falsy. `disableWidgetSharing: false` is correct; a hypothetical `requireWidgetApproval` whose safe default is `true` cannot use `booleanRestriction` and needs its meaning inverted before it can. -**The admin checkbox is inverted.** `group-detail.tsx` renders `checked={!editingConfig[feature.configKey]}` — ticked means *allowed*. A key named `allowX` would render backwards. Name it `hideX` or `disableX`. +**The admin checkbox is inverted.** `group-detail.tsx` renders `checked={!editingConfig[feature.configKey]}` — ticked means *allowed*. A key named `allowX` would render backwards. -**The hint must describe revoked access, not a hidden surface.** Every `enforcement: 'capability'` key refuses at the API. A hint reading "Hide the Tables module from the sidebar" tells an admin they are tidying a nav bar when they are revoking a module — and the same string is read a second time by `getActivePermissionGroupRestrictions` as the prose explaining an *active* restriction, where "hide" is simply false. Write what the member can no longer do: "Revoke the Tables module. Members cannot read or write any table." That wording drift is not hypothetical — twelve keys carried "hide from the sidebar" hints for a release after they started returning 403. +**The hint must describe revoked access, not a hidden surface.** Every `enforcement: 'capability'` key refuses at the API. A hint reading "Hide the Tables module from the sidebar" tells an admin they are tidying a nav bar when they are revoking a module — an admin ticking the box is revoking access, not hiding a link. The same string is read a second time by `getActivePermissionGroupRestrictions` in `features.ts` as the prose explaining an *active* restriction, where "hide" is simply false; that prose reaches users through the Copilot workspace VFS and the enterprise platform context. Write what the member can no longer do: "Revoke the Tables module. Members cannot read or write any table." That wording drift is not hypothetical — twelve keys carried "hide from the sidebar" hints for a release after they started returning 403. `PlatformFeatureMeta.hint` carries the rule in its own TSDoc; do not weaken it. **The category must be in `PLATFORM_CATEGORY_ORDER`.** That constant lives in `apps/sim/lib/permission-groups/features.ts` and currently reads `Modules`, `Knowledge Base`, `Tables`, `Files`, `Deployment`, `Tools`, `Logs`, `Collaboration`, `Credentials & Access`. An unlisted category still renders, but at the end, after every ordered section. The names describe what a group withholds, not where a link used to be hidden — do not reintroduce a surface-shaped section like "Sidebar" or "Settings Tabs". @@ -118,7 +122,7 @@ A static rule: }, ``` -`configKeys` is what the audit reads to prove your key is enforced — it must list every key `deniedBy` actually reads. `describe` is the subject of one shared sentence, `" is not available under your organization's permission group"`, so write it as a singular noun or gerund phrase that agrees with the verb. Two functions build that sentence and there is no third: `refuseCapability(capability)` in `capabilities.ts` throws it as a `PermissionGroupCapabilityError`, and `capabilityRefusal(capability)` in `capability-assertions.ts` returns it as a string for a raw route rendering its own response body. Never write the sentence out at a call site. +`configKeys` is what the audit reads to prove your key is enforced — it must list every key `deniedBy` actually reads. `describe` is the subject of one shared sentence, `" is not available under your organization's permission group"`, so write it as a singular noun or gerund phrase that agrees with the verb. Two functions build that sentence and there is no third: `refuseCapability(capability)` throws it as a `PermissionGroupCapabilityError`, and `capabilityRefusal(capability)` returns it as a string for a raw route rendering its own response body. Both are **defined in `capabilities.ts`**; `capability-assertions.ts` re-exports `capabilityRefusal` so a call site that gates inline reaches the sentence and the assertions through one module. Never write the sentence out at a call site. Use `'PERMISSION_GROUP_CAPABILITY_BLOCKED'` for `detailCode` unless a caller can act differently on this specific refusal. The set in `apps/sim/lib/core/application/forbidden.ts` is closed **over remedies, not over causes** — a new code is warranted only when the remedy differs from "ask an organization admin". Adding one also requires an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS` (a compile-time gate) and publishes a new value in the generated OpenAPI 403 description. @@ -145,10 +149,20 @@ Operation declares parameterized capability ; assert it from the use c That throw is deliberate. Left unchecked, the operation would read as gated and the gate would silently never fire. -Do not annotate `CAPABILITY_RULES` with its type instead of using `satisfies`. Annotating widens every entry to `CapabilityRule`, at which point `StaticPermissionGroupCapability` resolves to `never`, no operation can declare any capability, and every gate stops firing — with nothing at runtime looking wrong. `AssertsStaticCapabilityResolves` at the bottom of the file exists to catch exactly that. +### `satisfies`, never a type annotation + +Do not annotate `CAPABILITY_RULES` with its type instead of using `satisfies`. Annotating widens every entry to `CapabilityRule`, at which point `StaticPermissionGroupCapability` — which is derived by filtering the object's own entries for `kind: 'static'` — resolves to **`never`**. No operation can then declare any capability, the type system stops saying anything about capabilities at all, and every gate goes quiet with nothing at runtime looking wrong. `AssertsStaticCapabilityResolves` at the bottom of the file exists to catch exactly that. The same reasoning applies anywhere else you are tempted to annotate one of these registries. ## Step 4: Declare it on the operations it governs, or assert it at the call site +`capability` is a **required** field, and `defineWorkspaceOperation` *additionally* throws at definition time when it is `undefined`: + +``` +Operation declares no capability; name one, or 'none' with a reason +``` + +That guard looks unreachable given the field is required. It is not, and the reason is worth internalizing before you write a test fixture: **`apps/sim/tsconfig.json` excludes `*.test.ts` and `*.test.tsx` from type-checking**, and `check-permission-group-enforcement.ts` walks past test files too. A test fixture is therefore the one construction site no static check reads — and a fixture is exactly where an operation gets written from memory rather than from the surrounding domain. Left to reach authorization, an absent capability does not deny; it throws `Cannot read properties of undefined` from inside `capabilityDeniedBy`, and **only for a caller whose organization actually has a permission group**. It passes CI, it passes every personal workspace and every non-enterprise test, and it fails in the tenants that bought the feature. The guard names it at definition time instead. + **Static, and the operation is the whole decision** — set `capability` on the `defineWorkspaceOperation` call. The funnel does the rest; you write no gate code. ```ts @@ -163,27 +177,40 @@ export const shareWidget = defineWorkspaceOperation({ If the domain wraps `defineWorkspaceOperation` in a same-file factory, the audit resolves the capability through it — either fixed in the factory body or taken as a positional second argument. `apps/sim/lib/table/application/operations.ts` shows both, and deliberately gives the positional form **no default**: a default would let a new operation inherit `tables.use` without anyone deciding it should, which is the unreviewed omission the whole gate exists to prevent. -**Static, but no operation to hang it on** — a raw route, or an organization-level action — call `assertWorkspaceCapability` / `assertOrganizationCapability` from `capability-assertions.ts` directly, and annotate the call site: +**Static, but no operation to hang it on** — a raw route, or an organization-level action. There is no `assertOrganizationCapability`; it was deleted. Reach for whichever of these fits how the caller must respond: + +| Helper | Use when | +|---|---| +| `assertWorkspaceCapability(userId, workspaceId, cap, organizationId?)` | inside a use case, where a thrown `PermissionGroupCapabilityError` is projected to a 403 for you | +| `isWorkspaceCapabilityWithheld(userId, workspaceId, cap, organizationId?)` | a raw handler rendering its own body — pair it with `capabilityRefusal(cap)` | +| `isOrganizationCapabilityWithheld(organizationId, cap)` | an action that names an organization rather than a workspace | +| `capabilityDeniedBy(cap, config)` | you already hold a resolved config and are asking several questions of it | + +Annotate the call site either way: ```ts // permission-group-enforced: logs.export — raw streaming route, no workspace operation to declare it on - await assertWorkspaceCapability(userId, workspaceId, 'logs.export', organizationId) + if (capabilityDeniedBy('logs.export', permissionConfig)) { + return NextResponse.json({ error: capabilityRefusal('logs.export') }, { status: 403 }) + } ``` -**Parameterized** — the rule needs a request value, so no helper in `capability-assertions.ts` fits (they are all typed `StaticPermissionGroupCapability`). Write a small module-local wrapper that reads the rule and refuses, and annotate the call site. `assertConnectorTypeAllowed` in `apps/sim/lib/knowledge/application/connectors.ts` is the shape: +`isOrganizationCapabilityWithheld` resolves through `getUserPermissionConfigForOrganization`, which reads the organization's **default** group — a non-default group targets specific workspaces and has nothing to say about an action no workspace scopes. It sits outside the per-request memo on purpose: that memo is keyed by user and workspace, and this decision is keyed by organization alone. + +**Parameterized** — the rule needs a request value, so none of the helpers above fit (they are all typed `StaticPermissionGroupCapability`). Write a small module-local wrapper that reads the rule and refuses through `refuseCapability`, and annotate the call site. `assertConnectorTypeAllowed` in `apps/sim/lib/knowledge/application/connectors.ts` is the shape: ```ts - // permission-group-enforced: knowledge.connectors — needs the request's connector id, which the funnel never sees - await assertConnectorTypeAllowed( - resolvePrincipalSubjectUserId(principal), - workspaceId, - input.connectorType - ) +const CONNECTOR_ALLOWLIST_RULE = CAPABILITY_RULES['knowledge.connectors'] + +async function assertConnectorTypeAllowed(userId, workspaceId, connectorType) { + if (!userId) return + const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) + if (!config || !CONNECTOR_ALLOWLIST_RULE.deniedBy(config, connectorType)) return + refuseCapability('knowledge.connectors') +} ``` -Always route the decision through `CAPABILITY_RULES` (via `assertWorkspaceCapability`, `assertOrganizationCapability`, `capabilityDeniedBy`, the `isWorkspaceCapabilityWithheld` / `isOrganizationCapabilityWithheld` non-throwing pair, or a direct `CAPABILITY_RULES[''].deniedBy(config, value)` for a parameterized rule) and raise it with `refuseCapability`. Never spell the config key out at the call site, and never write the refusal sentence out: a renamed key silently stops denying anything, and a hand-written message drifts from the funnel's for the same refusal. - -Use `assertOrganizationCapability` for an action that names an organization rather than a workspace — creating a workspace, reading the member directory. It resolves the organization's *default* group, because a non-default group targets specific workspaces and has nothing to say about an action no workspace scopes. +Always route the decision through `CAPABILITY_RULES` and raise it with `refuseCapability`. Never spell the config key out at the call site, and never write the refusal sentence out: a renamed key silently stops denying anything, and a hand-written message drifts from the funnel's for the same refusal. `validatePublicFileSharing` and `validateChatDeployAuth` in `ee/access-control/utils/permission-check.ts` are the other two examples of this shape. Guard on the acting user being present. A permission group is a membership of users, so an actorless caller resolves no group; `assertConnectorTypeAllowed` returns early on a missing `userId` rather than throwing, which is what keeps a scheduled sync from becoming a 500 instead of a refusal anyone could act on. @@ -193,31 +220,65 @@ Guard on the acting user being present. A permission group is a membership of us // permission-group-exempt: the executor's own per-run store; no group key names it, and refusing would fail runs the group allows ``` +### Surfaces that do not go through the funnel + +Three of them, and each has its own required shape. + +**`/api/v1` routes** authorize in `apps/sim/app/api/v1/middleware.ts` rather than through `authorizeWorkspaceOperation`. Every route threads a `V1RouteCapability` (`StaticPermissionGroupCapability | 'none'`, required and spelled out, same reasoning as on the operation), and its value must be the one its v2 or internal counterpart already declares — v1 gets no mapping of its own. The subject **must** come from `capabilityGovernedUserId(rateLimit)`, which returns `null` for a workspace key: `rateLimit.userId` is populated for *both* key kinds, and for a workspace key it is the key's **creator**, a bystander. `scripts/check-capability-subject.ts` exists because that bug has shipped and been fixed twice. + +**Raw internal table routes** under `/api/table/**` share one gate inside `checkAccess` in `apps/sim/app/api/table/utils.ts`. Its signature takes a `TableAccessPrincipal` discriminated union — `{ kind: 'user'; userId }` or `{ kind: 'workspace_api_key'; keyCreatorUserId }` — rather than a bare `userId`, for the same reason: a bare id no longer type-checks, so a caller cannot reach the gated behavior without naming a kind, and only the kind that says so skips the gate. `tableAccessPrincipal(rateLimit)` in the v1 middleware builds it for v1's table handlers. + +**The route-wrapper graph.** `withRouteHandler` imports `request-scope.server.ts` and nothing heavier. If your gate needs a resolver, import it at the *call site*, not from anything the wrapper or `lib/core/application` reaches. See Step 6. + ## Step 5: Add it to the golden corpus -Add your key to **both** the `input` and the `expected` object of the `'a fully populated config'` fixture in `apps/sim/lib/permission-groups/types.test.ts`, set to a non-default value. +Add your key to **both** the `input` and the `expected` object of the `'a fully populated config'` fixture in `apps/sim/lib/permission-groups/fields.test.ts` (renamed from `types.test.ts` when `types.ts` was folded into `fields.ts`), set to a non-default value. -That file is the pinned coercion corpus: every row states what a stored `jsonb` value coerces to, so a row that changes in a later diff is a deliberate semantic decision someone defends rather than a silent regression. Its other assertions are derived from `DEFAULT_PERMISSION_GROUP_CONFIG` — wire-order, idempotence, read-schema acceptance, the 2000-iteration seeded fuzz, and the boolean-key-to-`PLATFORM_FEATURES` coverage check — so they pick your key up for free. Likewise `features.test.ts` iterates `PLATFORM_FEATURES` and needs no edit for a boolean. +That file is the pinned coercion corpus: every row states what a stored `jsonb` value coerces to, so a row that changes in a later diff is a deliberate semantic decision someone defends rather than a silent regression. Its other assertions are derived from `DEFAULT_PERMISSION_GROUP_CONFIG` — wire-order, idempotence, read-schema acceptance, the 2000-iteration seeded fuzz, the write/default/read key-set agreement and the boolean-key-to-`PLATFORM_FEATURES` coverage check — so they pick your key up for free. Likewise `features.test.ts` iterates `PLATFORM_FEATURES` and needs no edit for a boolean. Add a targeted case to `capabilities.test.ts` for a rule with any logic beyond reading one key. For an allowlist, assert the three states explicitly, because they are what the parser and the UI conspire to confuse: `null` permits every member, a populated list permits only the named ones, and `[]` permits **none**. `capabilities.test.ts` already pins this for `knowledge.connectors`; copy it. -## Step 6: Verify +## Step 6: Keep the graph light + +`scripts/check-application-graph.ts` (in `check:audits`) walks **runtime** `import` / `export … from` edges — `import type` is erased and deliberately allowed — out of five guarded roots and fails if any reaches a forbidden module tree. This is a real constraint on you: importing the wrong thing from a permission-group helper now fails CI. + +| Guarded root | Forbidden | +|---|---| +| `lib/core/application/index.ts` | `providers/`, `blocks/`, `tools/`, `executor/`, `lib/uploads/`, `lib/workflows/` | +| `lib/permission-groups/capabilities.ts` | same six | +| `lib/permission-groups/capability-assertions.ts` | same six | +| `lib/permission-groups/config-scope.server.ts` | same six | +| `lib/core/utils/with-route-handler.ts` | those six **plus** `lib/billing/`, `lib/permission-groups/resolve.server`, `lib/auth`, `lib/copilot/`, `lib/knowledge/` | + +The wider list on the route wrapper is not an app-wide ban — `resolve.server.ts` legitimately reads the subscription to decide whether an organization is on an enterprise plan, which is why `lib/billing/` stays allowed for the funnel roots. The wrapper is a request-lifecycle shim that opens the memo scope and nothing more, so it may not load any of it. That split is why the scope is two files: `request-scope.server.ts` holds `withPermissionGroupScope` and is import-free; `config-scope.server.ts` holds `resolvePermissionGroupConfig` and only the gate call sites import it. + +The symptom of breaking this is never the message you expect. One import once widened the funnel graph as far as `lib/uploads/utils/file-utils.ts`, and the only sign was two unrelated knowledge tests failing on a partial mock of a module they never meant to load; later one import in the route wrapper pulled the whole billing graph into every route test, surfacing as an OTP-route test failing on its own partial `zod` mock. If you see a failure like that after adding an import, run this audit before anything else. + +## Step 7: Verify ```bash bun run check:permission-group-enforcement +bun run check:application-graph +bun run check:capability-subject cd apps/sim && bun run type-check cd apps/sim && bunx vitest run lib/permission-groups ``` -If you touched a contract or the group routes, also `bun run check:api-validation`. `bun run check:audits` runs the whole audit set including the enforcement check. +If you touched a contract or the group routes, also `bun run check:api-validation`. `bun run check:audits` runs all three of the above and every other audit; it derives its list from the `check:*` scripts in `package.json`, so a new audit is opted *out* deliberately rather than opted in. -Read the audit's success line, not just its exit code: +Read the success lines, not just the exit codes: ``` ✓ permission-group enforcement: 287 operations declare a capability, 35 capabilities all enforced +✅ Application graph clean: 5 roots reach none of 11 forbidden module trees +check:capability-subject — 32 v1 files, 5 capability subjects resolved through capabilityGovernedUserId. ``` -The counts should have grown by your operation and your capability. The audit is now all-or-nothing — it either prints that line or fails with findings; there is no longer a count-down or migration mode that exits 0 with work outstanding. It does have a self-check that refuses to report success when `CAPABILITY_IDS`, `CAPABILITY_RULES` or `PERMISSION_GROUP_FIELDS` parse to nothing, and it fails when the rule count and the capability count disagree. If either fires, the script's regexes stopped matching a rename — fix the parsers rather than leaving it green. +The counts should have grown by your operation and your capability. The enforcement audit is all-or-nothing — it either prints that line or fails with findings; there is no count-down or migration mode that exits 0 with work outstanding. It carries three self-checks, because it reads source text with regexes rather than the type system: + +- It refuses to report success when `CAPABILITY_IDS`, `CAPABILITY_RULES` or `PERMISSION_GROUP_FIELDS` parse to nothing, and it fails when the rule count and the capability count disagree. +- A `defineWorkspaceOperation` call whose `id` it cannot read — a const-reference id, or a factory written as an arrow const rather than a `function` — is reported per call rather than silently skipped. +- **A file that calls `defineWorkspaceOperation` and parses to ZERO declarations is a finding**, not a pass. That catches the whole-file failure mode: a non-literal `id:` or an arrow-const factory that makes every operation in the file invisible at once. If it fires, teach the parsers the new form; do not work around it. What the audit proves is *reachability*: your capability is named somewhere and your key is read by some rule. It cannot tell whether the rule's logic is right or whether every operation reaching the behavior declares it. Do not treat a green run as proof the gate fires. @@ -227,7 +288,11 @@ These are the ones that actually bite. Each has a reason; understand the reason **The default must be permissive.** Every stored config predates your key, and the parser fills the gap from the default. A restrictive default applies a new restriction retroactively to every existing group, invisibly. -**Append, never insert.** Declaration order is the wire order, and the editor's dirty check compares stringified configs — a moved key reads as an unsaved change in every open editor. +**Append, never insert.** Declaration order is the wire order, `fields.test.ts` compares key order, and the editor's dirty check compares stringified configs — a moved key fails a test and reads as an unsaved change in every open editor. + +**`CAPABILITY_RULES` uses `satisfies`, never a type annotation.** Annotating collapses `StaticPermissionGroupCapability` to `never` and silently disables the type system around capabilities. See Step 3. + +**`capability` is required *and* guarded at definition time.** The guard is not redundant: `apps/sim/tsconfig.json` excludes test files, so a fixture is the one construction site no static check reads. See Step 4. **An operation carries exactly ONE capability.** Splitting a narrower capability off a broader one opens a hole unless the narrower rule *also* reads the broader key. This is real, not hypothetical: `knowledge.create` and `knowledge.upload` both list `hideKnowledgeBaseTab` alongside their own key — @@ -238,7 +303,9 @@ These are the ones that actually bite. Each has a reason; understand the reason — because moving knowledge-base creation off `knowledge.use` would otherwise let a group that withheld the entire module still create one through the API. **The narrower capability has to subsume the broader.** Any time you re-point an operation from a general capability to a specific one, the specific rule must read both keys. -**`.catch()` is whole-value tolerant; array coercion must be element-wise.** `z.array(item).catch(fallback)` discards every good member because one was bad. On an allowlist the fallback is `null`, and `null` means unrestricted — so whole-value tolerance is **fail-open**: a partially corrupt allowlist would stop restricting anything at all. `tolerantArray` filters element by element instead, keeping the members that parse. Never replace it with `.catch()` on an array field, and never hand-roll a parallel coercion path. +**`.catch()` on an array field is a fail-open security bug.** `z.array(item).catch(fallback)` is whole-value tolerant: one bad member discards every good one. On an allowlist the fallback is `null`, and `null` means **unrestricted** — so a partly-corrupt allowlist would stop restricting anything at all. `tolerantArray` in `fields.ts` filters element by element instead, keeping the members that parse and failing closed. Never replace it with `.catch()` on an array field, and never hand-roll a parallel coercion path. + +**`parsePermissionGroupConfig` must keep its `Array.isArray` guard.** `typeof [] === 'object'`, so a truthy-object check alone lets an array through, and `z.object().parse([])` throws — which is reachable, because the column is `jsonb` and a row can genuinely hold `[]`. The guard is what makes the parser return the defaults there instead of taking down the request. `tolerantArray` carries the mirror-image guard for the same reason. **An empty allowlist denies everything; `null` allows everything.** These must never collapse into one another — not in the parser, not in the UI setter, not in a rule's `deniedBy`. `allowlistDenies` encodes it as `allowed !== null && !allowed.includes(member)`. A `?? []` anywhere on this path inverts the meaning of the unrestricted case. @@ -246,22 +313,32 @@ These are the ones that actually bite. Each has a reason; understand the reason **Non-boolean keys get no admin UI.** `PLATFORM_FEATURES` filters to booleans. An allowlist without a `featureExtras` picker is a key no admin can ever set. -**Not everyone goes through the funnel.** A **workspace API key** authorizes as the workspace — there is no user, so no permission group resolves and `operation.capability` does not apply. (Substituting the key's creator would apply a bystander's group to every caller of a shared key, and break the key outright when that person left. The escape is closed at the door instead: minting a workspace key is itself capability-gated.) An **actorless deployment run** — a delegated executor principal with no subject — also passes through, because a deployed workflow acts with the workspace's authority, not its author's group; denying there would 403 every scheduled run, webhook, and public-API call in the organization the moment a group withheld anything. What such a run *does* is still governed, by `assertPermissionsAllowed` in the executor. If your item must bind a deployed run, it belongs at `enforcement: 'executor'`, not `'capability'`. +**Not everyone goes through the funnel.** Four cases, and the differences between them matter: + +- A **workspace API key** authorizes as the workspace — there is no user, so no permission group resolves and `operation.capability` does not apply. Substituting the key's creator would apply a bystander's group to every caller of a shared key, and break the key outright when that person left. The escape is closed at the door instead: minting a workspace key is itself capability-gated. Do not substitute the creator anywhere — not in the funnel, not in `checkAccess`, not in v1, not in the log projection. +- A **delegated `executor` principal that *does* carry a `sim_user` subject** is checked for **role only** (`requireCurrentHumanRole`), not capabilities. A workflow run carries the role of whoever triggered it but not their capabilities: a capability names what a *person* may reach in the product, while a run reaches those same resources because a block in the graph does. Applying capabilities here would turn "hide Tables" into a runtime kill-switch that breaks every workflow with a Table block for that cohort. +- An **actorless deployment run** — a delegated executor principal in `mode: 'deployment'` with no resolvable subject — also passes through, because a deployed workflow acts with the workspace's authority rather than its author's group; denying there would 403 every scheduled run, webhook, and public-API call in the organization the moment a group withheld anything. +- **Copilot is deliberately NOT exempt.** A delegated principal with a `sim_user` subject whose `serviceId` is anything other than `executor` goes through the full `requireCurrentHumanAccess`, capability check included. Copilot acts *as the person*, so it must not reach what the person may not. + +What a run *does* is still governed, by `assertPermissionsAllowed` in the executor. If your item must bind a deployed run, it belongs at `enforcement: 'executor'`, not `'capability'`. -**Capability is checked after the role check, on purpose.** `requireCurrentHumanAccess` runs `requirePermission` first. `NoWorkspaceAccessError` is concealed as a 404 by the v2 surface so a non-member cannot learn the resource exists; refusing on capability first would tell a complete outsider which capabilities the organization withholds. Do not reorder it, and do not add a capability check upstream of the role check in a raw route. +**Capability is checked after the role check, on purpose.** `requireCurrentHumanAccess` runs `requirePermission` first. `NoWorkspaceAccessError` is concealed as a 404 by the v2 surface so a non-member cannot learn the resource exists; refusing on capability first would hand a complete outsider an oracle for which capabilities the organization withholds. Do not reorder it, and do not add a capability check upstream of the role check in a raw route — the v1 middleware says so in its own TSDoc for the same reason. ## Checklist Before Finishing - [ ] Kind and `enforcement` chosen deliberately; `ui-only` justified in writing if used +- [ ] It is a gate, not a field projection — a projection belongs in `lib/logs/log-projection.ts` with `capability: 'none'` on the routes - [ ] Entry **appended** to `PERMISSION_GROUP_FIELDS`, permissive default, restriction-phrased name - [ ] Category present in `PLATFORM_CATEGORY_ORDER`, named after what is withheld rather than a surface - [ ] `hint` says what access is revoked, never "hide" — it is also the prose for an active restriction - [ ] Non-boolean key has a `featureExtras` picker that refuses empty and collapses "all" to `null` -- [ ] Capability id in `CAPABILITY_IDS`, rule in `CAPABILITY_RULES`, `configKeys` lists every key `deniedBy` reads +- [ ] Capability id in `CAPABILITY_IDS`, rule in `CAPABILITY_RULES` under `satisfies`, `configKeys` lists every key `deniedBy` reads - [ ] A narrower capability replacing a broader one also reads the broader key -- [ ] Declared on every operation it governs, or asserted from the use case with a `// permission-group-enforced:` annotation +- [ ] Declared on every operation it governs, or asserted from the use case with a `// permission-group-enforced:` annotation, raising through `refuseCapability` / `capabilityRefusal` - [ ] Any `capability: 'none'` you added carries a `// permission-group-exempt:` reason -- [ ] Added to the `'a fully populated config'` fixture in `types.test.ts`, input and expected +- [ ] v1 routes thread the capability through `middleware.ts` and take their subject from `capabilityGovernedUserId`; table routes pass a `TableAccessPrincipal` +- [ ] Added to the `'a fully populated config'` fixture in `fields.test.ts`, input and expected - [ ] Allowlist three-state (`null` / populated / `[]`) covered in `capabilities.test.ts` -- [ ] `check:permission-group-enforcement` passes and names your capability as enforced, not pending +- [ ] No new runtime import from a guarded root into a forbidden tree +- [ ] `check:permission-group-enforcement`, `check:application-graph` and `check:capability-subject` all pass and name your capability - [ ] `type-check` clean, `lib/permission-groups` suite green From 55b53568b77bddec031d45a11c3db567e9e691fd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 13:23:59 -0700 Subject: [PATCH 043/179] docs(permission-groups): state what the executor exemption actually does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exemption's rationale claimed a deployed workflow runs with the workspace's authority rather than any member's group. It does not. For an actorless run the actor falls back to the billing owner, so the block, tool and model gates resolve the payer's group — which is both a bypass (schedule a run to escape a denial) and a wrong denial (the payer's group narrows every unattended run). That predates this branch, but justifying the exemption with a premise that does not hold is our doing. --- .../application/workspace-authorization.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index 0e5be2643c4..32c5b141853 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -305,7 +305,9 @@ export async function authorizeWorkspaceOperation Date: Sun, 30 Aug 2026 13:29:05 -0700 Subject: [PATCH 044/179] docs(skills): realign validate-permission-group-item with the refactored code Drops the stale standing finding that assertConnectorTypeAllowed writes its own refusal sentence (it calls refuseCapability now), repoints types.test.ts at fields.test.ts and the deleted assertOrganizationCapability at isOrganizationCapabilityWithheld, and adds the projection-vs-gate case, the subject audit for v1 and the table routes, the executor/Copilot split, the Array.isArray and satisfies guards, and the two new audits. --- .../validate-permission-group-item/SKILL.md | 89 ++++++++++++++----- 1 file changed, 65 insertions(+), 24 deletions(-) diff --git a/.agents/skills/validate-permission-group-item/SKILL.md b/.agents/skills/validate-permission-group-item/SKILL.md index 867662c6bea..d05b12e5a7d 100644 --- a/.agents/skills/validate-permission-group-item/SKILL.md +++ b/.agents/skills/validate-permission-group-item/SKILL.md @@ -12,14 +12,18 @@ You are auditing one governed item in Sim's enterprise permission-group system. Twelve keys once shipped with an admin checkbox, a hint describing what they restrict, and no server check at all. Every one of them would have passed a structural audit. Assume nothing enforces until you have found the throw. +The authoring counterpart is the `add-permission-group-item` skill. It owns the procedure and the rationale for each invariant; this skill owns the audit. Where the two overlap, read that one for *why* and this one for *how to check*. + ## Read the system first -- `apps/sim/lib/permission-groups/fields.ts` — the registry every config surface derives from -- `apps/sim/lib/permission-groups/capabilities.ts` — `CAPABILITY_IDS`, `CAPABILITY_RULES` -- `apps/sim/lib/permission-groups/capability-assertions.ts` — the canonical assertion API +- `apps/sim/lib/permission-groups/fields.ts` — the registry every config surface derives from, plus `permissionGroupConfigSchema`, `tolerantArray` and `parsePermissionGroupConfig`. There is **no `types.ts`** — it was folded into this file, and the two DB constraint maps live in `constraints.ts` +- `apps/sim/lib/permission-groups/capabilities.ts` — `CAPABILITY_IDS`, `CAPABILITY_RULES`, `capabilityRefusal`, `refuseCapability` +- `apps/sim/lib/permission-groups/capability-assertions.ts` — the canonical assertion API (and it re-exports `capabilityRefusal`) +- `apps/sim/lib/permission-groups/resolve.server.ts` — group resolution, moved out of `ee/`; `ee/access-control/utils/permission-check.ts` now re-exports it and keeps only the executor gates - `apps/sim/lib/permission-groups/config-scope.server.ts` — `resolvePermissionGroupConfig`, the per-request memo every assertion resolves through +- `apps/sim/lib/permission-groups/request-scope.server.ts` — the import-free half of the scope, holding `withPermissionGroupScope` - `apps/sim/lib/core/application/workspace-authorization.ts` — the funnel, and who bypasses it -- `scripts/check-permission-group-enforcement.ts` — what the audit does and does not prove +- `scripts/check-permission-group-enforcement.ts`, `scripts/check-application-graph.ts`, `scripts/check-capability-subject.ts` — what the three audits do and do not prove ## Step 1: Registry entry @@ -27,13 +31,13 @@ Find the key in `PERMISSION_GROUP_FIELDS`. Record its builder (`booleanRestricti - **Default is permissive?** Boolean `false`, allowlist `null`, denylist `[]`. The builders hardcode these, so the real risk is a key whose *name* inverts the meaning — an `allowX` boolean whose permissive value would be `true`. Every stored config predates the key, so a restrictive default silently applies retroactively to every existing group. - **Named as a restriction?** `hideX` / `disableX` / `allowedX` / `deniedX`. The admin checkbox renders `checked={!editingConfig[feature.configKey]}` — ticked means allowed — so a positively-named boolean renders backwards. -- **Position stable?** Declaration order is the wire order of `PermissionGroupConfig`, both zod schemas, and every config JSON crossing the API boundary. If `git log -p` shows the key was ever *moved* rather than appended, that shipped as a dirty-check regression in the group editor. -- **Phrasing present and accurate?** An allowlist's `{ limited, empty }` and a denylist's string are read by `getActivePermissionGroupRestrictions` in `features.ts` and surface to users through the Copilot context and the group roster. Check the `empty` string genuinely says "none allowed" and not "unrestricted". -- **Does the boolean's `hint` tell the truth?** This is the highest-value read in Step 1. A key with `enforcement: 'capability'` refuses at the API, so a hint saying it hides a tab, a panel, a module "from the sidebar", or a nav item is a **lie** an admin acts on — they believe they are tidying chrome while they are revoking access. The same string is read a second time as the prose for an *active* restriction, where "hide" is simply false. It must name what a member can no longer do. Twelve keys carried that wording for a release after they started 403-ing; treat any surviving "Hide the …" hint on a `'capability'` key as a finding, not a nit. Check `label` and `category` the same way — a section headed "Sidebar" or "Settings Tabs" makes the same claim structurally. +- **Position stable?** Declaration order is the wire order of `PermissionGroupConfig`, both zod schemas, and every config JSON crossing the API boundary; `fields.test.ts` pins it with a contract test that compares key order. If `git log -p` shows the key was ever *moved* rather than appended, that shipped as a dirty-check regression in the group editor. +- **Phrasing present and accurate?** An allowlist's `{ limited, empty }` and a denylist's string are read by `getActivePermissionGroupRestrictions` in `features.ts` and surface to users through the Copilot workspace VFS and the enterprise platform context. Check the `empty` string genuinely says "none allowed" and not "unrestricted". +- **Does the boolean's `hint` tell the truth?** This is the highest-value read in Step 1. A key with `enforcement: 'capability'` refuses at the API, so a hint saying it hides a tab, a panel, a module "from the sidebar", or a nav item is a **lie** an admin acts on — an admin ticking that box is revoking access, not hiding a link, and they believe they are tidying chrome while they are withholding a module. The same string is read a second time as the prose for an *active* restriction, where "hide" is simply false. It must name what a member can no longer do. Twelve keys carried that wording for a release after they started 403-ing; treat any surviving "Hide the …" hint on a `'capability'` key as a finding, not a nit. Check `label` and `category` the same way — a section headed "Sidebar" or "Settings Tabs" makes the same claim structurally. ## Step 2: Schemas, type, defaults, parser -These are derived by `collectFieldProperty` — `permissionGroupWriteShape`, `permissionGroupReadShape`, `DEFAULT_PERMISSION_GROUP_CONFIG`, and the tolerant parser all read the same registry. **Do not hand-verify them one by one.** Verify instead that nothing has been introduced that bypasses the derivation: +These are derived by `collectFieldProperty` — `permissionGroupWriteShape` / `permissionGroupConfigSchema`, `permissionGroupReadShape`, `DEFAULT_PERMISSION_GROUP_CONFIG`, and the tolerant parser all read the same registry. **Do not hand-verify them one by one.** Verify instead that nothing has been introduced that bypasses the derivation: ```bash grep -rn "" apps/sim --include='*.ts' --include='*.tsx' | grep -v 'lib/permission-groups/' @@ -41,11 +45,16 @@ grep -rn "" apps/sim --include='*.ts' --include='*.tsx' | grep -v 'li Every hit outside `lib/permission-groups/` is either a rule's `deniedBy`, an enforcement site, a UI binding, or a test. Anything else — a route restating the key, a client re-deriving a default, a second coercion path — is a leak. In particular: -- A `z.array(...).catch(...)` anywhere on this key's path. `.catch()` is whole-value tolerant: one bad member discards every good one. On an **allowlist** that is fail-**open**, because the fallback is `null` and `null` means unrestricted — a partially corrupt allowlist would stop restricting anything. `tolerantArray` filters element-wise for exactly this reason. +- A `z.array(...).catch(...)` anywhere on this key's path. `.catch()` is whole-value tolerant: one bad member discards every good one. On an **allowlist** that is fail-**open**, because the fallback is `null` and `null` means unrestricted — a partially corrupt allowlist would stop restricting anything. `tolerantArray` filters element-wise for exactly this reason. Rank a regression here with the enforcement findings; it is a security bug, not a coercion nit. - A `?? []` applied to an allowlist. `null` allows everything and `[]` allows nothing; collapsing them inverts the unrestricted case. - Any read of the config that does not come from `parsePermissionGroupConfig` or a `resolvePermissionGroupConfig` caller. -Confirm the type assertions at the bottom of `fields.ts` still name a field of this kind (`AssertsAllowlistStaysPrecise`, `AssertsDenylistStaysPrecise`, `AssertsRestrictionStaysPrecise`, `AssertsAuthTypesStayPrecise`). They exist because a zod generic degrading to `unknown` is invisible at runtime — the values stay right, no test fails, and every call site quietly loses its narrowing. +Two structural guards to confirm are still present: + +- **`parsePermissionGroupConfig` still tests `Array.isArray(config)`** alongside its truthiness and `typeof … === 'object'` checks. `typeof [] === 'object'`, so without it an array reaches `z.object().parse([])`, which throws — and the column is `jsonb`, so a row genuinely can hold `[]`. The guard is what makes that path return the defaults instead of a 500. `tolerantArray` carries the mirror-image guard. +- **`CAPABILITY_RULES` still uses `satisfies`, not a type annotation.** An annotation widens every entry to `CapabilityRule`, and `StaticPermissionGroupCapability` — derived by filtering the object's entries for `kind: 'static'` — then resolves to `never`. No operation can declare any capability, the type system stops constraining capabilities entirely, and nothing at runtime looks wrong. `AssertsStaticCapabilityResolves` exists to catch it; report any weakening of it as a top-tier finding. + +Confirm the type assertions at the bottom of `fields.ts` still name a field of this kind (`AssertsAllowlistStaysPrecise`, `AssertsDenylistStaysPrecise`, `AssertsRestrictionStaysPrecise`, `AssertsAuthTypesStayPrecise`, `AssertsParserReturnsTheConfig`). They exist because a zod generic degrading to `unknown` is invisible at runtime — the values stay right, no test fails, and every call site quietly loses its narrowing. ## Step 3: Admin UI @@ -66,7 +75,7 @@ Then check the things the audit cannot: - **`kind` is right.** A rule whose decision needs a request value must be `'parameterized'`. A parameterized rule can never be declared on an operation — `defineWorkspaceOperation` throws at definition time — so if you find one named on an operation, that code does not run in production; something else is wrong. - **A narrower capability subsumes the broader one it replaced.** An operation carries exactly one capability. If this capability was split off a more general one, its rule must also read the general key. The precedent is `knowledge.create` and `knowledge.upload`, which both read `hideKnowledgeBaseTab` alongside their own key — without that, a group withholding the entire Knowledge Base module could still create one through the API. Check `git log` for a re-pointed `capability:` field and verify the narrower rule grew the broader key in the same commit. - **`detailCode` matches the remedy.** `FORBIDDEN_DETAIL_CODES` is closed over *remedies*, not causes. A distinct code is warranted only when a caller would act differently; otherwise `PERMISSION_GROUP_CAPABILITY_BLOCKED` is correct. Any code in use must have an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS`, which is a compile-time gate and also publishes the OpenAPI 403 text. -- **`describe` reads correctly in the sentence.** Two functions build it and there is no third: `refuseCapability(capability)` in `capabilities.ts` throws `" is not available under your organization's permission group"` as a `PermissionGroupCapabilityError`, and `capabilityRefusal(capability)` in `capability-assertions.ts` returns the same string for a raw route rendering its own body. `describe` must be a singular noun or gerund phrase that agrees with "is". Any call site that writes the sentence out itself is a drift finding. +- **`describe` reads correctly in the sentence.** Two functions build it and there is no third, both **defined in `capabilities.ts`**: `refuseCapability(capability)` throws `" is not available under your organization's permission group"` as a `PermissionGroupCapabilityError`, and `capabilityRefusal(capability)` returns the same string for a raw route rendering its own body (`capability-assertions.ts` re-exports it so an inline gate reaches both through one module). `describe` must be a singular noun or gerund phrase that agrees with "is". Any call site that writes the sentence out itself is a drift finding. ## Step 5: Prove the enforcement — do not assume it @@ -77,55 +86,87 @@ grep -rn "''" apps/sim --include='*.ts' --include='*.tsx' grep -rn "permission-group-enforced: " apps/sim ``` +The second grep will miss a gate written through `capabilityDeniedBy` with the annotation in a TSDoc block above the enclosing statement, so read the surrounding function rather than the matched line alone. + Classify what you find into exactly one of: 1. **Declared on operations.** `capability: ''` on one or more `defineWorkspaceOperation` calls. The funnel enforces in `requireCurrentHumanAccess` → `requireCapability`. Verify the set of operations is *complete*: enumerate every route and tool that reaches the same behavior and check each one's operation declares it. One route declaring `capability: 'none'` for the same behavior is the hole. -2. **Asserted at a call site**, with a `// permission-group-enforced: ` annotation. Verify the assertion goes through `capability-assertions.ts` or a `CAPABILITY_RULES` entry rather than spelling the config key out inline — a call site reading `config.disableX` directly stops denying anything the moment the key is renamed, and its wording drifts from the funnel's. Then check the second half, which is easy to miss because the decision looks right: does it *raise* through `refuseCapability`, or does it build its own `ForbiddenOperationError` with a hand-written message? `validatePublicFileSharing` and `validateChatDeployAuth` in `apps/sim/ee/access-control/utils/permission-check.ts` read the rule and call `refuseCapability` — that is the pattern. `assertConnectorTypeAllowed` in `apps/sim/lib/knowledge/application/connectors.ts` reads the rule but writes its own sentence; the decision is sound, the wording is a standing drift finding, not a new one. -3. **Executor-gated.** Read by `assertPermissionsAllowed` in `permission-check.ts`, per block / tool / model. Verify the branch exists and throws a real error, and that the id it compares against is the same vocabulary the admin UI writes — `deniedTools` holds block `tools.access` ids verbatim, version suffix included. -4. **Nothing.** Report it as a defect, with the sentence "an organization that sets this believes it applied a restriction that does not exist". +2. **Asserted at a call site**, with a `// permission-group-enforced: ` annotation. Verify the assertion goes through `capability-assertions.ts` (`assertWorkspaceCapability`, `isWorkspaceCapabilityWithheld`, `isOrganizationCapabilityWithheld`, `capabilityDeniedBy`) or a direct `CAPABILITY_RULES[''].deniedBy(...)` rather than spelling the config key out inline — a call site reading `config.disableX` directly stops denying anything the moment the key is renamed, and its wording drifts from the funnel's. Then check the second half, which is easy to miss because the decision looks right: does it *raise* through `refuseCapability` (or render `capabilityRefusal(cap)`), or does it build its own `ForbiddenOperationError` with a hand-written message? `validatePublicFileSharing` and `validateChatDeployAuth` in `ee/access-control/utils/permission-check.ts`, and `assertConnectorTypeAllowed` in `lib/knowledge/application/connectors.ts`, all read the rule and call `refuseCapability` — that is the pattern for a use case. A raw route that renders its own body pairs `isWorkspaceCapabilityWithheld` (or `capabilityDeniedBy`) with `capabilityRefusal`; `app/api/logs/export/route.ts` and the inbox and api-keys routes are the shape. +3. **Executor-gated.** Read by `assertPermissionsAllowed` in `ee/access-control/utils/permission-check.ts`, per block / tool / model. Verify the branch exists and throws a real error, and that the id it compares against is the same vocabulary the admin UI writes — `deniedTools` holds block `tools.access` ids verbatim, version suffix included. +4. **A field projection, not a gate.** `logs.trace_spans` and `logs.cost` withhold fields from a response rather than the response itself, so the logs routes correctly declare `capability: 'none'`. The single owner is `apps/sim/lib/logs/log-projection.ts` (`resolveLogFieldProjection`, `projectExecutionData`, `projectCostTotal`), which carries both `permission-group-enforced:` annotations. A **second** implementation of the same redaction anywhere else is the finding here — two copies is how one of them stops redacting. +5. **Nothing.** Report it as a defect, with the sentence "an organization that sets this believes it applied a restriction that does not exist". Then make the refusal happen. Either write a failing case, or take the existing test and **remove the gate** — delete the `capability:` field, or the `deniedBy` body, or the assertion call — and confirm the test goes red. A test that still passes with the gate removed is proving nothing. Restore the code afterward. For an allowlist, the three states have to be tested separately, because they are what the parser and the UI conspire to confuse: `null` permits every member, a populated list permits only the named ones, `[]` permits **none**. `capabilities.test.ts` pins all three for `knowledge.connectors`; anything less than that for another allowlist is a gap. +### Who the gate runs against + +A capability belongs to a *person*, so half of auditing a gate is auditing whose id it reads. + +- **`/api/v1`** authorizes in `apps/sim/app/api/v1/middleware.ts`, not through `authorizeWorkspaceOperation`. Every route threads a `V1RouteCapability` (`StaticPermissionGroupCapability | 'none'`, required and spelled out), and the subject must come from `capabilityGovernedUserId(rateLimit)`, which returns `null` for a workspace key. `rateLimit.userId` is populated for **both** key kinds and is the key's *creator* for a workspace key, so any gate keyed on the presence of a user id applies a bystander's group to every caller of a shared credential. Reading `rateLimit.userId` (or `auth.userId`) into a capability sink is the finding; `scripts/check-capability-subject.ts` exists because it has shipped twice. +- **Raw internal table routes** gate `tables.use` inside `checkAccess` in `apps/sim/app/api/table/utils.ts`, whose signature takes a `TableAccessPrincipal` discriminated union — `{ kind: 'user'; userId }` or `{ kind: 'workspace_api_key'; keyCreatorUserId }` — rather than a bare `userId`, so a caller cannot reach the gated behavior without naming a kind. A bare id passed here no longer type-checks; `tableAccessPrincipal(rateLimit)` in the v1 middleware is the one place v1 builds it. +- **The definition-time `undefined` guard.** `defineWorkspaceOperation` throws when `capability` is `undefined`, even though the field is required, because `apps/sim/tsconfig.json` excludes `*.test.ts` / `*.test.tsx` and the enforcement audit walks past test files — a fixture is the one construction site no static check reads. Without the guard, a capability-less operation defines cleanly and then throws `Cannot read properties of undefined` inside `capabilityDeniedBy` **only for tenants that actually have a permission group**, passing CI and every personal workspace. If you find a proposal to drop the guard as redundant, that is a finding. + ## Step 6: Tests -- **`apps/sim/lib/permission-groups/types.test.ts`** — the key must appear in both the `input` and `expected` halves of the `'a fully populated config'` fixture. The corpus is pinned deliberately: a row that changes in a later diff has to be defended as a semantic decision rather than slipping through as a regression. The rest of that file (wire order, idempotence, read-schema acceptance, the seeded 2000-iteration fuzz, boolean-to-`PLATFORM_FEATURES` coverage) derives from `DEFAULT_PERMISSION_GROUP_CONFIG` and needs no per-key edit. +- **`apps/sim/lib/permission-groups/fields.test.ts`** (formerly `types.test.ts`) — the key must appear in both the `input` and `expected` halves of the `'a fully populated config'` fixture. The corpus is pinned deliberately: a row that changes in a later diff has to be defended as a semantic decision rather than slipping through as a regression. The rest of that file (wire order, idempotence, read-schema acceptance, the seeded 2000-iteration fuzz, the write/default/read key-set agreement, boolean-to-`PLATFORM_FEATURES` coverage) derives from `DEFAULT_PERMISSION_GROUP_CONFIG` and needs no per-key edit. - **`capabilities.test.ts`** — a case for any rule with logic beyond reading one key: subsumption, allowlist three-state, auth-mode membership. - **`features.test.ts`** — derived from `PLATFORM_FEATURES`; a boolean key needs no edit. A non-boolean key contributing user-facing prose should have its `limited` / `empty` strings pinned there. +- **`config-scope.server.test.ts`** — covers the per-request memo. A new gate that resolves the config outside `resolvePermissionGroupConfig` bypasses it and is a finding in Step 2, not here. ## Step 7: Run the checks ```bash bun run check:permission-group-enforcement +bun run check:application-graph +bun run check:capability-subject cd apps/sim && bun run type-check cd apps/sim && bunx vitest run lib/permission-groups ``` -Read the audit's output, not just its exit code. It is all-or-nothing — it either prints one success line or fails with findings; there is no count-down or migration mode that exits 0 with work outstanding, so do not go looking for a `pending enforcement:` list. What it can still do is pass without proving what you want: +All three audits are inside `check:audits`, which derives its list from the `check:*` scripts in `package.json` — a new audit is opted *out* deliberately rather than opted in. Read their output, not just their exit codes. -- **Vacuous parse.** The audit reads source text with regexes. It has a self-check that refuses to report success when `CAPABILITY_IDS`, `CAPABILITY_RULES`, or `PERMISSION_GROUP_FIELDS` parse to nothing, and it cross-checks that the rule count equals the capability count. If either of those errors fires, the audit is broken, not the code — fix the parsers rather than leaving it passing. +**`check:permission-group-enforcement`** is all-or-nothing — one success line or findings; there is no count-down or migration mode that exits 0 with work outstanding, so do not go looking for a `pending enforcement:` list. What it can still do is pass without proving what you want: + +- **Vacuous parse.** It reads source text with regexes. It refuses to report success when `CAPABILITY_IDS`, `CAPABILITY_RULES`, or `PERMISSION_GROUP_FIELDS` parse to nothing, cross-checks that the rule count equals the capability count, reports per-call any `defineWorkspaceOperation` whose `id` it cannot read, and — the newest guard — **fails a file that calls `defineWorkspaceOperation` but parses to ZERO declarations**, which is what a non-literal `id:` or an arrow-const factory looks like. If any of those fire, the audit is broken, not the code — fix the parsers rather than leaving it passing. - **A capability declared on an operation nothing routes to.** Assertion C is satisfied by the declaration alone. An operation that no route, tool, or use case actually invokes still counts as reaching the capability. -The audit proves *reachability*, not correctness: it proves a capability is named somewhere and a key is read by some rule. It cannot tell whether the rule's logic is right, whether every relevant operation declares it, or whether an annotated call site actually calls anything. Step 5 is what covers that, and no amount of green CI substitutes for it. +**`check:application-graph`** asserts the authorization funnel and `with-route-handler.ts` reach no heavy module tree at runtime, across five guarded roots: `lib/core/application/index.ts`, `capabilities.ts`, `capability-assertions.ts` and `config-scope.server.ts` may not reach `providers/`, `blocks/`, `tools/`, `executor/`, `lib/uploads/` or `lib/workflows/`; `with-route-handler.ts` additionally may not reach `lib/billing/`, `lib/permission-groups/resolve.server`, `lib/auth`, `lib/copilot/` or `lib/knowledge/`. Only runtime edges count — `import type` is erased and deliberately allowed. A gate you are auditing that imports a resolver into one of those roots is a finding even if the gate itself is correct. The failure mode never announces itself: past regressions surfaced as unrelated knowledge tests failing on a partial mock and an OTP-route test failing on its own `zod` mock. + +**`check:capability-subject`** asserts every v1 capability sink takes its subject from `capabilityGovernedUserId`, that no v1 file outside the middleware imports the permission-group modules directly, and that at least one governed sink was found at all (so a refactor into an unparseable form cannot look like a clean tree). + +Reference success lines: + +``` +✓ permission-group enforcement: 287 operations declare a capability, 35 capabilities all enforced +✅ Application graph clean: 5 roots reach none of 11 forbidden module trees +check:capability-subject — 32 v1 files, 5 capability subjects resolved through capabilityGovernedUserId. +``` + +The audits prove *reachability*, not correctness: that a capability is named somewhere, that a key is read by some rule, that a subject came from the right helper. They cannot tell whether the rule's logic is right, whether every relevant operation declares it, or whether an annotated call site actually calls anything. Step 5 is what covers that, and no amount of green CI substitutes for it. ## Known gaps — recognize these, do not re-report them These are understood, deliberate, and documented in the code. Note them if they are material to what you were asked about; do not file them as new findings. -- **A workspace API key resolves no permission group.** It authorizes as the workspace, so there is no user and `operation.capability` does not apply — the `workspace_api_key` branch of `authorizeWorkspaceOperation` returns before any capability check. Substituting the key's creator would apply a bystander's group to every caller of a shared key and break the key outright when that person left the organization. The escape is closed at the door instead: minting a workspace key is itself capability-gated. +- **A workspace API key resolves no permission group.** It authorizes as the workspace, so there is no user and `operation.capability` does not apply — the `workspace_api_key` branch of `authorizeWorkspaceOperation` returns before any capability check, and the same reasoning shapes `TableAccessPrincipal`, `capabilityGovernedUserId` and the log projection. Substituting the key's creator would apply a bystander's group to every caller of a shared key and break the key outright when that person left the organization. The escape is closed at the door instead: minting a workspace key is itself capability-gated. +- **An executor delegation carries role but not capabilities.** A delegated `executor` principal *with* a `sim_user` subject goes through `requireCurrentHumanRole` only. A run carries the role of whoever triggered it, but a capability names what a *person* may reach in the product, while a run reaches those resources because a block in the graph does — applying capabilities would turn "hide Tables" into a runtime kill-switch that breaks every workflow with a Table block for that cohort. - **An actorless deployment run passes through.** A delegated `executor` principal in `mode: 'deployment'` with no resolvable subject is authorized without a capability check, because a deployed workflow acts with the workspace's authority rather than its author's permission group. Denying there would 403 every scheduled run, webhook, and public-API call in the organization the moment a group withheld anything. What such a run *does* is still governed, by `assertPermissionsAllowed` in the executor — which is precisely why the four run-scoped keys carry `enforcement: 'executor'` rather than `'capability'`. -- **Capability is checked after the role check.** `requireCurrentHumanAccess` runs `requirePermission` first. `NoWorkspaceAccessError` is concealed as a 404 by the v2 surface so a non-member cannot learn the resource exists; refusing on capability first would leak which capabilities the organization withholds to a complete outsider. It is also the cheaper check and names the remedy the caller can act on. Do not report the ordering as a bug. +- **Copilot is deliberately NOT exempt.** A delegated principal with a `sim_user` subject whose `serviceId` is anything other than `executor` takes the full `requireCurrentHumanAccess` path, capability check included. Copilot acts as the person, so it must not reach what the person may not. A proposal to exempt it is a finding, not a simplification. +- **Capability is checked after the role check.** `requireCurrentHumanAccess` runs `requirePermission` first. `NoWorkspaceAccessError` is concealed as a 404 by the v2 surface so a non-member cannot learn the resource exists; refusing on capability first would hand a complete outsider an oracle for which capabilities the organization withholds. It is also the cheaper check and names the remedy the caller can act on. The v1 middleware states the same ordering rule in its own TSDoc. Do not report the ordering as a bug. - **`allowedEgressHosts` does not exist.** There is no network-egress allowlist in `PERMISSION_GROUP_FIELDS`. Requests for one are a feature, not a missing wiring of an existing key. +- **Nothing currently ships as `ui-only`.** The `enforcement` union has the member and no user. An absent `ui-only` key is not a gap. ## Report Format For each item audited, state: 1. **Kind and enforcement** — as declared, and whether the declaration is true. -2. **The refusal** — file, line, the error thrown, and what a caller sees (status, `detailCode`, message). Or: *nothing refuses*. -3. **Proof** — the test that fails when the gate is removed, or the statement that no such test exists. -4. **Coverage gaps** — routes, tools, or surfaces reaching the same behavior without the gate. -5. **Findings**, ordered: unenforced key > incomplete operation coverage > fail-open coercion > allowlist three-state confusion > **admin copy that misstates the enforcement** > missing admin UI > missing test > cosmetic. +2. **The refusal** — file, line, the error thrown, and what a caller sees (status, `detailCode`, message). Or: *it is a projection, and here is its single owner*. Or: *nothing refuses*. +3. **The subject** — whose user id the gate reads, and that a workspace key reaches it ungated rather than as its creator. +4. **Proof** — the test that fails when the gate is removed, or the statement that no such test exists. +5. **Coverage gaps** — routes, tools, or surfaces reaching the same behavior without the gate. +6. **Findings**, ordered: unenforced key > key-creator substituted for the acting principal > fail-open coercion (`.catch()` on an array, a dropped `Array.isArray` guard, `CAPABILITY_RULES` annotated instead of `satisfies`) > incomplete operation coverage > allowlist three-state confusion > **admin copy that misstates the enforcement** > duplicated projection logic > missing admin UI > missing test > cosmetic. A hint that says "hide" for a key that 403s is not cosmetic. It is the one defect an admin acts on directly: they tick it believing they hid a link, and members lose the module. Rank it with the enforcement findings, not the polish. From f93bce72022e7296ddf5031b769106e5735751f1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 13:35:50 -0700 Subject: [PATCH 045/179] fix(permission-groups): enforce secrets.manage and integrations.manage where they were only claimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace environment route is raw `withRouteHandler` and never reaches the `secrets.*` operations, so `hideSecretsTab` restricted nothing on the one route the Secrets UI actually calls. Gate GET/PUT/DELETE on `secrets.manage`, after the workspace role check so a non-member still learns only that the workspace is out of reach. `defineCredentialUserOperation` mints operations without calling `defineWorkspaceOperation`, so neither that builder's definition-time guard nor `check:permission-group-enforcement` ever read them, and all five shipped with no capability at all. Give the interface a required `capability`, name `integrations.manage` on each, and apply it in `defineAuthorizedCredentialUserUseCase` — org-scoped, since a user's own OAuth connections belong to no workspace. --- .../api/workspaces/[id]/environment/route.ts | 38 +++++++++ .../application/authorized-user-use-case.ts | 36 +++++++++ .../lib/credentials/application/operations.ts | 77 +++++++++++++++++-- 3 files changed, 144 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index 9edf6a73889..68dd0b8023f 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -26,6 +26,10 @@ import { getPersonalAndWorkspaceEnv, invalidateEffectiveDecryptedEnvCache, } from '@/lib/environment/utils' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions, @@ -35,6 +39,31 @@ import { const logger = createLogger('WorkspaceEnvironmentAPI') +/** + * Refuses when the caller's permission group withholds secrets, and `null` when + * it does not. + * + * permission-group-enforced: secrets.manage — this route predates the operation + * boundary and is raw `withRouteHandler`, so the authorization funnel that + * applies the capability to `secretOperations` never sees it. It reads and + * writes the very values the Secrets tab shows, which is what the capability + * describes, so it takes the same one the `secrets.*` operations declare. + * + * Every handler here authenticates with `getSession` alone, so the caller is + * always a user-bearing session principal — a workspace API key cannot reach + * this route, and there is no executor delegation to refuse. Call this only + * after the workspace role check has passed: a caller with no role must learn + * that the workspace is out of reach, not how their organization's group is + * configured. + */ +async function secretsCapabilityRefusal( + userId: string, + workspaceId: string +): Promise { + if (!(await isWorkspaceCapabilityWithheld(userId, workspaceId, 'secrets.manage'))) return null + return NextResponse.json({ error: capabilityRefusal('secrets.manage') }, { status: 403 }) +} + /** * Reveals a workspace secret only to a workspace administrator, that secret's * credential administrator, or a caller allowed to use a secret explicitly @@ -120,6 +149,9 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const withheld = await secretsCapabilityRefusal(userId, workspaceId) + if (withheld) return withheld + const { workspaceDecrypted, personalDecrypted, @@ -191,6 +223,9 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + const withheld = await secretsCapabilityRefusal(userId, workspaceId) + if (withheld) return withheld + const incomingKeys = Object.keys(variables) if (incomingKeys.length === 0) { return NextResponse.json({ success: true }) @@ -347,6 +382,9 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + const withheld = await secretsCapabilityRefusal(userId, workspaceId) + if (withheld) return withheld + const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({ workspaceId, envKeys: keys, diff --git a/apps/sim/lib/credentials/application/authorized-user-use-case.ts b/apps/sim/lib/credentials/application/authorized-user-use-case.ts index 95fd6e08712..77010182ee9 100644 --- a/apps/sim/lib/credentials/application/authorized-user-use-case.ts +++ b/apps/sim/lib/credentials/application/authorized-user-use-case.ts @@ -1,9 +1,12 @@ import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit' import { resolvePrincipalAuditAttribution, type SessionPrincipal } from '@sim/auth/principal' +import { getUserOrganization } from '@/lib/billing/organizations/membership' import type { OperationUseCase } from '@/lib/core/application' import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { CredentialUserOperation } from '@/lib/credentials/application/operations' +import { refuseCapability } from '@/lib/permission-groups/capabilities' +import { isOrganizationCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' export interface CredentialUserAuditEntry { workspaceId: string | null @@ -65,6 +68,38 @@ function recordCredentialUserAudit( } } +/** + * Refuses when the group governing the acting user withholds the operation's + * capability. + * + * permission-group-enforced: integrations.manage — these operations have no + * workspace, so `authorizeWorkspaceOperation` never sees them and the capability + * is applied here instead, at the one place every current-user credential + * operation passes through. + * + * The user's own OAuth connections belong to no workspace, so this resolves the + * organization's default group — the same resolution personal API keys and + * invitations use for an organization-level action. A no-op when the user is in + * no organization or no group governs them, which is the personal-workspace and + * non-enterprise case. + * + * Runs after the session-principal check above, never before: the principal kind + * is this operation's whole access story, and answering the capability question + * first would tell a caller who is not a session about the organization's + * configuration. + */ +async function assertCurrentUserCapability( + userId: string, + operation: CredentialUserOperation +): Promise { + if (operation.capability === 'none') return + const membership = await getUserOrganization(userId) + if (!membership?.organizationId) return + if (await isOrganizationCapabilityWithheld(membership.organizationId, operation.capability)) { + refuseCapability(operation.capability) + } +} + /** Defines a current-user credential operation that cannot borrow workspace identity. */ export function defineAuthorizedCredentialUserUseCase< const O extends CredentialUserOperation, @@ -77,6 +112,7 @@ export function defineAuthorizedCredentialUserUseCase< if (principal.kind !== 'session') { throw new OrchestrationError('forbidden', 'Session authentication required') } + await assertCurrentUserCapability(principal.userId, definition.operation) try { const result = await definition.execute({ principal, input, request }) recordCredentialUserAudit( diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 6c1bea4224c..bac88e64e37 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -1,5 +1,9 @@ import type { ApplicationOperation } from '@/lib/core/application' import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' +import { + CAPABILITY_RULES, + type StaticPermissionGroupCapability, +} from '@/lib/permission-groups/capabilities' import { CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION } from '@/lib/resource-policies/registry' export type CredentialRole = 'member' | 'admin' @@ -183,22 +187,81 @@ export const credentialOperations = { }), } as const +/** + * A credential operation whose resource is the acting user's own account rather + * than a workspace, so it carries no role and no workspace-key policy. + * + * It still carries a `capability`, and required rather than optional for the + * same reason `defineWorkspaceOperation` requires one: an absent field cannot be + * told apart from an unreviewed one. Listing and disconnecting a user's OAuth + * connections is exactly what `hideIntegrationsTab` claims to revoke, and these + * operations shipped with no capability at all — invisibly, because this factory + * does not call `defineWorkspaceOperation` and so is read by neither that + * builder's definition-time guard nor `check:permission-group-enforcement`, + * which parses `defineWorkspaceOperation` call sites out of the source text. + */ export interface CredentialUserOperation extends ApplicationOperation { readonly principalKinds: readonly ['session'] + readonly capability: StaticPermissionGroupCapability | 'none' } function defineCredentialUserOperation( - id: Id + id: Id, + capability: StaticPermissionGroupCapability | 'none' ): CredentialUserOperation { if (!id.trim()) throw new Error('Credential user operation ID must not be empty') - return Object.freeze({ id, principalKinds: Object.freeze(['session'] as const) }) + if (capability === undefined) { + throw new Error( + `Credential user operation ${id} declares no capability; name one, or 'none' with a reason` + ) + } + if (capability !== 'none') { + const rule = CAPABILITY_RULES[capability] + if (!rule) + throw new Error(`Credential user operation ${id} names unknown capability ${capability}`) + /** + * A parameterized rule reads a value only the request carries, which + * `defineAuthorizedCredentialUserUseCase` never sees; declared here it would + * read as enforced while nothing applied it. + */ + if (rule.kind !== 'static') { + throw new Error( + `Credential user operation ${id} declares parameterized capability ${capability}; assert it from the use case instead` + ) + } + } + return Object.freeze({ id, capability, principalKinds: Object.freeze(['session'] as const) }) } +/** + * All five take `integrations.manage`, matching every other credential + * operation that is not personal-scope by construction — `credentials.list`, + * `credentials.connections.list`, `credentials.members.list` and the rest above. + * Not `credentials.personal`: that one is reserved for the OAuth *connect* flow, + * whose credential is personal by construction, and an organization that + * revokes the Integrations module means members cannot see or remove a + * connection either. + */ export const credentialUserOperations = { - listMemberships: defineCredentialUserOperation('credentials.memberships.list'), - leaveMembership: defineCredentialUserOperation('credentials.memberships.leave'), - listOAuthConnections: defineCredentialUserOperation('credentials.oauth_connections.list'), - listConnectedAccounts: defineCredentialUserOperation('credentials.accounts.list'), - disconnectOAuth: defineCredentialUserOperation('credentials.oauth_connections.disconnect'), + listMemberships: defineCredentialUserOperation( + 'credentials.memberships.list', + 'integrations.manage' + ), + leaveMembership: defineCredentialUserOperation( + 'credentials.memberships.leave', + 'integrations.manage' + ), + listOAuthConnections: defineCredentialUserOperation( + 'credentials.oauth_connections.list', + 'integrations.manage' + ), + listConnectedAccounts: defineCredentialUserOperation( + 'credentials.accounts.list', + 'integrations.manage' + ), + disconnectOAuth: defineCredentialUserOperation( + 'credentials.oauth_connections.disconnect', + 'integrations.manage' + ), } as const From 852267b03043960bf628be784a41adbd52cfee36 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 13:38:18 -0700 Subject: [PATCH 046/179] test(permission-groups): pin the secrets.manage gate on the environment route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects the refusal off the canonical PermissionGroupCapabilityError rather than rebuilding the sentence, so the message and the PERMISSION_GROUP_CAPABILITY_BLOCKED detail code match every other surface. Without the gate the read and the delete both answer 200 — the read hands back every stored workspace secret the tab would show. --- .../[id]/environment/capability-gate.test.ts | 200 ++++++++++++++++++ .../api/workspaces/[id]/environment/route.ts | 18 +- 2 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/environment/capability-gate.test.ts diff --git a/apps/sim/app/api/workspaces/[id]/environment/capability-gate.test.ts b/apps/sim/app/api/workspaces/[id]/environment/capability-gate.test.ts new file mode 100644 index 00000000000..6dcb807185b --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/environment/capability-gate.test.ts @@ -0,0 +1,200 @@ +/** + * @vitest-environment node + * + * The Secrets tab reads and writes workspace environment variables through this + * route, which is raw `withRouteHandler` and never reaches the `secrets.*` + * operations — so the authorization funnel that applies `secrets.manage` to + * those operations does not see it. These pin the gate the route now carries on + * all three handlers: the read that would hand back every stored value, and the + * write and the delete that would change them. + */ +import { + authMockFns, + createMockRequest, + environmentUtilsMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetWorkspaceById, + mockGetUserEntityPermissions, + mockGetWorkspaceEnvKeyAdminAccess, + mockGetPersonalEnvKeyRawAccess, +} = vi.hoisted(() => ({ + mockGetWorkspaceById: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), + mockGetPersonalEnvKeyRawAccess: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceById: mockGetWorkspaceById, + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/credentials/environment', () => ({ + getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, + getPersonalEnvKeyRawAccess: mockGetPersonalEnvKeyRawAccess, + createWorkspaceEnvCredentials: vi.fn(), + deleteWorkspaceEnvCredentials: vi.fn(), +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { DELETE, GET, PUT } from '@/app/api/workspaces/[id]/environment/route' + +const USER_ID = 'user-1' +const WORKSPACE_ID = 'ws-1' + +const mockGetSession = authMockFns.mockGetSession +const mockGetPersonalAndWorkspaceEnv = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv + +function params() { + return { params: Promise.resolve({ id: WORKSPACE_ID }) } +} + +function readEnvironment() { + return GET(createMockRequest('GET'), params()) +} + +function writeEnvironment() { + return PUT(createMockRequest('PUT', { variables: { OPENAI_API_KEY: 'sk-rotated' } }), params()) +} + +function deleteEnvironment() { + return DELETE(createMockRequest('DELETE', { keys: ['OPENAI_API_KEY'] }), params()) +} + +/** The sentence and detail code every capability refusal in the app uses. */ +const SECRETS_REFUSAL = { + error: "Managing secrets is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, +} + +describe('secrets.manage gate on the raw workspace environment route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mockGetSession.mockResolvedValue({ user: { id: USER_ID } }) + mockGetWorkspaceById.mockResolvedValue({ id: WORKSPACE_ID }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetPersonalAndWorkspaceEnv.mockResolvedValue({ + workspaceDecrypted: { OPENAI_API_KEY: 'sk-secret' }, + personalDecrypted: {}, + personalOwners: {}, + conflicts: [], + workspaceUnredactedKeys: [], + }) + mockGetPersonalEnvKeyRawAccess.mockResolvedValue({ + ownedKeys: new Set(), + adminKeys: new Set(), + }) + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(['OPENAI_API_KEY']), + knownKeys: new Set(['OPENAI_API_KEY']), + }) + }) + + describe('when the group withholds Secrets', () => { + beforeEach(() => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideSecretsTab: true, + }) + }) + + it('refuses the read, and never decrypts a single value', async () => { + const response = await readEnvironment() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual(SECRETS_REFUSAL) + expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled() + }) + + it('refuses the write, and never reaches the secret-admin check', async () => { + const response = await writeEnvironment() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual(SECRETS_REFUSAL) + expect(mockGetWorkspaceEnvKeyAdminAccess).not.toHaveBeenCalled() + }) + + it('refuses the delete, and never reaches the secret-admin check', async () => { + const response = await deleteEnvironment() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual(SECRETS_REFUSAL) + expect(mockGetWorkspaceEnvKeyAdminAccess).not.toHaveBeenCalled() + }) + + /** + * Concealment: the role check runs first, so someone outside the workspace + * gets the same answer they always did rather than being told how the + * organization's permission group is configured. + */ + it('still conceals a workspace the caller has no role in, rather than naming the capability', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + + const response = await readEnvironment() + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ error: 'Unauthorized' }) + }) + + it('still 404s a workspace that does not exist, rather than naming the capability', async () => { + mockGetWorkspaceById.mockResolvedValue(null) + + const response = await readEnvironment() + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Workspace not found' }) + }) + }) + + describe('when no group governs the caller', () => { + it('lets the read through', async () => { + const response = await readEnvironment() + + expect(response.status).toBe(200) + expect(mockGetPersonalAndWorkspaceEnv).toHaveBeenCalledTimes(1) + }) + + it('lets the write through to the secret-admin check', async () => { + await writeEnvironment() + + expect(mockGetWorkspaceEnvKeyAdminAccess).toHaveBeenCalledTimes(1) + }) + + it('lets the delete through to the secret-admin check', async () => { + await deleteEnvironment() + + expect(mockGetWorkspaceEnvKeyAdminAccess).toHaveBeenCalledTimes(1) + }) + }) + + describe('when a group governs the caller but permits Secrets', () => { + beforeEach(() => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideIntegrationsTab: true, + }) + }) + + it('lets the read through', async () => { + const response = await readEnvironment() + + expect(response.status).toBe(200) + expect(mockGetPersonalAndWorkspaceEnv).toHaveBeenCalledTimes(1) + }) + + it('lets the write through to the secret-admin check', async () => { + await writeEnvironment() + + expect(mockGetWorkspaceEnvKeyAdminAccess).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index 68dd0b8023f..26ffb66237f 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -26,10 +26,8 @@ import { getPersonalAndWorkspaceEnv, invalidateEffectiveDecryptedEnvCache, } from '@/lib/environment/utils' -import { - capabilityRefusal, - isWorkspaceCapabilityWithheld, -} from '@/lib/permission-groups/capability-assertions' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions, @@ -60,8 +58,16 @@ async function secretsCapabilityRefusal( userId: string, workspaceId: string ): Promise { - if (!(await isWorkspaceCapabilityWithheld(userId, workspaceId, 'secrets.manage'))) return null - return NextResponse.json({ error: capabilityRefusal('secrets.manage') }, { status: 403 }) + try { + await assertWorkspaceCapability(userId, workspaceId, 'secrets.manage') + return null + } catch (error) { + if (!(error instanceof PermissionGroupCapabilityError)) throw error + return NextResponse.json( + { error: error.message, details: { code: error.detailCode } }, + { status: 403 } + ) + } } /** From fe4edd0d9da1bb975b044419dba2a9b34fcb3627 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 13:39:38 -0700 Subject: [PATCH 047/179] fix(permission-groups): gate the CLI key mint on api_keys.manage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI handoff minted a workspace API key with no capability check. An admin denied `api_keys.manage` was refused by `/api/workspaces/[id]/api-keys` and got the identical key from `sim login --workspace`. That is worse than one missing gate. A `workspace_api_key` principal resolves no user and therefore no permission group, so the authorization funnel's capability gate never applies to it. That pass-through is deliberate — substituting the key's creator would apply a bystander's group to every caller of a shared key — and its entire safety argument, stated in `workspace-authorization.ts`, `app/api/table/utils.ts` and `app/api/v1/middleware.ts`, is that minting such a key is itself capability-gated. With the terminal ungated, a governed member could mint a key that escaped every capability their group withheld. Gated at approve, not poll. The poll is unauthenticated by necessity, so it has no session to check anything against; approve is the only moment a human is present. The approval record is already the sole carrier of the decision — the poll body is a request id and a secret, and cannot assert a scope, a workspace, or a binding — so a refusal writes no record and a poll driven directly answers `pending` forever. Resolved from the key's own scope: a bound key belongs to its workspace, an unbound personal key belongs to none and falls back to the organization's default group, matching `/api/users/me/api-keys`. Runs after the role check, so a non-admin still learns nothing about the group. The copilot scope mints from a separate key space the API-keys surface does not manage, so `cli.use` remains its whole gate. Also makes two admin hints honest. `disableWorkspaceCreation` and `disableCliAccess` gate actions that name no workspace, so both are read from the organization's default group and neither can be denied by a group scoped to specific workspaces — correct, since a member may be governed by different groups in different workspaces and there is no one scoped group to pick. The editor offers both checkboxes on such a group regardless, and the hints said nothing about it. --- .../app/api/cli/auth/approve/route.test.ts | 151 ++++++++++++++++++ apps/sim/app/api/cli/auth/approve/route.ts | 60 ++++++- apps/sim/app/api/cli/auth/poll/route.test.ts | 44 +++++ apps/sim/app/api/cli/auth/poll/route.ts | 5 +- apps/sim/lib/permission-groups/fields.test.ts | 16 ++ apps/sim/lib/permission-groups/fields.ts | 4 +- apps/sim/lib/workspaces/policy.test.ts | 28 ++++ 7 files changed, 300 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/api/cli/auth/approve/route.test.ts b/apps/sim/app/api/cli/auth/approve/route.test.ts index a421c922216..2c74b36d44d 100644 --- a/apps/sim/app/api/cli/auth/approve/route.test.ts +++ b/apps/sim/app/api/cli/auth/approve/route.test.ts @@ -209,6 +209,157 @@ describe('POST /api/cli/auth/approve', () => { expect(mockCreateApproval).not.toHaveBeenCalled() }) + /** + * The workspace-key pass-through in the authorization funnel resolves no + * group, so minting one is the only place the regime can be enforced. The + * workspaces API-keys route gates it; these prove the terminal does too. + */ + describe('api_keys.manage', () => { + const REFUSAL = "Managing API keys is not available under your organization's permission group" + + it('refuses to record a workspace-bound approval when the workspace group withholds it', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ hideApiKeysTab: true }) + + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses a personal platform approval when the organization group withholds it', async () => { + mockGetOrgPermissionConfig.mockResolvedValue({ hideApiKeysTab: true }) + + const response = await POST( + createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE, scope: 'platform' }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('reads the organization group for an unbound approval that still names a workspace', async () => { + // The workspace is only the terminal's default here, so the key is + // personal and the organization's group is the one that governs it. + mockGetPermissions.mockResolvedValue('write') + mockGetOrgPermissionConfig.mockResolvedValue({ hideApiKeysTab: true }) + + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + }) + ) + + expect(response.status).toBe(403) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('leaves a copilot approval alone — a separate key space the API-keys surface never manages', async () => { + mockGetOrgPermissionConfig.mockResolvedValue({ hideApiKeysTab: true }) + + const response = await POST( + createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE, scope: 'copilot' }) + ) + + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalled() + }) + + it('records the approval when a governing group permits key management', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ hideApiKeysTab: false }) + + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it('leaves a user no group governs unaffected', async () => { + mockGetUserPermissionConfig.mockResolvedValue(null) + mockGetOrgPermissionConfig.mockResolvedValue(null) + mockGetUserOrganization.mockResolvedValue(null) + + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalled() + }) + + it('refuses after the role check, so a non-admin still learns nothing about the group', async () => { + mockGetPermissions.mockResolvedValue('write') + mockGetUserPermissionConfig.mockResolvedValue({ hideApiKeysTab: true }) + + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: 'Workspace admin permission is required to issue a workspace API key', + }) + }) + + it('reports the coarser CLI refusal when the group withholds both', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ + disableCliAccess: true, + hideApiKeysTab: true, + }) + + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: "CLI access is not available under your organization's permission group", + }) + }) + }) + it('rejects an unauthenticated caller', async () => { mockGetSession.mockResolvedValue(null) const response = await POST( diff --git a/apps/sim/app/api/cli/auth/approve/route.ts b/apps/sim/app/api/cli/auth/approve/route.ts index 9ab1dd1715b..cebc34ff3a5 100644 --- a/apps/sim/app/api/cli/auth/approve/route.ts +++ b/apps/sim/app/api/cli/auth/approve/route.ts @@ -35,6 +35,37 @@ async function cliAccessWithheld(userId: string, workspaceId?: string): Promise< return isOrganizationCapabilityWithheld(membership.organizationId, 'cli.use') } +/** + * Whether `userId`'s permission group withholds minting the key this approval + * would redeem for. + * + * permission-group-enforced: api_keys.manage — a raw device-auth handler with + * inline queries, which the authorization funnel never sees. + * + * This is the door the workspace-key pass-through depends on. A + * `workspace_api_key` principal resolves no user and therefore no group, so the + * funnel's capability gate never applies to it; the whole safety argument for + * that pass-through (`workspace-authorization.ts`, `app/api/table/utils.ts`, + * `app/api/v1/middleware.ts` all state it) is that minting one is itself + * capability-gated. `/api/workspaces/[id]/api-keys` gates it; without this the + * terminal was the way around, and a member denied `api_keys.manage` could mint + * the identical key with `sim login --workspace` and then out-rank every other + * capability their group withholds. + * + * Resolved from the key's own scope, matching the surface that mints the same + * key: a bound key belongs to `workspaceId`, so the workspace group governs it, + * while a personal key is user-global and belongs to no workspace, so it falls + * back to the organization's default group exactly as + * `/api/users/me/api-keys` does. + */ +async function apiKeyMintWithheld(userId: string, workspaceId?: string): Promise { + if (workspaceId) return isWorkspaceCapabilityWithheld(userId, workspaceId, 'api_keys.manage') + + const membership = await getUserOrganization(userId) + if (!membership) return false + return isOrganizationCapabilityWithheld(membership.organizationId, 'api_keys.manage') +} + /** * Records a signed-in user's approval of a CLI handoff so the waiting terminal's * poll can complete. @@ -43,11 +74,16 @@ async function cliAccessWithheld(userId: string, workspaceId?: string): Promise< * user id here would let any caller approve a request redeemable for someone * else's key. No key is generated until the CLI polls. * - * Workspace binding and CLI-access permission are authorized here rather than at - * poll time: the poll is unauthenticated by necessity, so it has no session to - * check a permission against, and re-checking there would duplicate this - * decision while racing a permission-group change made between the two calls. - * Approving is the only moment a human is present. + * Workspace binding, CLI access, and permission to mint the key at all are + * authorized here rather than at poll time: the poll is unauthenticated by + * necessity, so it has no session to check a permission against, and re-checking + * there would duplicate this decision while racing a permission-group change + * made between the two calls. Approving is the only moment a human is present. + * + * That makes the approval record the sole carrier of the decision. The poll body + * is a request id and a secret and nothing else, so it cannot assert a scope, a + * workspace, or a binding of its own — a refusal here writes no record, and a + * poll driven directly against the same request id answers `pending` forever. */ export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() @@ -109,6 +145,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: capabilityRefusal('cli.use') }, { status: 403 }) } + // The platform scope is the one that redeems for a Sim API key. A copilot + // approval mints from a separate key space that the API-keys surface does not + // manage, so `cli.use` above is the whole gate for it. + if (scope === 'platform') { + const mintWorkspaceId = bindKeyToWorkspace ? workspaceId : undefined + if (await apiKeyMintWithheld(session.user.id, mintWorkspaceId)) { + logger.warn('CLI key mint blocked by permission group', { + userId: session.user.id, + workspaceId: mintWorkspaceId ?? null, + }) + return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) + } + } + await createApproval(session.user.id, requestId, challenge, { scope, workspaceId, diff --git a/apps/sim/app/api/cli/auth/poll/route.test.ts b/apps/sim/app/api/cli/auth/poll/route.test.ts index 81709bd411b..1e3ff967414 100644 --- a/apps/sim/app/api/cli/auth/poll/route.test.ts +++ b/apps/sim/app/api/cli/auth/poll/route.test.ts @@ -170,6 +170,50 @@ describe('POST /api/cli/auth/poll', () => { expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() }) + /** + * `/api/cli/auth/approve` is where the session exists to check workspace-admin + * permission and the `api_keys.manage` capability, so it must be impossible to + * reach a workspace-key mint by driving this endpoint instead. + */ + describe('cannot be driven past the approval-time capability gate', () => { + it('ignores a workspace binding asserted by the poll body', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'platform' })) + + const response = await POST( + pollRequest({ + request: REQUEST, + verifier: VERIFIER, + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(response.status).toBe(200) + expect(mockCreateWorkspaceApiKey).not.toHaveBeenCalled() + expect(mockCreatePersonalApiKey).toHaveBeenCalled() + await expect(response.json()).resolves.toMatchObject({ + workspaceId: null, + workspaceBound: false, + }) + }) + + it('mints nothing at all when approval was refused, however often it is polled', async () => { + // A refusal at approve writes no record, so the store answers `pending` + // forever — there is no state here for a caller to advance. + mockPollApproval.mockResolvedValue({ status: 'pending' }) + + for (let attempt = 0; attempt < 3; attempt++) { + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ status: 'pending' }) + } + + expect(mockCreateWorkspaceApiKey).not.toHaveBeenCalled() + expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() + expect(mockGenerateCopilotApiKey).not.toHaveBeenCalled() + }) + }) + it('releases the reservation (keeps the approval) when minting fails', async () => { mockPollApproval.mockResolvedValue(approved()) mockGenerateCopilotApiKey.mockRejectedValue(new Error('mothership down')) diff --git a/apps/sim/app/api/cli/auth/poll/route.ts b/apps/sim/app/api/cli/auth/poll/route.ts index c3e6e8c00c3..dff2be3a62a 100644 --- a/apps/sim/app/api/cli/auth/poll/route.ts +++ b/apps/sim/app/api/cli/auth/poll/route.ts @@ -66,7 +66,10 @@ async function mintForGrant( } // `workspaceId` alone only names the terminal's default workspace; binding the - // key to it is a separate, admin-gated decision made at approval. + // key to it is a separate decision made at approval, where the session exists + // to check workspace-admin permission and the `api_keys.manage` capability. + // Nothing in the poll body can set it, so this branch can only be reached by + // an approval that already passed both. const result = grant.workspaceBound && grant.workspaceId ? await performCreateWorkspaceApiKey({ diff --git a/apps/sim/lib/permission-groups/fields.test.ts b/apps/sim/lib/permission-groups/fields.test.ts index 5126780fc4f..63cc30224ba 100644 --- a/apps/sim/lib/permission-groups/fields.test.ts +++ b/apps/sim/lib/permission-groups/fields.test.ts @@ -326,6 +326,22 @@ describe('permission group config key coverage', () => { ) }) + /** + * Both keys gate an action that names no workspace, so both are read from the + * organization's default group only — a group scoped to specific workspaces + * cannot deny an account-level login or a workspace that does not exist yet. + * The editor still offers the checkbox on such a group, so the hint is the + * only place an admin learns where it applies; it shipped saying nothing. + */ + it.each(['disableWorkspaceCreation', 'disableCliAccess'] as const)( + "tells an admin that %s is read from the organization's default group", + (configKey) => { + const feature = PLATFORM_FEATURES.find((entry) => entry.configKey === configKey) + + expect(feature?.hint).toContain("organization's default group") + } + ) + it('gives every platform feature a unique id', () => { const ids = PLATFORM_FEATURES.map((feature) => feature.id) expect(new Set(ids).size).toBe(ids.length) diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index c8e16719942..fe41dca46de 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -395,7 +395,7 @@ export const PERMISSION_GROUP_FIELDS = { id: 'disable-workspace-creation', label: 'Workspace Creation', category: 'Collaboration', - hint: 'Prevent creating new workspaces, which no existing group would govern.', + hint: "Prevent creating new workspaces, which no existing group would govern. Read from the organization's default group, because creating a workspace names none.", }), hideOrgMemberDirectory: booleanRestriction('capability', { id: 'hide-org-member-directory', @@ -407,7 +407,7 @@ export const PERMISSION_GROUP_FIELDS = { id: 'disable-cli-access', label: 'CLI Access', category: 'Credentials & Access', - hint: 'Prevent approving a CLI login, which mints a key for the public API.', + hint: "Prevent approving a CLI login, which mints a key for the public API. A login naming one of this group's workspaces is refused; an account-level login names none, so it is read from the organization's default group.", }), disableWebhookTriggers: booleanRestriction('capability', { id: 'disable-webhook-triggers', diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 096ec5686b3..c77e274ee37 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -18,16 +18,19 @@ const { mockGetOrganizationSubscription, mockGetHighestPrioritySubscription, mockGetUserPermissionConfigForOrganization, + mockGetUserPermissionConfig, } = vi.hoisted(() => ({ mockAcquireOrganizationUserMutationLocks: vi.fn(), mockGetUserOrganization: vi.fn(), mockGetOrganizationSubscription: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), mockGetUserPermissionConfigForOrganization: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), })) vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfigForOrganization: mockGetUserPermissionConfigForOrganization, + getUserPermissionConfig: mockGetUserPermissionConfig, })) vi.mock('@/lib/billing/organizations/membership', () => ({ @@ -204,6 +207,31 @@ describe('getWorkspaceCreationPolicy', () => { expect(result.blockedReasonCode).toBe('permission-group-denied') }) + /** + * Creating a workspace names no workspace, so there is no workspace whose + * group could govern it — and a member may be governed by different groups in + * different workspaces, so there is no single scoped group to pick. The + * decision is read from the organization's default group and from nothing + * else, which is what `disableWorkspaceCreation`'s admin hint has to say. + */ + it("reads workspace creation from the organization's default group and no workspace group", async () => { + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'org-1', + role: 'member', + memberId: 'member-1', + }) + mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + disableWorkspaceCreation: true, + }) + queueTableRows(member, [{ role: 'member' }]) + + const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) + + expect(result.blockedReasonCode).toBe('permission-group-denied') + expect(mockGetUserPermissionConfigForOrganization).toHaveBeenCalledWith('org-1') + expect(mockGetUserPermissionConfig).not.toHaveBeenCalled() + }) + it('blocks free users once they already own one non-organization workspace', async () => { queueTableRows(workspace, [{ value: 1 }]) From 3681c0e535e2cedc85440045b53820a8fb89eb9c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 13:40:11 -0700 Subject: [PATCH 048/179] test(permission-groups): pin the integrations.manage gate on current-user credential operations Without the gate all three routes answer 200: a member whose group revokes Integrations can enumerate every OAuth connection and disconnect any of them. --- .../application/capability-gate.test.ts | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 apps/sim/lib/credentials/application/capability-gate.test.ts diff --git a/apps/sim/lib/credentials/application/capability-gate.test.ts b/apps/sim/lib/credentials/application/capability-gate.test.ts new file mode 100644 index 00000000000..247a3ae1260 --- /dev/null +++ b/apps/sim/lib/credentials/application/capability-gate.test.ts @@ -0,0 +1,192 @@ +/** + * @vitest-environment node + * + * The current-user credential operations govern listing and disconnecting a + * user's OAuth connections. They are minted by `defineCredentialUserOperation`, + * which does not call `defineWorkspaceOperation`, so `authorizeWorkspaceOperation` + * never sees them and they shipped with no capability at all — a member whose + * group revokes Integrations could still enumerate and disconnect every + * connection. These pin the gate through the real routes, so the refusal the + * caller actually receives is what is asserted. + * + * They have no workspace, so the gate resolves the organization's default group + * — the same resolution personal API keys use for an organization-level action. + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetUserOrganization, + mockGetOrgPermissionConfig, + mockListOAuthConnectionsForUser, + mockListConnectedAccountsForUser, + mockDisconnectOAuthAccounts, +} = vi.hoisted(() => ({ + mockGetUserOrganization: vi.fn(), + mockGetOrgPermissionConfig: vi.fn(), + mockListOAuthConnectionsForUser: vi.fn(), + mockListConnectedAccountsForUser: vi.fn(), + mockDisconnectOAuthAccounts: vi.fn(), +})) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: vi.fn(), + getUserPermissionConfigForOrganization: mockGetOrgPermissionConfig, + resolveVerifiedUserAccessControlContext: vi.fn(), +})) + +vi.mock('@/lib/credentials/oauth-accounts', () => ({ + listOAuthConnectionsForUser: mockListOAuthConnectionsForUser, + listConnectedAccountsForUser: mockListConnectedAccountsForUser, + disconnectOAuthAccounts: mockDisconnectOAuthAccounts, + OAuthDisconnectPartialFailureError: class OAuthDisconnectPartialFailureError extends Error { + credentials: unknown[] = [] + }, +})) + +import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GET as listConnectedAccounts } from '@/app/api/auth/accounts/route' +import { GET as listConnections } from '@/app/api/auth/oauth/connections/route' +import { POST as disconnect } from '@/app/api/auth/oauth/disconnect/route' + +const USER_ID = 'user-1' +const ORGANIZATION_ID = 'org-1' + +const mockGetSession = authMockFns.mockGetSession + +function callListConnections() { + return listConnections(createMockRequest('GET'), { params: Promise.resolve({}) }) +} + +function callListConnectedAccounts() { + return listConnectedAccounts( + createMockRequest('GET', undefined, {}, 'http://localhost/api/auth/accounts'), + { params: Promise.resolve({}) } + ) +} + +function callDisconnect() { + return disconnect(createMockRequest('POST', { provider: 'google' }), { + params: Promise.resolve({}), + }) +} + +const INTEGRATIONS_REFUSAL = capabilityRefusal('integrations.manage') + +describe('integrations.manage gate on the current-user credential operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ + user: { id: USER_ID }, + session: { id: 'session-1' }, + }) + mockGetUserOrganization.mockResolvedValue({ + organizationId: ORGANIZATION_ID, + role: 'member', + memberId: 'member-1', + }) + mockGetOrgPermissionConfig.mockResolvedValue(null) + mockListOAuthConnectionsForUser.mockResolvedValue([]) + mockListConnectedAccountsForUser.mockResolvedValue([]) + mockDisconnectOAuthAccounts.mockResolvedValue({ + credentials: [], + provider: 'google', + providerId: undefined, + }) + }) + + describe('when the group withholds Integrations', () => { + beforeEach(() => { + mockGetOrgPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideIntegrationsTab: true, + }) + }) + + it('refuses to enumerate the OAuth connections, and never reads them', async () => { + const response = await callListConnections() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: INTEGRATIONS_REFUSAL }) + expect(mockListOAuthConnectionsForUser).not.toHaveBeenCalled() + }) + + it('refuses to list the connected accounts, and never reads them', async () => { + const response = await callListConnectedAccounts() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: INTEGRATIONS_REFUSAL }) + expect(mockListConnectedAccountsForUser).not.toHaveBeenCalled() + }) + + it('refuses the disconnect, and never deletes a credential', async () => { + const response = await callDisconnect() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: INTEGRATIONS_REFUSAL }) + expect(mockDisconnectOAuthAccounts).not.toHaveBeenCalled() + }) + + /** + * Concealment: authentication still runs first, so an unauthenticated + * caller is told to authenticate rather than told how someone else's + * organization is configured. + */ + it('still answers an unauthenticated caller with 401, not the capability', async () => { + mockGetSession.mockResolvedValue(null) + + const response = await callListConnections() + + expect(response.status).toBe(401) + expect(mockGetOrgPermissionConfig).not.toHaveBeenCalled() + }) + }) + + describe('when no group governs the caller', () => { + it('lists the OAuth connections', async () => { + const response = await callListConnections() + + expect(response.status).toBe(200) + expect(mockListOAuthConnectionsForUser).toHaveBeenCalledWith(USER_ID) + }) + + it('disconnects', async () => { + const response = await callDisconnect() + + expect(response.status).toBe(200) + expect(mockDisconnectOAuthAccounts).toHaveBeenCalledTimes(1) + }) + + /** + * The personal-workspace case: a user in no organization has no group to + * resolve, so the gate is a no-op and never asks. + */ + it('does not even resolve a group for a user in no organization', async () => { + mockGetUserOrganization.mockResolvedValue(null) + + const response = await callListConnections() + + expect(response.status).toBe(200) + expect(mockGetOrgPermissionConfig).not.toHaveBeenCalled() + }) + }) + + describe('when a group governs the caller but permits Integrations', () => { + it('lets the disconnect through', async () => { + mockGetOrgPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideSecretsTab: true, + }) + + const response = await callDisconnect() + + expect(response.status).toBe(200) + expect(mockDisconnectOAuthAccounts).toHaveBeenCalledTimes(1) + }) + }) +}) From 4deb73fe454938d17df1427952f4a1f1d16cc12c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 13:55:36 -0700 Subject: [PATCH 049/179] fix(permission-groups): close the cost oracle and two capability-before-role inversions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `logs.cost` is a projection: the field is blanked, the row still comes back. That is only a withholding if the query surface cannot select on the number either. It could — `sortBy=cost` and `costOperator`/`costValue` reach the same indexed `cost_total` column on the first-party list, the dashboard stats read, and the CSV export, so a member whose group hides spend recovered every run's cost by bisecting `cost > X` and reading which rows (or how many, with `includeTotal`) came back. `assertLogCostQueryAllowed` in `log-projection.ts` refuses those queries rather than dropping the clause: a list of every run under a `cost > 5` chip, in an order nobody asked for, is a wrong answer presented as the right one, and the refusal discloses nothing — the workspace role check has already passed, so the caller is a member being told about their own group. `logs.trace_spans` needs no counterpart; nothing it withholds is filterable or sortable on any log surface. Two capability checks also ran ahead of their role check, inverting the concealment ordering the rest of the branch keeps. The funnel asserted the group's `personal_api_key.use` before resolving the caller's workspace permission, and `prepareWorkspaceInvitationContext` asserted `invitations.send` before `hasWorkspaceAdminAccess`. Both now follow the role check, so a caller with no reach into the workspace gets the concealed refusal instead of a 403 naming how the organization configured a cohort. The workspace-level `allowPersonalApiKeys` column keeps its fail-fast: it is a property of the workspace, not of a group. Three tests could not fail without the gate they named. The `logs.export` 403 fired in no test at all; the MCP operation refusals selected their subjects with `operation.capability === x`, a filter over the field under test, so dropping the capability from `mcp_servers.create` just removed it from the filter and left the suite green. Both are pinned now, along with the new refusals and a first test file for the stats route. --- apps/sim/app/api/logs/export/route.test.ts | 66 ++++++++++++ apps/sim/app/api/logs/export/route.ts | 10 ++ apps/sim/app/api/logs/stats/route.test.ts | 101 ++++++++++++++++++ apps/sim/app/api/logs/stats/route.ts | 23 ++++ .../workspace-authorization.test.ts | 36 +++++++ .../application/workspace-authorization.ts | 17 ++- .../invitations/workspace-invitations.test.ts | 61 ++++++++++- .../lib/invitations/workspace-invitations.ts | 12 ++- .../lib/logs/application/list-logs.test.ts | 43 ++++++++ apps/sim/lib/logs/application/list-logs.ts | 11 +- apps/sim/lib/logs/log-projection.ts | 56 ++++++++++ .../lib/mcp/application/operations.test.ts | 41 +++++++ 12 files changed, 472 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/api/logs/stats/route.test.ts diff --git a/apps/sim/app/api/logs/export/route.test.ts b/apps/sim/app/api/logs/export/route.test.ts index 51670cae491..259716516d9 100644 --- a/apps/sim/app/api/logs/export/route.test.ts +++ b/apps/sim/app/api/logs/export/route.test.ts @@ -46,6 +46,7 @@ vi.mock('@/lib/core/utils/concurrency', () => ({ mapWithConcurrency: mockMapWithConcurrency, })) +import { capabilityRefusal } from '@/lib/permission-groups/capabilities' import { GET } from '@/app/api/logs/export/route' const mockGetSession = authMockFns.mockGetSession @@ -269,6 +270,71 @@ describe('GET /api/logs/export', () => { expect(lines[1]).not.toContain('0.01') }) + /** + * One download carries every execution payload the workspace ever recorded, + * which is why the export is withheld separately from reading a single log. + */ + it('refuses the download when the group withholds log export', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ disableLogExport: true }) + queueTableRows(workflowExecutionLogs, [logRow(0)]) + + const response = await GET(makeRequest()) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: capabilityRefusal('logs.export'), + }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('exports normally when no group withholds log export', async () => { + queueTableRows(workflowExecutionLogs, [logRow(0)]) + + const response = await GET(makeRequest()) + + expect(response.status).toBe(200) + expect(await response.text()).toContain('execution-0') + }) + + /** + * Blanking the column while still answering `costOperator`/`costValue` + * faithfully leaves the CSV itself a bisection oracle over the figures it + * just withheld — one download per probe, the row count as the answer. + */ + it('refuses a cost-filtered export when the group withholds spend', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ hideCostInfo: true }) + queueTableRows(workflowExecutionLogs, [logRow(0)]) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/logs/export?workspaceId=workspace-1&costOperator=%3E&costValue=0.5' + ) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: capabilityRefusal('logs.cost') }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('answers the same cost-filtered export when no group withholds spend', async () => { + queueTableRows(workflowExecutionLogs, [logRow(0)]) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/logs/export?workspaceId=workspace-1&costOperator=%3E&costValue=0.5' + ) + ) + + expect(response.status).toBe(200) + expect(await response.text()).toContain('execution-0') + }) + it('keeps the cost column when no group withholds it', async () => { queueTableRows(workflowExecutionLogs, [logRow(0)]) diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index aeb972735f8..eea59855c4f 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -12,6 +12,7 @@ import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-s import { withheldSpendData } from '@/lib/logs/fetch-log-detail' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { logQuerySelectsCost } from '@/lib/logs/log-projection' import { capabilityDeniedBy, capabilityRefusal, @@ -127,6 +128,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { * file shape does not depend on who downloaded it; only its values go. */ const hideCostInfo = capabilityDeniedBy('logs.cost', permissionConfig) + /** + * The same filter the list refuses. Blanking the column while still + * answering `costOperator`/`costValue` faithfully would leave the CSV itself + * a bisection oracle over the figures it just withheld — one download per + * probe, with the row count as the answer. + */ + if (hideCostInfo && logQuerySelectsCost(params)) { + return NextResponse.json({ error: capabilityRefusal('logs.cost') }, { status: 403 }) + } const encoder = new TextEncoder() const csvChunks = (async function* () { diff --git a/apps/sim/app/api/logs/stats/route.test.ts b/apps/sim/app/api/logs/stats/route.test.ts new file mode 100644 index 00000000000..95d555a0895 --- /dev/null +++ b/apps/sim/app/api/logs/stats/route.test.ts @@ -0,0 +1,101 @@ +/** + * @vitest-environment node + */ +import { + authMockFns, + createMockRequest, + permissionGroupScopeMock, + permissionGroupScopeMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + checkWorkspaceAccess: vi.fn(), + expandFolderIdsWithDescendants: vi.fn(), + readLogStatsBounds: vi.fn(), + readLogStatsSegments: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.checkWorkspaceAccess, +})) + +vi.mock('@/lib/logs/folder-expansion', () => ({ + expandFolderIdsWithDescendants: mocks.expandFolderIdsWithDescendants, +})) + +vi.mock('@/lib/logs/stats-queries', () => ({ + readLogStatsBounds: mocks.readLogStatsBounds, + readLogStatsSegments: mocks.readLogStatsSegments, +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { capabilityRefusal } from '@/lib/permission-groups/capabilities' +import { GET } from '@/app/api/logs/stats/route' + +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +function makeRequest(query = '') { + return createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/logs/stats?workspaceId=workspace-1${query}` + ) +} + +describe('GET /api/logs/stats', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mocks.checkWorkspaceAccess.mockResolvedValue({ hasAccess: true }) + mocks.readLogStatsBounds.mockResolvedValue({ + minStartedAt: new Date('2026-08-01T00:00:00.000Z'), + maxStartedAt: new Date('2026-08-02T00:00:00.000Z'), + }) + mocks.readLogStatsSegments.mockResolvedValue([]) + resolveGroupConfigMock.mockResolvedValue(null) + }) + + /** + * The response carries no spend at all, but `costOperator`/`costValue` reach + * the same indexed column the list filters on, and the run counts answered + * under them are a bisection oracle over exactly what the group withholds. + */ + it('refuses a cost-filtered read when the group withholds spend', async () => { + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + const response = await GET(makeRequest('&costOperator=%3E&costValue=0.5')) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: capabilityRefusal('logs.cost') }) + expect(mocks.readLogStatsBounds).not.toHaveBeenCalled() + }) + + it('answers an unfiltered read under the same group', async () => { + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + const response = await GET(makeRequest()) + + expect(response.status).toBe(200) + expect(mocks.readLogStatsBounds).toHaveBeenCalled() + }) + + it('answers the same cost-filtered read when no group withholds spend', async () => { + const response = await GET(makeRequest('&costOperator=%3E&costValue=0.5')) + + expect(response.status).toBe(200) + expect(mocks.readLogStatsBounds).toHaveBeenCalled() + }) + + /** A caller with no workspace access is answered with a zeroed 200, as before. */ + it('does not consult the group for a caller without workspace access', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue({ hasAccess: false }) + + const response = await GET(makeRequest('&costOperator=%3E&costValue=0.5')) + + expect(response.status).toBe(200) + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index 7231af0bfd7..712f8ae63c7 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -9,8 +9,13 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { logQuerySelectsCost } from '@/lib/logs/log-projection' import { buildDashboardStats, resolveLogStatsWindow } from '@/lib/logs/stats' import { readLogStatsBounds, readLogStatsSegments } from '@/lib/logs/stats-queries' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('LogsStatsAPI') @@ -59,6 +64,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } + /** + * permission-group-enforced: logs.cost — this response carries no spend at + * all, but `costOperator`/`costValue` reach the same indexed column the + * list filters on, and the run counts it answers with are a bisection + * oracle over exactly the figure the group withholds. Refused rather than + * ignored, for the reason given on {@link assertLogCostQueryAllowed}; the + * workspace access check above has already passed, so the caller is a + * member learning about their own group. + */ + const hideCostInfo = await isWorkspaceCapabilityWithheld( + userId, + params.workspaceId, + 'logs.cost' + ) + if (hideCostInfo && logQuerySelectsCost(params)) { + return NextResponse.json({ error: capabilityRefusal('logs.cost') }, { status: 403 }) + } + const workspaceFilter = eq(workflowExecutionLogs.workspaceId, params.workspaceId) if (params.folderIds) { diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index 566f7abdf2f..a39b2bca0af 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -560,6 +560,42 @@ describe('authorizeWorkspaceOperation personal API key policy', () => { ).resolves.toBeUndefined() }) + /** + * A `NoWorkspaceAccessError` is concealed as a `404`, so it has to come first: + * a distinct `PersonalApiKeysDisabledError` would tell a caller with no reach + * into the workspace that it exists and that its organization withholds + * personal keys. + */ + it('conceals the workspace before refusing the group personal-key setting', async () => { + mocks.resolvePermission.mockResolvedValue(null) + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + + await expect( + authorizeWorkspaceOperation(personalKeyPrincipal, personalKeyOperation, context) + ).rejects.toBeInstanceOf(NoWorkspaceAccessError) + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) + + /** + * The workspace column keeps its fail-fast: it is a property of the workspace + * rather than of any group, so it is not the organization-configuration + * oracle the group key would be, and three call-site suites pin the ordering. + */ + it('keeps refusing the workspace personal-key column before the role lookup', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + authorizeWorkspaceOperation(personalKeyPrincipal, personalKeyOperation, { + ...context, + allowPersonalApiKeys: false, + }) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + }) + it('leaves a session principal alone', async () => { resolveGroupConfigMock.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index 0e5be2643c4..4efb1e68abd 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -258,12 +258,27 @@ export async function authorizeWorkspaceOperation ({ validateInvitationsAllowed: vi.fn(), })) -import { createWorkspaceInvitation } from '@/lib/invitations/workspace-invitations' +import { + createWorkspaceInvitation, + prepareWorkspaceInvitationContext, +} from '@/lib/invitations/workspace-invitations' +import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' +import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' +import { validateInvitationsAllowed } from '@/ee/access-control/utils/permission-check' function queueWhereResponses(responses: unknown[][]) { const queue = [...responses] @@ -682,3 +688,56 @@ describe('createWorkspaceInvitation', () => { ).rejects.toThrow('invitation changed concurrently') }) }) + +/** + * The capability runs after the role check, never before it. Refusing on + * `invitations.send` first would answer a non-admin with a distinct `403` + * naming an organization setting, which tells a bystander in the same + * organization how another workspace's permission group is configured. + */ +describe('prepareWorkspaceInvitationContext refusal ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + vi.mocked(validateInvitationsAllowed).mockResolvedValue(undefined) + vi.mocked(getWorkspaceInvitePolicy).mockResolvedValue({ + allowed: true, + reason: null, + requiresSeat: false, + organizationId: 'org-1', + upgradeRequired: false, + } as unknown as Awaited>) + }) + + it('refuses a non-admin on the role before consulting the permission group', async () => { + vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(false) + vi.mocked(validateInvitationsAllowed).mockRejectedValue( + new Error('Sending invitations is not available under your permission group') + ) + + await expect( + prepareWorkspaceInvitationContext({ + workspaceIds: ['ws-1'], + inviterId: 'outsider-1', + inviterName: 'Outsider', + }) + ).rejects.toThrow('You need admin permissions to invite users') + expect(validateInvitationsAllowed).not.toHaveBeenCalled() + }) + + it('still refuses an admin whose permission group withholds invitations', async () => { + vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true) + vi.mocked(validateInvitationsAllowed).mockRejectedValue( + new Error('Sending invitations is not available under your permission group') + ) + + await expect( + prepareWorkspaceInvitationContext({ + workspaceIds: ['ws-1'], + inviterId: 'user-1', + inviterName: 'Owner', + }) + ).rejects.toThrow('Sending invitations is not available under your permission group') + expect(validateInvitationsAllowed).toHaveBeenCalledWith('user-1', 'ws-1') + }) +}) diff --git a/apps/sim/lib/invitations/workspace-invitations.ts b/apps/sim/lib/invitations/workspace-invitations.ts index 03e19257890..c96c3934fbe 100644 --- a/apps/sim/lib/invitations/workspace-invitations.ts +++ b/apps/sim/lib/invitations/workspace-invitations.ts @@ -206,8 +206,6 @@ export async function prepareWorkspaceInvitationContext({ const targets: WorkspaceInvitationTarget[] = [] for (const workspaceId of uniqueWorkspaceIds) { - await validateInvitationsAllowed(inviterId, workspaceId) - const isAdmin = await hasWorkspaceAdminAccess(inviterId, workspaceId) if (!isAdmin) { throw new WorkspaceInvitationError({ @@ -216,6 +214,16 @@ export async function prepareWorkspaceInvitationContext({ }) } + /** + * permission-group-enforced: invitations.send — after the admin check, not + * before it. The refusal names an organization setting, so answering it to + * someone with no admin reach into `workspaceId` would tell a bystander in + * the same organization how another workspace's group is configured. The + * role check is also the cheaper of the two and names the remedy the caller + * can actually act on. + */ + await validateInvitationsAllowed(inviterId, workspaceId) + const workspaceDetails = await getWorkspaceWithOwner(workspaceId) if (!workspaceDetails) { throw new WorkspaceInvitationError({ message: 'Workspace not found', status: 404 }) diff --git a/apps/sim/lib/logs/application/list-logs.test.ts b/apps/sim/lib/logs/application/list-logs.test.ts index 4e9781ccaa2..730b2b9fab2 100644 --- a/apps/sim/lib/logs/application/list-logs.test.ts +++ b/apps/sim/lib/logs/application/list-logs.test.ts @@ -31,6 +31,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) import { listLogsUseCase } from '@/lib/logs/application/list-logs' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' const WORKSPACE_ID = 'workspace-1' const SESSION: Principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } @@ -67,6 +68,48 @@ describe('listLogsUseCase', () => { expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ hideCostInfo: false })) }) + /** + * Blanking the field is not enough on its own: `cost > X` answered faithfully + * is a bisection oracle over the very number that was withheld, and the sort + * leaks the same ranking more slowly. + */ + it.each([ + ['a cost sort', { sortBy: 'cost' }], + ['a cost filter', { costOperator: '>', costValue: 0.5 }], + ['an equality cost filter', { costOperator: '=', costValue: 0 }], + ])('refuses %s when the group withholds spend', async (_label, overrides) => { + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + await expect( + listLogsUseCase.execute({ + principal: SESSION, + input: { ...(INPUT as object), ...overrides } as never, + }) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + expect(mocks.readLogs).not.toHaveBeenCalled() + }) + + it('answers the same cost query when no group withholds spend', async () => { + await listLogsUseCase.execute({ + principal: SESSION, + input: { ...(INPUT as object), sortBy: 'cost', costOperator: '>', costValue: 0.5 } as never, + }) + + expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ sortBy: 'cost' })) + }) + + /** A duration filter names nothing the group withholds, so it still answers. */ + it('leaves a duration filter alone under a spend-withholding group', async () => { + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + await listLogsUseCase.execute({ + principal: SESSION, + input: { ...(INPUT as object), durationOperator: '>', durationValue: 100 } as never, + }) + + expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ hideCostInfo: true })) + }) + /** * An actorless run has no user, so there is no group to resolve — it reads its * own workspace's logs whole rather than being handed a stand-in viewer. diff --git a/apps/sim/lib/logs/application/list-logs.ts b/apps/sim/lib/logs/application/list-logs.ts index 7e1fc160f63..e5279760f54 100644 --- a/apps/sim/lib/logs/application/list-logs.ts +++ b/apps/sim/lib/logs/application/list-logs.ts @@ -8,6 +8,7 @@ import { } from '@/lib/logs/application/authorization' import { logOperations } from '@/lib/logs/application/operations' import { type ListLogsParams, readLogs } from '@/lib/logs/list-logs' +import { assertLogCostQueryAllowed } from '@/lib/logs/log-projection' import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' @@ -33,10 +34,18 @@ const authorizedListLogsUseCase = defineAuthorizedWorkspaceUseCase({ ) : null + const hideCostInfo = capabilityDeniedBy('logs.cost', permissionConfig) + /** + * The list's own `sortBy=cost` and `costOperator`/`costValue` select on the + * very figure the row above blanks, so they have to be refused rather than + * answered — see {@link assertLogCostQueryAllowed}. + */ + assertLogCostQueryAllowed(input, { hideCostInfo }) + return readLogs({ ...input, workspaceId: context.workspaceId, - hideCostInfo: capabilityDeniedBy('logs.cost', permissionConfig), + hideCostInfo, }) }, }) diff --git a/apps/sim/lib/logs/log-projection.ts b/apps/sim/lib/logs/log-projection.ts index b4f39c66e5d..cd9cab02ecd 100644 --- a/apps/sim/lib/logs/log-projection.ts +++ b/apps/sim/lib/logs/log-projection.ts @@ -1,4 +1,5 @@ import { withheldExecutionData, withheldSpendData } from '@/lib/logs/fetch-log-detail' +import { refuseCapability } from '@/lib/permission-groups/capabilities' import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' @@ -77,3 +78,58 @@ export function projectCostTotal( if (projection.hideCostInfo || costTotal == null) return null return { total: Number(costTotal) } } + +/** + * The spend-selecting halves of a log query, in the spellings the surfaces use. + * + * The first-party list spells its filter as an operator plus a value; the public + * adapters spell theirs as a `minCost`/`maxCost` pair. Both are read here so the + * rule lives once — a second copy is how one of them stops refusing. + */ +export interface LogCostQuerySurface { + sortBy?: string | null + costOperator?: string | null + costValue?: number | null + minCost?: number | null + maxCost?: number | null +} + +/** Whether the query orders or selects rows by run spend. */ +export function logQuerySelectsCost(query: LogCostQuerySurface): boolean { + if (query.sortBy === 'cost') return true + if (query.costOperator && query.costValue != null) return true + return query.minCost != null || query.maxCost != null +} + +/** + * Refuses a cost-ordered or cost-filtered query from a viewer whose group + * withholds spend. + * + * Withholding the *field* is not enough on its own: `cost > X` answered + * faithfully is an oracle, and a caller who can repeat it recovers every run's + * cost by bisection — with `includeTotal` they do not even have to read the + * rows. Ordering leaks the same thing more slowly, as a ranking. + * + * Refused rather than silently ignored. Dropping the clause would answer a + * question nobody asked — a list of every run under a `cost > 5` chip, in an + * order the caller did not request — and a wrong answer presented as the right + * one is worse than a refusal. The refusal discloses nothing new either: the + * workspace role check has already passed by the time this runs, so the caller + * is a member being told about their own group, not an outsider being handed an + * organization-configuration oracle. + * + * `logs.trace_spans` needs no counterpart. Nothing the trace projection + * withholds — `traceSpans`, `blockExecutions`, `finalOutput`, `workflowInput`, + * `blockInput` — is filterable or sortable on any log surface: `search` matches + * the execution id alone, and every sort key is a scalar column. + * + * permission-group-enforced: logs.cost + */ +export function assertLogCostQueryAllowed( + query: LogCostQuerySurface, + projection: Pick +): void { + if (!projection.hideCostInfo) return + if (!logQuerySelectsCost(query)) return + refuseCapability('logs.cost') +} diff --git a/apps/sim/lib/mcp/application/operations.test.ts b/apps/sim/lib/mcp/application/operations.test.ts index 3826c839466..f145601905f 100644 --- a/apps/sim/lib/mcp/application/operations.test.ts +++ b/apps/sim/lib/mcp/application/operations.test.ts @@ -129,6 +129,47 @@ const context = { allowPersonalApiKeys: true, } +/** + * Every operation's capability, by name. + * + * Pinned as a whole map rather than derived from the registry, because the + * refusal tests below select their subjects with `operation.capability === x` — + * a filter over the very field under test. Dropping the capability from one + * operation would simply remove it from that filter and leave those tests green + * (`mcp_servers.create`, which registers a server and stores its credentials + * against the workspace, was verified to do exactly that). This map fails + * instead. + */ +const EXPECTED_CAPABILITIES: Record = { + list: 'mcp_tools.use', + read: 'mcp_tools.use', + create: 'mcp_tools.use', + register: 'mcp_tools.use', + update: 'mcp_tools.use', + reconfigure: 'mcp_tools.use', + delete: 'mcp_tools.use', + discoverTools: 'mcp_tools.use', + executeTool: 'mcp_tools.use', + listWorkflowDeployments: 'deploy.mcp', + readWorkflowDeploymentServer: 'deploy.mcp', + listWorkflowDeploymentTools: 'deploy.mcp', + createWorkflowDeploymentServer: 'deploy.mcp', + updateWorkflowDeploymentServer: 'deploy.mcp', + deleteWorkflowDeploymentServer: 'deploy.mcp', + deployWorkflowTool: 'deploy.mcp', + undeployWorkflowTool: 'deploy.mcp', +} + +describe('MCP operation capability declarations', () => { + it('declares a capability on every operation, by name', () => { + const declared = Object.fromEntries( + Object.entries(mcpServerOperations).map(([key, operation]) => [key, operation.capability]) + ) + + expect(declared).toEqual(EXPECTED_CAPABILITIES) + }) +}) + /** `tools.execute` admits only the executor delegation, so a session cannot stand in for it. */ function sessionReachable(capability: string) { return Object.values(mcpServerOperations).filter( From 8aaa3e4f0be8763d9f734b014e1d219d4734cef9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 14:06:49 -0700 Subject: [PATCH 050/179] fix(permission-groups): make an operation the audit cannot read a failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:permission-group-enforcement` found operations by scanning for `defineWorkspaceOperation(` call sites, so a domain that minted operations through a builder of its own was invisible — and invisibly so: the file was scanned, some other operation in it was counted, and the audit printed a tick while the rest went unread. A silent undercount reads exactly like success, which is how five OAuth-connection operations shipped with no capability and `hideIntegrationsTab` stayed bypassable. Twenty-eight operations across eight registries were in that blind spot, not five: audit logs, organization BYOK keys, billing reads, organization usage, credential-group enrollment, `/api/v2/meta`, the session-scoped knowledge entry points, and account self-service — the last two minted from bare object literals with no builder at all. Both halves of the fix, because neither alone is enough: - the type. `capability` moves from `WorkspaceOperation` to the base `ApplicationOperation`, so an operation that answers the question nowhere does not compile, and every builder shares one `assertOperationCapability` guard. `apps/sim/tsconfig.json` excludes test files, so this narrows the blast radius rather than closing it. - the audit. It now follows the whole `define*Operation` family, and asserts that every member of an exported `*Operations` registry was actually read. That second check asks the only question whose answer distinguishes a clean file from one the parsers walked past, and it is what surfaced the two registries no builder touched. The file-level skip is gone with it: a module is examined when it mints an operation *or* exports a registry, rather than only when it names one builder. Every newly visible operation declares `'none'` with a reason — each is organization-scoped, account-scoped, or delegates to a workspace operation that already carries the capability. Count goes 287 -> 315. --- .../sim/lib/api-key/application/operations.ts | 9 +- apps/sim/lib/api/application/operations.ts | 2 + .../lib/audit-logs/application/operations.ts | 6 + .../sim/lib/billing/application/operations.ts | 6 + .../organization-usage/operations.ts | 28 ++- apps/sim/lib/core/application/index.ts | 1 + .../lib/core/application/operation.test.ts | 50 ++++- apps/sim/lib/core/application/operation.ts | 52 +++++ .../core/application/workspace-operation.ts | 40 +--- .../application/enrollment-operations.ts | 10 + .../lib/knowledge/application/operations.ts | 33 ++- apps/sim/lib/users/application/operations.ts | 17 +- ...check-permission-group-enforcement.test.ts | 164 ++++++++++++++ scripts/check-permission-group-enforcement.ts | 209 ++++++++++++++++-- 14 files changed, 556 insertions(+), 71 deletions(-) diff --git a/apps/sim/lib/api-key/application/operations.ts b/apps/sim/lib/api-key/application/operations.ts index 040f3d08bd6..7e00ff83c54 100644 --- a/apps/sim/lib/api-key/application/operations.ts +++ b/apps/sim/lib/api-key/application/operations.ts @@ -1,6 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { ApplicationOperation } from '@/lib/core/application' -import { defineWorkspaceOperation } from '@/lib/core/application' +import { assertOperationCapability, defineWorkspaceOperation } from '@/lib/core/application' export type OrganizationByokPrincipal = Extract @@ -16,6 +16,7 @@ export interface OrganizationByokOperation function defineOrganizationByokOperation( operation: OrganizationByokOperation ): OrganizationByokOperation { + assertOperationCapability(operation) Object.freeze(operation.organizationRoles) Object.freeze(operation.principalKinds) return Object.freeze(operation) @@ -33,24 +34,30 @@ export const apiKeyOperations = { } as const export const byokKeyOperations = { + // permission-group-exempt: BYOK is its own entitlement-gated section, and api_keys.manage names the Sim API Keys tab instead listOrganization: defineOrganizationByokOperation({ id: 'byok_keys.organization.list', + capability: 'none', authority: 'organization_admin', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', principalKinds: ['session'], entitlement: 'cleanup_allowed', }), + // permission-group-exempt: BYOK is its own entitlement-gated section, and api_keys.manage names the Sim API Keys tab instead saveOrganization: defineOrganizationByokOperation({ id: 'byok_keys.organization.save', + capability: 'none', authority: 'organization_admin', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', principalKinds: ['session'], entitlement: 'required', }), + // permission-group-exempt: BYOK is its own entitlement-gated section, and api_keys.manage names the Sim API Keys tab instead deleteOrganization: defineOrganizationByokOperation({ id: 'byok_keys.organization.delete', + capability: 'none', authority: 'organization_admin', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', diff --git a/apps/sim/lib/api/application/operations.ts b/apps/sim/lib/api/application/operations.ts index 619bad28adf..c524a401368 100644 --- a/apps/sim/lib/api/application/operations.ts +++ b/apps/sim/lib/api/application/operations.ts @@ -10,8 +10,10 @@ import { defineOperation } from '@/lib/core/application' * than hand-rolled inside the use case. */ export const v2MetaOperations = { + // permission-group-exempt: the resource is the API key the caller already proved it holds, and reporting what that key can do withholds nothing a group could read: defineOperation({ id: 'meta.capabilities.read', + capability: 'none', principalKinds: ['personal_api_key', 'workspace_api_key'], }), } as const diff --git a/apps/sim/lib/audit-logs/application/operations.ts b/apps/sim/lib/audit-logs/application/operations.ts index 3621f6732d1..cbb82f2fc7c 100644 --- a/apps/sim/lib/audit-logs/application/operations.ts +++ b/apps/sim/lib/audit-logs/application/operations.ts @@ -1,5 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability } from '@/lib/core/application' export type AuditLogPrincipal = Extract @@ -16,21 +17,26 @@ function defineAuditLogOperation( if ((operation.principalKinds as readonly string[]).includes('workspace_api_key')) { throw new Error(`Organization-admin operation ${operation.id} cannot allow workspace API keys`) } + assertOperationCapability(operation) Object.freeze(operation.organizationRoles) Object.freeze(operation.principalKinds) return Object.freeze(operation) } export const auditLogOperations = { + // permission-group-exempt: the organization audit trail is authorized by organization admin or owner role, a scope no workspace-shaped permission group can name list: defineAuditLogOperation({ id: 'audit_logs.list', + capability: 'none', authority: 'organization_admin', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', principalKinds: ['session', 'personal_api_key'], }), + // permission-group-exempt: same organization-admin authority as the list it expands; no group key names the audit trail readDetail: defineAuditLogOperation({ id: 'audit_logs.read_detail', + capability: 'none', authority: 'organization_admin', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', diff --git a/apps/sim/lib/billing/application/operations.ts b/apps/sim/lib/billing/application/operations.ts index 7fe8bc4fca6..f8a116fb6cd 100644 --- a/apps/sim/lib/billing/application/operations.ts +++ b/apps/sim/lib/billing/application/operations.ts @@ -1,5 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability } from '@/lib/core/application' export type BillingReadPrincipal = Extract< Principal, @@ -19,20 +20,25 @@ function defineBillingReadOperation( if (operation.workspaceMinimumRole !== 'read') { throw new Error(`Billing read operation ${operation.id} exceeds its workspace-key ceiling`) } + assertOperationCapability(operation) Object.freeze(operation.principalKinds) return Object.freeze(operation) } export const billingOperations = { + // permission-group-exempt: a personal account reading its own plan and balance; permission groups scope a workspace, not the billing account that owns it readStatus: defineBillingReadOperation({ id: 'billing.status.read', + capability: 'none', accountScope: 'personal_self', workspaceMinimumRole: 'read', workspaceApiKey: 'workspace_only', principalKinds: ['personal_api_key', 'workspace_api_key'], }), + // permission-group-exempt: the same personal billing account reading its own usage records; no group key names it listLogs: defineBillingReadOperation({ id: 'billing.logs.list', + capability: 'none', accountScope: 'personal_self', workspaceMinimumRole: 'read', workspaceApiKey: 'workspace_only', diff --git a/apps/sim/lib/billing/application/organization-usage/operations.ts b/apps/sim/lib/billing/application/organization-usage/operations.ts index a98aa7d9c6f..75a234a5358 100644 --- a/apps/sim/lib/billing/application/organization-usage/operations.ts +++ b/apps/sim/lib/billing/application/organization-usage/operations.ts @@ -1,5 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability } from '@/lib/core/application' /** * Session only. @@ -27,6 +28,7 @@ function defineOrganizationUsageOperation( `Organization usage operation ${operation.id} may only be performed by a session` ) } + assertOperationCapability(operation) Object.freeze(operation.organizationRoles) Object.freeze(operation.principalKinds) return Object.freeze(operation) @@ -37,17 +39,37 @@ const BASE = { organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', principalKinds: ['session'], -} as const satisfies Omit +} as const satisfies Omit +/** + * Every one takes `capability: 'none'`, written out at each call site rather than + * folded into `BASE`: `check:permission-group-enforcement` reads the literal at + * the call site, and a capability arriving through a spread is a capability + * nothing outside the type system ever sees. + */ export const organizationUsageOperations = { - readSummary: defineOrganizationUsageOperation({ id: 'organization_usage.summary.read', ...BASE }), + // permission-group-exempt: the organization's pooled ledger is authorized by organization billing-admin authority, which no workspace-shaped group key names + readSummary: defineOrganizationUsageOperation({ + id: 'organization_usage.summary.read', + capability: 'none', + ...BASE, + }), + // permission-group-exempt: the same pooled ledger, broken down; organization billing-admin authority governs it readBreakdown: defineOrganizationUsageOperation({ id: 'organization_usage.breakdown.read', + capability: 'none', + ...BASE, + }), + // permission-group-exempt: organization billing events, governed by organization billing-admin authority rather than a workspace group + listEvents: defineOrganizationUsageOperation({ + id: 'organization_usage.events.list', + capability: 'none', ...BASE, }), - listEvents: defineOrganizationUsageOperation({ id: 'organization_usage.events.list', ...BASE }), + // permission-group-exempt: exports the same organization billing events; logs.export names workflow run logs, not the billing ledger exportEvents: defineOrganizationUsageOperation({ id: 'organization_usage.events.export', + capability: 'none', ...BASE, }), } as const diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index 7e7f160d3ef..cca5d4af452 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -17,6 +17,7 @@ export { } from '@/lib/core/application/forbidden' export { type ApplicationOperation, + assertOperationCapability, assertOperationPrincipal, defineOperation, type OperationUseCase, diff --git a/apps/sim/lib/core/application/operation.test.ts b/apps/sim/lib/core/application/operation.test.ts index daa728d90b9..7a773eece1d 100644 --- a/apps/sim/lib/core/application/operation.test.ts +++ b/apps/sim/lib/core/application/operation.test.ts @@ -7,6 +7,7 @@ import { assertOperationPrincipal, defineOperation } from '@/lib/core/applicatio const readSelf = defineOperation({ id: 'meta.capabilities.read', + capability: 'none', principalKinds: ['personal_api_key', 'workspace_api_key'], }) @@ -17,14 +18,18 @@ describe('defineOperation', () => { }) it('rejects an operation that names no principal', () => { - expect(() => defineOperation({ id: 'meta.empty', principalKinds: [] })).toThrow( - 'Operation meta.empty must allow at least one principal kind' - ) + expect(() => + defineOperation({ id: 'meta.empty', capability: 'none', principalKinds: [] }) + ).toThrow('Operation meta.empty must allow at least one principal kind') }) it('rejects a duplicated principal kind', () => { expect(() => - defineOperation({ id: 'meta.duplicate', principalKinds: ['session', 'session'] }) + defineOperation({ + id: 'meta.duplicate', + capability: 'none', + principalKinds: ['session', 'session'], + }) ).toThrow('Operation meta.duplicate declares duplicate principal kinds') }) }) @@ -59,3 +64,40 @@ describe('assertOperationPrincipal', () => { expect(thrown).not.toHaveProperty('code') }) }) + +/** + * The guard the type cannot give, because `apps/sim/tsconfig.json` excludes test + * files: a fixture is the one construction site no static check reads. + */ +describe('assertOperationCapability', () => { + it('refuses an operation that declares no capability', () => { + expect(() => + // @ts-expect-error a fixture is the one place an absent capability can be written + defineOperation({ id: 'meta.uncapped', principalKinds: ['session'] }) + ).toThrow("Operation meta.uncapped declares no capability; name one, or 'none' with a reason") + }) + + it('refuses a capability the registry does not declare', () => { + expect(() => + defineOperation({ + id: 'meta.unknown', + // @ts-expect-error the point of the test is a capability outside the registry + capability: 'meta.invented', + principalKinds: ['session'], + }) + ).toThrow('Operation meta.unknown names unknown capability meta.invented') + }) + + it('refuses a parameterized capability the funnel cannot apply', () => { + expect(() => + defineOperation({ + id: 'meta.parameterized', + // @ts-expect-error a parameterized capability is not a StaticPermissionGroupCapability + capability: 'deploy.chat.auth_mode', + principalKinds: ['session'], + }) + ).toThrow( + 'Operation meta.parameterized declares parameterized capability deploy.chat.auth_mode; assert it from the use case instead' + ) + }) +}) diff --git a/apps/sim/lib/core/application/operation.ts b/apps/sim/lib/core/application/operation.ts index 8742c338a1e..79bed813edb 100644 --- a/apps/sim/lib/core/application/operation.ts +++ b/apps/sim/lib/core/application/operation.ts @@ -1,8 +1,34 @@ import type { Principal } from '@sim/auth/principal' import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { + CAPABILITY_RULES, + type StaticPermissionGroupCapability, +} from '@/lib/permission-groups/capabilities' export interface ApplicationOperation { readonly id: Id + /** + * The capability a permission group must not have withheld, or `'none'` when + * no group governs this operation. + * + * `'none'` is spelled out rather than left as an omission, because an absent + * field cannot be told apart from an unreviewed one — and unreviewed omission + * is exactly how twelve config keys shipped with an admin checkbox and no + * server gate. + * + * It lives on the base rather than on {@link WorkspaceOperation} because + * requiring it only there is what let five OAuth-connection operations ship + * with no capability at all: their domain minted them from a bare object + * literal that satisfied `ApplicationOperation`, so neither the builder's + * definition-time guard nor the type reached them. Declared here, an operation + * that answers the question nowhere does not compile. + * + * The type is not the whole guarantee — `apps/sim/tsconfig.json` excludes test + * files, so a fixture can still construct one — which is why + * `defineWorkspaceOperation` and {@link defineOperation} keep runtime guards + * and `check:permission-group-enforcement` keeps reading the source. + */ + readonly capability: StaticPermissionGroupCapability | 'none' } /** @@ -37,6 +63,31 @@ export interface PrincipalScopedOperation< readonly principalKinds: PrincipalKinds } +/** + * Refuses a capability the registry does not know, and one whose rule needs a + * request value the funnel never sees. + * + * Shared by every builder, because the guard has to hold wherever an operation + * is minted: a domain builder that skipped it is how the hole opened last time. + */ +export function assertOperationCapability(operation: ApplicationOperation): void { + if (operation.capability === undefined) { + throw new Error( + `Operation ${operation.id} declares no capability; name one, or 'none' with a reason` + ) + } + if (operation.capability === 'none') return + const rule = CAPABILITY_RULES[operation.capability] + if (!rule) { + throw new Error(`Operation ${operation.id} names unknown capability ${operation.capability}`) + } + if (rule.kind !== 'static') { + throw new Error( + `Operation ${operation.id} declares parameterized capability ${operation.capability}; assert it from the use case instead` + ) + } +} + export function defineOperation< const Id extends string, const PrincipalKinds extends readonly UndelegatedPrincipalKind[], @@ -49,6 +100,7 @@ export function defineOperation< if (new Set(operation.principalKinds).size !== operation.principalKinds.length) { throw new Error(`Operation ${operation.id} declares duplicate principal kinds`) } + assertOperationCapability(operation) Object.freeze(operation.principalKinds) Object.freeze(operation) return operation diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts index cebc7472512..fa14e5d291b 100644 --- a/apps/sim/lib/core/application/workspace-operation.ts +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -1,10 +1,7 @@ import type { DelegatedPrincipal, DelegatedServiceId, Principal } from '@sim/auth/principal' import type { PermissionType } from '@sim/platform-authz/workspace' import type { ApplicationOperation, PrincipalKind } from '@/lib/core/application/operation' -import { - CAPABILITY_RULES, - type StaticPermissionGroupCapability, -} from '@/lib/permission-groups/capabilities' +import { assertOperationCapability } from '@/lib/core/application/operation' import { type ResourcePolicyBinding, requireResourcePolicyBinding, @@ -47,18 +44,6 @@ export interface WorkspaceOperation< readonly workspaceApiKey: WorkspaceApiKeyPolicy readonly principalKinds: PrincipalKinds readonly delegatedServices?: DelegatedServices - /** - * The capability a permission group must not have withheld, or `'none'` when - * no group governs this operation. - * - * `'none'` is spelled out rather than left as an omission, because an absent - * field cannot be told apart from an unreviewed one — and unreviewed omission - * is exactly how twelve config keys shipped with an admin checkbox and no - * server gate. Required, so the question has to be answered once per - * operation; `check:permission-group-enforcement` additionally requires a - * `// permission-group-exempt:` reason wherever the answer is `'none'`. - */ - readonly capability: StaticPermissionGroupCapability | 'none' } type WorkspaceApiKeyPrincipalConsistency< @@ -136,28 +121,7 @@ export function defineWorkspaceOperation< * every personal workspace and every non-enterprise test, then fail in the * tenants that bought the feature. Named here instead, at definition time. */ - if (operation.capability === undefined) { - throw new Error( - `Operation ${operation.id} declares no capability; name one, or 'none' with a reason` - ) - } - - if (operation.capability !== 'none') { - const rule = CAPABILITY_RULES[operation.capability] - if (!rule) { - throw new Error(`Operation ${operation.id} names unknown capability ${operation.capability}`) - } - /** - * A parameterized rule reads a value only the request carries, which the - * authorization funnel never sees. Declared on an operation it would be - * silently skipped, so refuse it here rather than let it read as enforced. - */ - if (rule.kind !== 'static') { - throw new Error( - `Operation ${operation.id} declares parameterized capability ${operation.capability}; assert it from the use case instead` - ) - } - } + assertOperationCapability(operation) Object.freeze(operation.principalKinds) if (operation.delegatedServices) Object.freeze(operation.delegatedServices) diff --git a/apps/sim/lib/credential-groups/application/enrollment-operations.ts b/apps/sim/lib/credential-groups/application/enrollment-operations.ts index b53788bda45..eb17a758bca 100644 --- a/apps/sim/lib/credential-groups/application/enrollment-operations.ts +++ b/apps/sim/lib/credential-groups/application/enrollment-operations.ts @@ -1,4 +1,5 @@ import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability } from '@/lib/core/application' export interface CredentialGroupEnrollmentOperation extends ApplicationOperation { @@ -9,24 +10,33 @@ function defineCredentialGroupEnrollmentOperation( operation: CredentialGroupEnrollmentOperation ): CredentialGroupEnrollmentOperation { if (!operation.id.trim()) throw new Error('Credential Group enrollment operation ID is required') + assertOperationCapability(operation) return Object.freeze(operation) } export const credentialGroupEnrollmentOperations = { + // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it read: defineCredentialGroupEnrollmentOperation({ id: 'credential_groups.enrollment.read', + capability: 'none', principalKind: 'credential_group_enrollment', }), + // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it startOAuth: defineCredentialGroupEnrollmentOperation({ id: 'credential_groups.enrollment.oauth.start', + capability: 'none', principalKind: 'credential_group_enrollment', }), + // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it completeOAuth: defineCredentialGroupEnrollmentOperation({ id: 'credential_groups.enrollment.oauth.complete', + capability: 'none', principalKind: 'credential_group_enrollment', }), + // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it complete: defineCredentialGroupEnrollmentOperation({ id: 'credential_groups.enrollment.complete', + capability: 'none', principalKind: 'credential_group_enrollment', }), } as const diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index a384afcb2f6..ddd8cdc6fe8 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -1,4 +1,5 @@ -import { defineWorkspaceOperation } from '@/lib/core/application' +import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability, defineWorkspaceOperation } from '@/lib/core/application' const ALL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], @@ -449,12 +450,32 @@ export const knowledgeOperations = { }), } as const +/** + * The session-scoped entry points, which resolve a knowledge base first and then + * hand authorization to the workspace-scoped `knowledgeOperations` sibling that + * matches. The capability rides on that sibling, so each of these declares + * `'none'` — but declares it, rather than being minted from a bare object + * literal as they were, which is the form that kept them out of + * `check:permission-group-enforcement` entirely. + */ +function defineKnowledgeSessionOperation( + operation: ApplicationOperation +): ApplicationOperation { + assertOperationCapability(operation) + return Object.freeze(operation) +} + export const knowledgeSessionOperations = { - list: Object.freeze({ id: 'knowledge.session.list' as const }), - read: Object.freeze({ id: 'knowledge.session.read' as const }), - update: Object.freeze({ id: 'knowledge.session.update' as const }), - delete: Object.freeze({ id: 'knowledge.session.delete' as const }), - restore: Object.freeze({ id: 'knowledge.session.restore' as const }), + // permission-group-exempt: delegates to knowledgeOperations.list, which carries knowledge.use + list: defineKnowledgeSessionOperation({ id: 'knowledge.session.list', capability: 'none' }), + // permission-group-exempt: delegates to knowledgeOperations.read, which carries knowledge.use + read: defineKnowledgeSessionOperation({ id: 'knowledge.session.read', capability: 'none' }), + // permission-group-exempt: delegates to knowledgeOperations.update, which carries knowledge.use + update: defineKnowledgeSessionOperation({ id: 'knowledge.session.update', capability: 'none' }), + // permission-group-exempt: delegates to knowledgeOperations.delete, which carries knowledge.use + delete: defineKnowledgeSessionOperation({ id: 'knowledge.session.delete', capability: 'none' }), + // permission-group-exempt: delegates to knowledgeOperations.restore, which carries knowledge.use + restore: defineKnowledgeSessionOperation({ id: 'knowledge.session.restore', capability: 'none' }), } as const export type KnowledgeOperation = (typeof knowledgeOperations)[keyof typeof knowledgeOperations] diff --git a/apps/sim/lib/users/application/operations.ts b/apps/sim/lib/users/application/operations.ts index 145f2c9bfa3..ef1e8027a66 100644 --- a/apps/sim/lib/users/application/operations.ts +++ b/apps/sim/lib/users/application/operations.ts @@ -1,4 +1,12 @@ import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability } from '@/lib/core/application' + +function defineUserAccountOperation( + operation: ApplicationOperation +): ApplicationOperation { + assertOperationCapability(operation) + return Object.freeze(operation) +} /** * Operations an account performs on itself. They carry no workspace scope and no @@ -8,6 +16,11 @@ import type { ApplicationOperation } from '@/lib/core/application' * the principal guard in each use case — rather than restated as inert data here. */ export const userAccountOperations = { - previewDeletion: { id: 'users.account.deletion_preview' }, - delete: { id: 'users.account.delete' }, + // permission-group-exempt: the resource is the account itself, and a permission group scopes a workspace the account may leave rather than the account + previewDeletion: defineUserAccountOperation({ + id: 'users.account.deletion_preview', + capability: 'none', + }), + // permission-group-exempt: deleting your own account is not a workspace act, so no group key names it + delete: defineUserAccountOperation({ id: 'users.account.delete', capability: 'none' }), } as const satisfies Record diff --git a/scripts/check-permission-group-enforcement.test.ts b/scripts/check-permission-group-enforcement.test.ts index d9512299073..18e44a3bd45 100644 --- a/scripts/check-permission-group-enforcement.test.ts +++ b/scripts/check-permission-group-enforcement.test.ts @@ -3,6 +3,7 @@ import { parseCapabilityIds, parseFieldEnforcement, parseOperationCapabilities, + parseOperationRegistryMembers, } from './check-permission-group-enforcement' describe('operation capability parsing', () => { @@ -103,3 +104,166 @@ describe('registry parsing', () => { expect(enforcement.get('hideTablesTab')).toBe('capability') }) }) + +/** + * The blind spot that shipped five ungated OAuth-connection operations: a domain + * that mints operations through a builder of its own, never calling + * `defineWorkspaceOperation`, so nothing read what it declared and the audit + * still printed a tick. Both halves of the fix are pinned here — the parsers now + * follow the `define*Operation` family, and the registry check names any member + * they still could not read. + */ +describe('operation builders other than defineWorkspaceOperation', () => { + it('reads a domain builder that takes an id and a capability positionally', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + function defineCredentialUserOperation(id: string, capability: string) { + return Object.freeze({ id, capability, principalKinds: ['session'] }) + } + + export const credentialUserOperations = { + listOAuthConnections: defineCredentialUserOperation( + 'credentials.oauth_connections.list', + 'integrations.manage' + ), + disconnectOAuth: defineCredentialUserOperation( + 'credentials.oauth_connections.disconnect', + 'integrations.manage' + ), + } as const + `) + + expect(declarations).toEqual([ + expect.objectContaining({ + id: 'credentials.oauth_connections.list', + capability: 'integrations.manage', + }), + expect.objectContaining({ + id: 'credentials.oauth_connections.disconnect', + capability: 'integrations.manage', + }), + ]) + expect(unreadable).toEqual([]) + }) + + it('reads a domain builder that passes an object literal straight through', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + export const auditLogOperations = { + list: defineAuditLogOperation({ + id: 'audit_logs.list', + capability: 'none', + }), + } as const + `) + + expect(declarations).toEqual([ + expect.objectContaining({ id: 'audit_logs.list', capability: 'none' }), + ]) + expect(unreadable).toEqual([]) + }) + + /** + * The wrapper form. Counting the outer and the inner call separately would + * double every credential operation, so the nested match is skipped — its id + * and capability are already carried by the outer call's text. + */ + it('counts a wrapped operation once', () => { + const { declarations } = parseOperationCapabilities(` + export const credentialOperations = { + read: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.read', + minimumRole: 'read', + capability: 'integrations.manage', + }), + 'member' + ), + } as const + `) + + expect(declarations).toEqual([ + expect.objectContaining({ id: 'credentials.read', capability: 'integrations.manage' }), + ]) + }) + + it('reports a builder that mints the operation itself from a bare id argument', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + function defineCredentialUserOperation(id: string) { + return Object.freeze({ id, principalKinds: ['session'] }) + } + + export const credentialUserOperations = { + listOAuthConnections: defineCredentialUserOperation('credentials.oauth_connections.list'), + } as const + `) + + expect(declarations).toEqual([]) + expect(unreadable).toHaveLength(1) + }) +}) + +describe('registry completeness', () => { + const registrySource = ` + export const probeOperations = { + list: Object.freeze({ id: 'probe.list' as const }), + read: defineWorkspaceOperation({ + id: 'probe.read', + minimumRole: 'read', + capability: 'tables.use', + }), + } as const + ` + + it('enumerates each member with the line span it occupies', () => { + const members = parseOperationRegistryMembers(registrySource) + + expect(members.map((member) => `${member.registry}.${member.member}`)).toEqual([ + 'probeOperations.list', + 'probeOperations.read', + ]) + const [list, read] = members + expect(list.startLine).toBe(3) + expect(read.startLine).toBe(4) + expect(read.endLine).toBe(8) + }) + + /** + * The check the audit runs: a member no parsed declaration falls inside is a + * member nothing read. `list` is minted by no builder at all, so it yields + * nothing — and yielding nothing is what used to read as success. + */ + it('leaves a member no parser read outside every parsed line', () => { + const { declarations, unreadable } = parseOperationCapabilities(registrySource) + const readLines = [...declarations.map((declaration) => declaration.line), ...unreadable] + + const unread = parseOperationRegistryMembers(registrySource).filter( + (member) => !readLines.some((line) => line >= member.startLine && line <= member.endLine) + ) + + expect(unread.map((member) => member.member)).toEqual(['list']) + }) + + it('ignores a comment or a string that looks like a member key', () => { + const members = parseOperationRegistryMembers(` + export const probeOperations = { + // permission-group-exempt: nothing: here is a member + read: defineWorkspaceOperation({ + id: 'probe.read', + minimumRole: 'read', + capability: 'tables.use', + }), + } as const + `) + + expect(members.map((member) => member.member)).toEqual(['read']) + }) + + it('reads no registry from a module that exports none', () => { + expect( + parseOperationRegistryMembers(` + export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.applyOperations, + }) + `) + ).toEqual([]) + }) +}) diff --git a/scripts/check-permission-group-enforcement.ts b/scripts/check-permission-group-enforcement.ts index b48a871993e..059a39a3783 100644 --- a/scripts/check-permission-group-enforcement.ts +++ b/scripts/check-permission-group-enforcement.ts @@ -57,6 +57,30 @@ const ENFORCED_ANNOTATION = 'permission-group-enforced:' const EXEMPT_ANNOTATION = 'permission-group-exempt:' const MAX_ANNOTATION_LOOKBACK = 3 +/** + * The naming convention every operation-minting builder follows: + * `defineWorkspaceOperation`, `defineOperation`, and each domain's own + * `defineOperation`. + * + * The audit used to look for `defineWorkspaceOperation` and nothing else, so a + * domain that minted operations through a builder of its own — an object frozen + * by hand rather than passed to the shared one — was read by neither the + * builder's definition-time guard nor this script. Twenty-one operations across + * six domains were invisible that way, and the failure was silent: the files + * were scanned, some other operation in them was counted, and the audit printed + * a tick. Matching the family rather than the one name is what makes a new + * builder visible by default instead of on purpose. + */ +const MINTING_NAME = /^define[A-Za-z0-9_$]*Operation$/ +const MINTING_CALL_SOURCE = String.raw`\b(define[A-Za-z0-9_$]*Operation)\s*\(` +const mintingCallPattern = () => new RegExp(MINTING_CALL_SOURCE, 'g') +/** Whether a module mints an operation at all, so files that do not are skipped cheaply. */ +const MINTS_AN_OPERATION = new RegExp(MINTING_CALL_SOURCE) +/** An exported `*Operations` registry object, the second route by which operations reach a surface. */ +const OPERATION_REGISTRY = + /(?:^|\n)\s*export const ([A-Za-z0-9_$]+Operations)\s*(?::[^=\n]*)?=\s*\{/g +const DECLARES_A_REGISTRY = /(?:^|\n)\s*export const [A-Za-z0-9_$]+Operations\s*(?::[^=\n]*)?=\s*\{/ + interface Finding { file: string line?: number @@ -145,8 +169,8 @@ interface OperationDeclaration { interface ParsedOperations { declarations: OperationDeclaration[] /** - * Lines of `defineWorkspaceOperation` calls this parser could not read an id - * from, and which no recognized factory accounts for. + * Lines of operation-minting calls this parser could not read an id from, + * and which no recognized factory accounts for. * * A call whose `id` is a const reference, or one minted by a wrapper written * as an arrow const rather than a `function`, used to be dropped in silence — @@ -158,9 +182,9 @@ interface ParsedOperations { } /** - * Every `defineWorkspaceOperation` in a module and the capability it declares, - * resolved through a same-file factory when a domain wraps the builder (the - * table operations take only an id and a capability). + * Every operation minted in a module and the capability it declares, resolved + * through a same-file factory when a domain wraps a builder (the table + * operations take only an id and a capability). */ export function parseOperationCapabilities(source: string): ParsedOperations { const declarations: OperationDeclaration[] = [] @@ -168,10 +192,12 @@ export function parseOperationCapabilities(source: string): ParsedOperations { const lineAt = (index: number) => source.slice(0, index).split('\n').length /** - * Domains that wrap the builder in a same-file factory declare the capability - * one of two ways: fixed in the factory body, when every operation it makes - * belongs to one capability, or taken as a second argument when they differ. - * Both are legible at the call site, so both are read here. + * Domains that wrap a builder in a same-file factory declare the capability + * one of three ways: fixed in the factory body, when every operation it makes + * belongs to one capability; taken as a second argument when they differ; or + * passed straight through from an object literal at the call site. The first + * two are read from their call sites below, the third by the direct scan, + * which reads the literal exactly as it reads a builder's own. */ const factoryCapabilities = new Map() const factoryRanges: Array<[number, number]> = [] @@ -180,7 +206,7 @@ export function parseOperationCapabilities(source: string): ParsedOperations { const bodyIndex = source.indexOf('{', match.index + match[0].length - 1) if (bodyIndex === -1) continue const body = balancedGroup(source, bodyIndex) - if (!body.includes('defineWorkspaceOperation')) continue + if (!MINTS_AN_OPERATION.test(body) && !MINTING_NAME.test(match[1])) continue factoryRanges.push([bodyIndex, bodyIndex + body.length]) const fixed = /capability\s*:\s*'([a-z0-9_.]+)'/.exec(body)?.[1] if (fixed) factoryCapabilities.set(match[1], fixed) @@ -191,12 +217,27 @@ export function parseOperationCapabilities(source: string): ParsedOperations { const insideFactory = (index: number) => factoryRanges.some(([start, end]) => index >= start && index < end) - const directPattern = /defineWorkspaceOperation\s*\(/g + /** + * Ranges of calls already read. A domain wrapper such as + * `defineCredentialOperation(defineWorkspaceOperation({...}), 'admin')` matches + * twice over one operation; the outer match already carries the inner's `id` + * and `capability`, so the nested one is skipped rather than counted again. + */ + const accepted: Array<[number, number]> = [] + + const directPattern = mintingCallPattern() for (let match = directPattern.exec(source); match; match = directPattern.exec(source)) { - const call = balancedGroup(source, source.indexOf('(', match.index)) + const name = match[1] + if (/\bfunction\s+$/.test(source.slice(Math.max(0, match.index - 16), match.index))) continue + if (factoryCapabilities.has(name)) continue + if (insideFactory(match.index)) continue + const openIndex = source.indexOf('(', match.index) + if (accepted.some(([start, end]) => openIndex > start && openIndex < end)) continue + const call = balancedGroup(source, openIndex) + accepted.push([openIndex, openIndex + call.length]) const id = /id\s*:\s*'([^']+)'/.exec(call)?.[1] if (!id) { - if (!insideFactory(match.index)) unreadable.push(lineAt(match.index)) + unreadable.push(lineAt(match.index)) continue } declarations.push({ @@ -212,6 +253,7 @@ export function parseOperationCapabilities(source: string): ParsedOperations { ? new RegExp(`\\b${factory}\\s*\\(\\s*'([^']+)'\\s*,\\s*'([a-z0-9_.]+)'`, 'g') : new RegExp(`\\b${factory}\\s*\\(\\s*'([^']+)'`, 'g') for (let match = callPattern.exec(source); match; match = callPattern.exec(source)) { + if (insideFactory(match.index)) continue declarations.push({ id: match[1], line: lineAt(match.index), @@ -223,6 +265,121 @@ export function parseOperationCapabilities(source: string): ParsedOperations { return { declarations, unreadable } } +export interface OperationRegistryMember { + registry: string + member: string + startLine: number + endLine: number +} + +/** Index just past the string literal that starts at `openIndex`. */ +function skipStringLiteral(body: string, openIndex: number): number { + const quote = body[openIndex] + for (let index = openIndex + 1; index < body.length; index++) { + if (body[index] === '\\') { + index++ + continue + } + if (body[index] === quote) return index + 1 + } + return body.length +} + +/** The keyed members of an object literal, at its own top level only. */ +function topLevelMembers(body: string): Array<{ key: string; start: number; end: number }> { + const members: Array<{ key: string; start: number; end: number }> = [] + let depth = 0 + let index = 0 + let pending: { key: string; start: number } | null = null + const flush = (end: number) => { + if (pending) members.push({ ...pending, end }) + pending = null + } + + while (index < body.length) { + const char = body[index] + if (char === '/' && body[index + 1] === '/') { + const newline = body.indexOf('\n', index) + index = newline === -1 ? body.length : newline + 1 + continue + } + if (char === '/' && body[index + 1] === '*') { + const close = body.indexOf('*/', index) + index = close === -1 ? body.length : close + 2 + continue + } + if (char === "'" || char === '"' || char === '`') { + index = skipStringLiteral(body, index) + continue + } + if (char === '{' || char === '(' || char === '[') { + depth++ + index++ + continue + } + if (char === '}' || char === ')' || char === ']') { + depth-- + if (depth === 0) flush(index) + index++ + continue + } + if (depth === 1) { + if (char === ',') { + flush(index) + index++ + continue + } + if (!pending && /[\s{,]/.test(body[index - 1] ?? '{')) { + const key = /^([A-Za-z0-9_$]+)\s*:/.exec(body.slice(index)) + if (key) { + pending = { key: key[1], start: index } + index += key[0].length + continue + } + } + } + index++ + } + flush(body.length) + return members +} + +/** + * The members of every exported `*Operations` registry, with the line span each + * one occupies. + * + * This is the completeness half of the audit, and it asks a different question + * from everything above: not *does this operation declare a capability*, but + * *did this audit read this operation at all*. Assertion A can only speak about + * operations the parsers found; a member minted by a form they do not follow + * yields nothing, and nothing is indistinguishable from a clean file. Comparing + * the registry a surface actually imports against what was parsed is what turns + * that silence into a failure — an undercount reads exactly like success, which + * is how five OAuth-connection operations shipped ungated. + */ +export function parseOperationRegistryMembers(source: string): OperationRegistryMember[] { + const members: OperationRegistryMember[] = [] + const lineAt = (index: number) => source.slice(0, index).split('\n').length + OPERATION_REGISTRY.lastIndex = 0 + for ( + let match = OPERATION_REGISTRY.exec(source); + match; + match = OPERATION_REGISTRY.exec(source) + ) { + const openIndex = source.indexOf('{', match.index + match[0].length - 1) + const body = balancedGroup(source, openIndex) + for (const member of topLevelMembers(body)) { + members.push({ + registry: match[1], + member: member.key, + startLine: lineAt(openIndex + member.start), + endLine: lineAt(openIndex + member.end), + }) + } + } + return members +} + /** Capabilities declared enforced at a call site the funnel cannot reach. */ export function parseEnforcedAnnotations(source: string): string[] { return [...source.matchAll(new RegExp(`${ENFORCED_ANNOTATION}\\s*([a-z0-9_.]+)`, 'g'))].map( @@ -297,7 +454,7 @@ function main(): void { } } - if (!source.includes('defineWorkspaceOperation(')) continue + if (!MINTS_AN_OPERATION.test(source) && !DECLARES_A_REGISTRY.test(source)) continue const { declarations, unreadable } = parseOperationCapabilities(source) @@ -306,7 +463,7 @@ function main(): void { file: relativePath, line, message: - 'defineWorkspaceOperation call this audit cannot read an id from — a const-reference id, or a wrapper written as an arrow const rather than a `function`. Teach parseOperationCapabilities the form rather than letting the operation drop out of the count in silence', + 'operation-minting call this audit cannot read an id from — a const-reference id, an id taken as a bare argument by a builder that mints the operation itself, or a wrapper written as an arrow const rather than a `function`. Teach parseOperationCapabilities the form rather than letting the operation drop out of the count in silence', }) } @@ -315,11 +472,29 @@ function main(): void { * longer understand it. Per-file rather than a count floor: a floor rots on * every legitimate addition and invites bumping the number. */ - if (declarations.length === 0 && unreadable.length === 0) { + if (MINTS_AN_OPERATION.test(source) && declarations.length === 0 && unreadable.length === 0) { findings.push({ file: relativePath, message: - 'calls defineWorkspaceOperation but this audit parsed no operation from it — the declaration form changed and every operation in this file is now unchecked', + 'mints an operation but this audit parsed none from it — the declaration form changed and every operation in this file is now unchecked', + }) + } + + /** + * Assertion F: every member of an exported registry was read. + * + * Everything above can only speak about what the parsers found. This asks + * whether they found each thing a surface can actually import, which is the + * only question whose answer distinguishes a clean file from one the + * parsers walked straight past. + */ + const readLines = [...declarations.map((declaration) => declaration.line), ...unreadable] + for (const member of parseOperationRegistryMembers(source)) { + if (readLines.some((line) => line >= member.startLine && line <= member.endLine)) continue + findings.push({ + file: relativePath, + line: member.startLine, + message: `'${member.registry}.${member.member}' is exported as an operation but this audit read no operation from it — it is minted by a form the parsers do not follow, so nothing here checks what capability it declares. Name the builder \`defineOperation\` and pass it an object literal with a string \`id\` and \`capability\`, or teach parseOperationCapabilities the form; do not leave the member counted as reviewed while unread`, }) } From a2a8953545a58569539bfee2a3a1521c0ed792ae Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 14:06:59 -0700 Subject: [PATCH 051/179] fix(permission-groups): gate the session arm of the OAuth credentials read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/auth/oauth/credentials` lists a user's OAuth credentials for a provider and had no capability reference at all, so `hideIntegrationsTab` withheld the Integrations module everywhere except the endpoint that hands out its credentials. It could not simply be gated. The route authenticates through `checkSessionOrInternalAuth`, so one handler answers both a person opening the credential selector and the executor resolving a credential for a running workflow. Refusing both would 403 a deployed workflow the group permits — a run failing hours after an admin ticked a box, with nothing on the surface connecting the two. So the callers are split, the way `principalUserId` splits a workspace API key from a user: the session arm is gated on `integrations.manage`, the internal arm is not. A permission group describes what a person may reach, and the executor is not that person. Two call sites, because the query can carry a workspace or only a `credentialId`. The `credentialId` path is asked after each branch's own access check, never before, so a caller who may not reach the credential at all cannot learn from the refusal wording that its workspace is one their group governs. --- .../api/auth/oauth/credentials/route.test.ts | 161 ++++++++++++++++++ .../app/api/auth/oauth/credentials/route.ts | 74 +++++++- 2 files changed, 234 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index 66f28ccef82..50b57285ab7 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -7,8 +7,12 @@ import { dbChainMockFns, hybridAuthMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, permissionsMock, + permissionsMockFns, resetDbChainMock, + resetPermissionGroupScopeMock, workflowsUtilsMock, } from '@sim/testing' import { NextRequest } from 'next/server' @@ -18,10 +22,23 @@ vi.mock('@/lib/credentials/oauth', () => ({ syncWorkspaceOAuthCredentialsForUser: vi.fn(), })) +const { mockGetCredentialActorContext, mockCanUseCredential } = vi.hoisted(() => ({ + mockGetCredentialActorContext: vi.fn(), + mockCanUseCredential: vi.fn(() => true), +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, + canUseCredential: mockCanUseCredential, +})) + vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { GET } from '@/app/api/auth/oauth/credentials/route' describe('OAuth Credentials API Route', () => { @@ -33,6 +50,8 @@ describe('OAuth Credentials API Route', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + resetPermissionGroupScopeMock() + mockCanUseCredential.mockReturnValue(true) }) it('should handle unauthenticated user', async () => { @@ -126,4 +145,146 @@ describe('OAuth Credentials API Route', () => { expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ credentials: [] }) }) + + /** + * The split this route exists to make. It authenticates through + * `checkSessionOrInternalAuth`, so one handler answers both a person opening + * the credential selector and the executor resolving a credential mid-run. + * Gating both would 403 a deployed workflow whose group permits it, hours + * after an admin ticked a box and with nothing connecting the two. + */ + describe('integrations.manage', () => { + const INTEGRATIONS_WITHHELD = { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideIntegrationsTab: true, + } + + beforeEach(() => { + /** + * `mockResolvedValue`, not `...Once`: the missing-provider test above + * returns 400 before authentication runs, so its queued value is never + * consumed and every later `...Once` in this file reads one test stale. + */ + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'session', + }) + permissionsMockFns.mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + }) + + it('refuses a session whose group withholds Integrations', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'session', + }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue( + INTEGRATIONS_WITHHELD + ) + + const response = await GET( + createMockRequestWithQuery( + 'GET', + '?provider=google-email&workspaceId=3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' + ) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: expect.stringContaining("your organization's permission group"), + }) + }) + + /** + * The one that matters. A run resolving its credential must not be refused + * by a group that describes what a person may open. + */ + it('does not refuse the executor under the same withholding group', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'internal_jwt', + }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue( + INTEGRATIONS_WITHHELD + ) + dbChainMockFns.where.mockResolvedValue([]) + + const response = await GET( + createMockRequestWithQuery( + 'GET', + '?provider=google-email&workspaceId=3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' + ) + ) + + expect(response.status).toBe(200) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('allows a session whose group leaves Integrations alone', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'session', + }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue( + DEFAULT_PERMISSION_GROUP_CONFIG + ) + dbChainMockFns.where.mockResolvedValue([]) + + const response = await GET( + createMockRequestWithQuery( + 'GET', + '?provider=google-email&workspaceId=3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' + ) + ) + + expect(response.status).toBe(200) + }) + + /** + * A `credentialId` lookup can arrive with no workspace in the query, so the + * gate above never runs; the credential names the workspace whose group + * governs it. + */ + it('refuses a session credentialId lookup using the credential own workspace', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'session', + }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue( + INTEGRATIONS_WITHHELD + ) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth', + displayName: 'Gmail', + providerId: 'google-email', + accountId: 'account-1', + updatedAt: new Date('2026-01-01T00:00:00Z'), + accountProviderId: 'google-email', + accountScope: 'email', + accountUpdatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]) + + const response = await GET(createMockRequestWithQuery('GET', '?credentialId=credential-1')) + + expect(response.status).toBe(403) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).toHaveBeenCalledWith( + 'user-123', + 'workspace-1', + undefined + ) + }) + }) }) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index cdf25f7f159..cc9dfebf266 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -6,7 +6,7 @@ import { and, eq, inArray, isNotNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { oauthCredentialsQuerySchema } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { AuthType, type AuthTypeValue, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' @@ -16,6 +16,8 @@ import { getServiceAccountProviderForProviderId, providerIdsForService, } from '@/lib/oauth/utils' +import { capabilityRefusal } from '@/lib/permission-groups/capabilities' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -51,6 +53,29 @@ function toCredentialResponse( } } +/** + * Whether `integrations.manage` is withheld from the caller in `workspaceId`. + * + * Only a session is asked. This route authenticates through + * `checkSessionOrInternalAuth`, so the same handler answers both a person + * opening the credential selector and the executor resolving a credential for a + * running workflow. A permission group describes what a *person* may reach; the + * executor is not that person, and refusing it would stop a deployed workflow + * the group permits — a run failing hours after an admin ticked a box, with + * nothing on the surface connecting the two. So the arm that carries a human + * intent is gated and the machine arm is not, which is the same split + * `principalUserId` makes for a workspace API key. + */ +async function integrationsWithheldFromSession( + authType: AuthTypeValue | undefined, + userId: string, + workspaceId: string | null | undefined +): Promise { + if (authType !== AuthType.SESSION) return false + if (!workspaceId) return false + return isWorkspaceCapabilityWithheld(userId, workspaceId, 'integrations.manage') +} + /** * Get credentials for a specific provider */ @@ -123,6 +148,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } requesterCanAdmin = workspaceAccess.canAdmin + + // permission-group-enforced: integrations.manage — raw handler with inline queries, which the authorization funnel never sees + if ( + await integrationsWithheldFromSession( + authResult.authType, + requesterUserId, + effectiveWorkspaceId + ) + ) { + return NextResponse.json( + { error: capabilityRefusal('integrations.manage') }, + { status: 403 } + ) + } } if (credentialId) { @@ -145,6 +184,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .limit(1) if (platformCredential) { + /** + * A `credentialId` lookup may arrive with neither `workflowId` nor + * `workspaceId`, in which case the workspace gate above never ran. The + * credential still names a workspace, and that is the scope whose group + * governs it. Asked after each branch's own access check, never before: + * a caller who may not reach this credential at all must not learn from + * the refusal wording that the workspace it belongs to is one their + * group governs. + */ + const credentialScopeWithheld = async () => + !effectiveWorkspaceId && + (await integrationsWithheldFromSession( + authResult.authType, + requesterUserId, + platformCredential.workspaceId + )) + if (platformCredential.type === 'service_account') { if ( workflowId && @@ -160,6 +216,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } } + // permission-group-enforced: integrations.manage — the credentialId path carries its own workspace scope + if (await credentialScopeWithheld()) { + return NextResponse.json( + { error: capabilityRefusal('integrations.manage') }, + { status: 403 } + ) + } + return NextResponse.json( { credentials: [ @@ -192,6 +256,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } } + // permission-group-enforced: integrations.manage — the credentialId path carries its own workspace scope + if (await credentialScopeWithheld()) { + return NextResponse.json( + { error: capabilityRefusal('integrations.manage') }, + { status: 403 } + ) + } + if (!platformCredential.accountProviderId || !platformCredential.accountUpdatedAt) { return NextResponse.json({ credentials: [] }, { status: 200 }) } From e7a886f6b5990fd986b62a85b4b9a4ba5687d0fd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 14:10:07 -0700 Subject: [PATCH 052/179] fix(permission-groups): project logs on the v2 surface and close its cost oracle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/api/v2/logs` and `/api/v2/logs/{runId}` applied no field projection at all. A personal API key carries a user and therefore a group, so an enterprise member whose group sets `hideCostInfo` or `hideTraceSpans` read the run total, the itemized ledger, and the whole trace in full through v2 — while the same person was withheld all of it on the internal and v1 surfaces. The keys claimed an enforcement they did not have. Applied in the two application use cases rather than in the presenters, so the withholding travels with the read instead of with one surface's rendering: - `listPublicLogs` resolves the shared `resolveLogFieldProjection`, blanks the row's spend (`costTotal` for a workflow run, `cost` for a job run), runs `projectExecutionData` over the materialized payload, and turns off the `includeTraceSpans`/`includeFinalOutput` render flags. The flags matter: the presenter reads `executionData.traceSpans ?? []`, so a deleted array would come back as an empty one rather than as an absent field. - `getPublicLog` does the same and drops the cost ledger, which itemizes the very total it blanks. Both resolve their subject through `resolvePrincipalSubjectUserId`, the helper the sibling internal use cases already use: a workspace API key represents no user, resolves to `undefined`, and reads whole rather than borrowing the key creator's group. `listPublicLogs` then refuses `sortBy=cost` and `minCost`/`maxCost` for a group that withholds spend, and `/api/v1/logs` — which projected the value but still passed the bounds into `buildLogFilters` — gains the same refusal. Blanking the figure while answering a filter over it leaves a bisection oracle: one request per probe, with the page as the answer. Refused rather than dropped, after the workspace access check, so the caller is a member being told about their own group. `provider-catalog.test.ts` was vacuous: its env allowlist was the narrower half, so the intersection was `['salesforce']` whether or not the permission group was read at all. The fixture is inverted so dropping the group half makes Trello visible and the assertion goes red. --- apps/sim/app/api/v1/logs/projection.test.ts | 73 ++++ apps/sim/app/api/v1/logs/route.ts | 46 +++ .../application/provider-catalog.test.ts | 17 +- .../lib/logs/application/get-public-log.ts | 40 +- .../lib/logs/application/list-public-logs.ts | 100 ++++- .../application/public-log-projection.test.ts | 390 ++++++++++++++++++ 6 files changed, 644 insertions(+), 22 deletions(-) create mode 100644 apps/sim/lib/logs/application/public-log-projection.test.ts diff --git a/apps/sim/app/api/v1/logs/projection.test.ts b/apps/sim/app/api/v1/logs/projection.test.ts index 17c9adfd4ed..247b6ef6c6d 100644 --- a/apps/sim/app/api/v1/logs/projection.test.ts +++ b/apps/sim/app/api/v1/logs/projection.test.ts @@ -245,6 +245,79 @@ describe('GET /api/v1/logs?details=full', () => { }) }) +/** + * Blanking `cost` while still answering `minCost`/`maxCost` faithfully leaves + * the list a bisection oracle over the very figure it just withheld: one + * request per probe, with the page as the answer. The filter is therefore + * refused rather than silently dropped — dropping it would answer a question + * nobody asked, and a wrong answer presented as the right one is worse than a + * refusal. + */ +describe('GET /api/v1/logs cost-selective queries', () => { + function listFiltered(query: string) { + return listLogs(apiRequest(`/api/v1/logs?workspaceId=${WORKSPACE_ID}&${query}`)) + } + + it.each([['minCost=0.5'], ['maxCost=0.5'], ['minCost=0.1&maxCost=0.9']])( + 'refuses %s for a group that withholds spend', + async (query) => { + governedBy({ hideCostInfo: true }) + + const response = await listFiltered(query) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "Execution cost is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + expect(mockListPublicWorkflowLogs).not.toHaveBeenCalled() + } + ) + + it('answers the same filter for a group that withholds nothing', async () => { + const response = await listFiltered('minCost=0.5') + + expect(response.status).toBe(200) + expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( + expect.objectContaining({ filters: expect.objectContaining({ minCost: 0.5 }) }) + ) + }) + + /** A workspace key has no user and therefore no group to refuse on behalf of. */ + it('answers the same filter for a workspace API key', async () => { + mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) + governedBy({ hideCostInfo: true }) + + const response = await listFiltered('minCost=0.5') + + expect(response.status).toBe(200) + expect(mockListPublicWorkflowLogs).toHaveBeenCalled() + }) + + /** Only the spend filter is refused; the rest of the query is unaffected. */ + it('still answers a non-cost filter for a group that withholds spend', async () => { + governedBy({ hideCostInfo: true }) + + const response = await listFiltered('minDurationMs=100') + + expect(response.status).toBe(200) + }) + + /** + * The refusal must come from the caller's own membership, not from the door: + * a non-member is told nothing about how the organization configured a group. + */ + it('refuses a non-member for their access before naming the group', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + governedBy({ hideCostInfo: true }) + + const response = await listFiltered('minCost=0.5') + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Access denied' }) + }) +}) + describe('GET /api/v1/logs/[id]', () => { it('withholds the execution payloads when the group hides trace spans', async () => { governedBy({ hideTraceSpans: true }) diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index d04cf2e0153..daa9dd5084c 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -7,11 +7,14 @@ import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/co import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' import { + assertLogCostQueryAllowed, + type LogFieldProjection, projectCostTotal, projectExecutionData, resolveLogFieldProjection, } from '@/lib/logs/log-projection' import { decodePublicLogCursor, listPublicWorkflowLogs } from '@/lib/logs/public-queries' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { capabilityGovernedUserId, @@ -23,6 +26,32 @@ import { const logger = createLogger('V1LogsAPI') +/** + * Renders {@link assertLogCostQueryAllowed}'s refusal in the v1 `{ error, + * details }` body, or `null` when the query selects on nothing withheld. + * + * The assertion throws so every surface refuses in the same words; this route + * builds its own response rather than running inside a JSON route builder, so + * the throw is caught here instead of by a shared error projection. Caught + * narrowly on purpose — the handler's outer `catch` renders a 500, and letting + * a 403 fall into it would report an organization's policy as a Sim fault. + */ +function costQueryRefusal( + params: { minCost?: number | null; maxCost?: number | null }, + projection: LogFieldProjection +): NextResponse | null { + try { + assertLogCostQueryAllowed(params, projection) + return null + } catch (error) { + if (!(error instanceof PermissionGroupCapabilityError)) throw error + return NextResponse.json( + { error: error.message, details: { code: error.detailCode } }, + { status: 403 } + ) + } +} + export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -68,6 +97,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => { params.workspaceId ) + /** + * Project the value, then refuse the query that selects on it. `minCost` and + * `maxCost` bisect the very total `projectCostTotal` blanks below, so + * withholding one while answering the other is incoherent. This surface + * orders by `startedAt` alone — it publishes no `sortBy` — so the ordering + * half of the oracle is not reachable here. + * + * It runs after the workspace access check above, so the caller is a member + * being told about their own group rather than an outsider handed an + * organization-configuration oracle. + */ + const costRefusal = costQueryRefusal( + { minCost: params.minCost, maxCost: params.maxCost }, + projection + ) + if (costRefusal) return costRefusal + logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, { userId, filters: { diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index ab2fc8bbab4..6dbb3cf0c23 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -92,9 +92,15 @@ describe('listCredentialProviderCatalog', () => { beforeEach(() => { vi.clearAllMocks() mocks.getAllOAuthServices.mockReturnValue(services) - mocks.getAllowedIntegrationsFromEnv.mockReturnValue(['salesforce']) + /** + * The permission group is the NARROWER half on purpose. With the deployment + * allowlist narrower, the intersection is the same set whether or not the + * group is read at all, and every assertion below passes against a catalog + * that never consulted it — which is what this fixture used to look like. + */ + mocks.getAllowedIntegrationsFromEnv.mockReturnValue(['salesforce', 'trello']) mocks.getUserPermissionConfig.mockResolvedValue({ - allowedIntegrations: ['salesforce', 'trello'], + allowedIntegrations: ['salesforce'], }) mocks.getBlockVisibility.mockResolvedValue({ revealed: new Set(), @@ -186,8 +192,13 @@ describe('listCredentialProviderCatalog', () => { ) expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + /** + * The deployment allowlist alone, not the personal caller's narrower group: + * a workspace API key has no user and therefore no group, and borrowing the + * key creator's would hide Trello from every caller of a shared credential. + */ expect(mocks.createVisibility).toHaveBeenCalledWith( - expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce', 'trello']) }) ) }) diff --git a/apps/sim/lib/logs/application/get-public-log.ts b/apps/sim/lib/logs/application/get-public-log.ts index d99e2703d19..8e6b985f955 100644 --- a/apps/sim/lib/logs/application/get-public-log.ts +++ b/apps/sim/lib/logs/application/get-public-log.ts @@ -1,3 +1,4 @@ +import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import type { CostLedger } from '@/lib/api/contracts/logs' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -8,6 +9,7 @@ import { logDelegationAuthorization } from '@/lib/logs/application/authorization import { logOperations } from '@/lib/logs/application/operations' import { buildCostLedger } from '@/lib/logs/cost-ledger' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { projectExecutionData, resolveLogFieldProjection } from '@/lib/logs/log-projection' import { getPublicWorkflowLog, getPublicWorkflowLogScope } from '@/lib/logs/public-queries' import { sanitizeExecutionSnapshotState } from '@/lib/logs/snapshot-sanitizer' import { @@ -89,6 +91,27 @@ export const getPublicLog = defineAuthorizedWorkspaceUseCase({ }, authorizationOptions: logDelegationAuthorization(), execute: async ({ principal, context }): Promise => { + /** + * Attribution and the projection subject in one value; a workspace API key + * represents no user and therefore reads the run whole. See + * `list-public-logs.ts` for why the key's creator is never substituted. + */ + const viewerUserId = resolvePrincipalSubjectUserId(principal) + + /** + * permission-group-enforced: logs.trace_spans + * permission-group-enforced: logs.cost + * + * The same projection the list and the internal detail path apply. Without + * it this route published the whole trace and the itemized ledger to a + * member whose group withholds both everywhere else. + */ + const projection = await resolveLogFieldProjection( + viewerUserId, + context.workspaceId, + context.workspaceOrganizationId + ) + const log = await getPublicWorkflowLog( { column: 'executionId', value: context.executionId }, context.workspaceId @@ -110,22 +133,31 @@ export const getPublicLog = defineAuthorizedWorkspaceUseCase({ workspaceId: context.workspaceId, workflowId: log.workflowId, executionId: log.executionId, - userId: principal.kind === 'personal_api_key' ? principal.userId : undefined, + userId: viewerUserId, } ) if (log.workflowUserId && !log.workflowOwnerEmail) { throw new Error(`Unable to resolve workflow owner email for ${log.workflowUserId}`) } - const costLedger = await buildCostLedger(log.executionId) + /** + * The ledger is the itemization of the very total `costTotal` reports, so a + * group withholding spend has to lose both — blanking the total alone would + * leave the caller able to sum the lines. + */ + const costLedger = projection.hideCostInfo ? null : await buildCostLedger(log.executionId) return { - log: { ...log, workflowState: sanitizeExecutionSnapshotState(log.workflowState) }, + log: { + ...log, + costTotal: projection.hideCostInfo ? null : log.costTotal, + workflowState: sanitizeExecutionSnapshotState(log.workflowState), + }, costLedger, workflowFolderPath: publicLogFolderPath( folderIndex.pathById, log.workflowFolderId, log.workflowName !== null ), - executionData, + executionData: projectExecutionData(executionData, projection) as Record, } }, }) diff --git a/apps/sim/lib/logs/application/list-public-logs.ts b/apps/sim/lib/logs/application/list-public-logs.ts index 729f015c6c9..a9bba398a68 100644 --- a/apps/sim/lib/logs/application/list-public-logs.ts +++ b/apps/sim/lib/logs/application/list-public-logs.ts @@ -1,3 +1,4 @@ +import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -6,6 +7,12 @@ import { logDelegationAuthorization } from '@/lib/logs/application/authorization import { logOperations } from '@/lib/logs/application/operations' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' import { resolveLogFolderScope } from '@/lib/logs/folder-scope' +import { + assertLogCostQueryAllowed, + type LogFieldProjection, + projectExecutionData, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import type { LogFilters } from '@/lib/logs/public-filters' import { type PublicLogListRow, @@ -41,6 +48,20 @@ export interface ListPublicLogsResult { includeTraceSpans: boolean } +/** + * The row with its spend blanked when the viewer's group withholds it. + * + * Blanked on the row rather than in the presenter so a surface that reads + * `costTotal` or a job run's `cost` directly cannot report a figure the group + * withholds by forgetting to ask. The two branches spell the same column + * differently because the two tables do: a workflow run stores a `numeric` + * total, a job run a jsonb document. + */ +function projectRowSpend(log: PublicLogListRow, projection: LogFieldProjection): PublicLogListRow { + if (!projection.hideCostInfo) return log + return log.kind === 'job' ? { ...log, cost: null } : { ...log, costTotal: null } +} + export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ operation: logOperations.list, resolveContext: async ({ input }: { input: ListPublicLogsInput }) => { @@ -50,6 +71,44 @@ export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ }, authorizationOptions: logDelegationAuthorization(), execute: async ({ principal, input, context }): Promise => { + /** + * Attribution and the projection subject in one value: a workspace API key + * authorizes as the workspace and represents no user, so it resolves to + * `undefined` and reads the page whole. Substituting the key's creator would + * apply a bystander's group to every caller of a shared credential. + */ + const viewerUserId = resolvePrincipalSubjectUserId(principal) + + /** + * permission-group-enforced: logs.trace_spans + * permission-group-enforced: logs.cost + * + * A projection rather than a refusal, for the reason + * {@link resolveLogFieldProjection} gives — and applied here, in the use + * case, rather than in the v2 presenter, so the withholding cannot be lost + * by a second surface reading the same list. + */ + const projection = await resolveLogFieldProjection( + viewerUserId, + context.workspaceId, + context.workspaceOrganizationId + ) + + /** + * Refused after the workspace role check above and before the read below: + * `minCost`/`maxCost` bisect the very total the rows blank, and + * `sortBy=cost` leaks the same figure as a ranking — see + * {@link assertLogCostQueryAllowed}. + */ + assertLogCostQueryAllowed( + { + sortBy: input.sortBy, + minCost: input.filters.minCost, + maxCost: input.filters.maxCost, + }, + projection + ) + const folderScope = input.folderPaths ? await resolveLogFolderScope(context.workspaceId, input.folderPaths) : undefined @@ -66,7 +125,6 @@ export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ cursorKeys: input.cursorKeys, }) - const userId = principal.kind === 'personal_api_key' ? principal.userId : undefined /** * Job runs carry no materializable execution data on this surface: their * `execution_data` is a job envelope rather than a workflow trace, and @@ -76,28 +134,40 @@ export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ */ const items = needsMaterialization ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { - if (log.kind !== 'workflow' || !log.executionData) return { log } + const projectedLog = projectRowSpend(log, projection) + if (log.kind !== 'workflow' || !log.executionData) return { log: projectedLog } + const materialized = await materializeExecutionDataForDisplay( + log.executionData as Record, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + userId: viewerUserId, + } + ) return { - log, - executionData: await materializeExecutionDataForDisplay( - log.executionData as Record, - { - workspaceId: log.workspaceId, - workflowId: log.workflowId, - executionId: log.executionId, - userId, - } - ), + log: projectedLog, + executionData: projectExecutionData(materialized, projection) as Record< + string, + unknown + >, } }) - : data.map((log) => ({ log })) + : data.map((log) => ({ log: projectRowSpend(log, projection) })) + /** + * The render flags are narrowed rather than left for the presenter to + * re-check. `projectExecutionData` deletes the withheld payloads, but the + * presenter reads `executionData.traceSpans ?? []`, so a deleted array would + * come back as an empty one — present, and indistinguishable from a run + * whose spans aged out. Turning the flag off omits the field instead. + */ return { items, nextCursorKeys, includeFullDetails: input.includeFullDetails, - includeFinalOutput: input.includeFinalOutput, - includeTraceSpans: input.includeTraceSpans, + includeFinalOutput: input.includeFinalOutput && !projection.hideTraceSpans, + includeTraceSpans: input.includeTraceSpans && !projection.hideTraceSpans, } }, }) diff --git a/apps/sim/lib/logs/application/public-log-projection.test.ts b/apps/sim/lib/logs/application/public-log-projection.test.ts new file mode 100644 index 00000000000..fc5165a1e49 --- /dev/null +++ b/apps/sim/lib/logs/application/public-log-projection.test.ts @@ -0,0 +1,390 @@ +/** + * @vitest-environment node + * + * `logs.trace_spans` and `logs.cost` are PROJECTIONS, not gates — a group + * withholds those fields from the response rather than refusing the read, which + * is why `logOperations.list` and `logOperations.readDetail` correctly declare + * `capability: 'none'`. + * + * `/api/v2/logs` and `/api/v2/logs/{runId}` applied none of it: an enterprise + * member whose group hides spend or execution detail read both in full through + * a personal API key, while the same person was withheld them on the internal + * and v1 surfaces. These run the real use cases against the real + * `resolveLogFieldProjection` — the same helper `readLogDetail` and the v1 + * routes resolve their flags through — so they fail if this surface stops + * projecting. + */ +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getLogScope: vi.fn(), + getLog: vi.fn(), + listLogs: vi.fn(), + loadFolders: vi.fn(), + materialize: vi.fn(), + buildCostLedger: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/logs/public-queries', () => ({ + getPublicWorkflowLogScope: mocks.getLogScope, + getPublicWorkflowLog: mocks.getLog, + readPublicLogPage: mocks.listLogs, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mocks.loadFolders, +})) + +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionDataForDisplay: mocks.materialize, +})) + +vi.mock('@/lib/logs/cost-ledger', () => ({ + buildCostLedger: mocks.buildCostLedger, +})) + +vi.mock('@/lib/logs/snapshot-sanitizer', () => ({ + sanitizeExecutionSnapshotState: (state: unknown) => state, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { getPublicLog } from '@/lib/logs/application/get-public-log' +import { listPublicLogs } from '@/lib/logs/application/list-public-logs' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const WORKSPACE_ID = 'workspace-1' + +const workspaceContext = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const EXECUTION_DATA = { + finalOutput: { answer: 'a customer address' }, + workflowInput: { question: 'who?' }, + blockInput: { prompt: 'who?' }, + blockExecutions: [{ blockId: 'b1', cost: { total: 0.2 }, tokens: { total: 90 } }], + traceSpans: [ + { + id: 's1', + name: 'agent', + cost: { total: 0.5 }, + tokens: { total: 120 }, + children: [{ id: 's2', name: 'tool', cost: { total: 0.1 } }], + }, + ], +} + +const COST_LEDGER = { total: 0.75, items: [{ model: 'gpt-4', cost: 0.75 }] } + +const workflowLog = { + kind: 'workflow' as const, + id: 'log-1', + executionId: 'run-1', + workspaceId: WORKSPACE_ID, + workflowId: 'workflow-1', + workflowName: 'Support triage', + workflowFolderId: 'folder-1', + workflowUserId: 'owner-1', + workflowOwnerEmail: 'owner@example.com', + workflowState: { blocks: {} }, + costTotal: '0.75', + executionData: { pointer: true }, +} + +const jobLog = { + kind: 'job' as const, + executionId: 'job-1', + cost: { total: 0.4 }, + executionData: { pointer: true }, +} + +/** A person governed by a group; the group's own keys decide what is withheld. */ +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-9', + keyId: 'key-9', +} + +/** A workspace key has no user and therefore no group. */ +const workspacePrincipal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} + +function governedBy(overrides: Partial) { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + ...overrides, + }) +} + +function listInput(overrides: Record = {}) { + return { + workspaceId: WORKSPACE_ID, + filters: {}, + sortBy: 'startedAt' as const, + sortOrder: 'desc' as const, + cursorKeys: undefined, + limit: 50, + includeFullDetails: true, + includeFinalOutput: true, + includeTraceSpans: true, + includeJobRuns: false, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getLogScope.mockResolvedValue({ + executionId: 'run-1', + workspaceId: WORKSPACE_ID, + workflowId: 'workflow-1', + }) + mocks.getLog.mockResolvedValue(workflowLog) + mocks.listLogs.mockResolvedValue({ data: [workflowLog], nextCursorKeys: null }) + mocks.loadFolders.mockResolvedValue({ + idByPath: new Map([['/agents', 'folder-1']]), + pathById: new Map([['folder-1', '/agents']]), + }) + mocks.materialize.mockImplementation(async () => structuredClone(EXECUTION_DATA)) + mocks.buildCostLedger.mockResolvedValue(structuredClone(COST_LEDGER)) +}) + +describe('listPublicLogs field projection', () => { + it('blanks the run cost on the row when the group hides cost', async () => { + governedBy({ hideCostInfo: true }) + + const result = await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput(), + }) + + expect((result.items[0].log as { costTotal: string | null }).costTotal).toBeNull() + }) + + it('blanks a job run cost too, which the presenter reads from another column', async () => { + governedBy({ hideCostInfo: true }) + mocks.listLogs.mockResolvedValueOnce({ data: [jobLog], nextCursorKeys: null }) + + const result = await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput({ includeJobRuns: true }), + }) + + expect((result.items[0].log as { cost: unknown }).cost).toBeNull() + }) + + it('strips spend from the spans it still returns when only cost is hidden', async () => { + governedBy({ hideCostInfo: true }) + + const result = await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput(), + }) + const [span] = result.items[0].executionData?.traceSpans as Array> + + expect(span.name).toBe('agent') + expect(span).not.toHaveProperty('cost') + expect(span).not.toHaveProperty('tokens') + expect((span.children as Array>)[0]).not.toHaveProperty('cost') + }) + + /** + * The flags are what the presenter renders from. Deleting the payloads alone + * is not enough: it reads `executionData.traceSpans ?? []`, so a deleted + * array would come back as an empty one — present, and indistinguishable from + * a run whose spans aged out. + */ + it('withholds the execution payloads and turns off their render flags', async () => { + governedBy({ hideTraceSpans: true }) + + const result = await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput(), + }) + + expect(result.includeTraceSpans).toBe(false) + expect(result.includeFinalOutput).toBe(false) + expect(result.items[0].executionData).not.toHaveProperty('traceSpans') + expect(result.items[0].executionData).not.toHaveProperty('finalOutput') + expect(result.items[0].executionData).not.toHaveProperty('blockExecutions') + expect(result.items[0].executionData).not.toHaveProperty('workflowInput') + }) + + it('withholds nothing from a caller no group governs', async () => { + const result = await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput(), + }) + + expect((result.items[0].log as { costTotal: string | null }).costTotal).toBe('0.75') + expect(result.includeTraceSpans).toBe(true) + expect(result.items[0].executionData?.traceSpans).toHaveLength(1) + expect(result.items[0].executionData?.finalOutput).toEqual(EXECUTION_DATA.finalOutput) + }) + + /** + * A workspace API key authorizes as the workspace and represents no user, so + * there is no group to apply. Substituting the key's creator would govern + * every caller of a shared credential by a bystander's group. + */ + it('withholds nothing from a workspace API key and never resolves a group', async () => { + governedBy({ hideTraceSpans: true, hideCostInfo: true }) + + const result = await listPublicLogs.execute({ + principal: workspacePrincipal, + input: listInput(), + }) + + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + expect((result.items[0].log as { costTotal: string | null }).costTotal).toBe('0.75') + expect(result.includeTraceSpans).toBe(true) + expect(result.items[0].executionData?.traceSpans).toHaveLength(1) + }) +}) + +/** + * Withholding the figure is not enough on its own: `minCost`/`maxCost` bisect + * it, and `sortBy=cost` reads it as a ranking. Refused rather than dropped — + * dropping the clause answers a question nobody asked. + */ +describe('listPublicLogs cost-selective queries', () => { + it.each([ + ['a cost sort', { sortBy: 'cost' as const }], + ['a minCost filter', { filters: { minCost: 0.5 } }], + ['a maxCost filter', { filters: { maxCost: 0.5 } }], + ])('refuses %s for a group that withholds spend', async (_label, overrides) => { + governedBy({ hideCostInfo: true }) + + await expect( + listPublicLogs.execute({ principal: personalPrincipal, input: listInput(overrides) }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + message: "Execution cost is not available under your organization's permission group", + }) + + expect(mocks.listLogs).not.toHaveBeenCalled() + }) + + it('answers a cost sort for a group that withholds nothing', async () => { + await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput({ sortBy: 'cost' as const }), + }) + + expect(mocks.listLogs).toHaveBeenCalledWith(expect.objectContaining({ sortBy: 'cost' })) + }) + + it('answers a cost filter for a workspace API key', async () => { + governedBy({ hideCostInfo: true }) + + await listPublicLogs.execute({ + principal: workspacePrincipal, + input: listInput({ filters: { minCost: 0.5 } }), + }) + + expect(mocks.listLogs).toHaveBeenCalledWith( + expect.objectContaining({ filters: expect.objectContaining({ minCost: 0.5 }) }) + ) + }) + + it('leaves a non-spend filter alone for a group that withholds spend', async () => { + governedBy({ hideCostInfo: true }) + + await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput({ filters: { minDurationMs: 100 } }), + }) + + expect(mocks.listLogs).toHaveBeenCalled() + }) +}) + +describe('getPublicLog field projection', () => { + it('withholds the run total and the itemized ledger when the group hides cost', async () => { + governedBy({ hideCostInfo: true }) + + const result = await getPublicLog.execute({ + principal: personalPrincipal, + input: { runId: 'run-1' }, + }) + + expect(result.log.costTotal).toBeNull() + expect(result.costLedger).toBeNull() + expect( + (result.executionData.traceSpans as Array>)[0] + ).not.toHaveProperty('cost') + expect( + (result.executionData.blockExecutions as Array>)[0] + ).not.toHaveProperty('tokens') + }) + + it('withholds the execution payloads when the group hides trace spans', async () => { + governedBy({ hideTraceSpans: true }) + + const result = await getPublicLog.execute({ + principal: personalPrincipal, + input: { runId: 'run-1' }, + }) + + expect(result.executionData).not.toHaveProperty('traceSpans') + expect(result.executionData).not.toHaveProperty('finalOutput') + expect(result.executionData).not.toHaveProperty('workflowInput') + expect(result.executionData).not.toHaveProperty('blockExecutions') + }) + + it('withholds nothing from a caller no group governs', async () => { + const result = await getPublicLog.execute({ + principal: personalPrincipal, + input: { runId: 'run-1' }, + }) + + expect(result.log.costTotal).toBe('0.75') + expect(result.costLedger).toEqual(COST_LEDGER) + expect(result.executionData.finalOutput).toEqual(EXECUTION_DATA.finalOutput) + }) + + it('withholds nothing from a workspace API key', async () => { + governedBy({ hideTraceSpans: true, hideCostInfo: true }) + + const result = await getPublicLog.execute({ + principal: workspacePrincipal, + input: { runId: 'run-1' }, + }) + + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + expect(result.log.costTotal).toBe('0.75') + expect(result.costLedger).toEqual(COST_LEDGER) + expect(result.executionData.traceSpans).toHaveLength(1) + }) +}) From 1729b6956cc6322c642a863b0d1b43c830c28efd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 14:10:13 -0700 Subject: [PATCH 053/179] test(oauth): mock the organization lookup the new capability gate makes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The current-user credential gate resolves the acting user's organization to find the group that governs them. These two suites mock `@sim/db` with their own factory for the route's inline queries, so the unmocked lookup threw and every assertion saw a 500. Resolve no organization — the ungoverned case; the governed one is covered by credentials/application/capability-gate.test.ts. --- .../app/api/auth/oauth/connections/route.test.ts | 13 ++++++++++++- .../sim/app/api/auth/oauth/disconnect/route.test.ts | 9 +++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index 80db8ab7a39..1479986c6f0 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -8,11 +8,14 @@ import { createMockRequest, dbChainMock, dbChainMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, resetDbChainMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockParseProvider, mockDecodeJwt, mockEq } = vi.hoisted(() => ({ +const { mockParseProvider, mockDecodeJwt, mockEq, mockGetUserOrganization } = vi.hoisted(() => ({ + mockGetUserOrganization: vi.fn(), mockParseProvider: vi.fn(), mockDecodeJwt: vi.fn(), mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), @@ -25,6 +28,8 @@ vi.mock('@sim/db', () => ({ eq: mockEq, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + vi.mock('jose', () => ({ decodeJwt: mockDecodeJwt, })) @@ -33,10 +38,16 @@ vi.mock('@/lib/oauth/utils', () => ({ parseProvider: mockParseProvider, })) +vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, +})) + import { GET } from '@/app/api/auth/oauth/connections/route' describe('OAuth Connections API Route', () => { beforeEach(() => { + mockGetUserOrganization.mockResolvedValue(null) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(null) vi.clearAllMocks() resetDbChainMock() diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index e1dd3aa2eec..f5741f91b59 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -12,12 +12,17 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +const { mockGetUserOrganization } = vi.hoisted(() => ({ + mockGetUserOrganization: vi.fn(), +})) + vi.mock('@sim/audit', () => auditMock) import { POST } from '@/app/api/auth/oauth/disconnect/route' describe('OAuth Disconnect API Route', () => { beforeEach(() => { + mockGetUserOrganization.mockResolvedValue(null) vi.clearAllMocks() resetDbChainMock() dbChainMockFns.where.mockResolvedValue([]) @@ -95,6 +100,10 @@ describe('OAuth Disconnect API Route', () => { dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error')) + vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, + })) + const req = createMockRequest('POST', { provider: 'google', }) From 3f74686155c8b7a1eaad511bc3fe1fea4a3484ca Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 30 Aug 2026 14:16:51 -0700 Subject: [PATCH 054/179] fix(permission-groups): project run cost on the v2 workflow-runs listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This listing carried the same per-run total hideCostInfo withholds on every other log surface, and applied none of it — an enterprise member whose group hides spend read it in full through a personal API key. Projected in the use case rather than the presenter, so the withholding travels with the read. A workspace API key represents no user and therefore no group; it reads whole, and the key's creator is never substituted. --- .../application/list-workflow-runs.test.ts | 125 ++++++++++++++++++ .../application/list-workflow-runs.ts | 28 +++- 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/workflows/application/list-workflow-runs.test.ts diff --git a/apps/sim/lib/workflows/application/list-workflow-runs.test.ts b/apps/sim/lib/workflows/application/list-workflow-runs.test.ts new file mode 100644 index 00000000000..17a21a13841 --- /dev/null +++ b/apps/sim/lib/workflows/application/list-workflow-runs.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + * + * `logs.cost` is a PROJECTION, not a gate — a group withholds the figure from + * the response rather than refusing the read, which is why `workflows.listRuns` + * correctly declares `capability: 'none'`. + * + * This listing carries the same per-run total every other log surface withholds, + * and applied none of it: an enterprise member whose group hides spend read it + * in full here through a personal API key. These run the real use case against + * the real `resolveLogFieldProjection`, so they fail if this surface stops + * projecting. + */ +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkflowContext: vi.fn(), + listExecutions: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) + +vi.mock('@/lib/workflows/executor/execution-queries', () => ({ + listWorkflowExecutions: mocks.listExecutions, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { listWorkflowRuns } from '@/lib/workflows/application/list-workflow-runs' + +const WORKSPACE_ID = 'workspace-1' +const WORKFLOW_ID = 'workflow-1' + +const sessionPrincipal = { kind: 'session' as const, userId: 'user-1' } +const workspaceKeyPrincipal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} + +const input = { workflowId: WORKFLOW_ID, limit: 10, order: 'desc' as const } + +function runRow(costTotal: string | null) { + return { rowId: 1, executionId: 'run-1', startedAt: new Date(), status: 'success', costTotal } +} + +beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.resolveWorkflowContext.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + workflowId: WORKFLOW_ID, + }) + mocks.listExecutions.mockResolvedValue({ data: [runRow('0.75')], nextCursor: null }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(null) +}) + +describe('listWorkflowRuns cost projection', () => { + it('blanks the per-run total when the group hides cost', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + + const result = await listWorkflowRuns.execute({ principal: sessionPrincipal, input }) + + expect(result.data[0].costTotal).toBeNull() + }) + + it('returns the total when the group withholds nothing', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + }) + + const result = await listWorkflowRuns.execute({ principal: sessionPrincipal, input }) + + expect(result.data[0].costTotal).toBe('0.75') + }) + + it('returns the total when no group governs the caller', async () => { + const result = await listWorkflowRuns.execute({ principal: sessionPrincipal, input }) + + expect(result.data[0].costTotal).toBe('0.75') + }) + + it('withholds nothing from a workspace API key, and never resolves a group', async () => { + const result = await listWorkflowRuns.execute({ principal: workspaceKeyPrincipal, input }) + + expect(result.data[0].costTotal).toBe('0.75') + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/list-workflow-runs.ts b/apps/sim/lib/workflows/application/list-workflow-runs.ts index ab2b88b6a1c..5fd4a64b6cc 100644 --- a/apps/sim/lib/workflows/application/list-workflow-runs.ts +++ b/apps/sim/lib/workflows/application/list-workflow-runs.ts @@ -1,3 +1,5 @@ +import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' +import { resolveLogFieldProjection } from '@/lib/logs/log-projection' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -14,7 +16,22 @@ export const listWorkflowRuns = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.listRuns, resolveContext: ({ input }: { input: ListWorkflowRunsInput }) => resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), - async execute({ context, input }) { + async execute({ principal, context, input }) { + /** + * The per-run total this listing carries is the same figure `hideCostInfo` + * withholds on every other log surface, so it is projected here rather than + * in the presenter — the withholding travels with the read. + * + * `resolvePrincipalSubjectUserId` returns `undefined` for a workspace API + * key, which represents no user and therefore no group; the key's creator is + * never substituted. This listing publishes no cost sort or filter, so there + * is no query surface to refuse alongside the value. + */ + const projection = await resolveLogFieldProjection( + resolvePrincipalSubjectUserId(principal), + context.workspaceId, + context.workspaceOrganizationId + ) const result = await listWorkflowExecutions({ workflowId: context.workflowId, status: input.status, @@ -25,6 +42,13 @@ export const listWorkflowRuns = defineAuthorizedWorkflowUseCase({ cursor: input.cursor, order: input.order, }) - return { ...result, workflowId: context.workflowId, order: input.order } + return { + ...result, + data: projection.hideCostInfo + ? result.data.map((row) => ({ ...row, costTotal: null })) + : result.data, + workflowId: context.workflowId, + order: input.order, + } }, }) From 100f9196f68a566c9886c9bc28a6748de23063d1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:29:40 -0700 Subject: [PATCH 055/179] test(permission-groups): share the v1 ambient request-admission mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new v1 gate suites each carried a byte-identical 15-line block of subscription, rate-limiter, and rate-limit-context pass-through factories. None of them steers those seams — they only need admission to succeed — so the block moves to `packages/testing` beside the v2 equivalent. --- apps/sim/app/api/v1/capability-gate.test.ts | 22 +++------- apps/sim/app/api/v1/logs/projection.test.ts | 22 +++------- .../app/api/v1/tables/capability-gate.test.ts | 22 +++------- packages/testing/src/mocks/index.ts | 6 +++ packages/testing/src/mocks/v1-route.mock.ts | 42 +++++++++++++++++++ 5 files changed, 66 insertions(+), 48 deletions(-) create mode 100644 packages/testing/src/mocks/v1-route.mock.ts diff --git a/apps/sim/app/api/v1/capability-gate.test.ts b/apps/sim/app/api/v1/capability-gate.test.ts index 306a480d4d3..d7097285593 100644 --- a/apps/sim/app/api/v1/capability-gate.test.ts +++ b/apps/sim/app/api/v1/capability-gate.test.ts @@ -17,6 +17,9 @@ import { permissionGroupScopeMock, permissionGroupScopeMockFns, resetPermissionGroupScopeMock, + v1RateLimitContextModuleMock, + v1RateLimiterModuleMock, + v1SubscriptionModuleMock, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -52,22 +55,9 @@ vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, getWorkspaceBilledAccountUserId: vi.fn(async () => 'billed-user'), })) -vi.mock('@/lib/billing/core/subscription', () => ({ - getHighestPrioritySubscription: vi.fn(async () => null), -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitWithSubscription() { - return Promise.resolve({ allowed: true, remaining: 100, resetAt: new Date() }) - } - }, - getRateLimit: () => ({ maxTokens: 200 }), -})) -vi.mock('@/lib/api/server/rate-limit-context', () => ({ - buildRateLimitHeaders: () => ({}), - recordRateLimitSnapshot: vi.fn(), - getRateLimitHeaders: () => null, -})) +vi.mock('@/lib/billing/core/subscription', () => v1SubscriptionModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v1RateLimiterModuleMock) +vi.mock('@/lib/api/server/rate-limit-context', () => v1RateLimitContextModuleMock) vi.mock('@sim/audit', () => ({ AuditAction: {}, diff --git a/apps/sim/app/api/v1/logs/projection.test.ts b/apps/sim/app/api/v1/logs/projection.test.ts index 247b6ef6c6d..97d7aa4f30a 100644 --- a/apps/sim/app/api/v1/logs/projection.test.ts +++ b/apps/sim/app/api/v1/logs/projection.test.ts @@ -16,6 +16,9 @@ import { permissionGroupScopeMock, permissionGroupScopeMockFns, resetPermissionGroupScopeMock, + v1RateLimitContextModuleMock, + v1RateLimiterModuleMock, + v1SubscriptionModuleMock, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -46,22 +49,9 @@ vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceBilledAccountUserId: vi.fn(async () => 'billed-user'), getWorkspaceOrganizationId: vi.fn(async () => null), })) -vi.mock('@/lib/billing/core/subscription', () => ({ - getHighestPrioritySubscription: vi.fn(async () => null), -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitWithSubscription() { - return Promise.resolve({ allowed: true, remaining: 100, resetAt: new Date() }) - } - }, - getRateLimit: () => ({ maxTokens: 200 }), -})) -vi.mock('@/lib/api/server/rate-limit-context', () => ({ - buildRateLimitHeaders: () => ({}), - recordRateLimitSnapshot: vi.fn(), - getRateLimitHeaders: () => null, -})) +vi.mock('@/lib/billing/core/subscription', () => v1SubscriptionModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v1RateLimiterModuleMock) +vi.mock('@/lib/api/server/rate-limit-context', () => v1RateLimitContextModuleMock) vi.mock('@/lib/logs/public-queries', () => ({ listPublicWorkflowLogs: mockListPublicWorkflowLogs, getPublicWorkflowLog: mockGetPublicWorkflowLog, diff --git a/apps/sim/app/api/v1/tables/capability-gate.test.ts b/apps/sim/app/api/v1/tables/capability-gate.test.ts index e814e7c5343..cc27bfd9241 100644 --- a/apps/sim/app/api/v1/tables/capability-gate.test.ts +++ b/apps/sim/app/api/v1/tables/capability-gate.test.ts @@ -18,6 +18,9 @@ import { permissionGroupScopeMock, permissionGroupScopeMockFns, resetPermissionGroupScopeMock, + v1RateLimitContextModuleMock, + v1RateLimiterModuleMock, + v1SubscriptionModuleMock, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -44,22 +47,9 @@ vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceBilledAccountUserId: vi.fn(async () => 'billed-user'), getWorkspaceOrganizationId: vi.fn(async () => null), })) -vi.mock('@/lib/billing/core/subscription', () => ({ - getHighestPrioritySubscription: vi.fn(async () => null), -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitWithSubscription() { - return Promise.resolve({ allowed: true, remaining: 100, resetAt: new Date() }) - } - }, - getRateLimit: () => ({ maxTokens: 200 }), -})) -vi.mock('@/lib/api/server/rate-limit-context', () => ({ - buildRateLimitHeaders: () => ({}), - recordRateLimitSnapshot: vi.fn(), - getRateLimitHeaders: () => null, -})) +vi.mock('@/lib/billing/core/subscription', () => v1SubscriptionModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v1RateLimiterModuleMock) +vi.mock('@/lib/api/server/rate-limit-context', () => v1RateLimitContextModuleMock) vi.mock('@/lib/table', () => ({ getTableById: mockGetTableById, buildFilterClause: vi.fn(), diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 4f579ec9532..5edba2736a3 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -177,6 +177,12 @@ export { } from './terminal-console.mock' // URL mocks export { LOCALHOST_HOSTNAMES_MOCK, resetUrlsMock, urlsMock, urlsMockFns } from './urls.mock' +// v1 public API ambient request-admission mocks +export { + v1RateLimitContextModuleMock, + v1RateLimiterModuleMock, + v1SubscriptionModuleMock, +} from './v1-route.mock' export { MockV2ApiKeyUnauthenticatedError, V2_OPERATION_RATE_LIMIT_ALLOWED, diff --git a/packages/testing/src/mocks/v1-route.mock.ts b/packages/testing/src/mocks/v1-route.mock.ts new file mode 100644 index 00000000000..3714d6b2932 --- /dev/null +++ b/packages/testing/src/mocks/v1-route.mock.ts @@ -0,0 +1,42 @@ +import { vi } from 'vitest' + +/** + * Ambient request-admission modules for the v1 public API. + * + * `app/api/v1/middleware.ts` consults a subscription, a rate bucket, and the + * rate-limit snapshot context on the way into every handler. A suite whose + * subject is what the handler does — a capability gate, a field projection — + * needs all three to pass, and needs none of them to be controllable. These + * pass-through module mocks say exactly that, so the per-suite `vi.mock` block + * is left with only the seams the suite actually steers. + * + * Use `@/app/api/v1/middleware`'s own mocks instead when admission itself is + * the subject; these deliberately expose no handles to assert on. + * + * @example + * ```ts + * vi.mock('@/lib/billing/core/subscription', () => v1SubscriptionModuleMock) + * vi.mock('@/lib/core/rate-limiter', () => v1RateLimiterModuleMock) + * vi.mock('@/lib/api/server/rate-limit-context', () => v1RateLimitContextModuleMock) + * ``` + */ +export const v1SubscriptionModuleMock = { + getHighestPrioritySubscription: vi.fn(async () => null), +} + +/** Always-allowed bucket, so admission never decides a v1 test's outcome. */ +export const v1RateLimiterModuleMock = { + RateLimiter: class RateLimiter { + checkRateLimitWithSubscription() { + return Promise.resolve({ allowed: true, remaining: 100, resetAt: new Date() }) + } + }, + getRateLimit: () => ({ maxTokens: 200 }), +} + +/** Header plumbing the routes call unconditionally; it emits nothing to assert. */ +export const v1RateLimitContextModuleMock = { + buildRateLimitHeaders: () => ({}), + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: () => null, +} From dbf04d586a8a6ea598aa43fefb7a0708fbd89477 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:30:48 -0700 Subject: [PATCH 056/179] refactor(permission-groups): one builder for the internal capability-refusal 403 --- .../app/api/table/[tableId]/restore/route.ts | 3 +- apps/sim/app/api/table/import-async/route.ts | 3 +- apps/sim/app/api/table/import-csv/route.ts | 2 +- apps/sim/app/api/table/utils.ts | 21 +-------- apps/sim/app/api/v1/logs/route.ts | 46 ++++++------------- .../api/workspaces/[id]/environment/route.ts | 16 ++----- .../permission-groups/capability-response.ts | 31 +++++++++++++ 7 files changed, 55 insertions(+), 67 deletions(-) create mode 100644 apps/sim/lib/permission-groups/capability-response.ts diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts index a9dda129c3e..1d2f3e726c6 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -5,10 +5,11 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { getTableById } from '@/lib/table' import { performRestoreTable } from '@/lib/table/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { capabilityRefusalResponse, orchestrationOutcomeErrorResponse } from '@/app/api/table/utils' +import { orchestrationOutcomeErrorResponse } from '@/app/api/table/utils' const logger = createLogger('RestoreTableAPI') diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index ecb94687a8c..cf095e12084 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { captureServerEvent } from '@/lib/posthog/server' import { createTable, @@ -23,7 +24,7 @@ import { import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { capabilityRefusalResponse, orchestrationErrorResponse } from '@/app/api/table/utils' +import { orchestrationErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableImportAsync') diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index a4266de1c77..65a804e9f1a 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -10,12 +10,12 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { CSV_SYNC_MAX_FILE_SIZE_BYTES } from '@/lib/table' import { performCreateTableFromCsv } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { - capabilityRefusalResponse, csvProxyBodyCapResponse, multipartErrorResponse, orchestrationOutcomeErrorResponse, diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index fcaf0f97492..882e1459595 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -15,6 +15,7 @@ import { capabilityRefusal, isWorkspaceCapabilityWithheld, } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import type { ColumnDefinition, Filter, TableDefinition, TablePredicate } from '@/lib/table' import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table' import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' @@ -381,26 +382,6 @@ export async function checkAccess( return { ok: true, table } } -/** - * The 403 a raw table route returns when a permission group withholds a - * capability, as opposed to the caller's role being too low. - * - * One place so the sentence and the detail code cannot drift between the routes - * that gate through {@link checkAccess} and the few that assert inline because - * they name a workspace rather than a table. - */ -export function capabilityRefusalResponse( - capability: StaticPermissionGroupCapability -): NextResponse { - return NextResponse.json( - { - error: capabilityRefusal(capability), - details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, - }, - { status: 403 } - ) -} - export function accessError( result: Extract, requestId: string, diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index daa9dd5084c..866d1d89144 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -8,13 +8,13 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' import { assertLogCostQueryAllowed, - type LogFieldProjection, projectCostTotal, projectExecutionData, resolveLogFieldProjection, } from '@/lib/logs/log-projection' import { decodePublicLogCursor, listPublicWorkflowLogs } from '@/lib/logs/public-queries' import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { capabilityGovernedUserId, @@ -26,32 +26,6 @@ import { const logger = createLogger('V1LogsAPI') -/** - * Renders {@link assertLogCostQueryAllowed}'s refusal in the v1 `{ error, - * details }` body, or `null` when the query selects on nothing withheld. - * - * The assertion throws so every surface refuses in the same words; this route - * builds its own response rather than running inside a JSON route builder, so - * the throw is caught here instead of by a shared error projection. Caught - * narrowly on purpose — the handler's outer `catch` renders a 500, and letting - * a 403 fall into it would report an organization's policy as a Sim fault. - */ -function costQueryRefusal( - params: { minCost?: number | null; maxCost?: number | null }, - projection: LogFieldProjection -): NextResponse | null { - try { - assertLogCostQueryAllowed(params, projection) - return null - } catch (error) { - if (!(error instanceof PermissionGroupCapabilityError)) throw error - return NextResponse.json( - { error: error.message, details: { code: error.detailCode } }, - { status: 403 } - ) - } -} - export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -107,12 +81,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { * It runs after the workspace access check above, so the caller is a member * being told about their own group rather than an outsider handed an * organization-configuration oracle. + * + * The assertion throws so every surface refuses in the same words, and this + * route builds its own responses rather than running inside a JSON route + * builder, so the throw is caught here instead of by a shared error + * projection. Caught narrowly on purpose — the handler's outer `catch` + * renders a 500, and letting a 403 fall into it would report an + * organization's policy as a Sim fault. */ - const costRefusal = costQueryRefusal( - { minCost: params.minCost, maxCost: params.maxCost }, - projection - ) - if (costRefusal) return costRefusal + try { + assertLogCostQueryAllowed({ minCost: params.minCost, maxCost: params.maxCost }, projection) + } catch (error) { + if (!(error instanceof PermissionGroupCapabilityError)) throw error + return capabilityRefusalResponse(error.capability) + } logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, { userId, diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index 26ffb66237f..eadffd8ec92 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -26,8 +26,8 @@ import { getPersonalAndWorkspaceEnv, invalidateEffectiveDecryptedEnvCache, } from '@/lib/environment/utils' -import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' -import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions, @@ -58,16 +58,8 @@ async function secretsCapabilityRefusal( userId: string, workspaceId: string ): Promise { - try { - await assertWorkspaceCapability(userId, workspaceId, 'secrets.manage') - return null - } catch (error) { - if (!(error instanceof PermissionGroupCapabilityError)) throw error - return NextResponse.json( - { error: error.message, details: { code: error.detailCode } }, - { status: 403 } - ) - } + const withheld = await isWorkspaceCapabilityWithheld(userId, workspaceId, 'secrets.manage') + return withheld ? capabilityRefusalResponse('secrets.manage') : null } /** diff --git a/apps/sim/lib/permission-groups/capability-response.ts b/apps/sim/lib/permission-groups/capability-response.ts new file mode 100644 index 00000000000..a1d28471aaa --- /dev/null +++ b/apps/sim/lib/permission-groups/capability-response.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { + CAPABILITY_RULES, + capabilityRefusal, + type PermissionGroupCapability, +} from '@/lib/permission-groups/capabilities' + +/** + * The 403 a raw route returns when a permission group withholds a capability, + * as opposed to the caller's role being too low. + * + * One builder so the sentence and the detail code cannot drift between the + * routes that gate through a shared access check, the ones that assert inline, + * and the ones that catch {@link PermissionGroupCapabilityError} and render its + * capability. The detail code is read off the rule rather than spelled out at + * the call site — four capabilities carry a more specific one, and a literal + * would report them as the generic block. + * + * The v1 public API renders its own `{ error: { code, message } }` envelope and + * is deliberately not converged here; see `resolveCapabilityRefusal` in + * `app/api/v1/middleware.ts`. + */ +export function capabilityRefusalResponse(capability: PermissionGroupCapability): NextResponse { + return NextResponse.json( + { + error: capabilityRefusal(capability), + details: { code: CAPABILITY_RULES[capability].detailCode }, + }, + { status: 403 } + ) +} From f55c2b4457b7b83e2c6c9317e150a9c60e6a0688 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:32:38 -0700 Subject: [PATCH 057/179] test(permission-groups): drop the duplicated v1 log-projection gate tests, tidy the late patches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `v1/capability-gate.test.ts` re-asserted the `/api/v1/logs` cost projection that `v1/logs/projection.test.ts` already covers on the same route through the same `projectCostTotal` call, and more strongly — the workspace-key direction there pins spans as well as cost. The gate suite now says so in its header instead. The oauth patches arranged their mocks above `vi.clearAllMocks()` and, in the disconnect suite, declared a `vi.mock` from inside a test body; both now read like the rest of their file. The `integrations.manage` block repeated its own beforeEach in three of four tests. --- .../api/auth/oauth/connections/route.test.ts | 6 +- .../api/auth/oauth/credentials/route.test.ts | 89 +++++++------------ .../api/auth/oauth/disconnect/route.test.ts | 10 +-- apps/sim/app/api/v1/capability-gate.test.ts | 56 +----------- .../app/api/v1/tables/capability-gate.test.ts | 2 +- 5 files changed, 45 insertions(+), 118 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index 1479986c6f0..282cd6b7d46 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -9,8 +9,8 @@ import { dbChainMock, dbChainMockFns, permissionGroupScopeMock, - permissionGroupScopeMockFns, resetDbChainMock, + resetPermissionGroupScopeMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -46,10 +46,10 @@ import { GET } from '@/app/api/auth/oauth/connections/route' describe('OAuth Connections API Route', () => { beforeEach(() => { - mockGetUserOrganization.mockResolvedValue(null) - permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(null) vi.clearAllMocks() resetDbChainMock() + resetPermissionGroupScopeMock() + mockGetUserOrganization.mockResolvedValue(null) mockParseProvider.mockImplementation((providerId: string) => ({ baseProvider: providerId.split('-')[0] || providerId, diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index 50b57285ab7..783969bfe64 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -159,17 +159,34 @@ describe('OAuth Credentials API Route', () => { hideIntegrationsTab: true, } - beforeEach(() => { - /** - * `mockResolvedValue`, not `...Once`: the missing-provider test above - * returns 400 before authentication runs, so its queued value is never - * consumed and every later `...Once` in this file reads one test stale. - */ + /** + * `mockResolvedValue`, not `...Once`: the missing-provider test above + * returns 400 before authentication runs, so its queued value is never + * consumed and every later `...Once` in this file reads one test stale. + */ + function authenticatedAs(authType: 'session' | 'internal_jwt') { hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-123', - authType: 'session', + authType, }) + } + + function governedBy(config: typeof DEFAULT_PERMISSION_GROUP_CONFIG) { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(config) + } + + function callWithWorkspace() { + return GET( + createMockRequestWithQuery( + 'GET', + '?provider=google-email&workspaceId=3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' + ) + ) + } + + beforeEach(() => { + authenticatedAs('session') permissionsMockFns.mockCheckWorkspaceAccess.mockResolvedValue({ exists: true, hasAccess: true, @@ -179,21 +196,9 @@ describe('OAuth Credentials API Route', () => { }) it('refuses a session whose group withholds Integrations', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-123', - authType: 'session', - }) - permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue( - INTEGRATIONS_WITHHELD - ) + governedBy(INTEGRATIONS_WITHHELD) - const response = await GET( - createMockRequestWithQuery( - 'GET', - '?provider=google-email&workspaceId=3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' - ) - ) + const response = await callWithWorkspace() expect(response.status).toBe(403) await expect(response.json()).resolves.toEqual({ @@ -206,44 +211,21 @@ describe('OAuth Credentials API Route', () => { * by a group that describes what a person may open. */ it('does not refuse the executor under the same withholding group', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-123', - authType: 'internal_jwt', - }) - permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue( - INTEGRATIONS_WITHHELD - ) + authenticatedAs('internal_jwt') + governedBy(INTEGRATIONS_WITHHELD) dbChainMockFns.where.mockResolvedValue([]) - const response = await GET( - createMockRequestWithQuery( - 'GET', - '?provider=google-email&workspaceId=3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' - ) - ) + const response = await callWithWorkspace() expect(response.status).toBe(200) expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() }) it('allows a session whose group leaves Integrations alone', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-123', - authType: 'session', - }) - permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue( - DEFAULT_PERMISSION_GROUP_CONFIG - ) + governedBy(DEFAULT_PERMISSION_GROUP_CONFIG) dbChainMockFns.where.mockResolvedValue([]) - const response = await GET( - createMockRequestWithQuery( - 'GET', - '?provider=google-email&workspaceId=3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' - ) - ) + const response = await callWithWorkspace() expect(response.status).toBe(200) }) @@ -254,14 +236,7 @@ describe('OAuth Credentials API Route', () => { * governs it. */ it('refuses a session credentialId lookup using the credential own workspace', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-123', - authType: 'session', - }) - permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue( - INTEGRATIONS_WITHHELD - ) + governedBy(INTEGRATIONS_WITHHELD) dbChainMockFns.limit.mockResolvedValueOnce([ { id: 'credential-1', diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index f5741f91b59..f51bf6b2b19 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -18,14 +18,18 @@ const { mockGetUserOrganization } = vi.hoisted(() => ({ vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, +})) + import { POST } from '@/app/api/auth/oauth/disconnect/route' describe('OAuth Disconnect API Route', () => { beforeEach(() => { - mockGetUserOrganization.mockResolvedValue(null) vi.clearAllMocks() resetDbChainMock() dbChainMockFns.where.mockResolvedValue([]) + mockGetUserOrganization.mockResolvedValue(null) }) it('should disconnect provider successfully', async () => { @@ -100,10 +104,6 @@ describe('OAuth Disconnect API Route', () => { dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error')) - vi.mock('@/lib/billing/organizations/membership', () => ({ - getUserOrganization: mockGetUserOrganization, - })) - const req = createMockRequest('POST', { provider: 'google', }) diff --git a/apps/sim/app/api/v1/capability-gate.test.ts b/apps/sim/app/api/v1/capability-gate.test.ts index d7097285593..202e32c0e02 100644 --- a/apps/sim/app/api/v1/capability-gate.test.ts +++ b/apps/sim/app/api/v1/capability-gate.test.ts @@ -12,6 +12,10 @@ * the rate bucket, the workspace role and the governing group config are * mocked — so they fail if the capability a route declares is dropped, is * checked before the role check, or starts applying to a workspace API key. + * + * Scope: capability GATES only. `logs.cost` and `logs.trace_spans` are + * projections rather than gates — a route declaring `'none'` withholds fields + * instead of refusing — and are pinned in `app/api/v1/logs/projection.test.ts`. */ import { permissionGroupScopeMock, @@ -294,58 +298,6 @@ describe('v1 permission-group capability gate', () => { }) }) - /** - * `logs.cost` and `logs.trace_spans` are projections rather than gates: the - * route declares `'none'` and withholds fields instead. The substitution shows - * up here as silently blanked data rather than a 403 — a shared workspace key - * would report `cost: null` on every run because one bystander's group hides - * spend. - */ - describe('log field projection follows the caller, not the key creator', () => { - const LOG_ROW = { - id: 'log-1', - workflowId: 'wf-1', - workspaceId: WORKSPACE_ID, - executionId: 'exec-1', - deploymentVersionId: null, - level: 'info', - trigger: 'api', - startedAt: new Date('2026-01-01T00:00:00.000Z'), - endedAt: new Date('2026-01-01T00:00:01.000Z'), - totalDurationMs: 1000, - costTotal: '1.25', - files: null, - executionData: null, - workflowName: 'wf', - workflowDescription: null, - } - - beforeEach(() => { - mockListPublicWorkflowLogs.mockResolvedValue({ data: [LOG_ROW], nextCursor: null }) - }) - - it('withholds cost from a personal key whose group hides spend', async () => { - governedBy({ hideCostInfo: true }) - - const response = await getLogs(get(`/api/v1/logs?workspaceId=${WORKSPACE_ID}`)) - const body = await response.json() - - expect(response.status).toBe(200) - expect(body.data[0].cost).toBeNull() - }) - - it('still reports cost to a workspace key whose creator is in that group', async () => { - mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) - governedBy({ hideCostInfo: true }) - - const response = await getLogs(get(`/api/v1/logs?workspaceId=${WORKSPACE_ID}`)) - const body = await response.json() - - expect(response.status).toBe(200) - expect(body.data[0].cost).toEqual({ total: 1.25 }) - }) - }) - it('refuses on role before capability, so a non-member learns nothing about the group', async () => { mockGetUserEntityPermissions.mockResolvedValue(null) governedBy({ hideTablesTab: true }) diff --git a/apps/sim/app/api/v1/tables/capability-gate.test.ts b/apps/sim/app/api/v1/tables/capability-gate.test.ts index cc27bfd9241..30608d4b114 100644 --- a/apps/sim/app/api/v1/tables/capability-gate.test.ts +++ b/apps/sim/app/api/v1/tables/capability-gate.test.ts @@ -134,7 +134,7 @@ beforeEach(() => { mockGetTableById.mockResolvedValue(TABLE) }) -describe('tables.use on /api/v1/tables/[tableId]', () => { +describe('tables.use gate on /api/v1/tables/[tableId]', () => { it('lets a workspace API key through even when its CREATOR is denied Tables', async () => { mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) governedBy({ hideTablesTab: true }) From 26b9eae61b549f0879edacf34ad6a860e4587793 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:32:43 -0700 Subject: [PATCH 058/179] docs(skills): trim the permission-group-item skills to their load-bearing facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both skills were re-audited against the final code and are accurate, but they grew through three correction rounds into narrative. Cut history, duplicated explanations of the same invariant, and prose walkthroughs of code; kept every invariant, and moved enumerations (helper choice, principal rules, audits, forbidden graph edges) into tables. add-* now owns the procedure and the rationale; validate-* owns the checklist and cross-references it rather than restating it. Corrected against the code while trimming: the enforcement audit's success line reads 315 operations, not 287, and the audit's completeness half — the exported `*Operations` registry cross-check and the `defineOperation` family match — was missing from add-*. --- .../skills/add-permission-group-item/SKILL.md | 279 ++++++------------ .../validate-permission-group-item/SKILL.md | 162 +++++----- 2 files changed, 164 insertions(+), 277 deletions(-) diff --git a/.agents/skills/add-permission-group-item/SKILL.md b/.agents/skills/add-permission-group-item/SKILL.md index ce07df9e113..fd928b0e5e1 100644 --- a/.agents/skills/add-permission-group-item/SKILL.md +++ b/.agents/skills/add-permission-group-item/SKILL.md @@ -6,51 +6,45 @@ argument-hint: # Add Permission Group Item Skill -You are adding one governed item to the enterprise permission-group system: something an organization admin can withhold from a cohort of members. The system is registry-driven — one entry in `apps/sim/lib/permission-groups/fields.ts` produces the write schema, the read schema, the `PermissionGroupConfig` type, the defaults, the tolerant parser, and (for a boolean) the admin editor row. +You are adding one governed item an organization admin can withhold from a cohort of members. One entry in `apps/sim/lib/permission-groups/fields.ts` produces the write schema, the read schema, the `PermissionGroupConfig` type, the defaults, the tolerant parser, and (for a boolean) the admin editor row. -**What the registry does not produce is enforcement.** Twelve keys once shipped with an admin checkbox, a hint describing what they restrict, and no server check at all — an organization that ticked `hideCopilot` believed it had withheld a capability while every API route still answered. That failure is the reason for the `enforcement` field, the `capability` field on every operation, and `scripts/check-permission-group-enforcement.ts`. Your job is not done when the key parses; it is done when something refuses. +**The registry does not produce enforcement.** Twelve keys once shipped with a checkbox, a hint, and no server check — an organization that ticked `hideCopilot` believed it had withheld a capability while every route still answered. Hence the `enforcement` field, the required `capability` field on every operation, and `scripts/check-permission-group-enforcement.ts`. You are done when something *refuses*, not when the key parses. ## Read the system first -Read these completely before editing. Do not infer their shape from this document. +- `lib/permission-groups/fields.ts` — registry, three field builders, `permissionGroupConfigSchema`, `tolerantArray`, `parsePermissionGroupConfig`. There is **no `types.ts`** (folded in here); the DB constraint maps live in `constraints.ts` +- `lib/permission-groups/capabilities.ts` — `CAPABILITY_IDS`, `CAPABILITY_RULES`, `capabilityRefusal`, `refuseCapability`, the static/parameterized split +- `lib/permission-groups/capability-assertions.ts` — the sanctioned assertion API; re-exports `capabilityRefusal` +- `lib/permission-groups/resolve.server.ts` — `resolveWorkspaceGroup`, `resolveVerifiedUserAccessControlContext`, `getUserPermissionConfig`, `getUserPermissionConfigForOrganization`, `mergeEnvAllowlist`. `ee/access-control/utils/permission-check.ts` re-exports it and keeps the executor gates +- `lib/permission-groups/config-scope.server.ts` (`resolvePermissionGroupConfig`, the per-request memo every assertion resolves through) and `request-scope.server.ts` (`withPermissionGroupScope`, deliberately import-free because `withRouteHandler` imports it) +- `lib/core/application/workspace-operation.ts` and `workspace-authorization.ts` — the required `capability` field, and the funnel +- `scripts/check-permission-group-enforcement.ts`, `check-application-graph.ts`, `check-capability-subject.ts` -- `apps/sim/lib/permission-groups/fields.ts` — the registry, the three field builders, `permissionGroupConfigSchema`, the tolerant parser. There is **no `types.ts`**; it was folded into this file, and the two DB constraint maps live in `constraints.ts` -- `apps/sim/lib/permission-groups/capabilities.ts` — `CAPABILITY_IDS`, `CAPABILITY_RULES`, `capabilityRefusal`, `refuseCapability`, the static/parameterized split -- `apps/sim/lib/permission-groups/capability-assertions.ts` — the sanctioned way to ask whether a group withholds something (and it re-exports `capabilityRefusal`) -- `apps/sim/lib/permission-groups/resolve.server.ts` — group resolution: `resolveWorkspaceGroup`, `resolveVerifiedUserAccessControlContext`, `getUserPermissionConfig`, `getUserPermissionConfigForOrganization`, `mergeEnvAllowlist`. This moved out of `ee/`; `ee/access-control/utils/permission-check.ts` now only re-exports it and keeps the executor gates -- `apps/sim/lib/permission-groups/config-scope.server.ts` — `resolvePermissionGroupConfig`, the per-request memo every assertion resolves through -- `apps/sim/lib/permission-groups/request-scope.server.ts` — the light half of the scope: `withPermissionGroupScope` and the store, deliberately free of runtime imports because `withRouteHandler` imports it -- `apps/sim/lib/core/application/workspace-operation.ts` — `capability` is a **required** field on `defineWorkspaceOperation`, typed `StaticPermissionGroupCapability | 'none'`, with a definition-time guard -- `apps/sim/lib/core/application/workspace-authorization.ts` — where the funnel enforces, and who passes through -- `scripts/check-permission-group-enforcement.ts`, `scripts/check-application-graph.ts`, `scripts/check-capability-subject.ts` — the three audits you have to satisfy, all inside `check:audits` +(Paths are under `apps/sim/` unless noted.) ## Step 0: Decide what kind of thing it is -Three questions, in order. Answer all three before writing any code. - -**What shape is the value?** - | Kind | Builder | Default | Semantics | |---|---|---|---| -| Boolean restriction | `booleanRestriction(enforcement, feature)` | `false` | `true` withholds. Named `hideX` / `disableX`, never `allowX` | -| Allowlist | `allowlist(item, enforcement, { limited, empty })` | `null` | `null` allows everything; a set names the only permitted members; `[]` permits nothing | +| Boolean restriction | `booleanRestriction(enforcement, feature)` | `false` | `true` withholds. Name it `hideX` / `disableX`, never `allowX` | +| Allowlist | `allowlist(item, enforcement, { limited, empty })` | `null` | `null` allows everything; a list names the only permitted members; `[]` permits **none** | | Denylist | `denylist(item, enforcement, phrasing)` | `[]` | Empty permits everything; members are refused | -Choose an allowlist when the safe posture is "only what the admin named" and the member set is enumerable and stable (auth modes, connectors, model providers). Choose a denylist when the safe posture is "everything except what the admin named" and the member set is open-ended (individual tool ids, individual models — an allowlist over a thousand tools is unmaintainable and grows a hole every time a tool ships). - -**Which mechanism refuses?** This is the `enforcement` value and it is a claim the audit checks. +Allowlist when the safe posture is "only what the admin named" and the member set is enumerable (auth modes, connectors, model providers). Denylist when it is "everything except" and the set is open-ended (tool ids, models — an allowlist over a thousand tools grows a hole every time a tool ships). -- `'capability'` — an operation declares a capability whose rule reads the key, so the authorization funnel refuses before the use case runs. This is the default answer for anything reachable through an application operation. -- `'executor'` — read per block, tool, or model at execution time by `assertPermissionsAllowed` in `apps/sim/ee/access-control/utils/permission-check.ts`. It governs what a *run* may do, which no operation-level gate can express: one API call can execute fifty blocks. `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, and `deniedTools` are the four that live here. -- `'ui-only'` — the key hides a surface without withholding it, so a caller who skips the UI still reaches the API. **Almost never the right answer.** Choose it only when you can say, in the `enforcement` comment, why a determined caller reaching the data anyway is acceptable. Nothing currently ships as `ui-only` — the union member exists and has no user; if yours is the first, expect that to be questioned in review. +**Which mechanism refuses?** The `enforcement` value is a claim the audit checks. -**Is the decision knowable from the config alone?** A rule that needs a value only the request carries — an auth mode, a connector id, a file id — is *parameterized*, and parameterized rules cannot be declared on an operation. See Step 3. +| Value | Meaning | +|---|---| +| `'capability'` | An operation declares a capability whose rule reads the key; the funnel refuses before the use case runs. Default answer for anything reachable through an application operation | +| `'executor'` | Read per block/tool/model at run time by `assertPermissionsAllowed` in `ee/access-control/utils/permission-check.ts`. Governs what a *run* may do, which no operation gate can express (one API call executes fifty blocks). Only `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, `deniedTools` live here | +| `'ui-only'` | Hides a surface without withholding it. **Almost never right** — nothing ships as `ui-only`. Justify in the `enforcement` comment why a determined caller reaching the data is acceptable, and expect review to question it | -**Is it a gate at all, or a projection?** A key that withholds *fields from a response* rather than the response itself is a projection, not a gate. `hideTraceSpans` and `hideCostInfo` work this way: every logs route declares `capability: 'none'` and strips fields instead, because refusing the read would withhold the status and the error message too, which is not what an organization restricting execution detail or spend visibility asked for. The projections live in one place — `apps/sim/lib/logs/log-projection.ts`, which owns `resolveLogFieldProjection`, `projectExecutionData` and `projectCostTotal` and carries the `permission-group-enforced:` annotations for both capabilities. If your key is a projection, add it there rather than to an operation; two copies of a redaction rule is how one of them stops redacting. +**Is the decision knowable from the config alone?** A rule needing a request value (an auth mode, a connector id) is *parameterized* and cannot be declared on an operation — see Step 3. -## Step 1: Add the field entry — at the end +**Is it a gate or a projection?** A key that withholds *fields from a response* rather than the response is a projection. `hideTraceSpans` and `hideCostInfo` work this way: the logs routes declare `capability: 'none'` and strip fields, because refusing the read would withhold the status and error message too. Projections have one owner — `lib/logs/log-projection.ts` (`resolveLogFieldProjection`, `projectExecutionData`, `projectCostTotal`), carrying the `permission-group-enforced:` annotations. Add yours there; two copies of a redaction rule is how one of them stops redacting. Corollary: refuse the query that *selects on* a withheld field — otherwise the projection is a filter oracle. -Append one entry to `PERMISSION_GROUP_FIELDS`. **Append, never insert.** +## Step 1: Append the field entry — never insert ```ts disableWidgetSharing: booleanRestriction('capability', { @@ -61,56 +55,42 @@ Append one entry to `PERMISSION_GROUP_FIELDS`. **Append, never insert.** }), ``` -The object in the second argument is the field's `feature` property, typed `PlatformFeatureMeta`. `PLATFORM_FEATURES` spreads it and appends `configKey`, so `id`, `label`, `category` and `hint` are exactly what the editor renders. - -Declaration order here is the key order of `PermissionGroupConfig`, of both zod schemas, and of every config JSON that crosses the API boundary. `fields.test.ts` pins that order with a contract test comparing key order, so a moved key fails the suite. The group editor in `apps/sim/ee/access-control/components/group-detail.tsx` also runs its dirty check by comparing stringified configs, so moving an existing key makes every open editor read as having unsaved changes. The registry already carries a TSDoc note on `disablePersonalApiKeys` saying exactly this — extend the tail, do not tidy the middle. - -Three things to get right in the entry itself: +The second argument is the field's `feature` (`PlatformFeatureMeta`); `PLATFORM_FEATURES` spreads it and appends `configKey`, so those four values are what the editor renders. `PLATFORM_FEATURES` is *derived* from the registry in `features.ts`, so a boolean key cannot reach the config without reaching the editor. -**The default must be the permissive value.** Every config row already stored in the `permission_group.config` column predates your key. `parsePermissionGroupConfig` fills the gap from the field's default, and the create/update route merges a partial write over the stored config. If your default is the restrictive value, adding the key silently applies a new restriction to every existing group in every enterprise organization, with nothing in the admin UI having changed. This is why the boolean builder hardcodes `false`, the allowlist `null`, and the denylist `[]` — but it is also why a *new* key must be phrased so that the permissive value is falsy. `disableWidgetSharing: false` is correct; a hypothetical `requireWidgetApproval` whose safe default is `true` cannot use `booleanRestriction` and needs its meaning inverted before it can. - -**The admin checkbox is inverted.** `group-detail.tsx` renders `checked={!editingConfig[feature.configKey]}` — ticked means *allowed*. A key named `allowX` would render backwards. - -**The hint must describe revoked access, not a hidden surface.** Every `enforcement: 'capability'` key refuses at the API. A hint reading "Hide the Tables module from the sidebar" tells an admin they are tidying a nav bar when they are revoking a module — an admin ticking the box is revoking access, not hiding a link. The same string is read a second time by `getActivePermissionGroupRestrictions` in `features.ts` as the prose explaining an *active* restriction, where "hide" is simply false; that prose reaches users through the Copilot workspace VFS and the enterprise platform context. Write what the member can no longer do: "Revoke the Tables module. Members cannot read or write any table." That wording drift is not hypothetical — twelve keys carried "hide from the sidebar" hints for a release after they started returning 403. `PlatformFeatureMeta.hint` carries the rule in its own TSDoc; do not weaken it. - -**The category must be in `PLATFORM_CATEGORY_ORDER`.** That constant lives in `apps/sim/lib/permission-groups/features.ts` and currently reads `Modules`, `Knowledge Base`, `Tables`, `Files`, `Deployment`, `Tools`, `Logs`, `Collaboration`, `Credentials & Access`. An unlisted category still renders, but at the end, after every ordered section. The names describe what a group withholds, not where a link used to be hidden — do not reintroduce a surface-shaped section like "Sidebar" or "Settings Tabs". - -Note that `PLATFORM_FEATURES` — the array the editor renders — is *derived* from the registry in `features.ts`, not hand-listed. A boolean key cannot reach the config without reaching the editor, which is deliberate: an unrendered key is one an admin can neither set nor see. +- **Declaration order is the wire order** of `PermissionGroupConfig`, both zod schemas, and every config JSON crossing the API. `fields.test.ts` pins it with a key-order contract test, and `ee/access-control/components/group-detail.tsx` dirty-checks by comparing stringified configs — a moved key fails the suite *and* makes every open editor read as unsaved. Extend the tail; do not tidy the middle. +- **The default must be the permissive value.** Every stored `permission_group.config` row predates your key; `parsePermissionGroupConfig` fills the gap from the default and the update route merges a partial write over the stored config, so a restrictive default silently applies a new restriction to every existing group in every enterprise org. The builders hardcode `false` / `null` / `[]`, so a new key must be *phrased* so the permissive value is falsy: a `requireWidgetApproval` whose safe default is `true` must be inverted before it can use `booleanRestriction`. +- **The checkbox is inverted.** `group-detail.tsx` renders `checked={!editingConfig[feature.configKey]}` — ticked means *allowed*, so an `allowX` name renders backwards. +- **The hint must describe access withheld, never a surface hidden.** A `'capability'` key refuses at the API; "Hide the Tables module from the sidebar" tells an admin they are tidying a nav bar while they revoke a module. The same string is read again by `getActivePermissionGroupRestrictions` in `features.ts` as the prose for an *active* restriction — reaching users through the Copilot workspace VFS and the enterprise platform context — where "hide" is simply false. Write "Revoke the Tables module. Members cannot read or write any table." `PlatformFeatureMeta.hint` carries the rule in its TSDoc. +- **The category must be in `PLATFORM_CATEGORY_ORDER`** (`features.ts`): `Modules`, `Knowledge Base`, `Tables`, `Files`, `Deployment`, `Tools`, `Logs`, `Collaboration`, `Credentials & Access`. An unlisted category renders last. Categories name what is withheld — no surface-shaped section like "Sidebar". ## Step 2: Only booleans get an admin UI for free -`PLATFORM_FEATURES` filters on `field.kind === 'boolean-restriction'`. An allowlist or denylist you add renders **nothing** — the key exists, the API accepts it, and no admin can ever set it. +`PLATFORM_FEATURES` filters on `field.kind === 'boolean-restriction'`. An allowlist or denylist renders **nothing** — the key exists, the API accepts it, no admin can set it. -Nested pickers hang off the `featureExtras` map in `group-detail.tsx`, keyed by the **feature id of the boolean it nests under**, not by the allowlist's own config key: +Nested pickers hang off the `featureExtras` map in `group-detail.tsx`, keyed by the **feature id of the boolean it nests under**, not the allowlist's own config key: ```ts const featureExtras: Partial> = { - 'hide-knowledge-base': ( - - ), + 'hide-knowledge-base': , } ``` -Copy the shape of `setKnowledgeConnectors` for your setter. Two behaviors are load-bearing and easy to drop: +Copy `setKnowledgeConnectors`. Two load-bearing behaviors: -- **Refuse an empty selection** (`if (values.length === 0) return`). An emptied allowlist denies every member while the parent checkbox still reads as allowed, which is an admin footgun with no visible cause. Withholding the whole thing is what the parent checkbox is for. -- **Collapse "everything selected" back to `null`** (`values.length === ALL.length ? null : values`). Storing the full set works, but it freezes the allowlist at today's members — a connector added next release would be denied by a group that had selected "all". +- **Refuse an empty selection** (`if (values.length === 0) return`) — an emptied allowlist denies everyone while the parent checkbox still reads as allowed. Withholding the whole thing is what the parent is for. +- **Collapse "all selected" back to `null`** (`values.length === ALL.length ? null : values`) — storing the full set freezes the allowlist at today's members. -Choose the parent deliberately. `allowedKnowledgeConnectors` nests under `hide-knowledge-base` rather than under `disable-knowledge-base-creation`, because a connector attaches to an *existing* knowledge base: hanging it off creation would dim the picker for exactly the cohort it was written for. +Choose the parent deliberately: `allowedKnowledgeConnectors` nests under `hide-knowledge-base`, not `disable-knowledge-base-creation`, because a connector attaches to an *existing* KB — nesting under creation would dim the picker for exactly the cohort it serves. ## Step 3: Add the capability id and rule -Skip this step only if `enforcement` is `'executor'` or `'ui-only'`. For `'capability'`, add the id to `CAPABILITY_IDS` and an entry to `CAPABILITY_RULES` in `apps/sim/lib/permission-groups/capabilities.ts`. `CAPABILITY_RULES` uses `satisfies { readonly [K in PermissionGroupCapability]: CapabilityRule }`, so adding an id fails to compile until the rule exists. +Skip only for `'executor'` / `'ui-only'`. Add the id to `CAPABILITY_IDS` and the rule to `CAPABILITY_RULES` in `capabilities.ts`, which uses `satisfies { readonly [K in PermissionGroupCapability]: CapabilityRule }` so a new id fails to compile until its rule exists. -Capability ids are **domain-shaped** (`tables.create`), while config keys are **surface-shaped** (`disableTableCreation`). That is intentional: an operation names what it does, the config names what an admin sees, and `CAPABILITY_RULES` is the only place the two vocabularies meet. +**Never replace that `satisfies` with a type annotation.** Annotating widens every entry to `CapabilityRule`, at which point `StaticPermissionGroupCapability` — derived by filtering the object's own entries for `kind: 'static'` — resolves to **`never`**: no operation can declare any capability, the type system goes quiet about capabilities entirely, and nothing at runtime looks wrong. `AssertsStaticCapabilityResolves` at the bottom of the file exists to catch it. Same reasoning for any of these registries. -A static rule: +Capability ids are **domain-shaped** (`tables.create`); config keys are **surface-shaped** (`disableTableCreation`). `CAPABILITY_RULES` is the only place the two vocabularies meet. ```ts 'widgets.share': { @@ -122,48 +102,17 @@ A static rule: }, ``` -`configKeys` is what the audit reads to prove your key is enforced — it must list every key `deniedBy` actually reads. `describe` is the subject of one shared sentence, `" is not available under your organization's permission group"`, so write it as a singular noun or gerund phrase that agrees with the verb. Two functions build that sentence and there is no third: `refuseCapability(capability)` throws it as a `PermissionGroupCapabilityError`, and `capabilityRefusal(capability)` returns it as a string for a raw route rendering its own response body. Both are **defined in `capabilities.ts`**; `capability-assertions.ts` re-exports `capabilityRefusal` so a call site that gates inline reaches the sentence and the assertions through one module. Never write the sentence out at a call site. - -Use `'PERMISSION_GROUP_CAPABILITY_BLOCKED'` for `detailCode` unless a caller can act differently on this specific refusal. The set in `apps/sim/lib/core/application/forbidden.ts` is closed **over remedies, not over causes** — a new code is warranted only when the remedy differs from "ask an organization admin". Adding one also requires an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS` (a compile-time gate) and publishes a new value in the generated OpenAPI 403 description. - -### Static vs parameterized - -If the decision needs a request value, the rule is `kind: 'parameterized'` and takes a second argument: - -```ts - 'knowledge.connectors': { - kind: 'parameterized', - configKeys: ['allowedKnowledgeConnectors'], - detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - describe: 'This knowledge base connector', - deniedBy: (config, connectorType) => - allowlistDenies(config.allowedKnowledgeConnectors, connectorType), - }, -``` - -A parameterized capability **cannot be declared on an operation**. The authorization funnel decides from the principal, the workspace, and the operation — it never sees request input, and widening the authorization context to carry it would reach every one of the ~287 operations for the sake of two keys. `defineWorkspaceOperation` throws at definition time if you try: - -``` -Operation declares parameterized capability ; assert it from the use case instead -``` - -That throw is deliberate. Left unchecked, the operation would read as gated and the gate would silently never fire. +`configKeys` is what the audit reads to prove your key is enforced — it must list every key `deniedBy` reads. `describe` is the subject of one shared sentence, `" is not available under your organization's permission group"`, so make it a singular noun or gerund that agrees with "is". Exactly two functions build it, both defined in `capabilities.ts`: `refuseCapability(cap)` throws it as a `PermissionGroupCapabilityError`; `capabilityRefusal(cap)` returns it as a string for a raw route rendering its own body (`capability-assertions.ts` re-exports it so an inline gate reaches both through one module). Never write the sentence at a call site. -### `satisfies`, never a type annotation +Use `'PERMISSION_GROUP_CAPABILITY_BLOCKED'` for `detailCode`. The set in `lib/core/application/forbidden.ts` is closed **over remedies, not causes** — a new code is warranted only when the remedy differs from "ask an organization admin", and requires an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS` (a compile-time gate) plus a new value in the generated OpenAPI 403 description. -Do not annotate `CAPABILITY_RULES` with its type instead of using `satisfies`. Annotating widens every entry to `CapabilityRule`, at which point `StaticPermissionGroupCapability` — which is derived by filtering the object's own entries for `kind: 'static'` — resolves to **`never`**. No operation can then declare any capability, the type system stops saying anything about capabilities at all, and every gate goes quiet with nothing at runtime looking wrong. `AssertsStaticCapabilityResolves` at the bottom of the file exists to catch exactly that. The same reasoning applies anywhere else you are tempted to annotate one of these registries. +A **parameterized** rule is the same shape with `kind: 'parameterized'` and a `deniedBy` taking the request value second — `'knowledge.connectors'` is `(config, connectorType) => allowlistDenies(config.allowedKnowledgeConnectors, connectorType)`. It **cannot be declared on an operation**: the funnel decides from principal, workspace and operation, never request input, and widening it would touch all ~315 operations for the sake of two keys. `defineWorkspaceOperation` throws at definition time (`Operation declares parameterized capability ; assert it from the use case instead`) rather than letting the operation read as gated while the gate never fires. ## Step 4: Declare it on the operations it governs, or assert it at the call site -`capability` is a **required** field, and `defineWorkspaceOperation` *additionally* throws at definition time when it is `undefined`: - -``` -Operation declares no capability; name one, or 'none' with a reason -``` - -That guard looks unreachable given the field is required. It is not, and the reason is worth internalizing before you write a test fixture: **`apps/sim/tsconfig.json` excludes `*.test.ts` and `*.test.tsx` from type-checking**, and `check-permission-group-enforcement.ts` walks past test files too. A test fixture is therefore the one construction site no static check reads — and a fixture is exactly where an operation gets written from memory rather than from the surrounding domain. Left to reach authorization, an absent capability does not deny; it throws `Cannot read properties of undefined` from inside `capabilityDeniedBy`, and **only for a caller whose organization actually has a permission group**. It passes CI, it passes every personal workspace and every non-enterprise test, and it fails in the tenants that bought the feature. The guard names it at definition time instead. +`capability` is **required** on `defineWorkspaceOperation`, typed `StaticPermissionGroupCapability | 'none'`, *and* guarded at definition time (`Operation declares no capability; name one, or 'none' with a reason`). The guard is not redundant: **`apps/sim/tsconfig.json` excludes `*.test.ts` / `*.test.tsx` from type-checking** and the enforcement audit walks past test files, so a fixture is the one construction site no static check reads. An absent capability does not deny — it throws `Cannot read properties of undefined` inside `capabilityDeniedBy`, and **only for a caller whose organization actually has a permission group**. It passes CI and every personal workspace, then fails in the tenants that bought the feature. -**Static, and the operation is the whole decision** — set `capability` on the `defineWorkspaceOperation` call. The funnel does the rest; you write no gate code. +**Static, and the operation is the whole decision** — set `capability` and write no gate code: ```ts export const shareWidget = defineWorkspaceOperation({ @@ -175,16 +124,16 @@ export const shareWidget = defineWorkspaceOperation({ }) ``` -If the domain wraps `defineWorkspaceOperation` in a same-file factory, the audit resolves the capability through it — either fixed in the factory body or taken as a positional second argument. `apps/sim/lib/table/application/operations.ts` shows both, and deliberately gives the positional form **no default**: a default would let a new operation inherit `tables.use` without anyone deciding it should, which is the unreviewed omission the whole gate exists to prevent. +**The factory trap.** An operation minted by a factory that does not call `defineWorkspaceOperation` — a hand-frozen object — bypasses the required type *and*, once bypassed, the audit; twenty-one operations across six domains were invisible that way, and the file still printed a tick because some other operation in it was counted. The audit now matches the whole `defineOperation` family, resolves a same-file `function` factory (capability fixed in the body or taken as a positional second argument — `lib/table/application/operations.ts` shows both, with **no default** on the positional form so nothing inherits `tables.use` unreviewed), and cross-checks the members of every exported `*Operations` registry against what it parsed. Keep new operations inside an exported `*Operations` registry, mint them through a `define*Operation` builder taking an object literal with a string `id`, and use a `function` factory rather than an arrow const. -**Static, but no operation to hang it on** — a raw route, or an organization-level action. There is no `assertOrganizationCapability`; it was deleted. Reach for whichever of these fits how the caller must respond: +**Static, but no operation to hang it on** — a raw route or an organization-level action. There is no `assertOrganizationCapability`; it was deleted. | Helper | Use when | |---|---| -| `assertWorkspaceCapability(userId, workspaceId, cap, organizationId?)` | inside a use case, where a thrown `PermissionGroupCapabilityError` is projected to a 403 for you | -| `isWorkspaceCapabilityWithheld(userId, workspaceId, cap, organizationId?)` | a raw handler rendering its own body — pair it with `capabilityRefusal(cap)` | -| `isOrganizationCapabilityWithheld(organizationId, cap)` | an action that names an organization rather than a workspace | -| `capabilityDeniedBy(cap, config)` | you already hold a resolved config and are asking several questions of it | +| `assertWorkspaceCapability(userId, workspaceId, cap, organizationId?)` | inside a use case — the thrown `PermissionGroupCapabilityError` is projected to a 403 for you | +| `isWorkspaceCapabilityWithheld(userId, workspaceId, cap, organizationId?)` | a raw handler rendering its own body — pair with `capabilityRefusal(cap)` | +| `isOrganizationCapabilityWithheld(organizationId, cap)` | an action naming an organization rather than a workspace | +| `capabilityDeniedBy(cap, config)` | you already hold a resolved config | Annotate the call site either way: @@ -195,64 +144,45 @@ Annotate the call site either way: } ``` -`isOrganizationCapabilityWithheld` resolves through `getUserPermissionConfigForOrganization`, which reads the organization's **default** group — a non-default group targets specific workspaces and has nothing to say about an action no workspace scopes. It sits outside the per-request memo on purpose: that memo is keyed by user and workspace, and this decision is keyed by organization alone. +`isOrganizationCapabilityWithheld` resolves through `getUserPermissionConfigForOrganization`, reading the organization's **default** group — a non-default group targets specific workspaces. It sits outside the per-request memo because that memo is keyed by user and workspace, and this decision is keyed by organization alone. -**Parameterized** — the rule needs a request value, so none of the helpers above fit (they are all typed `StaticPermissionGroupCapability`). Write a small module-local wrapper that reads the rule and refuses through `refuseCapability`, and annotate the call site. `assertConnectorTypeAllowed` in `apps/sim/lib/knowledge/application/connectors.ts` is the shape: +**Parameterized** — the helpers above are all typed `StaticPermissionGroupCapability`, so write a module-local wrapper that reads the rule and raises through `refuseCapability`, and annotate the call site. `assertConnectorTypeAllowed` in `lib/knowledge/application/connectors.ts` is the shape: ```ts -const CONNECTOR_ALLOWLIST_RULE = CAPABILITY_RULES['knowledge.connectors'] - -async function assertConnectorTypeAllowed(userId, workspaceId, connectorType) { - if (!userId) return - const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) - if (!config || !CONNECTOR_ALLOWLIST_RULE.deniedBy(config, connectorType)) return - refuseCapability('knowledge.connectors') -} +const RULE = CAPABILITY_RULES['knowledge.connectors'] +if (!userId) return +const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) +if (config && RULE.deniedBy(config, connectorType)) refuseCapability('knowledge.connectors') ``` -Always route the decision through `CAPABILITY_RULES` and raise it with `refuseCapability`. Never spell the config key out at the call site, and never write the refusal sentence out: a renamed key silently stops denying anything, and a hand-written message drifts from the funnel's for the same refusal. `validatePublicFileSharing` and `validateChatDeployAuth` in `ee/access-control/utils/permission-check.ts` are the other two examples of this shape. +Always route through `CAPABILITY_RULES` and raise with `refuseCapability` — a config key spelled out inline silently stops denying when renamed, and a hand-written message drifts from the funnel's. `validatePublicFileSharing` and `validateChatDeployAuth` in `ee/access-control/utils/permission-check.ts` are the other two examples. Return early on a missing `userId`: a permission group is a membership of users, so an actorless caller resolves none, and throwing there turns a scheduled sync into a 500 instead of a refusal anyone can act on. -Guard on the acting user being present. A permission group is a membership of users, so an actorless caller resolves no group; `assertConnectorTypeAllowed` returns early on a missing `userId` rather than throwing, which is what keeps a scheduled sync from becoming a 500 instead of a refusal anyone could act on. - -**The operation is genuinely ungoverned** — write `capability: 'none'` with a `// permission-group-exempt: ` comment directly above it. `'none'` is spelled out rather than omitted because an absent field cannot be told apart from an unreviewed one. Good exemption reasons name why no key applies *and* why a gate would be wrong: - -```ts - // permission-group-exempt: the executor's own per-run store; no group key names it, and refusing would fail runs the group allows -``` +**Genuinely ungoverned** — write `capability: 'none'` with a `// permission-group-exempt: ` comment directly above it (`'none'` is spelled out because an absent field cannot be told apart from an unreviewed one). A good reason names why no key applies *and* why a gate would be wrong: *"the executor's own per-run store; no group key names it, and refusing would fail runs the group allows"*. ### Surfaces that do not go through the funnel -Three of them, and each has its own required shape. - -**`/api/v1` routes** authorize in `apps/sim/app/api/v1/middleware.ts` rather than through `authorizeWorkspaceOperation`. Every route threads a `V1RouteCapability` (`StaticPermissionGroupCapability | 'none'`, required and spelled out, same reasoning as on the operation), and its value must be the one its v2 or internal counterpart already declares — v1 gets no mapping of its own. The subject **must** come from `capabilityGovernedUserId(rateLimit)`, which returns `null` for a workspace key: `rateLimit.userId` is populated for *both* key kinds, and for a workspace key it is the key's **creator**, a bystander. `scripts/check-capability-subject.ts` exists because that bug has shipped and been fixed twice. - -**Raw internal table routes** under `/api/table/**` share one gate inside `checkAccess` in `apps/sim/app/api/table/utils.ts`. Its signature takes a `TableAccessPrincipal` discriminated union — `{ kind: 'user'; userId }` or `{ kind: 'workspace_api_key'; keyCreatorUserId }` — rather than a bare `userId`, for the same reason: a bare id no longer type-checks, so a caller cannot reach the gated behavior without naming a kind, and only the kind that says so skips the gate. `tableAccessPrincipal(rateLimit)` in the v1 middleware builds it for v1's table handlers. - -**The route-wrapper graph.** `withRouteHandler` imports `request-scope.server.ts` and nothing heavier. If your gate needs a resolver, import it at the *call site*, not from anything the wrapper or `lib/core/application` reaches. See Step 6. +- **`/api/v1`** authorizes in `app/api/v1/middleware.ts`. Every route threads a `V1RouteCapability` (`StaticPermissionGroupCapability | 'none'`, required and spelled out) whose value must match what its v2 or internal counterpart declares — v1 gets no mapping of its own. The subject **must** come from `capabilityGovernedUserId(rateLimit)`, which returns `null` for a workspace key: `rateLimit.userId` is populated for *both* key kinds and is the key's **creator** for a workspace key, a bystander. `check-capability-subject.ts` exists because that has shipped and been fixed twice. +- **Raw internal table routes** (`/api/table/**`) share one gate in `checkAccess` (`app/api/table/utils.ts`), whose signature takes a `TableAccessPrincipal` union — `{ kind: 'user'; userId }` or `{ kind: 'workspace_api_key'; keyCreatorUserId }` — so a bare id no longer type-checks and only the kind that says so skips the gate. `tableAccessPrincipal(rateLimit)` builds it for v1. +- **The route-wrapper graph.** `withRouteHandler` imports `request-scope.server.ts` and nothing heavier. Import a resolver at the *call site*, never from the wrapper or `lib/core/application` — see Step 6. ## Step 5: Add it to the golden corpus -Add your key to **both** the `input` and the `expected` object of the `'a fully populated config'` fixture in `apps/sim/lib/permission-groups/fields.test.ts` (renamed from `types.test.ts` when `types.ts` was folded into `fields.ts`), set to a non-default value. - -That file is the pinned coercion corpus: every row states what a stored `jsonb` value coerces to, so a row that changes in a later diff is a deliberate semantic decision someone defends rather than a silent regression. Its other assertions are derived from `DEFAULT_PERMISSION_GROUP_CONFIG` — wire-order, idempotence, read-schema acceptance, the 2000-iteration seeded fuzz, the write/default/read key-set agreement and the boolean-key-to-`PLATFORM_FEATURES` coverage check — so they pick your key up for free. Likewise `features.test.ts` iterates `PLATFORM_FEATURES` and needs no edit for a boolean. +Add the key to **both** the `input` and `expected` objects of the `'a fully populated config'` fixture in `lib/permission-groups/fields.test.ts`, set to a non-default value. That fixture is the pinned coercion corpus: a row that changes in a later diff is a semantic decision someone defends rather than a silent regression. The file's other assertions derive from `DEFAULT_PERMISSION_GROUP_CONFIG` (wire order, idempotence, read-schema acceptance, the 2000-iteration seeded fuzz, write/default/read key-set agreement, boolean-to-`PLATFORM_FEATURES` coverage) and pick your key up for free, as does `features.test.ts`. -Add a targeted case to `capabilities.test.ts` for a rule with any logic beyond reading one key. For an allowlist, assert the three states explicitly, because they are what the parser and the UI conspire to confuse: `null` permits every member, a populated list permits only the named ones, and `[]` permits **none**. `capabilities.test.ts` already pins this for `knowledge.connectors`; copy it. +Add a case to `capabilities.test.ts` for any rule with logic beyond reading one key. For an allowlist assert all three states — `null` permits every member, a populated list only the named ones, `[]` permits **none** — as `capabilities.test.ts` already does for `knowledge.connectors`. ## Step 6: Keep the graph light -`scripts/check-application-graph.ts` (in `check:audits`) walks **runtime** `import` / `export … from` edges — `import type` is erased and deliberately allowed — out of five guarded roots and fails if any reaches a forbidden module tree. This is a real constraint on you: importing the wrong thing from a permission-group helper now fails CI. +`scripts/check-application-graph.ts` walks **runtime** `import` / `export … from` edges (`import type` is erased and allowed) out of five guarded roots: | Guarded root | Forbidden | |---|---| -| `lib/core/application/index.ts` | `providers/`, `blocks/`, `tools/`, `executor/`, `lib/uploads/`, `lib/workflows/` | -| `lib/permission-groups/capabilities.ts` | same six | -| `lib/permission-groups/capability-assertions.ts` | same six | -| `lib/permission-groups/config-scope.server.ts` | same six | +| `lib/core/application/index.ts`, and `lib/permission-groups/` `capabilities.ts` / `capability-assertions.ts` / `config-scope.server.ts` | `providers/`, `blocks/`, `tools/`, `executor/`, `lib/uploads/`, `lib/workflows/` | | `lib/core/utils/with-route-handler.ts` | those six **plus** `lib/billing/`, `lib/permission-groups/resolve.server`, `lib/auth`, `lib/copilot/`, `lib/knowledge/` | -The wider list on the route wrapper is not an app-wide ban — `resolve.server.ts` legitimately reads the subscription to decide whether an organization is on an enterprise plan, which is why `lib/billing/` stays allowed for the funnel roots. The wrapper is a request-lifecycle shim that opens the memo scope and nothing more, so it may not load any of it. That split is why the scope is two files: `request-scope.server.ts` holds `withPermissionGroupScope` and is import-free; `config-scope.server.ts` holds `resolvePermissionGroupConfig` and only the gate call sites import it. +`lib/billing/` stays allowed for the funnel roots because `resolve.server.ts` legitimately reads the subscription to decide whether an organization is on an enterprise plan; the wrapper is a lifecycle shim that opens the memo scope and nothing more. That split is why the scope is two files. -The symptom of breaking this is never the message you expect. One import once widened the funnel graph as far as `lib/uploads/utils/file-utils.ts`, and the only sign was two unrelated knowledge tests failing on a partial mock of a module they never meant to load; later one import in the route wrapper pulled the whole billing graph into every route test, surfacing as an OTP-route test failing on its own partial `zod` mock. If you see a failure like that after adding an import, run this audit before anything else. +Breaking this never announces itself — past regressions surfaced only as unrelated tests failing on partial mocks of modules they never meant to load. After adding an import, run this audit first. ## Step 7: Verify @@ -264,81 +194,58 @@ cd apps/sim && bun run type-check cd apps/sim && bunx vitest run lib/permission-groups ``` -If you touched a contract or the group routes, also `bun run check:api-validation`. `bun run check:audits` runs all three of the above and every other audit; it derives its list from the `check:*` scripts in `package.json`, so a new audit is opted *out* deliberately rather than opted in. +Also `bun run check:api-validation` if you touched a contract or the group routes. `bun run check:audits` runs all of these; it derives its list from the `check:*` scripts in `package.json`, so a new audit is opted *out* deliberately rather than opted in. -Read the success lines, not just the exit codes: +Read the success lines, not the exit codes — the counts should have grown by your operation and capability: ``` -✓ permission-group enforcement: 287 operations declare a capability, 35 capabilities all enforced +✓ permission-group enforcement: 315 operations declare a capability, 35 capabilities all enforced ✅ Application graph clean: 5 roots reach none of 11 forbidden module trees check:capability-subject — 32 v1 files, 5 capability subjects resolved through capabilityGovernedUserId. ``` -The counts should have grown by your operation and your capability. The enforcement audit is all-or-nothing — it either prints that line or fails with findings; there is no count-down or migration mode that exits 0 with work outstanding. It carries three self-checks, because it reads source text with regexes rather than the type system: - -- It refuses to report success when `CAPABILITY_IDS`, `CAPABILITY_RULES` or `PERMISSION_GROUP_FIELDS` parse to nothing, and it fails when the rule count and the capability count disagree. -- A `defineWorkspaceOperation` call whose `id` it cannot read — a const-reference id, or a factory written as an arrow const rather than a `function` — is reported per call rather than silently skipped. -- **A file that calls `defineWorkspaceOperation` and parses to ZERO declarations is a finding**, not a pass. That catches the whole-file failure mode: a non-literal `id:` or an arrow-const factory that makes every operation in the file invisible at once. If it fires, teach the parsers the new form; do not work around it. +The enforcement audit is all-or-nothing — one success line or findings, no migration mode that exits 0 with work outstanding. Because it reads source text with regexes it carries self-checks: it refuses success when `CAPABILITY_IDS`, `CAPABILITY_RULES` or `PERMISSION_GROUP_FIELDS` parse to nothing or when rule and capability counts disagree; it reports per call any operation whose `id` it cannot read; **a file that mints an operation and parses to ZERO declarations is a finding**; and every member of an exported `*Operations` registry that it read no operation from is a finding. If one fires, teach the parsers the new form — do not work around it. -What the audit proves is *reachability*: your capability is named somewhere and your key is read by some rule. It cannot tell whether the rule's logic is right or whether every operation reaching the behavior declares it. Do not treat a green run as proof the gate fires. +The audits prove *reachability*: your capability is named somewhere, your key is read by some rule. They cannot tell whether the rule's logic is right or whether every operation reaching the behavior declares it. Green is not proof the gate fires. ## Traps -These are the ones that actually bite. Each has a reason; understand the reason and you will get the cases this list does not enumerate right too. +**An operation carries exactly ONE capability, and a narrower capability must subsume the broader one it replaced.** `knowledge.create` and `knowledge.upload` list `configKeys: ['disableKnowledgeBaseCreation', 'hideKnowledgeBaseTab']` and OR both in `deniedBy`, because moving KB creation off `knowledge.use` would otherwise let a group that withheld the whole module still create one through the API. Any time you re-point an operation to a more specific capability, the specific rule must read both keys. -**The default must be permissive.** Every stored config predates your key, and the parser fills the gap from the default. A restrictive default applies a new restriction retroactively to every existing group, invisibly. +**`.catch()` on an array field is a fail-open security bug.** `z.array(item).catch(fallback)` is whole-value tolerant: one bad member discards every good one. On an allowlist the fallback is `null`, and `null` means **unrestricted** — a partly corrupt allowlist stops restricting anything. `tolerantArray` in `fields.ts` filters element by element, keeping what parses and failing closed. Never swap it for `.catch()`, never hand-roll a parallel coercion path. -**Append, never insert.** Declaration order is the wire order, `fields.test.ts` compares key order, and the editor's dirty check compares stringified configs — a moved key fails a test and reads as an unsaved change in every open editor. +**`parsePermissionGroupConfig` must keep its `Array.isArray` guard.** `typeof [] === 'object'`, so a truthy-object check alone lets an array through and `z.object().parse([])` throws — reachable, because the column is `jsonb` and a row can genuinely hold `[]`. The guard returns the defaults there instead of taking down the request. `tolerantArray` carries the mirror-image guard. -**`CAPABILITY_RULES` uses `satisfies`, never a type annotation.** Annotating collapses `StaticPermissionGroupCapability` to `never` and silently disables the type system around capabilities. See Step 3. +**An empty allowlist denies everything; `null` allows everything.** They must never collapse — not in the parser, the UI setter, or a `deniedBy`. `allowlistDenies` encodes it as `allowed !== null && !allowed.includes(member)`; a `?? []` anywhere on this path inverts the unrestricted case. -**`capability` is required *and* guarded at definition time.** The guard is not redundant: `apps/sim/tsconfig.json` excludes test files, so a fixture is the one construction site no static check reads. See Step 4. +**Not everyone goes through the funnel.** -**An operation carries exactly ONE capability.** Splitting a narrower capability off a broader one opens a hole unless the narrower rule *also* reads the broader key. This is real, not hypothetical: `knowledge.create` and `knowledge.upload` both list `hideKnowledgeBaseTab` alongside their own key — - -```ts - configKeys: ['disableKnowledgeBaseCreation', 'hideKnowledgeBaseTab'], - deniedBy: (config) => config.disableKnowledgeBaseCreation || config.hideKnowledgeBaseTab, -``` - -— because moving knowledge-base creation off `knowledge.use` would otherwise let a group that withheld the entire module still create one through the API. **The narrower capability has to subsume the broader.** Any time you re-point an operation from a general capability to a specific one, the specific rule must read both keys. - -**`.catch()` on an array field is a fail-open security bug.** `z.array(item).catch(fallback)` is whole-value tolerant: one bad member discards every good one. On an allowlist the fallback is `null`, and `null` means **unrestricted** — so a partly-corrupt allowlist would stop restricting anything at all. `tolerantArray` in `fields.ts` filters element by element instead, keeping the members that parse and failing closed. Never replace it with `.catch()` on an array field, and never hand-roll a parallel coercion path. - -**`parsePermissionGroupConfig` must keep its `Array.isArray` guard.** `typeof [] === 'object'`, so a truthy-object check alone lets an array through, and `z.object().parse([])` throws — which is reachable, because the column is `jsonb` and a row can genuinely hold `[]`. The guard is what makes the parser return the defaults there instead of taking down the request. `tolerantArray` carries the mirror-image guard for the same reason. - -**An empty allowlist denies everything; `null` allows everything.** These must never collapse into one another — not in the parser, not in the UI setter, not in a rule's `deniedBy`. `allowlistDenies` encodes it as `allowed !== null && !allowed.includes(member)`. A `?? []` anywhere on this path inverts the meaning of the unrestricted case. - -**A parameterized capability declared on an operation is refused at definition time.** `defineWorkspaceOperation` throws rather than accepting it, because the funnel never sees request input and the gate would silently never fire. - -**Non-boolean keys get no admin UI.** `PLATFORM_FEATURES` filters to booleans. An allowlist without a `featureExtras` picker is a key no admin can ever set. - -**Not everyone goes through the funnel.** Four cases, and the differences between them matter: - -- A **workspace API key** authorizes as the workspace — there is no user, so no permission group resolves and `operation.capability` does not apply. Substituting the key's creator would apply a bystander's group to every caller of a shared key, and break the key outright when that person left. The escape is closed at the door instead: minting a workspace key is itself capability-gated. Do not substitute the creator anywhere — not in the funnel, not in `checkAccess`, not in v1, not in the log projection. -- A **delegated `executor` principal that *does* carry a `sim_user` subject** is checked for **role only** (`requireCurrentHumanRole`), not capabilities. A workflow run carries the role of whoever triggered it but not their capabilities: a capability names what a *person* may reach in the product, while a run reaches those same resources because a block in the graph does. Applying capabilities here would turn "hide Tables" into a runtime kill-switch that breaks every workflow with a Table block for that cohort. -- An **actorless deployment run** — a delegated executor principal in `mode: 'deployment'` with no resolvable subject — also passes through, because a deployed workflow acts with the workspace's authority rather than its author's group; denying there would 403 every scheduled run, webhook, and public-API call in the organization the moment a group withheld anything. -- **Copilot is deliberately NOT exempt.** A delegated principal with a `sim_user` subject whose `serviceId` is anything other than `executor` goes through the full `requireCurrentHumanAccess`, capability check included. Copilot acts *as the person*, so it must not reach what the person may not. +| Principal | Rule | +|---|---| +| **Workspace API key** | Authorizes as the workspace — no user, so no group resolves and `operation.capability` does not apply. **Never substitute the key's creator** (not in the funnel, `checkAccess`, v1, or the log projection): it applies a bystander's group to every caller of a shared key and breaks the key when that person leaves. The escape is closed at the door — minting a workspace key is itself capability-gated | +| **Delegated `executor` with a `sim_user` subject** | **Role only** (`requireCurrentHumanRole`). A run carries the trigger-er's role but not their capabilities: a capability names what a *person* may reach, while a run reaches resources because a block does. Applying capabilities would make "hide Tables" a kill-switch breaking every workflow with a Table block | +| **Actorless deployment run** (delegated executor, `mode: 'deployment'`, no subject) | Passes through — a deployed workflow acts with the workspace's authority, not its author's group. Denying would 403 every scheduled run, webhook and public-API call the moment a group withheld anything | +| **Copilot** | **NOT exempt.** A delegated principal with a `sim_user` subject whose `serviceId` is anything other than `executor` takes the full `requireCurrentHumanAccess`, capability check included. Copilot acts *as the person* | -What a run *does* is still governed, by `assertPermissionsAllowed` in the executor. If your item must bind a deployed run, it belongs at `enforcement: 'executor'`, not `'capability'`. +What a run *does* is still governed by `assertPermissionsAllowed`. An item that must bind a deployed run belongs at `enforcement: 'executor'`. -**Capability is checked after the role check, on purpose.** `requireCurrentHumanAccess` runs `requirePermission` first. `NoWorkspaceAccessError` is concealed as a 404 by the v2 surface so a non-member cannot learn the resource exists; refusing on capability first would hand a complete outsider an oracle for which capabilities the organization withholds. Do not reorder it, and do not add a capability check upstream of the role check in a raw route — the v1 middleware says so in its own TSDoc for the same reason. +**Capability is checked after the role check, on purpose.** `requireCurrentHumanAccess` runs `requirePermission` first. `NoWorkspaceAccessError` is concealed as a 404 by the v2 surface so a non-member cannot learn the resource exists; refusing on capability first hands an outsider an oracle for which capabilities the organization withholds. Do not reorder, and do not add a capability check upstream of the role check in a raw route — the v1 middleware states the same rule in its TSDoc. ## Checklist Before Finishing - [ ] Kind and `enforcement` chosen deliberately; `ui-only` justified in writing if used -- [ ] It is a gate, not a field projection — a projection belongs in `lib/logs/log-projection.ts` with `capability: 'none'` on the routes +- [ ] It is a gate, not a projection — a projection belongs in `lib/logs/log-projection.ts` with `capability: 'none'` on the routes, and still refuses queries that select on the withheld field - [ ] Entry **appended** to `PERMISSION_GROUP_FIELDS`, permissive default, restriction-phrased name -- [ ] Category present in `PLATFORM_CATEGORY_ORDER`, named after what is withheld rather than a surface -- [ ] `hint` says what access is revoked, never "hide" — it is also the prose for an active restriction +- [ ] Category present in `PLATFORM_CATEGORY_ORDER`, named after what is withheld +- [ ] `hint` says what access is revoked, never "hide" — it is also the active-restriction prose - [ ] Non-boolean key has a `featureExtras` picker that refuses empty and collapses "all" to `null` - [ ] Capability id in `CAPABILITY_IDS`, rule in `CAPABILITY_RULES` under `satisfies`, `configKeys` lists every key `deniedBy` reads - [ ] A narrower capability replacing a broader one also reads the broader key -- [ ] Declared on every operation it governs, or asserted from the use case with a `// permission-group-enforced:` annotation, raising through `refuseCapability` / `capabilityRefusal` -- [ ] Any `capability: 'none'` you added carries a `// permission-group-exempt:` reason +- [ ] Declared on every operation it governs, or asserted from the use case with a `// permission-group-enforced:` annotation raising through `refuseCapability` / `capabilityRefusal` +- [ ] New operations minted through a `define*Operation` builder and exported from an `*Operations` registry +- [ ] Any `capability: 'none'` carries a `// permission-group-exempt:` reason - [ ] v1 routes thread the capability through `middleware.ts` and take their subject from `capabilityGovernedUserId`; table routes pass a `TableAccessPrincipal` - [ ] Added to the `'a fully populated config'` fixture in `fields.test.ts`, input and expected - [ ] Allowlist three-state (`null` / populated / `[]`) covered in `capabilities.test.ts` - [ ] No new runtime import from a guarded root into a forbidden tree -- [ ] `check:permission-group-enforcement`, `check:application-graph` and `check:capability-subject` all pass and name your capability -- [ ] `type-check` clean, `lib/permission-groups` suite green +- [ ] All three audits pass and name your capability; `type-check` clean, `lib/permission-groups` suite green diff --git a/.agents/skills/validate-permission-group-item/SKILL.md b/.agents/skills/validate-permission-group-item/SKILL.md index d05b12e5a7d..2c7fa9e7f55 100644 --- a/.agents/skills/validate-permission-group-item/SKILL.md +++ b/.agents/skills/validate-permission-group-item/SKILL.md @@ -6,114 +6,96 @@ argument-hint: # Validate Permission Group Item Skill -You are auditing one governed item in Sim's enterprise permission-group system. The question you are answering is not "does this key exist in the right places" — the registry makes most of that compiler-enforced. The question is: +The question is not "does this key exist in the right places" — the registry makes most of that compiler-enforced. It is: > **If an organization admin sets this, what refuses, and can I make that refusal happen?** -Twelve keys once shipped with an admin checkbox, a hint describing what they restrict, and no server check at all. Every one of them would have passed a structural audit. Assume nothing enforces until you have found the throw. +Twelve keys once shipped with a checkbox, a hint, and no server check. Every one would have passed a structural audit. Assume nothing enforces until you have found the throw. -The authoring counterpart is the `add-permission-group-item` skill. It owns the procedure and the rationale for each invariant; this skill owns the audit. Where the two overlap, read that one for *why* and this one for *how to check*. +**`add-permission-group-item` owns the procedure and the rationale for every invariant named below.** Read it for *why*; this skill is the checklist. Its "Read the system first" list is the same one — start there. -## Read the system first +## Step 1: Registry entry (`lib/permission-groups/fields.ts`) -- `apps/sim/lib/permission-groups/fields.ts` — the registry every config surface derives from, plus `permissionGroupConfigSchema`, `tolerantArray` and `parsePermissionGroupConfig`. There is **no `types.ts`** — it was folded into this file, and the two DB constraint maps live in `constraints.ts` -- `apps/sim/lib/permission-groups/capabilities.ts` — `CAPABILITY_IDS`, `CAPABILITY_RULES`, `capabilityRefusal`, `refuseCapability` -- `apps/sim/lib/permission-groups/capability-assertions.ts` — the canonical assertion API (and it re-exports `capabilityRefusal`) -- `apps/sim/lib/permission-groups/resolve.server.ts` — group resolution, moved out of `ee/`; `ee/access-control/utils/permission-check.ts` now re-exports it and keeps only the executor gates -- `apps/sim/lib/permission-groups/config-scope.server.ts` — `resolvePermissionGroupConfig`, the per-request memo every assertion resolves through -- `apps/sim/lib/permission-groups/request-scope.server.ts` — the import-free half of the scope, holding `withPermissionGroupScope` -- `apps/sim/lib/core/application/workspace-authorization.ts` — the funnel, and who bypasses it -- `scripts/check-permission-group-enforcement.ts`, `scripts/check-application-graph.ts`, `scripts/check-capability-subject.ts` — what the three audits do and do not prove +Record the builder, the `enforcement`, and the position. -## Step 1: Registry entry - -Find the key in `PERMISSION_GROUP_FIELDS`. Record its builder (`booleanRestriction` / `allowlist` / `denylist`), its `enforcement`, and its position. - -- **Default is permissive?** Boolean `false`, allowlist `null`, denylist `[]`. The builders hardcode these, so the real risk is a key whose *name* inverts the meaning — an `allowX` boolean whose permissive value would be `true`. Every stored config predates the key, so a restrictive default silently applies retroactively to every existing group. -- **Named as a restriction?** `hideX` / `disableX` / `allowedX` / `deniedX`. The admin checkbox renders `checked={!editingConfig[feature.configKey]}` — ticked means allowed — so a positively-named boolean renders backwards. -- **Position stable?** Declaration order is the wire order of `PermissionGroupConfig`, both zod schemas, and every config JSON crossing the API boundary; `fields.test.ts` pins it with a contract test that compares key order. If `git log -p` shows the key was ever *moved* rather than appended, that shipped as a dirty-check regression in the group editor. -- **Phrasing present and accurate?** An allowlist's `{ limited, empty }` and a denylist's string are read by `getActivePermissionGroupRestrictions` in `features.ts` and surface to users through the Copilot workspace VFS and the enterprise platform context. Check the `empty` string genuinely says "none allowed" and not "unrestricted". -- **Does the boolean's `hint` tell the truth?** This is the highest-value read in Step 1. A key with `enforcement: 'capability'` refuses at the API, so a hint saying it hides a tab, a panel, a module "from the sidebar", or a nav item is a **lie** an admin acts on — an admin ticking that box is revoking access, not hiding a link, and they believe they are tidying chrome while they are withholding a module. The same string is read a second time as the prose for an *active* restriction, where "hide" is simply false. It must name what a member can no longer do. Twelve keys carried that wording for a release after they started 403-ing; treat any surviving "Hide the …" hint on a `'capability'` key as a finding, not a nit. Check `label` and `category` the same way — a section headed "Sidebar" or "Settings Tabs" makes the same claim structurally. +- **Default permissive?** The builders hardcode `false` / `null` / `[]`, so the risk is a *name* that inverts the meaning — an `allowX` boolean. The checkbox renders `checked={!editingConfig[feature.configKey]}` (ticked = allowed), so a positively-named boolean renders backwards. +- **Position stable?** Declaration order is the wire order and `fields.test.ts` pins it with a key-order contract test. If `git log -p` shows the key was ever *moved* rather than appended, that shipped as an editor dirty-check regression. +- **Phrasing accurate?** An allowlist's `{ limited, empty }` and a denylist's string are read by `getActivePermissionGroupRestrictions` in `features.ts` and surface to users through the Copilot workspace VFS and the enterprise platform context. Confirm `empty` says "none allowed", not "unrestricted". +- **Does the `hint` tell the truth?** Highest-value read in this step. A `'capability'` key refuses at the API, so a hint saying it hides a tab, module, or nav item "from the sidebar" is a **lie an admin acts on** — they believe they are tidying chrome while withholding a module. The same string is reused as the prose for an *active* restriction, where "hide" is simply false. Any surviving "Hide the …" hint on a `'capability'` key is a finding, not a nit; check `label` and `category` the same way (a "Sidebar" or "Settings Tabs" section makes the claim structurally). ## Step 2: Schemas, type, defaults, parser -These are derived by `collectFieldProperty` — `permissionGroupWriteShape` / `permissionGroupConfigSchema`, `permissionGroupReadShape`, `DEFAULT_PERMISSION_GROUP_CONFIG`, and the tolerant parser all read the same registry. **Do not hand-verify them one by one.** Verify instead that nothing has been introduced that bypasses the derivation: +All derived by `collectFieldProperty` from the same registry. **Do not hand-verify them.** Verify nothing bypasses the derivation: ```bash grep -rn "" apps/sim --include='*.ts' --include='*.tsx' | grep -v 'lib/permission-groups/' ``` -Every hit outside `lib/permission-groups/` is either a rule's `deniedBy`, an enforcement site, a UI binding, or a test. Anything else — a route restating the key, a client re-deriving a default, a second coercion path — is a leak. In particular: +Every hit should be a rule's `deniedBy`, an enforcement site, a UI binding, or a test. A route restating the key, a client re-deriving a default, or a second coercion path is a leak. Specifically: -- A `z.array(...).catch(...)` anywhere on this key's path. `.catch()` is whole-value tolerant: one bad member discards every good one. On an **allowlist** that is fail-**open**, because the fallback is `null` and `null` means unrestricted — a partially corrupt allowlist would stop restricting anything. `tolerantArray` filters element-wise for exactly this reason. Rank a regression here with the enforcement findings; it is a security bug, not a coercion nit. -- A `?? []` applied to an allowlist. `null` allows everything and `[]` allows nothing; collapsing them inverts the unrestricted case. -- Any read of the config that does not come from `parsePermissionGroupConfig` or a `resolvePermissionGroupConfig` caller. +- **`z.array(...).catch(...)` anywhere on this key's path** — whole-value tolerant, so one bad member discards every good one, and on an allowlist the `null` fallback means unrestricted. That is fail-**open**. `tolerantArray` filters element-wise. Rank a regression here with the enforcement findings. +- **`?? []` applied to an allowlist** — collapses "allows everything" into "allows nothing". +- **Any config read not from `parsePermissionGroupConfig` or a `resolvePermissionGroupConfig` caller.** -Two structural guards to confirm are still present: +Two structural guards must still be present: -- **`parsePermissionGroupConfig` still tests `Array.isArray(config)`** alongside its truthiness and `typeof … === 'object'` checks. `typeof [] === 'object'`, so without it an array reaches `z.object().parse([])`, which throws — and the column is `jsonb`, so a row genuinely can hold `[]`. The guard is what makes that path return the defaults instead of a 500. `tolerantArray` carries the mirror-image guard. -- **`CAPABILITY_RULES` still uses `satisfies`, not a type annotation.** An annotation widens every entry to `CapabilityRule`, and `StaticPermissionGroupCapability` — derived by filtering the object's entries for `kind: 'static'` — then resolves to `never`. No operation can declare any capability, the type system stops constraining capabilities entirely, and nothing at runtime looks wrong. `AssertsStaticCapabilityResolves` exists to catch it; report any weakening of it as a top-tier finding. +- **`parsePermissionGroupConfig` still tests `Array.isArray(config)`.** `typeof [] === 'object'`, the column is `jsonb` so a row genuinely can hold `[]`, and `z.object().parse([])` throws — the guard is what returns defaults instead of a 500. `tolerantArray` carries the mirror image. +- **`CAPABILITY_RULES` still uses `satisfies`, not an annotation.** An annotation collapses `StaticPermissionGroupCapability` to `never`, silently disabling the type system around capabilities with nothing wrong at runtime. `AssertsStaticCapabilityResolves` catches it; any weakening is a top-tier finding. -Confirm the type assertions at the bottom of `fields.ts` still name a field of this kind (`AssertsAllowlistStaysPrecise`, `AssertsDenylistStaysPrecise`, `AssertsRestrictionStaysPrecise`, `AssertsAuthTypesStayPrecise`, `AssertsParserReturnsTheConfig`). They exist because a zod generic degrading to `unknown` is invisible at runtime — the values stay right, no test fails, and every call site quietly loses its narrowing. +Confirm the assertions at the bottom of `fields.ts` still name a field of this kind (`AssertsAllowlistStaysPrecise`, `AssertsDenylistStaysPrecise`, `AssertsRestrictionStaysPrecise`, `AssertsAuthTypesStayPrecise`, `AssertsParserReturnsTheConfig`) — a zod generic degrading to `unknown` is invisible at runtime and quietly loses every call site's narrowing. -## Step 3: Admin UI +## Step 3: Admin UI (`ee/access-control/components/group-detail.tsx`) -Open `apps/sim/ee/access-control/components/group-detail.tsx`. - -- **Boolean:** it should appear automatically — `PLATFORM_FEATURES` in `features.ts` is derived by filtering `field.kind === 'boolean-restriction'`. Confirm its `category` is in `PLATFORM_CATEGORY_ORDER`; an unlisted category renders after every ordered section. -- **Allowlist or denylist:** it renders **nothing** unless something puts it there. Look for the key in the `featureExtras` map — which is keyed by the *feature id of the parent boolean*, not by the allowlist's own config key. A non-boolean key with no picker and no bespoke section is a key no admin can ever set. Report it. -- For a picker, check both behaviors: the setter refuses an empty selection (`if (values.length === 0) return`), and collapses a full selection back to `null` (`values.length === ALL.length ? null : values`). Storing the full set freezes the allowlist at today's members, so a member added next release is denied by a group that had chosen "all". -- Check the parent the picker nests under is the right one. `allowedKnowledgeConnectors` hangs off `hide-knowledge-base`, not `disable-knowledge-base-creation`, because a connector attaches to an existing knowledge base — nesting it under creation would dim the picker for exactly the cohort it was written for. +- **Boolean:** appears automatically via `PLATFORM_FEATURES`. Confirm its `category` is in `PLATFORM_CATEGORY_ORDER`; an unlisted one renders after every ordered section. +- **Allowlist / denylist:** renders **nothing** unless it is in the `featureExtras` map — keyed by the *parent boolean's feature id*, not the config key. No picker and no bespoke section means no admin can ever set it. Report it. +- For a picker, check both behaviors: refuses an empty selection (`if (values.length === 0) return`) and collapses a full one back to `null` (otherwise the allowlist freezes at today's members). +- Check the parent is the right one (`allowedKnowledgeConnectors` under `hide-knowledge-base`, not `disable-knowledge-base-creation`). ## Step 4: Capability rule -If `enforcement` is `'capability'`, the key must appear in some rule's `configKeys` in `CAPABILITY_RULES` — the audit asserts this (assertion D) and also asserts the converse (E): a key declared `'executor'` or `'ui-only'` that a rule reads is flagged, so a key cannot gain enforcement while staying documented as something weaker. - -Then check the things the audit cannot: +A `'capability'` key must appear in some rule's `configKeys` — the audit asserts this (D) and the converse (E): a key declared `'executor'` or `'ui-only'` that a rule reads is flagged, so a key cannot gain enforcement while staying documented as weaker. Then check what the audit cannot: -- **`configKeys` lists every key `deniedBy` actually reads.** The audit parses `configKeys` textually; it does not read the closure. A key read by `deniedBy` but missing from `configKeys` is invisible to assertions D and E. -- **`kind` is right.** A rule whose decision needs a request value must be `'parameterized'`. A parameterized rule can never be declared on an operation — `defineWorkspaceOperation` throws at definition time — so if you find one named on an operation, that code does not run in production; something else is wrong. -- **A narrower capability subsumes the broader one it replaced.** An operation carries exactly one capability. If this capability was split off a more general one, its rule must also read the general key. The precedent is `knowledge.create` and `knowledge.upload`, which both read `hideKnowledgeBaseTab` alongside their own key — without that, a group withholding the entire Knowledge Base module could still create one through the API. Check `git log` for a re-pointed `capability:` field and verify the narrower rule grew the broader key in the same commit. -- **`detailCode` matches the remedy.** `FORBIDDEN_DETAIL_CODES` is closed over *remedies*, not causes. A distinct code is warranted only when a caller would act differently; otherwise `PERMISSION_GROUP_CAPABILITY_BLOCKED` is correct. Any code in use must have an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS`, which is a compile-time gate and also publishes the OpenAPI 403 text. -- **`describe` reads correctly in the sentence.** Two functions build it and there is no third, both **defined in `capabilities.ts`**: `refuseCapability(capability)` throws `" is not available under your organization's permission group"` as a `PermissionGroupCapabilityError`, and `capabilityRefusal(capability)` returns the same string for a raw route rendering its own body (`capability-assertions.ts` re-exports it so an inline gate reaches both through one module). `describe` must be a singular noun or gerund phrase that agrees with "is". Any call site that writes the sentence out itself is a drift finding. +- **`configKeys` lists every key `deniedBy` reads.** The audit parses it textually and never reads the closure; a key read but unlisted is invisible to D and E. +- **`kind` is right.** A rule needing a request value must be `'parameterized'` — and a parameterized rule named on an operation cannot have run in production (`defineWorkspaceOperation` throws at definition time), so something else is wrong. +- **A narrower capability subsumes the broader one it replaced.** An operation carries exactly one capability. Precedent: `knowledge.create` / `knowledge.upload` both read `hideKnowledgeBaseTab`, without which a group withholding the whole module could still create a KB through the API. Check `git log` for a re-pointed `capability:` and verify the narrower rule grew the broader key in the same commit. +- **`detailCode` matches the remedy** — `FORBIDDEN_DETAIL_CODES` is closed over remedies, not causes; otherwise `PERMISSION_GROUP_CAPABILITY_BLOCKED`. Any code in use needs an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS`, a compile-time gate that also publishes the OpenAPI 403 text. +- **`describe` reads correctly** as the subject of `" is not available under your organization's permission group"` — a singular noun or gerund agreeing with "is". Exactly two functions build that sentence, both defined in `capabilities.ts` (`refuseCapability` throws it, `capabilityRefusal` returns it); any call site writing it out is a drift finding. ## Step 5: Prove the enforcement — do not assume it -This is the step the whole skill exists for. Find the **actual refusal**, name the file and line, and describe what a caller sees. +The step the skill exists for. Find the **actual refusal**, name file and line, and say what a caller sees. ```bash grep -rn "''" apps/sim --include='*.ts' --include='*.tsx' grep -rn "permission-group-enforced: " apps/sim ``` -The second grep will miss a gate written through `capabilityDeniedBy` with the annotation in a TSDoc block above the enclosing statement, so read the surrounding function rather than the matched line alone. +The second grep misses a gate whose annotation sits in a TSDoc block above the enclosing statement — read the surrounding function. -Classify what you find into exactly one of: +Classify into exactly one of: -1. **Declared on operations.** `capability: ''` on one or more `defineWorkspaceOperation` calls. The funnel enforces in `requireCurrentHumanAccess` → `requireCapability`. Verify the set of operations is *complete*: enumerate every route and tool that reaches the same behavior and check each one's operation declares it. One route declaring `capability: 'none'` for the same behavior is the hole. -2. **Asserted at a call site**, with a `// permission-group-enforced: ` annotation. Verify the assertion goes through `capability-assertions.ts` (`assertWorkspaceCapability`, `isWorkspaceCapabilityWithheld`, `isOrganizationCapabilityWithheld`, `capabilityDeniedBy`) or a direct `CAPABILITY_RULES[''].deniedBy(...)` rather than spelling the config key out inline — a call site reading `config.disableX` directly stops denying anything the moment the key is renamed, and its wording drifts from the funnel's. Then check the second half, which is easy to miss because the decision looks right: does it *raise* through `refuseCapability` (or render `capabilityRefusal(cap)`), or does it build its own `ForbiddenOperationError` with a hand-written message? `validatePublicFileSharing` and `validateChatDeployAuth` in `ee/access-control/utils/permission-check.ts`, and `assertConnectorTypeAllowed` in `lib/knowledge/application/connectors.ts`, all read the rule and call `refuseCapability` — that is the pattern for a use case. A raw route that renders its own body pairs `isWorkspaceCapabilityWithheld` (or `capabilityDeniedBy`) with `capabilityRefusal`; `app/api/logs/export/route.ts` and the inbox and api-keys routes are the shape. -3. **Executor-gated.** Read by `assertPermissionsAllowed` in `ee/access-control/utils/permission-check.ts`, per block / tool / model. Verify the branch exists and throws a real error, and that the id it compares against is the same vocabulary the admin UI writes — `deniedTools` holds block `tools.access` ids verbatim, version suffix included. -4. **A field projection, not a gate.** `logs.trace_spans` and `logs.cost` withhold fields from a response rather than the response itself, so the logs routes correctly declare `capability: 'none'`. The single owner is `apps/sim/lib/logs/log-projection.ts` (`resolveLogFieldProjection`, `projectExecutionData`, `projectCostTotal`), which carries both `permission-group-enforced:` annotations. A **second** implementation of the same redaction anywhere else is the finding here — two copies is how one of them stops redacting. -5. **Nothing.** Report it as a defect, with the sentence "an organization that sets this believes it applied a restriction that does not exist". +1. **Declared on operations.** The funnel enforces in `requireCurrentHumanAccess` → `requireCapability`. Verify the set is *complete*: enumerate every route and tool reaching the same behavior. One declaring `capability: 'none'` is the hole. +2. **Asserted at a call site** with a `// permission-group-enforced: ` annotation. Verify it goes through `capability-assertions.ts` (`assertWorkspaceCapability`, `isWorkspaceCapabilityWithheld`, `isOrganizationCapabilityWithheld`, `capabilityDeniedBy`) or a direct `CAPABILITY_RULES[''].deniedBy(...)` rather than reading `config.disableX` inline, **and** that it *raises* through `refuseCapability` / renders `capabilityRefusal(cap)` rather than building its own `ForbiddenOperationError` with a hand-written message — the easy half to miss, because the decision looks right. Use-case shape: `validatePublicFileSharing`, `validateChatDeployAuth` (`ee/access-control/utils/permission-check.ts`), `assertConnectorTypeAllowed` (`lib/knowledge/application/connectors.ts`). Raw-route shape: `app/api/logs/export/route.ts` and the inbox and api-keys routes. +3. **Executor-gated** by `assertPermissionsAllowed`, per block / tool / model. Verify the branch throws a real error and that the id it compares against is the vocabulary the admin UI writes — `deniedTools` holds block `tools.access` ids verbatim, version suffix included. +4. **A field projection, not a gate.** `logs.trace_spans` and `logs.cost` withhold fields, so the logs routes correctly declare `capability: 'none'`. Single owner: `lib/logs/log-projection.ts` (`resolveLogFieldProjection`, `projectExecutionData`, `projectCostTotal`), which carries both annotations. A **second** implementation of the same redaction is the finding — as is a query that lets a caller filter or sort on a withheld field, which turns the projection into an oracle. +5. **Nothing.** Report as a defect: "an organization that sets this believes it applied a restriction that does not exist". -Then make the refusal happen. Either write a failing case, or take the existing test and **remove the gate** — delete the `capability:` field, or the `deniedBy` body, or the assertion call — and confirm the test goes red. A test that still passes with the gate removed is proving nothing. Restore the code afterward. +Then **make the refusal happen**: write a failing case, or remove the gate (the `capability:` field, the `deniedBy` body, the assertion call) and confirm an existing test goes red. A test that still passes with the gate removed proves nothing. Restore afterward. -For an allowlist, the three states have to be tested separately, because they are what the parser and the UI conspire to confuse: `null` permits every member, a populated list permits only the named ones, `[]` permits **none**. `capabilities.test.ts` pins all three for `knowledge.connectors`; anything less than that for another allowlist is a gap. +For an allowlist the three states must be tested separately — `null` permits every member, a populated list only the named ones, `[]` permits **none**. `capabilities.test.ts` pins all three for `knowledge.connectors`; less than that elsewhere is a gap. ### Who the gate runs against -A capability belongs to a *person*, so half of auditing a gate is auditing whose id it reads. - -- **`/api/v1`** authorizes in `apps/sim/app/api/v1/middleware.ts`, not through `authorizeWorkspaceOperation`. Every route threads a `V1RouteCapability` (`StaticPermissionGroupCapability | 'none'`, required and spelled out), and the subject must come from `capabilityGovernedUserId(rateLimit)`, which returns `null` for a workspace key. `rateLimit.userId` is populated for **both** key kinds and is the key's *creator* for a workspace key, so any gate keyed on the presence of a user id applies a bystander's group to every caller of a shared credential. Reading `rateLimit.userId` (or `auth.userId`) into a capability sink is the finding; `scripts/check-capability-subject.ts` exists because it has shipped twice. -- **Raw internal table routes** gate `tables.use` inside `checkAccess` in `apps/sim/app/api/table/utils.ts`, whose signature takes a `TableAccessPrincipal` discriminated union — `{ kind: 'user'; userId }` or `{ kind: 'workspace_api_key'; keyCreatorUserId }` — rather than a bare `userId`, so a caller cannot reach the gated behavior without naming a kind. A bare id passed here no longer type-checks; `tableAccessPrincipal(rateLimit)` in the v1 middleware is the one place v1 builds it. -- **The definition-time `undefined` guard.** `defineWorkspaceOperation` throws when `capability` is `undefined`, even though the field is required, because `apps/sim/tsconfig.json` excludes `*.test.ts` / `*.test.tsx` and the enforcement audit walks past test files — a fixture is the one construction site no static check reads. Without the guard, a capability-less operation defines cleanly and then throws `Cannot read properties of undefined` inside `capabilityDeniedBy` **only for tenants that actually have a permission group**, passing CI and every personal workspace. If you find a proposal to drop the guard as redundant, that is a finding. +- **`/api/v1`** authorizes in `app/api/v1/middleware.ts`, not through `authorizeWorkspaceOperation`. The subject must come from `capabilityGovernedUserId(rateLimit)`, which returns `null` for a workspace key; `rateLimit.userId` is populated for **both** key kinds and is the key's *creator* for a workspace key, so a gate keyed on the presence of a user id applies a bystander's group to every caller of a shared credential. Reading `rateLimit.userId` (or `auth.userId`) into a capability sink is the finding — `check-capability-subject.ts` exists because it has shipped twice. Each route also threads a required, spelled-out `V1RouteCapability`. +- **Raw internal table routes** gate `tables.use` in `checkAccess` (`app/api/table/utils.ts`) via a `TableAccessPrincipal` union — `{ kind: 'user'; userId }` or `{ kind: 'workspace_api_key'; keyCreatorUserId }` — so a bare id no longer type-checks. `tableAccessPrincipal(rateLimit)` is the one place v1 builds it. +- **The definition-time `undefined` guard** on `defineWorkspaceOperation` is not redundant even though `capability` is required (`StaticPermissionGroupCapability | 'none'`): `apps/sim/tsconfig.json` excludes `*.test.ts` / `*.test.tsx` and the enforcement audit walks past test files, so a fixture is the one construction site no static check reads. Without it a capability-less operation defines cleanly and then throws `Cannot read properties of undefined` inside `capabilityDeniedBy` **only for tenants that actually have a permission group**, passing CI and every personal workspace. A proposal to drop it is a finding. ## Step 6: Tests -- **`apps/sim/lib/permission-groups/fields.test.ts`** (formerly `types.test.ts`) — the key must appear in both the `input` and `expected` halves of the `'a fully populated config'` fixture. The corpus is pinned deliberately: a row that changes in a later diff has to be defended as a semantic decision rather than slipping through as a regression. The rest of that file (wire order, idempotence, read-schema acceptance, the seeded 2000-iteration fuzz, the write/default/read key-set agreement, boolean-to-`PLATFORM_FEATURES` coverage) derives from `DEFAULT_PERMISSION_GROUP_CONFIG` and needs no per-key edit. +- **`fields.test.ts`** — the key must be in both the `input` and `expected` halves of the `'a fully populated config'` fixture; that corpus is pinned so a changed row is defended rather than slipping through. The rest of the file derives from `DEFAULT_PERMISSION_GROUP_CONFIG` and needs no per-key edit. - **`capabilities.test.ts`** — a case for any rule with logic beyond reading one key: subsumption, allowlist three-state, auth-mode membership. -- **`features.test.ts`** — derived from `PLATFORM_FEATURES`; a boolean key needs no edit. A non-boolean key contributing user-facing prose should have its `limited` / `empty` strings pinned there. -- **`config-scope.server.test.ts`** — covers the per-request memo. A new gate that resolves the config outside `resolvePermissionGroupConfig` bypasses it and is a finding in Step 2, not here. +- **`features.test.ts`** — no edit for a boolean; a non-boolean key's `limited` / `empty` prose should be pinned here. +- **`config-scope.server.test.ts`** — the per-request memo. A gate resolving the config outside `resolvePermissionGroupConfig` is a Step 2 finding, not one here. ## Step 7: Run the checks @@ -125,48 +107,46 @@ cd apps/sim && bun run type-check cd apps/sim && bunx vitest run lib/permission-groups ``` -All three audits are inside `check:audits`, which derives its list from the `check:*` scripts in `package.json` — a new audit is opted *out* deliberately rather than opted in. Read their output, not just their exit codes. - -**`check:permission-group-enforcement`** is all-or-nothing — one success line or findings; there is no count-down or migration mode that exits 0 with work outstanding, so do not go looking for a `pending enforcement:` list. What it can still do is pass without proving what you want: - -- **Vacuous parse.** It reads source text with regexes. It refuses to report success when `CAPABILITY_IDS`, `CAPABILITY_RULES`, or `PERMISSION_GROUP_FIELDS` parse to nothing, cross-checks that the rule count equals the capability count, reports per-call any `defineWorkspaceOperation` whose `id` it cannot read, and — the newest guard — **fails a file that calls `defineWorkspaceOperation` but parses to ZERO declarations**, which is what a non-literal `id:` or an arrow-const factory looks like. If any of those fire, the audit is broken, not the code — fix the parsers rather than leaving it passing. -- **A capability declared on an operation nothing routes to.** Assertion C is satisfied by the declaration alone. An operation that no route, tool, or use case actually invokes still counts as reaching the capability. - -**`check:application-graph`** asserts the authorization funnel and `with-route-handler.ts` reach no heavy module tree at runtime, across five guarded roots: `lib/core/application/index.ts`, `capabilities.ts`, `capability-assertions.ts` and `config-scope.server.ts` may not reach `providers/`, `blocks/`, `tools/`, `executor/`, `lib/uploads/` or `lib/workflows/`; `with-route-handler.ts` additionally may not reach `lib/billing/`, `lib/permission-groups/resolve.server`, `lib/auth`, `lib/copilot/` or `lib/knowledge/`. Only runtime edges count — `import type` is erased and deliberately allowed. A gate you are auditing that imports a resolver into one of those roots is a finding even if the gate itself is correct. The failure mode never announces itself: past regressions surfaced as unrelated knowledge tests failing on a partial mock and an OTP-route test failing on its own `zod` mock. - -**`check:capability-subject`** asserts every v1 capability sink takes its subject from `capabilityGovernedUserId`, that no v1 file outside the middleware imports the permission-group modules directly, and that at least one governed sink was found at all (so a refactor into an unparseable form cannot look like a clean tree). - -Reference success lines: +All three are inside `check:audits`, which derives its list from the `check:*` scripts in `package.json` — a new audit is opted *out* deliberately. Read the output, not the exit codes. Reference success lines (counts grow): ``` -✓ permission-group enforcement: 287 operations declare a capability, 35 capabilities all enforced +✓ permission-group enforcement: 315 operations declare a capability, 35 capabilities all enforced ✅ Application graph clean: 5 roots reach none of 11 forbidden module trees check:capability-subject — 32 v1 files, 5 capability subjects resolved through capabilityGovernedUserId. ``` -The audits prove *reachability*, not correctness: that a capability is named somewhere, that a key is read by some rule, that a subject came from the right helper. They cannot tell whether the rule's logic is right, whether every relevant operation declares it, or whether an annotated call site actually calls anything. Step 5 is what covers that, and no amount of green CI substitutes for it. +| Audit | What it catches | +|---|---| +| `check:permission-group-enforcement` | Every operation declares a capability and every capability is enforced. All-or-nothing — no migration mode exits 0 with work outstanding, so do not go looking for a `pending enforcement:` list | +| `check:application-graph` | The funnel roots (`lib/core/application/index.ts`, `capabilities.ts`, `capability-assertions.ts`, `config-scope.server.ts`) and `with-route-handler.ts` reach no heavy module tree at *runtime* (`import type` is erased and allowed). A gate that imports a resolver into a guarded root is a finding even if the gate is correct; past regressions surfaced only as unrelated tests failing on partial mocks | +| `check:capability-subject` | Every v1 capability sink takes its subject from `capabilityGovernedUserId`, no v1 file outside the middleware imports the permission-group modules, and at least one governed sink was found at all | + +Two ways the enforcement audit passes without proving what you want: + +- **Vacuous parse.** It reads source text with regexes, so it refuses success when the three registries parse to nothing, cross-checks rule count against capability count, reports per call any unreadable `id`, fails a file that mints an operation but parses to **zero** declarations, and flags any exported `*Operations` registry member it read no operation from. If one fires the audit is broken, not the code — fix the parsers rather than leaving it green. (That last guard exists because an operation minted by a factory that never calls the builder bypasses the required type *and* the audit; twenty-one operations across six domains were invisible that way while the file still printed a tick.) +- **A capability declared on an operation nothing routes to.** Assertion C is satisfied by the declaration alone. + +The audits prove *reachability*, never correctness — that a capability is named, a key is read by some rule, a subject came from the right helper. Step 5 is what covers the rest. ## Known gaps — recognize these, do not re-report them -These are understood, deliberate, and documented in the code. Note them if they are material to what you were asked about; do not file them as new findings. +Each is deliberate and documented in the code; `add-permission-group-item` carries the full reasoning. -- **A workspace API key resolves no permission group.** It authorizes as the workspace, so there is no user and `operation.capability` does not apply — the `workspace_api_key` branch of `authorizeWorkspaceOperation` returns before any capability check, and the same reasoning shapes `TableAccessPrincipal`, `capabilityGovernedUserId` and the log projection. Substituting the key's creator would apply a bystander's group to every caller of a shared key and break the key outright when that person left the organization. The escape is closed at the door instead: minting a workspace key is itself capability-gated. -- **An executor delegation carries role but not capabilities.** A delegated `executor` principal *with* a `sim_user` subject goes through `requireCurrentHumanRole` only. A run carries the role of whoever triggered it, but a capability names what a *person* may reach in the product, while a run reaches those resources because a block in the graph does — applying capabilities would turn "hide Tables" into a runtime kill-switch that breaks every workflow with a Table block for that cohort. -- **An actorless deployment run passes through.** A delegated `executor` principal in `mode: 'deployment'` with no resolvable subject is authorized without a capability check, because a deployed workflow acts with the workspace's authority rather than its author's permission group. Denying there would 403 every scheduled run, webhook, and public-API call in the organization the moment a group withheld anything. What such a run *does* is still governed, by `assertPermissionsAllowed` in the executor — which is precisely why the four run-scoped keys carry `enforcement: 'executor'` rather than `'capability'`. -- **Copilot is deliberately NOT exempt.** A delegated principal with a `sim_user` subject whose `serviceId` is anything other than `executor` takes the full `requireCurrentHumanAccess` path, capability check included. Copilot acts as the person, so it must not reach what the person may not. A proposal to exempt it is a finding, not a simplification. -- **Capability is checked after the role check.** `requireCurrentHumanAccess` runs `requirePermission` first. `NoWorkspaceAccessError` is concealed as a 404 by the v2 surface so a non-member cannot learn the resource exists; refusing on capability first would hand a complete outsider an oracle for which capabilities the organization withholds. It is also the cheaper check and names the remedy the caller can act on. The v1 middleware states the same ordering rule in its own TSDoc. Do not report the ordering as a bug. -- **`allowedEgressHosts` does not exist.** There is no network-egress allowlist in `PERMISSION_GROUP_FIELDS`. Requests for one are a feature, not a missing wiring of an existing key. -- **Nothing currently ships as `ui-only`.** The `enforcement` union has the member and no user. An absent `ui-only` key is not a gap. +- **A workspace API key resolves no permission group** — it authorizes as the workspace, so there is no user and `operation.capability` does not apply; the same reasoning shapes `TableAccessPrincipal`, `capabilityGovernedUserId` and the log projection. Substituting the key's creator would apply a bystander's group to every caller of a shared key and break the key when that person left. Minting a workspace key is itself capability-gated. +- **An executor delegation carries role but not capabilities** — a delegated `executor` principal with a `sim_user` subject goes through `requireCurrentHumanRole` only. A capability names what a *person* may reach; applying it to a run makes "hide Tables" a kill-switch for every workflow with a Table block. +- **An actorless deployment run passes through** — a delegated executor principal in `mode: 'deployment'` with no resolvable subject acts with the workspace's authority; denying would 403 every scheduled run, webhook and public-API call. What such a run *does* is still governed by `assertPermissionsAllowed`, which is why the four run-scoped keys carry `enforcement: 'executor'`. +- **Copilot is NOT exempt** — a delegated principal with a `sim_user` subject whose `serviceId` is anything other than `executor` takes the full `requireCurrentHumanAccess`. Copilot acts as the person. A proposal to exempt it is a finding. +- **Capability is checked after the role check** — `NoWorkspaceAccessError` is concealed as a 404 by the v2 surface, so refusing on capability first would hand a non-member an oracle for what the organization withholds. The v1 middleware states the same ordering in its TSDoc. Not a bug. +- **`allowedEgressHosts` does not exist** — there is no network-egress allowlist. Requests for one are a feature, not missing wiring. +- **Nothing currently ships as `ui-only`** — the union member has no user; an absent `ui-only` key is not a gap. ## Report Format -For each item audited, state: - 1. **Kind and enforcement** — as declared, and whether the declaration is true. -2. **The refusal** — file, line, the error thrown, and what a caller sees (status, `detailCode`, message). Or: *it is a projection, and here is its single owner*. Or: *nothing refuses*. +2. **The refusal** — file, line, error thrown, what the caller sees (status, `detailCode`, message). Or: it is a projection, and here is its single owner. Or: nothing refuses. 3. **The subject** — whose user id the gate reads, and that a workspace key reaches it ungated rather than as its creator. -4. **Proof** — the test that fails when the gate is removed, or the statement that no such test exists. -5. **Coverage gaps** — routes, tools, or surfaces reaching the same behavior without the gate. +4. **Proof** — the test that fails when the gate is removed, or that no such test exists. +5. **Coverage gaps** — routes, tools, surfaces reaching the same behavior without the gate. 6. **Findings**, ordered: unenforced key > key-creator substituted for the acting principal > fail-open coercion (`.catch()` on an array, a dropped `Array.isArray` guard, `CAPABILITY_RULES` annotated instead of `satisfies`) > incomplete operation coverage > allowlist three-state confusion > **admin copy that misstates the enforcement** > duplicated projection logic > missing admin UI > missing test > cosmetic. -A hint that says "hide" for a key that 403s is not cosmetic. It is the one defect an admin acts on directly: they tick it believing they hid a link, and members lose the module. Rank it with the enforcement findings, not the polish. +A hint saying "hide" for a key that 403s is not cosmetic — it is the one defect an admin acts on directly: they tick it believing they hid a link, and members lose the module. Rank it with the enforcement findings. From 98ce514ccaabbf93f3a33a960fd1f234c72cdad5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:34:47 -0700 Subject: [PATCH 059/179] test(v1): make the mocked middleware helpers mirror the real ones `tables/[tableId]/route.test.ts` re-implemented `tableAccessPrincipal` branching on `keyType === 'workspace'`; production branches on `=== 'personal'`, so the stub disagreed with it for any other keyType and would have quietly authorized the wrong principal the moment a caller stopped setting one. Both stubs now name the helper they mirror and say why the branch reads `keyType` rather than the presence of a user id. --- .../v1/logs/executions/[executionId]/route.test.ts | 5 +++++ apps/sim/app/api/v1/tables/[tableId]/route.test.ts | 14 ++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts index 7a10930557a..bfde46a4b91 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts @@ -12,6 +12,11 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/app/api/v1/middleware', () => ({ + /** + * Mirrors the real `capabilityGovernedUserId`: a workspace key reports its + * creator's `userId` too, so `keyType` — not the presence of a user — is what + * decides whether a permission group governs the caller. + */ capabilityGovernedUserId: (rateLimit: { keyType?: string; userId?: string }) => rateLimit.keyType === 'personal' ? (rateLimit.userId ?? null) : null, checkRateLimit: mocks.checkRateLimit, diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.test.ts b/apps/sim/app/api/v1/tables/[tableId]/route.test.ts index b9206531317..3f1d4c0cb7b 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.test.ts @@ -29,12 +29,18 @@ const { vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, - tableAccessPrincipal: (rateLimit: { keyType?: string; userId?: string }) => - rateLimit.keyType === 'workspace' - ? { kind: 'workspace_api_key', keyCreatorUserId: rateLimit.userId } - : { kind: 'user', userId: rateLimit.userId }, checkWorkspaceScope: mockCheckWorkspaceScope, createRateLimitResponse: () => NextResponse.json({ error: 'Rate limited' }, { status: 429 }), + /** + * Mirrors the real `tableAccessPrincipal`, which branches on `keyType` being + * `'personal'` — NOT on it being `'workspace'`. Only a personal key names a + * person; anything else, an absent `keyType` included, reaches `checkAccess` + * as the workspace so no bystander's permission group is applied to it. + */ + tableAccessPrincipal: (rateLimit: { keyType?: string; userId?: string }) => + rateLimit.keyType === 'personal' + ? { kind: 'user', userId: rateLimit.userId } + : { kind: 'workspace_api_key', keyCreatorUserId: rateLimit.userId }, })) vi.mock('@/lib/table', () => ({ From 62fcc1941df2fd42a177b8aac85564a09867bc29 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:35:19 -0700 Subject: [PATCH 060/179] refactor(permission-groups): converge the workspace-else-organization capability fallback --- apps/sim/app/api/cli/auth/approve/route.ts | 88 +++++++------------ apps/sim/app/api/users/me/api-keys/route.ts | 20 ++--- .../permission-groups/config-scope.server.ts | 4 +- .../permission-groups/request-scope.server.ts | 9 +- .../permission-groups/user-scope.server.ts | 37 ++++++++ scripts/check-capability-subject.ts | 1 + 6 files changed, 84 insertions(+), 75 deletions(-) create mode 100644 apps/sim/lib/permission-groups/user-scope.server.ts diff --git a/apps/sim/app/api/cli/auth/approve/route.ts b/apps/sim/app/api/cli/auth/approve/route.ts index cebc34ff3a5..fa463ab9238 100644 --- a/apps/sim/app/api/cli/auth/approve/route.ts +++ b/apps/sim/app/api/cli/auth/approve/route.ts @@ -3,69 +3,15 @@ import { type NextRequest, NextResponse } from 'next/server' import { approveCliAuthContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { getUserOrganization } from '@/lib/billing/organizations/membership' import { createApproval } from '@/lib/cli-auth/approval-store' import { enforceUserRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - capabilityRefusal, - isOrganizationCapabilityWithheld, - isWorkspaceCapabilityWithheld, -} from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' +import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CliAuthApproveAPI') -/** - * Whether `userId`'s permission group withholds CLI access. - * - * permission-group-enforced: cli.use — gates a device-auth handoff, which owns - * no workspace resource for the authorization funnel to authorize. - * - * `workspaceId` is set only for a platform-scope handoff. A personal-scope - * login has no workspace, so it falls back to the organization's default group - * rather than going ungoverned — otherwise the narrower scope would be the - * unguarded one. - */ -async function cliAccessWithheld(userId: string, workspaceId?: string): Promise { - if (workspaceId) return isWorkspaceCapabilityWithheld(userId, workspaceId, 'cli.use') - - const membership = await getUserOrganization(userId) - if (!membership) return false - return isOrganizationCapabilityWithheld(membership.organizationId, 'cli.use') -} - -/** - * Whether `userId`'s permission group withholds minting the key this approval - * would redeem for. - * - * permission-group-enforced: api_keys.manage — a raw device-auth handler with - * inline queries, which the authorization funnel never sees. - * - * This is the door the workspace-key pass-through depends on. A - * `workspace_api_key` principal resolves no user and therefore no group, so the - * funnel's capability gate never applies to it; the whole safety argument for - * that pass-through (`workspace-authorization.ts`, `app/api/table/utils.ts`, - * `app/api/v1/middleware.ts` all state it) is that minting one is itself - * capability-gated. `/api/workspaces/[id]/api-keys` gates it; without this the - * terminal was the way around, and a member denied `api_keys.manage` could mint - * the identical key with `sim login --workspace` and then out-rank every other - * capability their group withholds. - * - * Resolved from the key's own scope, matching the surface that mints the same - * key: a bound key belongs to `workspaceId`, so the workspace group governs it, - * while a personal key is user-global and belongs to no workspace, so it falls - * back to the organization's default group exactly as - * `/api/users/me/api-keys` does. - */ -async function apiKeyMintWithheld(userId: string, workspaceId?: string): Promise { - if (workspaceId) return isWorkspaceCapabilityWithheld(userId, workspaceId, 'api_keys.manage') - - const membership = await getUserOrganization(userId) - if (!membership) return false - return isOrganizationCapabilityWithheld(membership.organizationId, 'api_keys.manage') -} - /** * Records a signed-in user's approval of a CLI handoff so the waiting terminal's * poll can complete. @@ -136,7 +82,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } - if (await cliAccessWithheld(session.user.id, workspaceId)) { + /** + * permission-group-enforced: cli.use — gates a device-auth handoff, which owns + * no workspace resource for the authorization funnel to authorize. + * + * `workspaceId` is set only for a platform-scope handoff; a personal-scope + * login falls back to the organization's default group. + */ + if (await isCapabilityWithheldForUser(session.user.id, 'cli.use', workspaceId)) { logger.warn('CLI authorization blocked by permission group', { userId: session.user.id, scope, @@ -150,7 +103,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { // manage, so `cli.use` above is the whole gate for it. if (scope === 'platform') { const mintWorkspaceId = bindKeyToWorkspace ? workspaceId : undefined - if (await apiKeyMintWithheld(session.user.id, mintWorkspaceId)) { + /** + * permission-group-enforced: api_keys.manage — a raw device-auth handler + * with inline queries, which the authorization funnel never sees. + * + * This is the door the workspace-key pass-through depends on. A + * `workspace_api_key` principal resolves no user and therefore no group, so + * the funnel's capability gate never applies to it; the whole safety + * argument for that pass-through (`workspace-authorization.ts`, + * `app/api/table/utils.ts`, `app/api/v1/middleware.ts` all state it) is that + * minting one is itself capability-gated. `/api/workspaces/[id]/api-keys` + * gates it; without this the terminal was the way around, and a member + * denied `api_keys.manage` could mint the identical key with + * `sim login --workspace` and then out-rank every other capability their + * group withholds. + * + * Scoped to the key being minted: a bound key belongs to `workspaceId`, so + * the workspace group governs it, while a personal key is user-global and + * falls back to the organization's default group. + */ + if (await isCapabilityWithheldForUser(session.user.id, 'api_keys.manage', mintWorkspaceId)) { logger.warn('CLI key mint blocked by permission group', { userId: session.user.id, workspaceId: mintWorkspaceId ?? null, diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts index 652ebbabe79..88a51d54a7d 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -8,12 +8,9 @@ import { parseRequest } from '@/lib/api/server' import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { performCreatePersonalApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' -import { getUserOrganization } from '@/lib/billing/organizations/membership' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - capabilityRefusal, - isOrganizationCapabilityWithheld, -} from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' +import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('ApiKeysAPI') @@ -24,14 +21,13 @@ const logger = createLogger('ApiKeysAPI') * permission-group-enforced: api_keys.manage — a raw handler with inline * queries, which the authorization funnel never sees. * - * Personal keys are user-global and so belong to no workspace, which is why - * this resolves the organization's default group — the group that governs an - * organization-level action, the same resolution invitations use. + * Personal keys are user-global and so belong to no workspace, which is why no + * workspace is named: {@link isCapabilityWithheldForUser} then resolves the + * organization's default group — the group that governs an organization-level + * action, the same resolution invitations use. */ -async function personalKeyManagementWithheld(userId: string): Promise { - const membership = await getUserOrganization(userId) - if (!membership?.organizationId) return false - return isOrganizationCapabilityWithheld(membership.organizationId, 'api_keys.manage') +function personalKeyManagementWithheld(userId: string): Promise { + return isCapabilityWithheldForUser(userId, 'api_keys.manage') } // GET /api/users/me/api-keys - Get all API keys for the current user diff --git a/apps/sim/lib/permission-groups/config-scope.server.ts b/apps/sim/lib/permission-groups/config-scope.server.ts index ba9021faba9..42d480c46c7 100644 --- a/apps/sim/lib/permission-groups/config-scope.server.ts +++ b/apps/sim/lib/permission-groups/config-scope.server.ts @@ -1,6 +1,6 @@ import { cache } from 'react' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' -import type { PermissionGroupConfigKey } from '@/lib/permission-groups/request-scope.server' +import type { PermissionGroupScopeKey } from '@/lib/permission-groups/request-scope.server' import { getPermissionGroupConfigStore } from '@/lib/permission-groups/request-scope.server' import { getUserPermissionConfig, @@ -58,7 +58,7 @@ export function resolvePermissionGroupConfig( const store = getPermissionGroupConfigStore() if (!store) return resolveCached(userId, workspaceId, organizationId) - const key: PermissionGroupConfigKey = `${userId}:${workspaceId}` + const key: PermissionGroupScopeKey = `${userId}:${workspaceId}` const existing = store.get(key) if (existing) return existing diff --git a/apps/sim/lib/permission-groups/request-scope.server.ts b/apps/sim/lib/permission-groups/request-scope.server.ts index b28e3acd507..c972449dd5f 100644 --- a/apps/sim/lib/permission-groups/request-scope.server.ts +++ b/apps/sim/lib/permission-groups/request-scope.server.ts @@ -3,8 +3,11 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' /** * A resolution key, `userId:workspaceId`. `organizationId` is deliberately not * part of it — see `resolvePermissionGroupConfig` for why. + * + * Named for the scope rather than the config, so it cannot be mistaken for + * `PermissionGroupConfigKey` in `fields.ts`, which is a config *field* name. */ -export type PermissionGroupConfigKey = `${string}:${string}` +export type PermissionGroupScopeKey = `${string}:${string}` /** * The per-scope memo: a resolution key to the in-flight resolution for it. @@ -13,7 +16,7 @@ export type PermissionGroupConfigKey = `${string}:${string}` * authorizations share one query instead of racing to start several. */ export type PermissionGroupConfigStore = Map< - PermissionGroupConfigKey, + PermissionGroupScopeKey, Promise > @@ -47,7 +50,7 @@ if (typeof globalThis.process !== 'undefined' && globalThis.process.versions?.no * A request that authorizes several operations — a bulk mutation, a route that * runs two use cases — would otherwise resolve the same group once per * operation. Nesting this inside the request context means every route handler - * gets it without threading a parameter through 266 operations. + * gets it without threading a parameter through every operation. * * This module is deliberately free of runtime imports. `withRouteHandler` wraps * every route in the app, so anything reachable from here is loaded by every diff --git a/apps/sim/lib/permission-groups/user-scope.server.ts b/apps/sim/lib/permission-groups/user-scope.server.ts new file mode 100644 index 00000000000..e160ec1076a --- /dev/null +++ b/apps/sim/lib/permission-groups/user-scope.server.ts @@ -0,0 +1,37 @@ +import { getUserOrganization } from '@/lib/billing/organizations/membership' +import type { StaticPermissionGroupCapability } from '@/lib/permission-groups/capabilities' +import { + isOrganizationCapabilityWithheld, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' + +/** + * Whether the group governing `userId` withholds `capability`, for an action + * that may or may not name a workspace. + * + * A workspace-scoped action is governed by the group targeting that workspace. + * A user-global one — a personal API key, a CLI login with no workspace — falls + * back to the organization's default group rather than going ungoverned, which + * would leave the narrower scope as the unguarded one. + * + * Shared so that fallback cannot drift between the surfaces that mint the same + * credential: `/api/users/me/api-keys`, `/api/cli/auth/approve`. It restates no + * capability of its own — each caller names the one it enforces, and carries + * the `permission-group-enforced:` annotation for it. + * + * Not in `capability-assertions.ts` on purpose: reading the caller's + * organization membership reaches the billing graph, and that module is a + * guarded root of `check:application-graph` precisely so the authorization + * funnel never loads it. + */ +export async function isCapabilityWithheldForUser( + userId: string, + capability: StaticPermissionGroupCapability, + workspaceId?: string +): Promise { + if (workspaceId) return isWorkspaceCapabilityWithheld(userId, workspaceId, capability) + + const membership = await getUserOrganization(userId) + if (!membership?.organizationId) return false + return isOrganizationCapabilityWithheld(membership.organizationId, capability) +} diff --git a/scripts/check-capability-subject.ts b/scripts/check-capability-subject.ts index 99009ca5288..fc6c5fe1fa8 100644 --- a/scripts/check-capability-subject.ts +++ b/scripts/check-capability-subject.ts @@ -66,6 +66,7 @@ const CAPABILITY_MODULES = [ * config or asserts a capability from a user id belongs on this list. */ const CAPABILITY_SINKS: Record = { + isCapabilityWithheldForUser: 0, isWorkspaceCapabilityWithheld: 0, assertWorkspaceCapability: 0, resolvePermissionGroupConfig: 0, From 405ff13c3b2e3a74a008681fefa196a6ef4c6bf4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:37:52 -0700 Subject: [PATCH 061/179] refactor(table): delete the dead ungated table access checks --- apps/sim/app/api/table/utils.ts | 51 ---------------------- apps/sim/lib/permission-groups/features.ts | 6 +-- 2 files changed, 2 insertions(+), 55 deletions(-) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 882e1459595..a460f1e458d 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -215,19 +215,6 @@ export function multipartErrorResponse(error: MultipartError): NextResponse { return NextResponse.json({ error: message }, { status: 400 }) } -interface TableAccessResult { - hasAccess: true - table: TableDefinition -} - -interface TableAccessDenied { - hasAccess: false - notFound?: boolean - reason?: string -} - -export type TableAccessCheck = TableAccessResult | TableAccessDenied - /** * A denial carries `capability` when the caller's permission group withheld the * Tables module, so {@link accessError} can say so rather than reporting the @@ -243,44 +230,6 @@ interface ApiErrorResponse { details?: unknown } -/** - * Check if a user has read access to a table. - * Read access requires any workspace permission (read, write, or admin). - */ -async function checkTableAccess(tableId: string, userId: string): Promise { - const table = await getTableById(tableId) - - if (!table) { - return { hasAccess: false, notFound: true } - } - - const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId) - if (userPermission !== null) { - return { hasAccess: true, table } - } - - return { hasAccess: false, reason: 'User does not have access to this table' } -} - -/** - * Check if a user has write access to a table. - * Write access requires write or admin workspace permission. - */ -async function checkTableWriteAccess(tableId: string, userId: string): Promise { - const table = await getTableById(tableId) - - if (!table) { - return { hasAccess: false, notFound: true } - } - - const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId) - if (permissionSatisfies(userPermission, 'write')) { - return { hasAccess: true, table } - } - - return { hasAccess: false, reason: 'User does not have write access to this table' } -} - /** * Who is asking, for the purposes of {@link checkAccess}. * diff --git a/apps/sim/lib/permission-groups/features.ts b/apps/sim/lib/permission-groups/features.ts index c7602af67e9..5dea5b68895 100644 --- a/apps/sim/lib/permission-groups/features.ts +++ b/apps/sim/lib/permission-groups/features.ts @@ -24,10 +24,8 @@ export interface ActivePermissionGroupRestriction { /** * Render order for the platform-feature category sections; unlisted ones follow. * - * Named after what a group withholds, not after where the key's cosmetic - * ancestor used to hide a link. Every key here is server-enforced, so a section - * headed "Sidebar" or "Settings Tabs" would tell an admin they were tidying a - * nav bar while they were in fact revoking an API. + * Named after what a group withholds rather than after the surface the key once + * hid, for the reason `PlatformFeatureMeta.hint` in `fields.ts` gives at length. */ export const PLATFORM_CATEGORY_ORDER: readonly string[] = [ 'Modules', From 061d6a183271a7cfe24b45a721bdcfd7313f3f4c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:39:23 -0700 Subject: [PATCH 062/179] refactor(permission-groups): fold the single-caller unverified context resolver --- .../lib/permission-groups/resolve.server.ts | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/apps/sim/lib/permission-groups/resolve.server.ts b/apps/sim/lib/permission-groups/resolve.server.ts index e2af7963d76..2575b04514a 100644 --- a/apps/sim/lib/permission-groups/resolve.server.ts +++ b/apps/sim/lib/permission-groups/resolve.server.ts @@ -236,32 +236,25 @@ export async function resolveVerifiedUserAccessControlContext( * The unverified counterpart of {@link resolveVerifiedUserAccessControlContext}: * it loads the workspace itself to learn the owning organization. * - * Module-private on purpose — every caller outside this file has already - * access-checked the workspace and so holds the organization id, and exporting a - * resolver that looks it up again invites a second query on a path that does not - * need one. {@link getUserPermissionConfig} is the one caller. + * For the callers that have not already access-checked the workspace — a raw + * route, typically. Everything else holds the organization id already and + * should pass it, rather than paying for a second lookup of a value it has. */ -async function resolveUserAccessControlContext( +export async function getUserPermissionConfig( userId: string, workspaceId: string -): Promise { +): Promise { if (!isHosted && !isAccessControlEnabled) { - return inactiveUserAccessControlContext(null) + return mergeEnvAllowlist(null) } const workspace = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) - return resolveUserAccessControlContextForOrganization( + const context = await resolveUserAccessControlContextForOrganization( userId, workspaceId, workspace?.organizationId ?? null ) -} - -export async function getUserPermissionConfig( - userId: string, - workspaceId: string -): Promise { - return (await resolveUserAccessControlContext(userId, workspaceId)).config + return context.config } /** From e9c4f21005214fba9c8cf25600a63badd1711d77 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:40:49 -0700 Subject: [PATCH 063/179] docs(permission-groups): correct the organization-scope call-site claim --- apps/sim/lib/permission-groups/capability-assertions.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/permission-groups/capability-assertions.ts b/apps/sim/lib/permission-groups/capability-assertions.ts index dbeb6f7ad17..9d5c51f5506 100644 --- a/apps/sim/lib/permission-groups/capability-assertions.ts +++ b/apps/sim/lib/permission-groups/capability-assertions.ts @@ -77,8 +77,9 @@ export async function isWorkspaceCapabilityWithheld( * Outside the per-request memo on purpose. That memo is keyed by user and * workspace, and this decision is keyed by organization alone, so sharing it * would need a second key vocabulary in the store. No request asks an - * organization-scoped capability twice — each of the four call sites gates one - * listing — so the memo would never be hit. Key it if that changes. + * organization-scoped capability twice — every call site gates one + * organization-level act — so the memo would never be hit. Key it if that + * changes. */ export async function isOrganizationCapabilityWithheld( organizationId: string, From 12e616d6b88206d11b33105d8087c730ca5cb915 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:47:06 -0700 Subject: [PATCH 064/179] chore(permission-groups): apply the /cleanup pass findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Query: the api-keys policy read now fails closed while its query is pending or errored instead of treating an unanswered question as an unrestricted answer, and the admin switch binds to the stored workspace column it edits rather than the combined policy — a group's disablePersonalApiKeys no longer renders the stored setting as off with a toggle that mutates successfully and changes nothing visible. Comments: relocate one misattached TSDoc block, drop test-file restatements of rationale the route files canonically own, drop one-liners restating the adjacent type composition, and convert the one non-TSDoc comment block to TSDoc. The machine-read enforcement annotations and every invariant comment stay. --- .../api/auth/oauth/credentials/route.test.ts | 8 +----- apps/sim/app/api/logs/export/route.test.ts | 14 ---------- apps/sim/app/api/logs/stats/route.test.ts | 5 ---- .../[tableId]/export/download/route.test.ts | 5 ---- .../v1/logs/executions/[executionId]/route.ts | 12 ++++----- apps/sim/app/api/webhooks/route.test.ts | 1 - .../settings/components/api-keys/api-keys.tsx | 26 +++++++++++++++---- .../components/group-detail.tsx | 18 ++++++------- .../access-control/hooks/permission-groups.ts | 5 ---- .../sim/lib/api-key/application/operations.ts | 6 ----- apps/sim/lib/core/utils/with-route-handler.ts | 5 ---- 11 files changed, 37 insertions(+), 68 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index 783969bfe64..f30f604e342 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -146,13 +146,7 @@ describe('OAuth Credentials API Route', () => { await expect(response.json()).resolves.toEqual({ credentials: [] }) }) - /** - * The split this route exists to make. It authenticates through - * `checkSessionOrInternalAuth`, so one handler answers both a person opening - * the credential selector and the executor resolving a credential mid-run. - * Gating both would 403 a deployed workflow whose group permits it, hours - * after an admin ticked a box and with nothing connecting the two. - */ + /** The session/executor split documented on {@link integrationsWithheldFromSession} in the route. */ describe('integrations.manage', () => { const INTEGRATIONS_WITHHELD = { ...DEFAULT_PERMISSION_GROUP_CONFIG, diff --git a/apps/sim/app/api/logs/export/route.test.ts b/apps/sim/app/api/logs/export/route.test.ts index 259716516d9..9834e4aca7e 100644 --- a/apps/sim/app/api/logs/export/route.test.ts +++ b/apps/sim/app/api/logs/export/route.test.ts @@ -245,11 +245,6 @@ describe('GET /api/logs/export', () => { await expect(Promise.all([pendingRead, cancellation])).resolves.toBeDefined() }) - /** - * A whole-workspace CSV of run spend is the widest disclosure of the figures - * the detail view already withholds. The header keeps its column so the file - * shape does not depend on who downloaded it. - */ it('blanks the cost column and span spend when the group withholds cost', async () => { mockGetUserPermissionConfig.mockResolvedValue({ hideCostInfo: true }) queueTableRows(workflowExecutionLogs, [ @@ -270,10 +265,6 @@ describe('GET /api/logs/export', () => { expect(lines[1]).not.toContain('0.01') }) - /** - * One download carries every execution payload the workspace ever recorded, - * which is why the export is withheld separately from reading a single log. - */ it('refuses the download when the group withholds log export', async () => { mockGetUserPermissionConfig.mockResolvedValue({ disableLogExport: true }) queueTableRows(workflowExecutionLogs, [logRow(0)]) @@ -296,11 +287,6 @@ describe('GET /api/logs/export', () => { expect(await response.text()).toContain('execution-0') }) - /** - * Blanking the column while still answering `costOperator`/`costValue` - * faithfully leaves the CSV itself a bisection oracle over the figures it - * just withheld — one download per probe, the row count as the answer. - */ it('refuses a cost-filtered export when the group withholds spend', async () => { mockGetUserPermissionConfig.mockResolvedValue({ hideCostInfo: true }) queueTableRows(workflowExecutionLogs, [logRow(0)]) diff --git a/apps/sim/app/api/logs/stats/route.test.ts b/apps/sim/app/api/logs/stats/route.test.ts index 95d555a0895..25e13f2607d 100644 --- a/apps/sim/app/api/logs/stats/route.test.ts +++ b/apps/sim/app/api/logs/stats/route.test.ts @@ -58,11 +58,6 @@ describe('GET /api/logs/stats', () => { resolveGroupConfigMock.mockResolvedValue(null) }) - /** - * The response carries no spend at all, but `costOperator`/`costValue` reach - * the same indexed column the list filters on, and the run counts answered - * under them are a bisection oracle over exactly what the group withholds. - */ it('refuses a cost-filtered read when the group withholds spend', async () => { resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts index a7b6a27206b..23690550609 100644 --- a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts @@ -115,11 +115,6 @@ describe('GET /api/table/[tableId]/export/download', () => { }) }) -/** - * The second door to a finished export. Gating only the job that produces one - * left this route handing the file to anyone who could name a `jobId`, and the - * workspace job listing names every colleague's. - */ describe('tables.export capability', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts index 22d1ce54283..3b3bfa3822b 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts @@ -58,18 +58,18 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) } - /** - * The stored snapshot carries `password: true` sub-block values and `oauth-input` - * credential ids, so it is redacted before it reaches this public wire — the same - * treatment the v2 run detail applies. A snapshot the sanitizer cannot walk projects - * as `null`, which keeps the pre-existing "not found" outcome for an absent one. - */ /** `logs.cost` is a projection, not a gate — see `resolveLogFieldProjection`. */ const projection = await resolveLogFieldProjection( capabilityGovernedUserId(rateLimit), workflowLog.workspaceId ) + /** + * The stored snapshot carries `password: true` sub-block values and `oauth-input` + * credential ids, so it is redacted before it reaches this public wire — the same + * treatment the v2 run detail applies. A snapshot the sanitizer cannot walk projects + * as `null`, which keeps the pre-existing "not found" outcome for an absent one. + */ const workflowState = sanitizeExecutionSnapshotState(workflowLog.workflowState) if (!workflowState) { return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 }) diff --git a/apps/sim/app/api/webhooks/route.test.ts b/apps/sim/app/api/webhooks/route.test.ts index ce69d22dbcd..2e86b53d8a0 100644 --- a/apps/sim/app/api/webhooks/route.test.ts +++ b/apps/sim/app/api/webhooks/route.test.ts @@ -100,7 +100,6 @@ describe('POST /api/webhooks', () => { const response = await POST(upsertRequest()) expect(response.status).toBe(403) - // Refused before the provider is told to start delivering. expect(mocks.createExternalWebhookSubscription).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index dd76082989b..ba1d6852ea3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -111,17 +111,33 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { * block and model availability, which would pull the block registry into this * settings page's module graph for one boolean. */ - const { data: permissionData } = useUserPermissionConfig(workspaceId) + const permissionConfigQuery = useUserPermissionConfig(workspaceId) /** * Both layers have to agree. The workspace column is the coarse switch every * workspace has; the permission group narrows it for one cohort inside an * enterprise organization. The server combines them the same way, so offering - * a key type here that it would refuse is the only failure worth avoiding. + * a key type here that it would refuse is the only failure worth avoiding — + * which is why the policy fails closed while its query is pending or errored, + * rather than treating an unanswered question as an unrestricted answer. In + * personal scope the hook is disabled (no `workspaceId`) and no group + * applies, so `isSuccess` is only required when the query actually runs. */ + const permissionPolicyReady = !workspaceId || permissionConfigQuery.isSuccess + + /** + * The stored workspace column alone. The admin switch below binds to this, + * not to the combined policy — a group's `disablePersonalApiKeys` must not + * render the stored setting as off, or toggling it "on" fires a successful + * mutation with no visible effect. + */ + const storedAllowPersonalApiKeys = + workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true + const allowPersonalApiKeys = - (workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true) && - !permissionData?.config?.disablePersonalApiKeys + storedAllowPersonalApiKeys && + permissionPolicyReady && + !permissionConfigQuery.data?.config?.disablePersonalApiKeys const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const [deleteKey, setDeleteKey] = useState(null) @@ -351,7 +367,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { {isLoadingSettings ? null : ( { try { diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index f906f8d384b..57e668a3102 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -110,11 +110,9 @@ const ALL_CHAT_DEPLOY_AUTH_TYPES: ShareAuthType[] = CHAT_DEPLOY_AUTH_TYPE_OPTION /** * Knowledge base connectors an admin can allow or disallow. `null` config = all * allowed. Sorted by display name because the picker is read alphabetically, - * while the registry is keyed by the snake_case id the server stores. - * - * The registry is the metadata half of the connector split — one small `meta.ts` - * per connector, deliberately client-safe — not the executable half, so the page - * weight it adds is sixty-eight tiny modules rather than connector runtimes. + * while the registry is keyed by the snake_case id the server stores. Reads + * {@link CONNECTOR_META_REGISTRY}, the client-safe metadata half of the + * connector split. */ const KNOWLEDGE_CONNECTOR_OPTIONS: { value: string; label: string }[] = Object.values( CONNECTOR_META_REGISTRY @@ -1214,11 +1212,13 @@ export function GroupDetail({ [editingConfig.allowedKnowledgeConnectors] ) + /** + * At least one connector must stay allowed while the Knowledge Base module is + * visible — an empty allow-list would silently block every connector while + * the add-connector button still offered them. To withhold connectors along + * with the rest of the module, uncheck Knowledge Base instead. + */ const setKnowledgeConnectors = useCallback((values: string[]) => { - // At least one connector must stay allowed while the Knowledge Base module - // is visible — an empty allow-list would silently block every connector - // while the add-connector button still offered them. To withhold connectors - // along with the rest of the module, uncheck Knowledge Base instead. if (values.length === 0) return setEditingConfig((prev) => ({ ...prev, diff --git a/apps/sim/ee/access-control/hooks/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts index beeb49746e3..d2f6882e113 100644 --- a/apps/sim/ee/access-control/hooks/permission-groups.ts +++ b/apps/sim/ee/access-control/hooks/permission-groups.ts @@ -111,7 +111,6 @@ export function useUserPermissionConfig(workspaceId?: string) { }) } -/** The create body, plus the organization the route params name. */ type CreatePermissionGroupVariables = CreatePermissionGroupBody & { organizationId: string } export function useCreatePermissionGroup() { @@ -132,7 +131,6 @@ export function useCreatePermissionGroup() { }) } -/** The update body, plus the group and organization the route params name. */ type UpdatePermissionGroupVariables = UpdatePermissionGroupBody & { id: string organizationId: string @@ -157,7 +155,6 @@ export function useUpdatePermissionGroup() { }) } -/** Route params only — the delete carries no wire payload of its own. */ interface DeletePermissionGroupVariables { permissionGroupId: string organizationId: string @@ -178,7 +175,6 @@ export function useDeletePermissionGroup() { }) } -/** The remove query (`memberId`), plus the group and organization it targets. */ type RemovePermissionGroupMemberVariables = RemovePermissionGroupMemberQuery & { organizationId: string permissionGroupId: string @@ -200,7 +196,6 @@ export function useRemovePermissionGroupMember() { }) } -/** The bulk-add body, plus the group and organization the route params name. */ type BulkAddPermissionGroupMembersVariables = BulkAddPermissionGroupMembersBody & { organizationId: string permissionGroupId: string diff --git a/apps/sim/lib/api-key/application/operations.ts b/apps/sim/lib/api-key/application/operations.ts index 7e00ff83c54..4c77a0d0dec 100644 --- a/apps/sim/lib/api-key/application/operations.ts +++ b/apps/sim/lib/api-key/application/operations.ts @@ -64,12 +64,6 @@ export const byokKeyOperations = { principalKinds: ['session'], entitlement: 'cleanup_allowed', }), - /** - * Not `api_keys.manage`: that capability hides the API Keys settings tab, - * which holds Sim's own keys. BYOK is a separate, entitlement-gated section - * for provider keys, and this read only reports which providers the - * organization already supplies — no group key names it. - */ // permission-group-exempt: BYOK is its own entitlement-gated section, and api_keys.manage names the Sim API Keys tab instead readInheritedStatus: defineWorkspaceOperation({ id: 'byok_keys.inherited_status.read', diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index 00bf7781160..3e225c3b9d4 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -115,11 +115,6 @@ export function withRouteHandler( return runWithRequestContext({ requestId, method, path, traceId }, async () => { let response: NextResponse | Response try { - /** - * One permission-group memo per request. A handler that authorizes - * several operations — a bulk mutation, or a route running two use - * cases — would otherwise resolve the same group once per operation. - */ response = await withPermissionGroupScope(() => handler(request, context)) } catch (error) { const duration = Date.now() - startTime From 23a6d17a718c8f229ab7b39e31f425b6a5429205 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 14:07:20 -0700 Subject: [PATCH 065/179] docs(skills): patch six omissions in the permission-group skills The trim round left gaps where the remaining text reads as exhaustive: the gate-helper enumeration, the raw-route 403 example, the executor enforcement path, the personal_api_key.use classification, where the capability field is required, and the org-null test short-circuit. --- .agents/skills/add-permission-group-item/SKILL.md | 15 +++++++++++---- .../validate-permission-group-item/SKILL.md | 10 ++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.agents/skills/add-permission-group-item/SKILL.md b/.agents/skills/add-permission-group-item/SKILL.md index fd928b0e5e1..ef6f0671199 100644 --- a/.agents/skills/add-permission-group-item/SKILL.md +++ b/.agents/skills/add-permission-group-item/SKILL.md @@ -37,9 +37,11 @@ Allowlist when the safe posture is "only what the admin named" and the member se | Value | Meaning | |---|---| | `'capability'` | An operation declares a capability whose rule reads the key; the funnel refuses before the use case runs. Default answer for anything reachable through an application operation | -| `'executor'` | Read per block/tool/model at run time by `assertPermissionsAllowed` in `ee/access-control/utils/permission-check.ts`. Governs what a *run* may do, which no operation gate can express (one API call executes fifty blocks). Only `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, `deniedTools` live here | +| `'executor'` | Read per block/tool/model at run time by `assertPermissionsAllowed` in `ee/access-control/utils/permission-check.ts`. Governs what a *run* may do, which no operation gate can express (one API call executes fifty blocks). Only `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, `deniedTools` live here. The matching primitives these four keys are compared with live in `lib/permission-groups/` — `block-access.ts` (exemptions, superseded-version resolution), `operation-access.ts` (`createToolAccessGate`), `model-access.ts` (`createModelAccessGate`), `integration-allowlist.ts` — shared so the run-time gate and the editor/Copilot projections cannot drift | | `'ui-only'` | Hides a surface without withholding it. **Almost never right** — nothing ships as `ui-only`. Justify in the `enforcement` comment why a determined caller reaching the data is acceptable, and expect review to question it | +**Is it per-operation at all?** `personal_api_key.use` is the one capability that is not: it withholds a *principal kind* across every operation, checked in the funnel's `personal_api_key` branch (`workspace-authorization.ts`) and again in `app/api/v1/middleware.ts`, so no operation declares it and its absence from every `capability:` field is correct rather than a hole. + **Is the decision knowable from the config alone?** A rule needing a request value (an auth mode, a connector id) is *parameterized* and cannot be declared on an operation — see Step 3. **Is it a gate or a projection?** A key that withholds *fields from a response* rather than the response is a projection. `hideTraceSpans` and `hideCostInfo` work this way: the logs routes declare `capability: 'none'` and strip fields, because refusing the read would withhold the status and error message too. Projections have one owner — `lib/logs/log-projection.ts` (`resolveLogFieldProjection`, `projectExecutionData`, `projectCostTotal`), carrying the `permission-group-enforced:` annotations. Add yours there; two copies of a redaction rule is how one of them stops redacting. Corollary: refuse the query that *selects on* a withheld field — otherwise the projection is a filter oracle. @@ -104,13 +106,13 @@ Capability ids are **domain-shaped** (`tables.create`); config keys are **surfac `configKeys` is what the audit reads to prove your key is enforced — it must list every key `deniedBy` reads. `describe` is the subject of one shared sentence, `" is not available under your organization's permission group"`, so make it a singular noun or gerund that agrees with "is". Exactly two functions build it, both defined in `capabilities.ts`: `refuseCapability(cap)` throws it as a `PermissionGroupCapabilityError`; `capabilityRefusal(cap)` returns it as a string for a raw route rendering its own body (`capability-assertions.ts` re-exports it so an inline gate reaches both through one module). Never write the sentence at a call site. -Use `'PERMISSION_GROUP_CAPABILITY_BLOCKED'` for `detailCode`. The set in `lib/core/application/forbidden.ts` is closed **over remedies, not causes** — a new code is warranted only when the remedy differs from "ask an organization admin", and requires an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS` (a compile-time gate) plus a new value in the generated OpenAPI 403 description. +Use `'PERMISSION_GROUP_CAPABILITY_BLOCKED'` for `detailCode`. Four rules carry a more specific one — `deploy.chat.auth_mode` (`CHAT_AUTH_MODE_NOT_PERMITTED`), `file_share.publish` / `file_share.auth_mode` (`PUBLIC_SHARING_NOT_ALLOWED`), `personal_api_key.use` (`PERSONAL_API_KEYS_DISABLED`) — which is why a call site reads the code off the rule and never spells one out. The set in `lib/core/application/forbidden.ts` is closed **over remedies, not causes** — a new code is warranted only when the remedy differs from "ask an organization admin", and requires an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS` (a compile-time gate) plus a new value in the generated OpenAPI 403 description. A **parameterized** rule is the same shape with `kind: 'parameterized'` and a `deniedBy` taking the request value second — `'knowledge.connectors'` is `(config, connectorType) => allowlistDenies(config.allowedKnowledgeConnectors, connectorType)`. It **cannot be declared on an operation**: the funnel decides from principal, workspace and operation, never request input, and widening it would touch all ~315 operations for the sake of two keys. `defineWorkspaceOperation` throws at definition time (`Operation declares parameterized capability ; assert it from the use case instead`) rather than letting the operation read as gated while the gate never fires. ## Step 4: Declare it on the operations it governs, or assert it at the call site -`capability` is **required** on `defineWorkspaceOperation`, typed `StaticPermissionGroupCapability | 'none'`, *and* guarded at definition time (`Operation declares no capability; name one, or 'none' with a reason`). The guard is not redundant: **`apps/sim/tsconfig.json` excludes `*.test.ts` / `*.test.tsx` from type-checking** and the enforcement audit walks past test files, so a fixture is the one construction site no static check reads. An absent capability does not deny — it throws `Cannot read properties of undefined` inside `capabilityDeniedBy`, and **only for a caller whose organization actually has a permission group**. It passes CI and every personal workspace, then fails in the tenants that bought the feature. +`capability` is **required on the `ApplicationOperation` base type** (`lib/core/application/operation.ts:31`), typed `StaticPermissionGroupCapability | 'none'` — required there, not only on `defineWorkspaceOperation`, so a bare object literal minted by a domain factory does not compile without it (five OAuth-connection operations once shipped capability-less that way) — *and* guarded at definition time (`Operation declares no capability; name one, or 'none' with a reason`). The guard is not redundant: **`apps/sim/tsconfig.json` excludes `*.test.ts` / `*.test.tsx` from type-checking** and the enforcement audit walks past test files, so a fixture is the one construction site no static check reads. An absent capability does not deny — it throws `Cannot read properties of undefined` inside `capabilityDeniedBy`, and **only for a caller whose organization actually has a permission group**. It passes CI and every personal workspace, then fails in the tenants that bought the feature. **Static, and the operation is the whole decision** — set `capability` and write no gate code: @@ -133,6 +135,7 @@ export const shareWidget = defineWorkspaceOperation({ | `assertWorkspaceCapability(userId, workspaceId, cap, organizationId?)` | inside a use case — the thrown `PermissionGroupCapabilityError` is projected to a 403 for you | | `isWorkspaceCapabilityWithheld(userId, workspaceId, cap, organizationId?)` | a raw handler rendering its own body — pair with `capabilityRefusal(cap)` | | `isOrganizationCapabilityWithheld(organizationId, cap)` | an action naming an organization rather than a workspace | +| `isCapabilityWithheldForUser(userId, cap, workspaceId?)` (`lib/permission-groups/user-scope.server.ts`) | a user-level act that *may or may not* name a workspace — a personal API key, a CLI device-auth handoff. Resolves the workspace's group when given one, else falls back to the organization's default group rather than going ungoverned. Deliberately outside `capability-assertions.ts`: it reads org membership through the billing graph, and that module is a guarded root of `check:application-graph` | | `capabilityDeniedBy(cap, config)` | you already hold a resolved config | Annotate the call site either way: @@ -140,10 +143,12 @@ Annotate the call site either way: ```ts // permission-group-enforced: logs.export — raw streaming route, no workspace operation to declare it on if (capabilityDeniedBy('logs.export', permissionConfig)) { - return NextResponse.json({ error: capabilityRefusal('logs.export') }, { status: 403 }) + return capabilityRefusalResponse('logs.export') } ``` +`capabilityRefusalResponse` (`lib/permission-groups/capability-response.ts`) is the one builder for that 403 — it renders `capabilityRefusal(cap)` *and* reads `details.code` off the rule, so a hand-rolled `NextResponse.json({ error: … }, { status: 403 })` reports the four specifically-coded capabilities as the generic block. v1 is deliberately not converged on it (`resolveCapabilityRefusal` in `app/api/v1/middleware.ts` renders v1's own `{ error: { code, message } }` envelope). + `isOrganizationCapabilityWithheld` resolves through `getUserPermissionConfigForOrganization`, reading the organization's **default** group — a non-default group targets specific workspaces. It sits outside the per-request memo because that memo is keyed by user and workspace, and this decision is keyed by organization alone. **Parameterized** — the helpers above are all typed `StaticPermissionGroupCapability`, so write a module-local wrapper that reads the rule and raises through `refuseCapability`, and annotate the call site. `assertConnectorTypeAllowed` in `lib/knowledge/application/connectors.ts` is the shape: @@ -169,6 +174,8 @@ Always route through `CAPABILITY_RULES` and raise with `refuseCapability` — a Add the key to **both** the `input` and `expected` objects of the `'a fully populated config'` fixture in `lib/permission-groups/fields.test.ts`, set to a non-default value. That fixture is the pinned coercion corpus: a row that changes in a later diff is a semantic decision someone defends rather than a silent regression. The file's other assertions derive from `DEFAULT_PERMISSION_GROUP_CONFIG` (wire order, idempotence, read-schema acceptance, the 2000-iteration seeded fuzz, write/default/read key-set agreement, boolean-to-`PLATFORM_FEATURES` coverage) and pick your key up for free, as does `features.test.ts`. +**Give the funnel test a real `workspaceOrganizationId`.** `requireCapability` short-circuits on `context.workspaceOrganizationId === null` (`lib/core/application/workspace-authorization.ts:171`), so a fixture whose workspace context leaves it null passes with the gate present *and* with it removed — a vacuous test that reads as load-bearing. + Add a case to `capabilities.test.ts` for any rule with logic beyond reading one key. For an allowlist assert all three states — `null` permits every member, a populated list only the named ones, `[]` permits **none** — as `capabilities.test.ts` already does for `knowledge.connectors`. ## Step 6: Keep the graph light diff --git a/.agents/skills/validate-permission-group-item/SKILL.md b/.agents/skills/validate-permission-group-item/SKILL.md index 2c7fa9e7f55..acece4284f2 100644 --- a/.agents/skills/validate-permission-group-item/SKILL.md +++ b/.agents/skills/validate-permission-group-item/SKILL.md @@ -75,12 +75,14 @@ The second grep misses a gate whose annotation sits in a TSDoc block above the e Classify into exactly one of: 1. **Declared on operations.** The funnel enforces in `requireCurrentHumanAccess` → `requireCapability`. Verify the set is *complete*: enumerate every route and tool reaching the same behavior. One declaring `capability: 'none'` is the hole. -2. **Asserted at a call site** with a `// permission-group-enforced: ` annotation. Verify it goes through `capability-assertions.ts` (`assertWorkspaceCapability`, `isWorkspaceCapabilityWithheld`, `isOrganizationCapabilityWithheld`, `capabilityDeniedBy`) or a direct `CAPABILITY_RULES[''].deniedBy(...)` rather than reading `config.disableX` inline, **and** that it *raises* through `refuseCapability` / renders `capabilityRefusal(cap)` rather than building its own `ForbiddenOperationError` with a hand-written message — the easy half to miss, because the decision looks right. Use-case shape: `validatePublicFileSharing`, `validateChatDeployAuth` (`ee/access-control/utils/permission-check.ts`), `assertConnectorTypeAllowed` (`lib/knowledge/application/connectors.ts`). Raw-route shape: `app/api/logs/export/route.ts` and the inbox and api-keys routes. -3. **Executor-gated** by `assertPermissionsAllowed`, per block / tool / model. Verify the branch throws a real error and that the id it compares against is the vocabulary the admin UI writes — `deniedTools` holds block `tools.access` ids verbatim, version suffix included. +2. **Asserted at a call site** with a `// permission-group-enforced: ` annotation. Verify it goes through `capability-assertions.ts` (`assertWorkspaceCapability`, `isWorkspaceCapabilityWithheld`, `isOrganizationCapabilityWithheld`, `capabilityDeniedBy`), through `isCapabilityWithheldForUser` (`lib/permission-groups/user-scope.server.ts` — workspace group first, else the organization's default, for a user-level act that may or may not name a workspace; outside `capability-assertions.ts` on purpose because it reads org membership through the billing graph, a guarded root of `check:application-graph`; `app/api/cli/auth/approve/route.ts` is the shape), or a direct `CAPABILITY_RULES[''].deniedBy(...)` rather than reading `config.disableX` inline, **and** that it *raises* through `refuseCapability` / renders `capabilityRefusal(cap)` rather than building its own `ForbiddenOperationError` with a hand-written message — the easy half to miss, because the decision looks right. Use-case shape: `validatePublicFileSharing`, `validateChatDeployAuth` (`ee/access-control/utils/permission-check.ts`), `assertConnectorTypeAllowed` (`lib/knowledge/application/connectors.ts`). Raw-route shape: `app/api/logs/export/route.ts` and the inbox and api-keys routes; a raw route should render through `capabilityRefusalResponse` (`lib/permission-groups/capability-response.ts`), which reads `details.code` off the rule — a hand-rolled `NextResponse.json({ error: capabilityRefusal(cap) }, { status: 403 })` drops it, reporting the four specifically-coded capabilities (`deploy.chat.auth_mode`, `file_share.publish`, `file_share.auth_mode`, `personal_api_key.use`) as the generic block. v1 is deliberately not converged on it (`resolveCapabilityRefusal` in `app/api/v1/middleware.ts`). +3. **Executor-gated** by `assertPermissionsAllowed`, per block / tool / model, matching through the shared primitives in `lib/permission-groups/` — `block-access.ts`, `operation-access.ts`, `model-access.ts`, `integration-allowlist.ts` — which the editor and Copilot projections read too, so a second copy of a match rule is a finding. Verify the branch throws a real error and that the id it compares against is the vocabulary the admin UI writes — `deniedTools` holds block `tools.access` ids verbatim, version suffix included. 4. **A field projection, not a gate.** `logs.trace_spans` and `logs.cost` withhold fields, so the logs routes correctly declare `capability: 'none'`. Single owner: `lib/logs/log-projection.ts` (`resolveLogFieldProjection`, `projectExecutionData`, `projectCostTotal`), which carries both annotations. A **second** implementation of the same redaction is the finding — as is a query that lets a caller filter or sort on a withheld field, which turns the projection into an oracle. +Ahead of all five: `personal_api_key.use` fits none of them. It withholds a *principal kind* across every operation — the funnel's `personal_api_key` branch (`lib/core/application/workspace-authorization.ts`) and `app/api/v1/middleware.ts` — so no operation declares it and `disablePersonalApiKeys` being absent from every `capability:` field is correct, not a hole. + 5. **Nothing.** Report as a defect: "an organization that sets this believes it applied a restriction that does not exist". -Then **make the refusal happen**: write a failing case, or remove the gate (the `capability:` field, the `deniedBy` body, the assertion call) and confirm an existing test goes red. A test that still passes with the gate removed proves nothing. Restore afterward. +Then **make the refusal happen**: write a failing case, or remove the gate (the `capability:` field, the `deniedBy` body, the assertion call) and confirm an existing test goes red. A test that still passes with the gate removed proves nothing. Restore afterward. **Check the fixture's `workspaceOrganizationId` first**: `requireCapability` short-circuits when it is `null` (`lib/core/application/workspace-authorization.ts:171`), so a context that leaves it unset passes either way and the existing test proves nothing even before you touch it. For an allowlist the three states must be tested separately — `null` permits every member, a populated list only the named ones, `[]` permits **none**. `capabilities.test.ts` pins all three for `knowledge.connectors`; less than that elsewhere is a gap. @@ -88,7 +90,7 @@ For an allowlist the three states must be tested separately — `null` permits e - **`/api/v1`** authorizes in `app/api/v1/middleware.ts`, not through `authorizeWorkspaceOperation`. The subject must come from `capabilityGovernedUserId(rateLimit)`, which returns `null` for a workspace key; `rateLimit.userId` is populated for **both** key kinds and is the key's *creator* for a workspace key, so a gate keyed on the presence of a user id applies a bystander's group to every caller of a shared credential. Reading `rateLimit.userId` (or `auth.userId`) into a capability sink is the finding — `check-capability-subject.ts` exists because it has shipped twice. Each route also threads a required, spelled-out `V1RouteCapability`. - **Raw internal table routes** gate `tables.use` in `checkAccess` (`app/api/table/utils.ts`) via a `TableAccessPrincipal` union — `{ kind: 'user'; userId }` or `{ kind: 'workspace_api_key'; keyCreatorUserId }` — so a bare id no longer type-checks. `tableAccessPrincipal(rateLimit)` is the one place v1 builds it. -- **The definition-time `undefined` guard** on `defineWorkspaceOperation` is not redundant even though `capability` is required (`StaticPermissionGroupCapability | 'none'`): `apps/sim/tsconfig.json` excludes `*.test.ts` / `*.test.tsx` and the enforcement audit walks past test files, so a fixture is the one construction site no static check reads. Without it a capability-less operation defines cleanly and then throws `Cannot read properties of undefined` inside `capabilityDeniedBy` **only for tenants that actually have a permission group**, passing CI and every personal workspace. A proposal to drop it is a finding. +- **The definition-time `undefined` guard** on `defineWorkspaceOperation` is not redundant even though `capability` is required on the `ApplicationOperation` **base type** (`lib/core/application/operation.ts:31`, not merely on the builder — which is what stops a bare-literal factory from compiling): `apps/sim/tsconfig.json` excludes `*.test.ts` / `*.test.tsx` and the enforcement audit walks past test files, so a fixture is the one construction site no static check reads. Without it a capability-less operation defines cleanly and then throws `Cannot read properties of undefined` inside `capabilityDeniedBy` **only for tenants that actually have a permission group**, passing CI and every personal workspace. A proposal to drop it is a finding. ## Step 6: Tests From 1285a08cd610a89c568d2ae158356b082e19109d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 14:09:10 -0700 Subject: [PATCH 066/179] fix(permission-groups): say where hideOrgMemberDirectory is read from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The member directory is an organization-scoped read with no workspace for the funnel to authorize, so both call sites resolve the organization's default group. An admin who ticks the box on a workspace-scoped group gets nothing, and the hint said nothing about it — the same silence disableWorkspaceCreation and disableCliAccess were given wording for. Extends the pinning test that covers those two siblings. --- apps/sim/lib/permission-groups/fields.test.ts | 13 ++++++++----- apps/sim/lib/permission-groups/fields.ts | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/permission-groups/fields.test.ts b/apps/sim/lib/permission-groups/fields.test.ts index 63cc30224ba..e5ae2c82d0d 100644 --- a/apps/sim/lib/permission-groups/fields.test.ts +++ b/apps/sim/lib/permission-groups/fields.test.ts @@ -327,13 +327,16 @@ describe('permission group config key coverage', () => { }) /** - * Both keys gate an action that names no workspace, so both are read from the + * Each key gates an act that names no workspace, so each is read from the * organization's default group only — a group scoped to specific workspaces - * cannot deny an account-level login or a workspace that does not exist yet. - * The editor still offers the checkbox on such a group, so the hint is the - * only place an admin learns where it applies; it shipped saying nothing. + * cannot deny an account-level login, a workspace that does not exist yet, or + * a roster read that belongs to the organization rather than to any one + * workspace. The editor still offers the checkbox on such a group, so the + * hint is the only place an admin learns where it applies; all three shipped + * saying nothing, and a hint that omits it is a checkbox that silently + * enforces nothing wherever an admin is most likely to tick it. */ - it.each(['disableWorkspaceCreation', 'disableCliAccess'] as const)( + it.each(['disableWorkspaceCreation', 'disableCliAccess', 'hideOrgMemberDirectory'] as const)( "tells an admin that %s is read from the organization's default group", (configKey) => { const feature = PLATFORM_FEATURES.find((entry) => entry.configKey === configKey) diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index fe41dca46de..31f8132953a 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -401,7 +401,7 @@ export const PERMISSION_GROUP_FIELDS = { id: 'hide-org-member-directory', label: 'Member Directory', category: 'Collaboration', - hint: 'Withhold the member directory. Members cannot see the names or email addresses of other members.', + hint: "Withhold the member directory. Members cannot see the names or email addresses of other members. Read from the organization's default group, because the directory belongs to the organization and names no workspace.", }), disableCliAccess: booleanRestriction('capability', { id: 'disable-cli-access', From a5da2770cafcd951e3a84e3e85a0ef7b7518b72c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 14:11:18 -0700 Subject: [PATCH 067/179] fix(logs): project cost and execution payloads on the run-detail and snapshot reads The run-detail family (getWorkflowExecutionStatus, its v2 use case and the internal executions route) and the execution-snapshot read returned cost, finalOutput and blockOutputs whole, so a member whose group withholds spend or execution detail on the list and log-detail surfaces read them one click deeper. Copilot's @log mention context did the same with the run total and every span's own cost. Projection now lives in the shared reads, resolved through the one helper the v1 and internal log paths already read. --- .../[id]/executions/[executionId]/route.ts | 19 +++++ apps/sim/lib/copilot/chat/process-contents.ts | 35 ++++++++- .../application/read-execution-snapshot.ts | 26 ++++++- .../application/read-workflow-run.ts | 18 ++++- .../application/workflow-runs.test.ts | 3 + .../workflows/executor/execution-status.ts | 73 +++++++++++++++++++ 6 files changed, 168 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts index 0b0e17f6e43..4e0f74a4d9c 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' +import { type AuthResult, AuthType } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, @@ -11,6 +12,22 @@ import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-s import { validateWorkflowAccess } from '@/app/api/workflows/middleware' const logger = createLogger('WorkflowExecutionStatusAPI') + +/** + * The user whose permission group governs this read, or `null` when none does. + * + * `auth.userId` is populated for every credential this route accepts, and for a + * workspace API key it is the key's *creator* — a bystander who may not be the + * caller — while an internal JWT is the executor, which carries a role but no + * capabilities. Keying on the presence of a user id would apply a group to both. + * `authType` and `apiKeyType` are the authoritative signals, the same pair + * `capabilityGovernedUserId` reads on the v1 surface. + */ +function capabilityGovernedUserId(auth: AuthResult | undefined): string | null { + if (!auth?.userId) return null + if (auth.authType === AuthType.SESSION) return auth.userId + return auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal' ? auth.userId : null +} export const GET = withRouteHandler( async ( request: NextRequest, @@ -33,6 +50,8 @@ export const GET = withRouteHandler( executionId, includeOutput, selectedOutputs, + workspaceId: access.workflow.workspaceId, + viewerUserId: capabilityGovernedUserId(access.auth), }) } catch (error) { if (error instanceof FunctionalOutputsUnavailableError) { diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index ff4f0b55d28..cb734df50ae 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -32,6 +32,11 @@ import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilit import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { readKnowledgeBase } from '@/lib/knowledge/application/knowledge-bases' +import { + projectCostTotal, + projectExecutionData, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import { toOverview } from '@/lib/logs/log-views' import type { TraceSpan } from '@/lib/logs/types' import { mcpService } from '@/lib/mcp/service' @@ -753,11 +758,35 @@ async function processExecutionLogFromDb( } } + /** + * Copilot is deliberately not exempt: it acts as the person, so the run it + * inlines is withheld exactly as the person's own log surfaces withhold it. + * `userId` here is the chatting user — both callers of + * `processContextsServer` pass the request's authenticated subject, and this + * is session context rather than an executor delegation — so it is the right + * subject for the projection, and an absent one reads whole. + * + * `logs.trace_spans` withholds the overview entirely rather than merely + * thinning it: the tree is derived from `traceSpans`, which is on the + * withheld list that the log-detail route strips outright. + * `logs.cost` blanks the run total AND every span's own `cost`, through the + * shared projector — a viewer who can sum the spans has been withheld + * nothing. + * + * permission-group-enforced: logs.trace_spans + * permission-group-enforced: logs.cost + */ + const projection = await resolveLogFieldProjection(userId, log.workspaceId) + const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store') - const executionData = (await materializeExecutionData( + const materialized = (await materializeExecutionData( log.executionData as Record | null, { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } - )) as { traceSpans?: TraceSpan[] } | undefined + )) as Record | null | undefined + const executionData = projectExecutionData(materialized ?? null, projection) as + | { traceSpans?: TraceSpan[] } + | null + | undefined const overview = executionData?.traceSpans?.length ? toOverview(executionData.traceSpans) : undefined @@ -772,7 +801,7 @@ async function processExecutionLogFromDb( endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null), totalDurationMs: log.totalDurationMs ?? null, workflowName: log.workflowName || '', - cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined, + cost: projectCostTotal(log.costTotal, projection) ?? undefined, overview, note: `For a block's input/output/error, or to grep the trace, call ${QueryLogs.id} with executionId: '${log.executionId}' — view: 'full' (scope with blockId or blockName), or pattern to grep.`, } diff --git a/apps/sim/lib/logs/application/read-execution-snapshot.ts b/apps/sim/lib/logs/application/read-execution-snapshot.ts index c44f803852a..3c7733fa377 100644 --- a/apps/sim/lib/logs/application/read-execution-snapshot.ts +++ b/apps/sim/lib/logs/application/read-execution-snapshot.ts @@ -12,6 +12,7 @@ import { import { logOperations } from '@/lib/logs/application/operations' import { hydrateChildTraces } from '@/lib/logs/execution/hydrate-child-traces' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { projectCostTotal, resolveLogFieldProjection } from '@/lib/logs/log-projection' import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' import { type ActiveWorkspaceApplicationContext, @@ -133,6 +134,27 @@ const authorizedReadExecutionSnapshotUseCase = defineAuthorizedWorkspaceUseCase( async execute({ principal, input, context }): Promise { input.signal?.throwIfAborted() const record = context.record + + /** + * A projection rather than a refusal, resolved through the shared helper the + * log-detail and v1 paths read — see {@link resolveLogFieldProjection}. Applied + * here in the use case so both doors onto this read inherit it: the internal + * snapshot route and the `logs_get_execution` Copilot tool. + * + * `cost` is the only field on the withheld list this resource carries. The + * snapshot's other payloads are the workflow's own definition — its state + * snapshot and any child-workflow snapshots — which neither capability + * withholds, and the execution data is read only to collect child snapshot + * ids; no trace span, block execution, input or final output is returned. + * + * permission-group-enforced: logs.cost + */ + const projection = await resolveLogFieldProjection( + resolvePrincipalSubjectUserId(principal), + context.workspaceId, + context.workspaceOrganizationId + ) + if (record.kind === 'job') { return { executionId: record.executionId, @@ -144,7 +166,7 @@ const authorizedReadExecutionSnapshotUseCase = defineAuthorizedWorkspaceUseCase( startedAt: record.startedAt.toISOString(), endedAt: record.endedAt?.toISOString(), totalDurationMs: record.totalDurationMs, - cost: record.cost || null, + cost: projection.hideCostInfo ? null : record.cost || null, }, } } @@ -208,7 +230,7 @@ const authorizedReadExecutionSnapshotUseCase = defineAuthorizedWorkspaceUseCase( startedAt: record.startedAt.toISOString(), endedAt: record.endedAt?.toISOString(), totalDurationMs: record.totalDurationMs, - cost: record.costTotal != null ? { total: Number(record.costTotal) } : null, + cost: projectCostTotal(record.costTotal, projection), }, } }, diff --git a/apps/sim/lib/workflows/application/read-workflow-run.ts b/apps/sim/lib/workflows/application/read-workflow-run.ts index 8674a45208b..a19111a852f 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.ts @@ -1,3 +1,4 @@ +import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import { isValidUuid } from '@sim/utils/id' import { OrchestrationError } from '@/lib/core/orchestration/types' import { @@ -59,16 +60,31 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ runId: input.runId, assertedWorkflowId: input.workflowId, }), - async execute({ context, input }) { + async execute({ principal, context, input }) { try { + /** + * The projection subject, not an attribution: a workspace API key + * authorizes as the workspace and represents no user, so it resolves to + * `undefined` and reads the run whole. Substituting the key's creator would + * apply a bystander's group to every caller of a shared credential. + */ const status = await getWorkflowExecutionStatus({ workflowId: context.workflowId, executionId: context.runId, includeOutput: input.includeOutput, selectedOutputs: input.selectedOutputs, + workspaceId: context.workspaceId, + workspaceOrganizationId: context.workspaceOrganizationId, + viewerUserId: resolvePrincipalSubjectUserId(principal), }) if (!status) throw new OrchestrationError('not_found', 'Run not found') + /** + * A run whose `blockOutputs` the viewer's group withholds joins the same + * set as a queued or `includeOutput: false` run: the selector is judged on + * its shape alone. A block *name* still hears that this resource matches + * ids, and a well-formed id still gets the legitimate empty answer. + */ const unresolvable = unresolvableSelectors(input.selectedOutputs, status.blockOutputs) if (unresolvable.length > 0) { throw new OrchestrationError( diff --git a/apps/sim/lib/workflows/application/workflow-runs.test.ts b/apps/sim/lib/workflows/application/workflow-runs.test.ts index e910167f43d..d095c5a11fb 100644 --- a/apps/sim/lib/workflows/application/workflow-runs.test.ts +++ b/apps/sim/lib/workflows/application/workflow-runs.test.ts @@ -130,6 +130,9 @@ describe('workflow run application use cases', () => { executionId: 'run-1', includeOutput: true, selectedOutputs: ['4f1c2b3a-0000-4000-8000-000000000001.value'], + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + viewerUserId: undefined, }) }) diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index bc6cf1370df..0a5540d1b7f 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -5,6 +5,11 @@ import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workfl import { getJobQueue } from '@/lib/core/async-jobs' import type { Job } from '@/lib/core/async-jobs/types' import { materializeExecutionDataForDisplayWithBlockOutputs } from '@/lib/logs/execution/trace-store' +import { + type LogFieldProjection, + projectCostTotal, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import { RESUME_EXECUTION_JOB_ID_PREFIX, WORKFLOW_EXECUTION_JOB_ID_PREFIX, @@ -120,10 +125,78 @@ export interface GetWorkflowExecutionStatusInput { executionId: string includeOutput: boolean selectedOutputs: string[] + /** + * The workspace the caller already authorized against. Passed in rather than + * read off the log row because this resource also answers from the job queue, + * for a run that has no log row yet — and that branch must be projected too. + */ + workspaceId: string + /** + * The user whose permission group governs the projection, or `null`/`undefined` + * when none does — a workspace API key (which authorizes as the workspace and + * whose reported user is only the key's creator) and an executor delegation + * (which carries a role but no capabilities) both read whole. + * + * Required rather than optional on purpose: a new consumer of this read has to + * name its subject to compile, instead of silently inheriting an unprojected + * response. + */ + viewerUserId: string | null | undefined + /** The workspace's organization, when the caller already loaded it. */ + workspaceOrganizationId?: string | null +} + +/** + * Applies a viewer's log projection to a run-detail resource. + * + * `finalOutput` and `blockOutputs` are the run-shaped spellings of `finalOutput` + * and `blockExecutions` on the withheld list in `withheldExecutionData` — the + * same per-block execution detail the log-detail path strips — so + * `logs.trace_spans` withholds them here too. A caller that could still name a + * block in `selectedOutputs` and get its output back would read exactly what the + * detail surface refuses, one query parameter later. + * + * `status`, `error` and the timings stay: these are projections, not gates, and + * withholding whether a run failed is not what the two capabilities restrict. + * + * permission-group-enforced: logs.trace_spans + * permission-group-enforced: logs.cost + */ +function projectExecutionStatus( + status: WorkflowExecutionStatusResponse, + projection: LogFieldProjection +): WorkflowExecutionStatusResponse { + if (!projection.hideCostInfo && !projection.hideTraceSpans) return status + return { + ...status, + cost: projectCostTotal(status.cost?.total ?? null, projection), + finalOutput: projection.hideTraceSpans ? null : status.finalOutput, + blockOutputs: projection.hideTraceSpans ? null : status.blockOutputs, + } } +/** + * Reads the execution status resource, projected for the viewer. + * + * Projection lives in this shared read rather than in each of its two route + * adapters so the rule has one copy and the next consumer inherits it, and it + * runs after the caller's authorization, on whichever branch answered. + */ export async function getWorkflowExecutionStatus( input: GetWorkflowExecutionStatusInput +): Promise { + const status = await readWorkflowExecutionStatus(input) + if (!status) return null + const projection = await resolveLogFieldProjection( + input.viewerUserId, + input.workspaceId, + input.workspaceOrganizationId + ) + return projectExecutionStatus(status, projection) +} + +async function readWorkflowExecutionStatus( + input: GetWorkflowExecutionStatusInput ): Promise { const { workflowId, executionId, includeOutput, selectedOutputs } = input From 02bee706831253d1e3fbe77e3567259d95a0f2aa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 14:12:20 -0700 Subject: [PATCH 068/179] fix(permission-groups): let the fail-closed api-keys policy heal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `permissionPolicyReady` withholds the create button until the permission config read succeeds, which is right — offering a key type the server would refuse is the failure worth avoiding. But the app's query defaults give an errored read no way back: retry once on the web, never on remount, and no focus refetch outside the desktop app. One transient failure disabled the button for the rest of the session with nothing to say why. Raises the retry count on this query and lets a remount retry, stopping short of retrying a 4xx, which asking again cannot change. The comment at the call site claimed a personal scope that does not exist — the component ships only as scope='combined' under the workspace settings route — so it now says what is actually true about the `!workspaceId` arm. --- .../settings/components/api-keys/api-keys.tsx | 18 +- .../hooks/permission-groups.test.tsx | 174 ++++++++++++++++++ .../access-control/hooks/permission-groups.ts | 36 ++++ 3 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 apps/sim/ee/access-control/hooks/permission-groups.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index ba1d6852ea3..8fb672b1950 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -119,9 +119,21 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { * enterprise organization. The server combines them the same way, so offering * a key type here that it would refuse is the only failure worth avoiding — * which is why the policy fails closed while its query is pending or errored, - * rather than treating an unanswered question as an unrestricted answer. In - * personal scope the hook is disabled (no `workspaceId`) and no group - * applies, so `isSuccess` is only required when the query actually runs. + * rather than treating an unanswered question as an unrestricted answer. + * + * Failing closed on `isSuccess` needs the query to be able to recover, or one + * transient failure disables the create button for the session with nothing + * to say why: the client's defaults retry once, never on remount, and do not + * refetch on focus outside the desktop app. `useUserPermissionConfig` raises + * both, which is what makes this gate self-healing rather than sticky. + * + * The `!workspaceId` arm is defense, not a live case: this component ships + * only from the workspace settings panel, always as `scope='combined'`, and + * `workspaceId` is that route's own param, so the query always runs. It + * covers the `|| ''` fallback above — a render outside the route would + * disable the hook, and `isSuccess` on a query that never runs is false + * forever, which would present as a dead button rather than a refusal. The + * server is the enforcement either way; this gate is the affordance. */ const permissionPolicyReady = !workspaceId || permissionConfigQuery.isSuccess diff --git a/apps/sim/ee/access-control/hooks/permission-groups.test.tsx b/apps/sim/ee/access-control/hooks/permission-groups.test.tsx new file mode 100644 index 00000000000..040667724bd --- /dev/null +++ b/apps/sim/ee/access-control/hooks/permission-groups.test.tsx @@ -0,0 +1,174 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { sleep } from '@sim/utils/helpers' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), +})) + +vi.mock('@/lib/api/client/request', () => ({ + requestJson: mockRequestJson, +})) + +import { ApiClientError } from '@/lib/api/client/errors' +import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' + +const WORKSPACE_ID = 'ws-1' + +const CONFIG_RESPONSE = { + entitled: true, + permissionGroupId: null, + config: null, +} + +/** + * Mounts the hook in a real React root under a real `QueryClientProvider`, the + * way `hooks/queries/unsubscribe.test.tsx` does (the repo has no + * `@testing-library/react`). + * + * The client deliberately overrides only `retryDelay`, so the hook's own + * `retry` and `retryOnMount` are the options under test rather than the + * harness's. `gcTime: Infinity` keeps the failed query in the cache across the + * unmount/remount that `retryOnMount` is about. + */ +function makeHarness() { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retryDelay: 0, gcTime: Number.POSITIVE_INFINITY }, + mutations: { retry: false }, + }, + }) + + function mount(useHook: () => T) { + const container = document.createElement('div') + const root: Root = createRoot(container) + let latest: T + + function Probe() { + latest = useHook() + return null + } + + function Wrapper({ children }: { children: ReactNode }) { + return {children} + } + + act(() => { + root.render( + + + + ) + }) + + return { + result: () => latest, + unmount: () => act(() => root.unmount()), + } + } + + return { queryClient, mount } +} + +/** Drives React and the query observer until `predicate` holds, or gives up. */ +async function settle(predicate: () => boolean) { + for (let attempt = 0; attempt < 200; attempt++) { + if (predicate()) return + await act(async () => { + await Promise.resolve() + await sleep(0) + }) + } +} + +function serverError() { + return new ApiClientError({ status: 500, message: 'boom' }) +} + +function refusal() { + return new ApiClientError({ status: 403, message: 'Forbidden' }) +} + +/** + * Consumers of this query fail CLOSED — the API-keys page withholds the create + * button until the read succeeds. The app's own query defaults (`retry: 1`, + * `retryOnMount: false`, no focus refetch on the web) would leave one transient + * failure disabling that button for the rest of the session, so the retry + * policy is the thing that keeps a fail-closed gate from wedging. + */ +describe('useUserPermissionConfig retry policy', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('retries a transient failure three times before giving up', async () => { + mockRequestJson.mockRejectedValue(serverError()) + + const { mount } = makeHarness() + const { result, unmount } = mount(() => useUserPermissionConfig(WORKSPACE_ID)) + await settle(() => result().isError) + + expect(result().isError).toBe(true) + expect(mockRequestJson).toHaveBeenCalledTimes(4) + + unmount() + }) + + it('recovers when a retry succeeds, so the gate is never left unanswered', async () => { + mockRequestJson + .mockRejectedValueOnce(serverError()) + .mockResolvedValueOnce(structuredClone(CONFIG_RESPONSE)) + + const { mount } = makeHarness() + const { result, unmount } = mount(() => useUserPermissionConfig(WORKSPACE_ID)) + await settle(() => result().isSuccess) + + expect(result().isSuccess).toBe(true) + expect(mockRequestJson).toHaveBeenCalledTimes(2) + + unmount() + }) + + it('does not retry a refusal, which asking again cannot change', async () => { + mockRequestJson.mockRejectedValue(refusal()) + + const { mount } = makeHarness() + const { result, unmount } = mount(() => useUserPermissionConfig(WORKSPACE_ID)) + await settle(() => result().isError) + + expect(result().isError).toBe(true) + expect(mockRequestJson).toHaveBeenCalledTimes(1) + + unmount() + }) + + it('retries again on remount, so reopening settings is a real retry', async () => { + mockRequestJson.mockRejectedValue(refusal()) + + const { mount } = makeHarness() + const first = mount(() => useUserPermissionConfig(WORKSPACE_ID)) + await settle(() => first.result().isError) + expect(mockRequestJson).toHaveBeenCalledTimes(1) + first.unmount() + + mockRequestJson.mockReset() + mockRequestJson.mockResolvedValue(structuredClone(CONFIG_RESPONSE)) + + const second = mount(() => useUserPermissionConfig(WORKSPACE_ID)) + await settle(() => second.result().isSuccess) + + expect(mockRequestJson).toHaveBeenCalledTimes(1) + expect(second.result().isSuccess).toBe(true) + + second.unmount() + }) +}) diff --git a/apps/sim/ee/access-control/hooks/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts index d2f6882e113..93d9d9dd6d8 100644 --- a/apps/sim/ee/access-control/hooks/permission-groups.ts +++ b/apps/sim/ee/access-control/hooks/permission-groups.ts @@ -1,6 +1,7 @@ 'use client' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { type BulkAddPermissionGroupMembersBody, @@ -96,6 +97,33 @@ export function useOrganizationWorkspaces(organizationId?: string, enabled = tru }) } +/** + * How many times a failed policy read is retried before the UI is left with no + * answer, and the app default this raises it from. + * + * Consumers of this query fail CLOSED — the API-keys page withholds the create + * button until the read succeeds, because offering a key type the server would + * refuse is the only failure worth avoiding. That makes an unanswered question + * a withheld capability, and the client's default query options give it no way + * back: `retry: 1` on the web, `retryOnMount: false`, and `refetchOnWindowFocus` + * off outside the desktop app. One transient failure would otherwise disable + * the button for the rest of the session with nothing to say why. + * + * The read is a small, idempotent, cacheable GET, so retrying it is close to + * free — cheap enough to justify self-healing rather than a page reload. + */ +const USER_PERMISSION_CONFIG_RETRIES = 3 + +/** + * A refusal will not heal by asking again: the caller's session or membership + * is what the server disagrees with, and three more requests spend latency to + * arrive at the same 4xx. Only a transport failure or a 5xx is worth a retry. + */ +function retryUserPermissionConfig(failureCount: number, error: Error): boolean { + if (isApiClientError(error) && error.status >= 400 && error.status < 500) return false + return failureCount < USER_PERMISSION_CONFIG_RETRIES +} + export function useUserPermissionConfig(workspaceId?: string) { return useQuery({ queryKey: permissionGroupKeys.userConfig(workspaceId), @@ -108,6 +136,14 @@ export function useUserPermissionConfig(workspaceId?: string) { }, enabled: Boolean(workspaceId), staleTime: PERMISSION_GROUPS_STALE_TIME, + retry: retryUserPermissionConfig, + /** + * The self-heal. Without it a query left in error stays there for the life + * of the browser session, because nothing else remounts it back to life: + * `refetchOnWindowFocus` is off on the web, and the settings modal + * unmounting and reopening is exactly the moment a user retries by hand. + */ + retryOnMount: true, }) } From 806848e2c269e09eb40ea624c6a43126cdf424a8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 14:15:50 -0700 Subject: [PATCH 069/179] fix(v1): run the personal-key group check behind the role check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveWorkspaceScope` asked `personal_api_key.use` inline, and `resolveWorkspaceAccess` calls it before `getUserEntityPermissions` — so a caller with no reach into the workspace at all was answered with a refusal naming how the organization had configured one of its cohorts, instead of the concealed role failure. `resolveCapabilityRefusal` states the invariant the other way round: never call a group key before the workspace role check. Splits the group half out of the scope check. The workspace COLUMN stays early — it names no group, needs no query, and is the answer whatever the role turns out to be, which is the split `authorizeWorkspaceOperation` makes. The group half answers only after the role, and takes the id whose role a caller already verified rather than a boolean, so a caller that checked some other subject cannot vouch for this one; `checkWorkspaceScope` passes none and resolves the role itself, since the table routes' own role check runs later in `checkAccess` and gates the module, not the key kind. --- apps/sim/app/api/v1/capability-gate.test.ts | 43 ++++++- apps/sim/app/api/v1/middleware.test.ts | 118 ++++++++++++++++++-- apps/sim/app/api/v1/middleware.ts | 114 +++++++++++++------ 3 files changed, 231 insertions(+), 44 deletions(-) diff --git a/apps/sim/app/api/v1/capability-gate.test.ts b/apps/sim/app/api/v1/capability-gate.test.ts index 202e32c0e02..92eee049439 100644 --- a/apps/sim/app/api/v1/capability-gate.test.ts +++ b/apps/sim/app/api/v1/capability-gate.test.ts @@ -271,9 +271,10 @@ describe('v1 permission-group capability gate', () => { /** * `personal_api_key.use` refuses a *principal kind* rather than a module, so it - * is asserted in `resolveWorkspaceScope` — before, and separately from, the - * capability the route declares. A workspace key is not a personal key, and its - * creator's group must not decide whether it may be used. + * is asserted separately from the capability the route declares — but, like + * every other group key, only after the workspace role check. A workspace key + * is not a personal key, and its creator's group must not decide whether it + * may be used. */ describe('personal_api_key.use — the key kind, not the module', () => { it('refuses a personal key whose group disables personal API keys', async () => { @@ -296,6 +297,42 @@ describe('v1 permission-group capability gate', () => { expect(response.status).toBe(200) expect(mockListTables).toHaveBeenCalledWith(WORKSPACE_ID) }) + + /** + * The group key runs behind the role check, so a stranger to the workspace + * is answered with the concealed role failure rather than with a refusal + * naming how an organization configured one of its cohorts. Asked the other + * way round, a caller with no reach into the workspace at all learns that + * the workspace's organization runs a group, and that the group withholds + * personal keys. + * + * The workspace COLUMN still answers first — it names no group, needs no + * query, and is the answer whatever the role turns out to be — which is the + * split `authorizeWorkspaceOperation` makes and the next case pins. + */ + it('answers a non-member on role, not on the group that withholds personal keys', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + governedBy({ disablePersonalApiKeys: true }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toBe('Access denied') + expect(mockListTables).not.toHaveBeenCalled() + }) + + it("answers a non-member on the workspace's own column, which names no group", async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toMatch(/personal API key/i) + expect(mockListTables).not.toHaveBeenCalled() + }) }) it('refuses on role before capability, so a non-member learns nothing about the group', async () => { diff --git a/apps/sim/app/api/v1/middleware.test.ts b/apps/sim/app/api/v1/middleware.test.ts index 2a8e598a7b6..c9a2d822ed7 100644 --- a/apps/sim/app/api/v1/middleware.test.ts +++ b/apps/sim/app/api/v1/middleware.test.ts @@ -7,7 +7,12 @@ * `used = limit - remaining` gets a negative number. */ -import { createMockRequest } from '@sim/testing' +import { + createMockRequest, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { workspaceIdSchema } from '@/lib/api/contracts/primitives' @@ -17,13 +22,21 @@ import { recordRateLimitSnapshot, } from '@/lib/api/server/rate-limit-context' -const { mockAuthenticateV1Request, mockGetSubscription, mockCheckRateLimit, mockGetRateLimit } = - vi.hoisted(() => ({ - mockAuthenticateV1Request: vi.fn(), - mockGetSubscription: vi.fn(), - mockCheckRateLimit: vi.fn(), - mockGetRateLimit: vi.fn(), - })) +const { + mockAuthenticateV1Request, + mockGetSubscription, + mockCheckRateLimit, + mockGetRateLimit, + mockGetUserEntityPermissions, + mockGetWorkspaceBillingSettings, +} = vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockGetSubscription: vi.fn(), + mockCheckRateLimit: vi.fn(), + mockGetRateLimit: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceBillingSettings: vi.fn(), +})) vi.mock('@/app/api/v1/auth', () => ({ authenticateV1Request: mockAuthenticateV1Request, @@ -40,9 +53,22 @@ vi.mock('@/lib/core/rate-limiter', () => ({ }, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, + getWorkspaceBilledAccountUserId: vi.fn(async () => 'billed-user'), +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { authenticateRequest, checkRateLimit, + checkWorkspaceScope, createRateLimitResponse, v1ValidationErrorResponse, } from '@/app/api/v1/middleware' @@ -274,3 +300,79 @@ describe('rate-limit snapshot context', () => { expect(getRateLimitHeaders(req)).toBeNull() }) }) + +/** + * The table routes authorize with `checkWorkspaceScope` and then a domain + * helper (`checkAccess`) that runs the workspace ROLE check. `checkAccess` + * gates the module (`tables.use`), never the key kind, so `personal_api_key.use` + * has to be asked in the wrapper — which means the wrapper has to order itself + * behind the role, because nothing downstream will. + * + * The workspace column keeps answering first: it names no group, so refusing on + * it tells a stranger only what the workspace itself is set to. + */ +describe('checkWorkspaceScope', () => { + const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' + const USER_ID = 'user-1' + + function personalKeyRateLimit() { + return { + allowed: true, + remaining: 1, + limit: 1, + resetAt: new Date(), + userId: USER_ID, + keyType: 'personal' as const, + principal: { kind: 'personal_api_key' as const, userId: USER_ID, keyId: 'key-1' }, + } + } + + function withholdsPersonalKeys() { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + } + + beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + }) + + it('refuses a member whose group withholds personal API keys', async () => { + withholdsPersonalKeys() + + const response = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID) + + expect(response).not.toBeNull() + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toMatchObject({ + error: expect.stringMatching(/personal API key/i), + }) + }) + + it('leaves a non-member to the downstream role check rather than naming the group', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + withholdsPersonalKeys() + + const response = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID) + + expect(response).toBeNull() + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it("still refuses a non-member on the workspace's own column, which names no group", async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false }) + + const response = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID) + + expect(response).not.toBeNull() + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toMatchObject({ + error: expect.stringMatching(/personal API key/i), + }) + }) +}) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 188eded9b82..57b95b43a8a 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -344,6 +344,15 @@ export async function resolveCapabilityRefusal( * - A personal key is rejected when the workspace has disabled personal API * keys (`allowPersonalApiKeys = false`). Other surfaces enforcing the same * policy share `PERSONAL_KEY_DENIED`. + * + * Both are properties of the workspace rather than of any group, so both run + * ahead of the role check, exactly as `authorizeWorkspaceOperation` runs the + * `allowPersonalApiKeys` column ahead of `requireCurrentHumanRole`: they need + * no group to resolve, and refusing a key the workspace has switched off is the + * answer whatever the caller's role turns out to be. + * + * The group half of the same policy is NOT here — see + * {@link resolvePersonalKeyGroupRefusal}. */ export async function resolveWorkspaceScope( rateLimit: RateLimitResult, @@ -370,40 +379,68 @@ export async function resolveWorkspaceScope( message: PERSONAL_KEY_DENIED, } } - - /** - * permission-group-enforced: personal_api_key.use — v1 authorizes in this - * middleware rather than through the application funnel, so the group check - * the funnel applies has to be repeated here or the same key that v2 - * refuses would still work against v1. - */ - const governedUserId = capabilityGovernedUserId(rateLimit) - if (governedUserId) { - const withheld = await isWorkspaceCapabilityWithheld( - governedUserId, - requestedWorkspaceId, - 'personal_api_key.use' - ) - if (withheld) { - return { - status: 403, - code: 'FORBIDDEN', - message: PERSONAL_KEY_DENIED, - } - } - } } return null } /** - * Core workspace-access check: key scope, then the user's workspace permission - * level, then the permission-group capability the route declares. Returns a - * structured failure or null on success. + * The group half of the personal-key policy: `personal_api_key.use`, repeated + * here because v1 authorizes in this middleware rather than through the + * application funnel, and without it the same key that v2 refuses would still + * work against v1. + * + * It answers only AFTER the caller's workspace role has been verified, which is + * the ordering `authorizeWorkspaceOperation` uses and the reason + * {@link resolveCapabilityRefusal}'s contract says never to run a group key + * ahead of the role: the refusal names how an organization configured one + * cohort, and handing that to a caller with no reach into the workspace tells a + * stranger about the organization's configuration. The column check above may + * stay early precisely because it names no group. * - * Capability comes last, matching `authorizeWorkspaceOperation` — see - * {@link resolveCapabilityRefusal} for why the ordering is load-bearing. + * `roleVerifiedFor` is the user id a caller has already checked, not a boolean, + * so a caller that verified some OTHER subject's role cannot vouch for this + * one. When it does not match, the role is resolved here instead, and a caller + * with no read access is handed back `null` so the surface's own role failure — + * the concealed one — is what it answers with. That second lookup is free: + * `getUserEntityPermissions` for a workspace goes through the request-scoped + * memo the role check itself uses. + */ +async function resolvePersonalKeyGroupRefusal( + rateLimit: RateLimitResult, + workspaceId: string, + roleVerifiedFor: string | null +): Promise { + const governedUserId = capabilityGovernedUserId(rateLimit) + if (!governedUserId) return null + + if (roleVerifiedFor !== governedUserId) { + const permission = await getUserEntityPermissions(governedUserId, 'workspace', workspaceId) + if (!permissionSatisfies(permission, 'read')) return null + } + + // permission-group-enforced: personal_api_key.use — v1 authorizes in this middleware, not through the funnel + if (!(await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'personal_api_key.use'))) { + return null + } + + return { + status: 403, + code: 'FORBIDDEN', + message: PERSONAL_KEY_DENIED, + } +} + +/** + * Core workspace-access check: key scope and the workspace's own columns, then + * the user's workspace permission level, then the two permission-group + * decisions — the personal-key refusal, then the capability the route declares. + * Returns a structured failure or null on success. + * + * Both group keys come after the role, matching `authorizeWorkspaceOperation` — + * see {@link resolveCapabilityRefusal} for why the ordering is load-bearing. + * The personal-key refusal sits first of the two for the reason the funnel + * gives: the remedies differ, and the narrower one is worth naming first. */ export async function resolveWorkspaceAccess( rateLimit: RateLimitResult, @@ -420,22 +457,33 @@ export async function resolveWorkspaceAccess( return { status: 403, code: 'FORBIDDEN', message: 'Access denied' } } + const personalKeyRefusal = await resolvePersonalKeyGroupRefusal(rateLimit, workspaceId, userId) + if (personalKeyRefusal) return personalKeyRefusal + return resolveCapabilityRefusal(rateLimit, workspaceId, capability) } /** - * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body. + * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body, + * plus the personal-key group refusal that belongs with it. + * + * It deliberately gates no MODULE capability: it runs before the route's role + * check, and a route using it authorizes its resource through a domain helper + * afterwards (the table routes call `checkAccess`, which applies `tables.use` + * itself), so the capability is declared there. * - * Scope only — it deliberately gates no module capability, because it runs - * before the route's role check. A route using it authorizes its resource - * through a domain helper afterwards (the table routes call `checkAccess`, - * which applies `tables.use` itself), so the capability is declared there. + * `personal_api_key.use` cannot wait for that helper — `checkAccess` gates the + * module, not the key kind — so it is asked here, and + * {@link resolvePersonalKeyGroupRefusal} resolves the caller's role itself + * before answering rather than relying on a role check this wrapper never runs. */ export async function checkWorkspaceScope( rateLimit: RateLimitResult, requestedWorkspaceId: string ): Promise { - const failure = await resolveWorkspaceScope(rateLimit, requestedWorkspaceId) + const failure = + (await resolveWorkspaceScope(rateLimit, requestedWorkspaceId)) ?? + (await resolvePersonalKeyGroupRefusal(rateLimit, requestedWorkspaceId, null)) return failure ? workspaceAccessErrorResponse(failure) : null } From 414302c8b2999f554979e9919bb9ea37dac1b315 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 14:19:21 -0700 Subject: [PATCH 070/179] fix(audits): close the user-scope gap in check:capability-subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assertion B lists the modules a v1 route may not import, and `user-scope.server` was missing from it. That module answers a capability for a caller who names no workspace by falling back to the organization's default group, and takes a bare `userId` like every other sink — so a route reaching for `isCapabilityWithheldForUser` directly was one property access away from the key creator with nothing in between. Splits the script's procedural body into `auditSource` and `auditMiddlewareExport` so the assertions can be probed without a tree on disk, and adds the probe suite (wired into `bun run test`, which check:script-tests requires). Also records on `parseEnforcedAnnotations` why its bare scan is left as it is: both cheap shapes that would require a gate beneath an annotation were measured against the tree and each misses seven live sites, all of the same kind — an annotation over a call to a domain helper that enforces the group elsewhere. --- package.json | 3 +- scripts/check-capability-subject.test.ts | 82 ++++++++++++ scripts/check-capability-subject.ts | 119 +++++++++++------- scripts/check-permission-group-enforcement.ts | 35 +++++- 4 files changed, 193 insertions(+), 46 deletions(-) create mode 100644 scripts/check-capability-subject.test.ts diff --git a/package.json b/package.json index 9d76e280c9b..695432fdd84 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "dev:sockets": "cd apps/realtime && bun run dev", "dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev\" \"cd apps/realtime && bun run dev\"", "dev:full:capped": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev:capped\" \"cd apps/realtime && bun run dev\"", - "test": "bun run test:setup && bun run test:npm-package-versions && bun run test:icon-path-precision && bun run test:tool-registry-boundary && bun run test:tool-request-boundary && bun run test:actorless-executor-operations && bun run test:permission-group-enforcement && bun run test:application-graph && bun run test:migrations-safety && bun run test:generators && turbo run test", + "test": "bun run test:setup && bun run test:npm-package-versions && bun run test:icon-path-precision && bun run test:tool-registry-boundary && bun run test:tool-request-boundary && bun run test:actorless-executor-operations && bun run test:permission-group-enforcement && bun run test:capability-subject && bun run test:application-graph && bun run test:migrations-safety && bun run test:generators && turbo run test", "test:setup": "bun run --cwd packages/sim-setup test", "test:npm-package-versions": "bunx vitest run scripts/bump-npm-package-versions.test.ts", "test:icon-path-precision": "bunx vitest run scripts/check-icon-path-precision.test.ts", @@ -22,6 +22,7 @@ "test:tool-request-boundary": "bunx vitest run scripts/check-tool-request-boundary.test.ts", "test:actorless-executor-operations": "bunx vitest run scripts/check-actorless-executor-operations.test.ts", "test:permission-group-enforcement": "bunx vitest run scripts/check-permission-group-enforcement.test.ts", + "test:capability-subject": "bunx vitest run scripts/check-capability-subject.test.ts", "test:application-graph": "bunx vitest run scripts/check-application-graph.test.ts", "test:migrations-safety": "bunx vitest run scripts/check-migrations-safety.test.ts", "test:generators": "bunx vitest run scripts/generate-v2-cli-api.test.ts scripts/generate-cli-docs.test.ts scripts/generate-docs.test.ts", diff --git a/scripts/check-capability-subject.test.ts b/scripts/check-capability-subject.test.ts new file mode 100644 index 00000000000..cca011a97d6 --- /dev/null +++ b/scripts/check-capability-subject.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { auditMiddlewareExport, auditSource } from './check-capability-subject' + +const ROUTE = 'apps/sim/app/api/v1/tables/route.ts' +const MIDDLEWARE = 'apps/sim/app/api/v1/middleware.ts' + +describe('assertion B — a v1 route may not decide a capability for itself', () => { + /** + * The user-global resolver takes a bare `userId` and falls back to the + * organization's default group, so a route reaching for it is one property + * access away from `rateLimit.userId` — the key's creator. It was absent from + * the module list, which is exactly the shape of gap that passes in silence. + */ + it('reports a route that imports the user-global resolver directly', () => { + const { findings } = auditSource( + ROUTE, + "import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server'\n" + ) + + expect(findings).toHaveLength(1) + expect(findings[0].message).toContain('user-scope.server') + }) + + it.each([ + '@/lib/permission-groups/capability-assertions', + '@/lib/permission-groups/capabilities', + '@/lib/permission-groups/resolve.server', + '@/lib/permission-groups/config-scope.server', + '@/lib/permission-groups/user-scope.server', + ])('reports a route that imports %s', (module) => { + const { findings } = auditSource(ROUTE, `import { thing } from '${module}'\n`) + + expect(findings).toHaveLength(1) + }) + + it('allows the middleware itself, which is where the decision belongs', () => { + const { findings } = auditSource( + MIDDLEWARE, + "import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server'\n" + ) + + expect(findings).toEqual([]) + }) +}) + +describe('assertion C — the subject came from capabilityGovernedUserId', () => { + it('accepts a subject bound to the governed id', () => { + const { findings, sinks } = auditSource( + MIDDLEWARE, + [ + 'const governedUserId = capabilityGovernedUserId(rateLimit)', + "await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'personal_api_key.use')", + ].join('\n') + ) + + expect(findings).toEqual([]) + expect(sinks).toBe(1) + }) + + it('reports the key creator read straight off the rate-limit result', () => { + const { findings, sinks } = auditSource( + MIDDLEWARE, + "await isWorkspaceCapabilityWithheld(rateLimit.userId, workspaceId, 'personal_api_key.use')\n" + ) + + expect(sinks).toBe(0) + expect(findings).toHaveLength(1) + expect(findings[0].message).toContain('rateLimit.userId') + }) +}) + +describe('assertion A — the name the audit is written in terms of', () => { + it('reports a middleware that no longer exports it', () => { + expect(auditMiddlewareExport('export function someOtherName() {}')).toHaveLength(1) + }) + + it('accepts a middleware that still does', () => { + expect( + auditMiddlewareExport('export function capabilityGovernedUserId(rateLimit) { return null }') + ).toEqual([]) + }) +}) diff --git a/scripts/check-capability-subject.ts b/scripts/check-capability-subject.ts index fc6c5fe1fa8..61966c1b88e 100644 --- a/scripts/check-capability-subject.ts +++ b/scripts/check-capability-subject.ts @@ -40,9 +40,15 @@ * from a `Principal` that has no user to substitute in the first place. */ import { readdirSync, readFileSync, statSync } from 'node:fs' -import { join, relative, resolve } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' -const ROOT = resolve(import.meta.dir, '..') +/** + * `import.meta.url` rather than Bun's `import.meta.dir`, so the module also + * imports cleanly under vitest — the assertions below are unit-tested, and + * `import.meta.dir` is undefined outside Bun. + */ +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const V1_ROOT = 'apps/sim/app/api/v1' const MIDDLEWARE = `${V1_ROOT}/middleware.ts` /** Out of scope: the platform-admin surface authenticates platform admins, not workspace keys. */ @@ -58,6 +64,14 @@ const CAPABILITY_MODULES = [ '@/lib/permission-groups/capabilities', '@/lib/permission-groups/resolve.server', '@/lib/permission-groups/config-scope.server', + /** + * The user-global resolver, which answers a capability for a caller who names + * no workspace by falling back to the organization's default group. It takes + * a bare `userId` like every other sink, so a v1 route that imported it would + * be one property access away from the key creator with nothing in between — + * and being absent from this list is precisely how it would stay green. + */ + '@/lib/permission-groups/user-scope.server', ] /** @@ -140,27 +154,10 @@ function lineOf(source: string, index: number): number { return source.slice(0, index).split('\n').length } -const files: string[] = [] -walk(join(ROOT, V1_ROOT), files) -const relativeFiles = files.map((file) => relative(ROOT, file)).sort() - -const findings: Finding[] = [] -let governedSinkCalls = 0 - -const middlewareSource = readFileSync(join(ROOT, MIDDLEWARE), 'utf8') -if (!new RegExp(`export function ${GOVERNED}\\s*\\(`).test(middlewareSource)) { - findings.push({ - file: MIDDLEWARE, - line: 1, - message: - `${MIDDLEWARE} no longer exports \`${GOVERNED}\`. Every assertion in this audit is ` + - 'written in terms of it; rename it here and in this script together, or the audit ' + - 'silently stops checking anything.', - }) -} - -for (const file of relativeFiles) { - const source = readFileSync(join(ROOT, file), 'utf8') +/** One v1 source file's findings, so the assertions are testable without a tree on disk. */ +export function auditSource(file: string, source: string): { findings: Finding[]; sinks: number } { + const findings: Finding[] = [] + let sinks = 0 if (file !== MIDDLEWARE) { for (const module of CAPABILITY_MODULES) { @@ -205,7 +202,7 @@ for (const file of relativeFiles) { subject.startsWith(`await ${GOVERNED}(`) || governedLocals.has(subject) if (governed) { - governedSinkCalls++ + sinks++ continue } @@ -219,30 +216,64 @@ for (const file of relativeFiles) { }) } } + + return { findings, sinks } } -if (governedSinkCalls === 0 && findings.length === 0) { - findings.push({ - file: V1_ROOT, - line: 1, - message: - `no call to a capability sink passed through \`${GOVERNED}\` anywhere under ${V1_ROOT}. ` + - 'Either v1 stopped gating capabilities, or it now gates them in a form this audit ' + - 'cannot read — both mean the audit is passing without checking anything.', - }) +/** Assertion A: the name every other assertion is written in terms of still exists. */ +export function auditMiddlewareExport(middlewareSource: string): Finding[] { + if (new RegExp(`export function ${GOVERNED}\\s*\\(`).test(middlewareSource)) return [] + return [ + { + file: MIDDLEWARE, + line: 1, + message: + `${MIDDLEWARE} no longer exports \`${GOVERNED}\`. Every assertion in this audit is ` + + 'written in terms of it; rename it here and in this script together, or the audit ' + + 'silently stops checking anything.', + }, + ] } -if (findings.length > 0) { - console.error( - `check:capability-subject — ${findings.length} finding${findings.length === 1 ? '' : 's'}:\n` - ) - for (const finding of findings) { - console.error(` ${finding.file}:${finding.line}\n ${finding.message}\n`) +function main(): void { + const files: string[] = [] + walk(join(ROOT, V1_ROOT), files) + const relativeFiles = files.map((file) => relative(ROOT, file)).sort() + + const findings: Finding[] = auditMiddlewareExport(readFileSync(join(ROOT, MIDDLEWARE), 'utf8')) + let governedSinkCalls = 0 + + for (const file of relativeFiles) { + const result = auditSource(file, readFileSync(join(ROOT, file), 'utf8')) + findings.push(...result.findings) + governedSinkCalls += result.sinks } - process.exit(1) + + if (governedSinkCalls === 0 && findings.length === 0) { + findings.push({ + file: V1_ROOT, + line: 1, + message: + `no call to a capability sink passed through \`${GOVERNED}\` anywhere under ${V1_ROOT}. ` + + 'Either v1 stopped gating capabilities, or it now gates them in a form this audit ' + + 'cannot read — both mean the audit is passing without checking anything.', + }) + } + + if (findings.length > 0) { + console.error( + `check:capability-subject — ${findings.length} finding${findings.length === 1 ? '' : 's'}:\n` + ) + for (const finding of findings) { + console.error(` ${finding.file}:${finding.line}\n ${finding.message}\n`) + } + process.exit(1) + } + + console.log( + `check:capability-subject — ${relativeFiles.length} v1 files, ${governedSinkCalls} capability ` + + `subject${governedSinkCalls === 1 ? '' : 's'} resolved through ${GOVERNED}.` + ) } -console.log( - `check:capability-subject — ${relativeFiles.length} v1 files, ${governedSinkCalls} capability ` + - `subject${governedSinkCalls === 1 ? '' : 's'} resolved through ${GOVERNED}.` -) +if (import.meta.main) main() diff --git a/scripts/check-permission-group-enforcement.ts b/scripts/check-permission-group-enforcement.ts index 059a39a3783..114079eaf0a 100644 --- a/scripts/check-permission-group-enforcement.ts +++ b/scripts/check-permission-group-enforcement.ts @@ -380,7 +380,40 @@ export function parseOperationRegistryMembers(source: string): OperationRegistry return members } -/** Capabilities declared enforced at a call site the funnel cannot reach. */ +/** + * Capabilities declared enforced at a call site the funnel cannot reach. + * + * Deliberately a bare scan of the whole file, with no check that anything below + * the annotation actually gates: an annotation left behind after its gate was + * deleted still counts the capability as reachable, and assertion C stays + * green. That is a real gap, and it is left open because every cheap shape that + * would close it is wrong more often than it is right. + * + * The obvious shapes were measured against the 50-odd annotations in the tree: + * + * - "the capability id appears in code in the same file" misses 7, among them + * `logs/application/list-public-logs.ts` and `get-public-log.ts`, which + * annotate `logs.cost` and `logs.trace_spans` over a call to + * `resolveLogFieldProjection` — the one place those two are read, named + * nowhere else because naming them twice is how one copy stops redacting. + * - "a capability sink is called within N lines below" misses 7 at any N, + * including `core/application/workspace-authorization.ts` (delegates to + * `requirePersonalApiKeysAllowed`), `invitations/workspace-invitations.ts` + * (`validateInvitationsAllowed`) and the three `integrations.manage` sites in + * `auth/oauth/credentials/route.ts` (`checkOAuthCredentialAccess`). + * + * Both fail on the same case, and it is the common one: the annotation sits + * above a call to a DOMAIN helper that enforces the group somewhere else. A + * lookahead would have to enumerate every such helper — which is the open-ended + * set the annotation exists to describe in the first place, so the list would + * go stale in exactly the direction that makes the audit lie. + * + * What does hold the line is assertion E's other half: an annotation cannot + * invent enforcement for a key whose field says `ui-only` or `executor`, and a + * capability whose gate is deleted along with its annotation is reported by + * assertion C immediately. The gap is narrow — a gate deleted while its comment + * is kept — and closing it wants a call-graph, not a regex. + */ export function parseEnforcedAnnotations(source: string): string[] { return [...source.matchAll(new RegExp(`${ENFORCED_ANNOTATION}\\s*([a-z0-9_.]+)`, 'g'))].map( (match) => match[1] From eb5a07e13405efba15474acfc563bfa285c76127 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 14:19:57 -0700 Subject: [PATCH 071/179] test(logs): cover the run-detail, snapshot and @log-mention projections Each door gets: hidden-cost member withheld spend, hidden-spans member withheld execution payloads, ungoverned member reads whole, and the subjectless caller reachable on that door reads whole without ever resolving a group. --- .../executions/[executionId]/route.test.ts | 106 ++++++++++ .../process-contents-log-projection.test.ts | 150 +++++++++++++ .../read-execution-snapshot.test.ts | 198 ++++++++++++++++++ .../application/read-workflow-run.test.ts | 43 ++++ .../execution-status-projection.test.ts | 180 ++++++++++++++++ .../executor/execution-status.test.ts | 3 + 6 files changed, 680 insertions(+) create mode 100644 apps/sim/app/api/workflows/[id]/executions/[executionId]/route.test.ts create mode 100644 apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts create mode 100644 apps/sim/lib/logs/application/read-execution-snapshot.test.ts create mode 100644 apps/sim/lib/workflows/executor/execution-status-projection.test.ts diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.test.ts new file mode 100644 index 00000000000..2edbc6fdd56 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment node + * + * The internal run-detail door. `logs.cost` and `logs.trace_spans` withhold + * fields inside a run, and the shared read applies them — but only for the + * subject this route names. `auth.userId` is populated for every credential the + * route accepts, so naming it unconditionally would apply a workspace key + * creator's group to every caller of a shared credential, and the executor's + * actor's group to a delegation that carries no capabilities at all. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockValidateWorkflowAccess, mockGetStatus } = vi.hoisted(() => ({ + mockValidateWorkflowAccess: vi.fn(), + mockGetStatus: vi.fn(), +})) + +vi.mock('@/app/api/workflows/middleware', () => ({ + validateWorkflowAccess: mockValidateWorkflowAccess, +})) + +vi.mock('@/lib/workflows/executor/execution-status', () => ({ + getWorkflowExecutionStatus: mockGetStatus, +})) + +import { GET } from './route' + +const WORKFLOW_ID = 'b1f0c7e2-0000-4000-8000-00000000000a' +const EXECUTION_ID = 'b1f0c7e2-0000-4000-8000-00000000000b' + +function request() { + return new NextRequest(`https://sim.test/api/workflows/${WORKFLOW_ID}/executions/${EXECUTION_ID}`) +} + +function context() { + return { params: Promise.resolve({ id: WORKFLOW_ID, executionId: EXECUTION_ID }) } +} + +function grantAccess(auth: Record) { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: WORKFLOW_ID, workspaceId: 'workspace-1' }, + auth, + }) +} + +describe('internal execution status route projection subject', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetStatus.mockResolvedValue({ + executionId: EXECUTION_ID, + workflowId: WORKFLOW_ID, + status: 'completed', + trigger: 'api', + level: 'info', + startedAt: '2026-08-05T12:00:00.000Z', + endedAt: null, + totalDurationMs: null, + paused: null, + cost: null, + error: null, + finalOutput: null, + blockOutputs: null, + }) + }) + + it('names the session user as the projection subject', async () => { + grantAccess({ success: true, userId: 'user-1', authType: 'session' }) + + const response = await GET(request(), context()) + + expect(response.status).toBe(200) + expect(mockGetStatus).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'workspace-1', viewerUserId: 'user-1' }) + ) + }) + + it('names the personal API key owner as the projection subject', async () => { + grantAccess({ success: true, userId: 'user-1', authType: 'api_key', apiKeyType: 'personal' }) + + await GET(request(), context()) + + expect(mockGetStatus).toHaveBeenCalledWith(expect.objectContaining({ viewerUserId: 'user-1' })) + }) + + it('names no subject for a workspace API key', async () => { + grantAccess({ + success: true, + userId: 'key-creator-1', + authType: 'api_key', + apiKeyType: 'workspace', + }) + + await GET(request(), context()) + + expect(mockGetStatus).toHaveBeenCalledWith(expect.objectContaining({ viewerUserId: null })) + }) + + it('names no subject for an executor delegation', async () => { + grantAccess({ success: true, userId: 'run-actor-1', authType: 'internal_jwt' }) + + await GET(request(), context()) + + expect(mockGetStatus).toHaveBeenCalledWith(expect.objectContaining({ viewerUserId: null })) + }) +}) diff --git a/apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts b/apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts new file mode 100644 index 00000000000..e12f73bbde3 --- /dev/null +++ b/apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts @@ -0,0 +1,150 @@ +/** + * @vitest-environment node + * + * Copilot's `@log` mention context, projected for the chatting user. + * + * `logs.cost` and `logs.trace_spans` are PROJECTIONS, not gates, and Copilot is + * deliberately not exempt from them: it acts as the person, so a run inlined as + * mention context must be withheld exactly as the person's own log surfaces + * withhold it. This path resolved the run row directly with a role-only + * authorization and inlined the run total, every span's own cost, and the whole + * block overview — so a member withheld all three on `/api/logs/**` read them by + * typing `@` in chat. + * + * Kept in its own file because it mocks `config-scope.server`, which the sibling + * `process-contents.test.ts` deliberately leaves real so its integration-allowlist + * tests exercise `getUserPermissionConfig`. + */ +import { + dbChainMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, + workflowAuthzMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChatContext } from '@/stores/panel' + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { processContextsServer } from '@/lib/copilot/chat/process-contents' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +function queueRun(): void { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: new Date('2026-01-01T00:00:01.000Z'), + totalDurationMs: 1000, + executionData: { + traceSpans: [ + { + id: 'span-1', + blockId: 'block-1', + name: 'Agent 1', + type: 'agent', + status: 'failed', + duration: 500, + cost: { total: 0.04 }, + children: [ + { id: 'span-2', name: 'tool', type: 'tool', duration: 10, cost: { total: 0.01 } }, + ], + }, + ], + }, + costTotal: '0.05', + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-1' }, + }) +} + +async function mentionSummary(userId?: string) { + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + userId as string, + 'hello', + 'ws-1' + ) + expect(result).toHaveLength(1) + return JSON.parse(result[0].content) +} + +describe('@log mention context projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + }) + + it('inlines spend whole for a member no group governs', async () => { + queueRun() + + const summary = await mentionSummary('user-1') + + expect(summary.cost).toEqual({ total: 0.05 }) + expect(summary.overview[0].cost).toEqual({ total: 0.04 }) + expect(summary.overview[0].children[0].cost).toEqual({ total: 0.01 }) + }) + + it('withholds the run total and every span cost when the group hides spend', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + queueRun() + + const summary = await mentionSummary('user-1') + + expect(summary.cost).toBeUndefined() + expect(summary.overview).toHaveLength(1) + expect(summary.overview[0].name).toBe('Agent 1') + expect(summary.overview[0].cost).toBeUndefined() + expect(summary.overview[0].children[0].cost).toBeUndefined() + }) + + /** + * The overview is derived from `traceSpans`, which is on the withheld list the + * log-detail path strips outright — so it is withheld entirely rather than + * merely thinned. The run's identity, level and timings stay: these are + * projections, not gates. + */ + it('withholds the whole block overview when the group hides trace spans', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTraceSpans: true, + }) + queueRun() + + const summary = await mentionSummary('user-1') + + expect(summary.overview).toBeUndefined() + expect(summary.cost).toEqual({ total: 0.05 }) + expect(summary.executionId).toBe('exec-1') + expect(JSON.stringify(summary)).not.toContain('Agent 1') + }) + + /** No subject, no group — never a bystander's. */ + it('resolves no group when the mention carries no subject', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + hideTraceSpans: true, + }) + queueRun() + + const summary = await mentionSummary(undefined) + + expect(summary.cost).toEqual({ total: 0.05 }) + expect(summary.overview).toHaveLength(1) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/logs/application/read-execution-snapshot.test.ts b/apps/sim/lib/logs/application/read-execution-snapshot.test.ts new file mode 100644 index 00000000000..30df45ff087 --- /dev/null +++ b/apps/sim/lib/logs/application/read-execution-snapshot.test.ts @@ -0,0 +1,198 @@ +/** + * @vitest-environment node + * + * `logs.cost` is a PROJECTION, not a gate — `logOperations.readExecutionSnapshot` + * correctly declares `capability: 'none'`, and the run stays readable while its + * spend does not. + * + * The snapshot read applied none of it, on either of its two doors: the internal + * `/api/logs/execution/{executionId}` route and the `logs_get_execution` Copilot + * tool both presented `executionMetadata.cost` verbatim, so a member whose group + * hides spend read the run total here after being withheld it everywhere else. + * Projecting in the use case is what makes both doors inherit it, which is why + * these exercise the use case against the real `resolveLogFieldProjection`. + */ +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + select: vi.fn(), + resolveWorkspace: vi.fn(), + resolvePermission: vi.fn(), + materialize: vi.fn(), + hydrateChildTraces: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@sim/db', () => ({ db: { select: mocks.select } })) + +vi.mock('@sim/audit', () => ({ + AuditAction: {}, + AuditResourceType: {}, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === 'write' || actual === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspace, +})) + +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionData: mocks.materialize, +})) + +vi.mock('@/lib/logs/execution/hydrate-child-traces', () => ({ + hydrateChildTraces: mocks.hydrateChildTraces, +})) + +import { readExecutionSnapshotUseCase } from '@/lib/logs/application/read-execution-snapshot' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const WORKSPACE_ID = 'workspace-1' + +const workspaceContext = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const workflowRecord = { + id: 'log-1', + workflowId: 'workflow-1', + workspaceId: WORKSPACE_ID, + executionId: 'run-1', + stateSnapshotId: 'snapshot-1', + trigger: 'api', + startedAt: new Date('2026-08-05T12:00:00.000Z'), + endedAt: new Date('2026-08-05T12:00:01.000Z'), + totalDurationMs: 1000, + costTotal: '0.75', + executionData: null, +} + +const jobRecord = { + id: 'job-log-1', + workspaceId: WORKSPACE_ID, + executionId: 'job-1', + trigger: 'schedule', + startedAt: new Date('2026-08-05T12:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + cost: { total: 0.75, input: 0.5, output: 0.25 }, +} + +/** + * Answers `db.select(...)` calls in order. The snapshot read walks the workflow + * log, then (only when that missed) the job log, then the state snapshot. + */ +function queueSelects(...results: unknown[][]): void { + for (const rows of results) { + mocks.select.mockReturnValueOnce({ + from: () => ({ where: () => ({ limit: () => Promise.resolve(rows) }) }), + }) + } +} + +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +/** + * `logs.read_execution_snapshot` denies a workspace API key outright, so the + * subjectless caller that actually reaches this read is the executor delegation — + * which carries a workspace role but no capabilities, and must read whole. + */ +const executorPrincipal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + workspaceId: WORKSPACE_ID, + delegationId: 'delegation-1', + audience: 'sim:logs', + issuedAt: new Date(Date.now() - 60_000), + expiresAt: new Date(Date.now() + 60 * 60_000), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-1', + currentWorkflow: { mode: 'deployment' as const }, + /** Never the projection subject: it is compatibility policy, not the caller. */ + compatibilityActor: { kind: 'legacy_execution_user' as const, userId: 'user-1' }, + }, +} + +function read(actor: typeof principal | typeof executorPrincipal, executionId: string) { + return readExecutionSnapshotUseCase.execute({ + principal: actor, + input: { executionId }, + }) +} + +describe('readExecutionSnapshot spend projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mocks.resolveWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.materialize.mockResolvedValue(null) + }) + + it('reads the run total whole for a member no group governs', async () => { + queueSelects([workflowRecord], [{ id: 'snapshot-1', stateData: { blocks: {} } }]) + + const result = await read(principal, 'run-1') + + expect(result.executionMetadata.cost).toEqual({ total: 0.75 }) + }) + + it('withholds the run total from a member whose group hides spend', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + queueSelects([workflowRecord], [{ id: 'snapshot-1', stateData: { blocks: {} } }]) + + const result = await read(principal, 'run-1') + + expect(result.executionMetadata.cost).toBeNull() + expect(result.executionMetadata.trigger).toBe('api') + expect(result.workflowState).toEqual({ blocks: {} }) + }) + + /** A job run spells its spend as a jsonb document; the same rule covers it. */ + it("withholds a job run's spend document from a member whose group hides spend", async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + queueSelects([], [jobRecord]) + + const result = await read(principal, 'job-1') + + expect(result.executionMetadata.cost).toBeNull() + }) + + /** + * A workspace API key authorizes as the workspace and represents no user, so + * it resolves to no subject — the key's creator is never substituted. + */ + it('reads whole and resolves no group for an executor delegation', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + queueSelects([workflowRecord], [{ id: 'snapshot-1', stateData: { blocks: {} } }]) + + const result = await read(executorPrincipal, 'run-1') + + expect(result.executionMetadata.cost).toEqual({ total: 0.75 }) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-run.test.ts b/apps/sim/lib/workflows/application/read-workflow-run.test.ts index a4a1d7cb735..750be295b92 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.test.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.test.ts @@ -59,6 +59,49 @@ function input(selectedOutputs: string[]) { return { workflowId: 'workflow-1', runId: 'run-1', includeOutput: true, selectedOutputs } } +/** + * `logs.cost` and `logs.trace_spans` withhold fields inside a run, and the shared + * read applies them — but only for the subject this use case names. A workspace + * API key authorizes as the workspace and represents no user, so it must resolve + * to none: substituting the key's creator would apply a bystander's group to + * every caller of a shared credential. + */ +describe('readWorkflowRun projection subject', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getRunFiles.mockResolvedValue(null) + mocks.getStatus.mockResolvedValue({ status: 'completed', blockOutputs: {} }) + }) + + it('names the acting user as the projection subject', async () => { + await readWorkflowRun.execute({ principal, input: input([]) }) + + expect(mocks.getStatus).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + viewerUserId: 'user-1', + }) + ) + }) + + it('names no subject for a workspace API key', async () => { + const workspaceKey = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + } + + await readWorkflowRun.execute({ principal: workspaceKey, input: input([]) }) + + expect(mocks.getStatus).toHaveBeenCalledWith( + expect.objectContaining({ viewerUserId: undefined }) + ) + }) +}) + describe('readWorkflowRun selector resolution', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/workflows/executor/execution-status-projection.test.ts b/apps/sim/lib/workflows/executor/execution-status-projection.test.ts new file mode 100644 index 00000000000..823629e610c --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-status-projection.test.ts @@ -0,0 +1,180 @@ +/** + * @vitest-environment node + * + * `logs.trace_spans` and `logs.cost` are PROJECTIONS, not gates — a group + * withholds those fields from the response rather than refusing the read. + * + * The run-detail family applied none of it: a member whose group hides spend saw + * `cost` blanked on the log list and then read `cost.total` on the run one click + * deeper, and a member whose group hides execution detail got `finalOutput` and + * `blockOutputs` back whole from both the internal executions route and + * `/api/v2/workflows/{id}/runs/{runId}`. These run the real + * `getWorkflowExecutionStatus` against the real `resolveLogFieldProjection` — the + * same helper `readLogDetail` and the v1 routes resolve their flags through — so + * they fail if this read stops projecting. + */ +import { + dbChainMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + queueTableRows, + resetDbChainMock, + resetPermissionGroupScopeMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetJob, mockMaterializeForDisplayWithBlockOutputs } = vi.hoisted(() => ({ + mockGetJob: vi.fn(), + mockMaterializeForDisplayWithBlockOutputs: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }), +})) + +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionDataForDisplayWithBlockOutputs: mockMaterializeForDisplayWithBlockOutputs, +})) + +vi.mock('@/lib/workflows/executor/paused-execution-metadata', () => ({ + getAutomaticResumeWaitingMetadata: vi.fn().mockReturnValue(null), +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' + +const BLOCK_ID = 'block-1' + +function queueCompletedRun(): void { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + level: 'info', + trigger: 'api', + startedAt: new Date('2026-08-05T12:00:00.000Z'), + endedAt: new Date('2026-08-05T12:00:01.000Z'), + totalDurationMs: 1000, + executionData: { executionState: {} }, + costTotal: '0.75', + }, + ]) + queueTableRows(schemaMock.resumeQueue, []) + queueTableRows(schemaMock.pausedExecutions, []) + mockMaterializeForDisplayWithBlockOutputs.mockResolvedValueOnce({ + executionData: { finalOutput: { answer: 'a customer address' } }, + blockOutputs: new Map([[BLOCK_ID, { answer: 'a customer address' }]]), + }) +} + +function readRun(viewerUserId: string | null | undefined) { + return getWorkflowExecutionStatus({ + workflowId: 'workflow-1', + executionId: 'execution-1', + includeOutput: true, + selectedOutputs: [BLOCK_ID], + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + viewerUserId, + }) +} + +describe('run-detail field projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + resetPermissionGroupScopeMock() + mockMaterializeForDisplayWithBlockOutputs.mockResolvedValue({ + executionData: {}, + blockOutputs: new Map(), + }) + }) + + it('reads the run whole for a member no group governs', async () => { + queueCompletedRun() + + const status = await readRun('user-1') + + expect(status?.cost).toEqual({ total: 0.75 }) + expect(status?.finalOutput).toEqual({ answer: 'a customer address' }) + expect(status?.blockOutputs).toEqual({ [BLOCK_ID]: { answer: 'a customer address' } }) + }) + + it('withholds the run total from a member whose group hides spend', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + queueCompletedRun() + + const status = await readRun('user-1') + + expect(status?.cost).toBeNull() + expect(status?.status).toBe('completed') + expect(status?.finalOutput).toEqual({ answer: 'a customer address' }) + }) + + it('withholds the execution payloads from a member whose group hides trace spans', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTraceSpans: true, + }) + queueCompletedRun() + + const status = await readRun('user-1') + + expect(status?.finalOutput).toBeNull() + expect(status?.blockOutputs).toBeNull() + expect(status?.cost).toEqual({ total: 0.75 }) + expect(JSON.stringify(status)).not.toContain('a customer address') + }) + + /** + * A workspace API key authorizes as the workspace and represents no user, so + * its caller resolves to no subject. Substituting the key's creator would apply + * a bystander's group to every caller of a shared credential — which is why + * this asserts the resolver is never reached, not merely that the run came back + * whole. + */ + it('reads whole and resolves no group for a subjectless caller', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + hideTraceSpans: true, + }) + queueCompletedRun() + + const status = await readRun(undefined) + + expect(status?.cost).toEqual({ total: 0.75 }) + expect(status?.finalOutput).toEqual({ answer: 'a customer address' }) + expect(status?.blockOutputs).toEqual({ [BLOCK_ID]: { answer: 'a customer address' } }) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + /** The queue branch answers before any log row exists, and is projected too. */ + it('withholds a queued run output from a member whose group hides trace spans', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTraceSpans: true, + }) + dbChainMockFns.limit.mockResolvedValueOnce([]).mockResolvedValueOnce([]) + mockGetJob.mockResolvedValue({ + status: 'completed', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + completedAt: new Date('2026-08-05T12:00:01.000Z'), + output: { output: { answer: 'a customer address' } }, + metadata: { workflowId: 'workflow-1', correlation: { triggerType: 'api' } }, + }) + + const status = await readRun('user-1') + + expect(status?.status).toBe('completed') + expect(status?.finalOutput).toBeNull() + }) +}) diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts index 94f83e9453b..e593b9614fc 100644 --- a/apps/sim/lib/workflows/executor/execution-status.test.ts +++ b/apps/sim/lib/workflows/executor/execution-status.test.ts @@ -29,6 +29,9 @@ const input = { executionId: 'execution-1', includeOutput: false, selectedOutputs: [], + workspaceId: 'workspace-1', + /** No governing subject: field projection has its own suite next door. */ + viewerUserId: undefined, } describe('getWorkflowExecutionStatus queue projection', () => { From 94a1cc1a8789898f2b128bb789818d77bc337e61 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 15:58:25 -0700 Subject: [PATCH 072/179] feat(permission-groups): declare capabilities on the operations staging added The merge brought three new operations, which the required capability field and the enforcement audit both refused until answered: files.search_content takes files.use like every sibling; the paused-execution read is exempt (its detail is pause points and resume state, never the fields logs.cost or logs.trace_spans withhold); SSO JIT admission runs before any membership exists, so no group can govern it yet. --- apps/sim/lib/auth/sso/application/operations.ts | 2 ++ apps/sim/lib/workflows/application/operations.ts | 2 ++ apps/sim/lib/workspace-files/application/operations.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/apps/sim/lib/auth/sso/application/operations.ts b/apps/sim/lib/auth/sso/application/operations.ts index ca63085acb5..1d68afdc7fe 100644 --- a/apps/sim/lib/auth/sso/application/operations.ts +++ b/apps/sim/lib/auth/sso/application/operations.ts @@ -5,7 +5,9 @@ import { defineOperation } from '@/lib/core/application/operation' * exists. Workspace-role authorization cannot apply yet; the use case instead * proves the exact provider link, verified domain, and provider-bound target. */ +// permission-group-exempt: SSO admission runs before any organization membership exists, so no group can govern the identity being admitted yet export const ssoJitAdmissionOperation = defineOperation({ id: 'sso.jit-admit', principalKinds: ['session'] as const, + capability: 'none', }) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 37d20a8f084..eac00638ea5 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -440,10 +440,12 @@ export const workflowOperations = { capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: a paused execution's detail is pause points and resume state, not the run's execution data — the fields logs.cost and logs.trace_spans withhold never appear here readPausedExecution: defineWorkspaceOperation({ id: 'workflows.paused_executions.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), /** diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index d9068cfff8d..09b480c84fb 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -42,6 +42,7 @@ export const fileOperations = { id: 'files.search_content', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), download: defineWorkspaceOperation({ From 492018d8391e21e154f229ea517276b3dabf495f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:12:32 -0700 Subject: [PATCH 073/179] feat(permission-groups): declare a capability on the unified selector operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectors.execute is exempt with its reason on record: credential access is authorized per credential, and per-integration denial is the parameterized allowedIntegrations key, enforced at workflow save and execution. Gating the picker itself needs a selector-key-to-block-type mapping — a follow-up, not something to claim silently here. --- apps/sim/lib/selectors/application/operations.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/sim/lib/selectors/application/operations.ts b/apps/sim/lib/selectors/application/operations.ts index 72f00f08a3b..1abd11fbae4 100644 --- a/apps/sim/lib/selectors/application/operations.ts +++ b/apps/sim/lib/selectors/application/operations.ts @@ -1,10 +1,12 @@ import { defineWorkspaceOperation } from '@/lib/core/application' export const selectorOperations = { + // permission-group-exempt: no static capability names selector browsing — credential access is authorized per credential, and per-integration denial is the parameterized allowedIntegrations key, enforced today at workflow save and execution. Gating the picker itself needs a selector-key-to-block-type mapping and is tracked as a follow-up, not silently covered here. execute: defineWorkspaceOperation({ id: 'selectors.execute', minimumRole: 'read', workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'none', }), } as const From ad07b52e52bb4cec3796b1705ae5b1fbd7167d60 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:30:51 -0700 Subject: [PATCH 074/179] fix(permission-groups): gate copilot.use on the workspace a chat lands in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cubic findings on the copilot surfaces. /api/v2/chat is a raw special route: `admitV2Request` authenticates and rate-limits but never authorizes, so the `copilot.use` capability declared on `chat.send` was never applied there. Gate it explicitly after workspace access — read off the operation, so the route and the funnel cannot name different capabilities — and project it as a 403 carrying PERMISSION_GROUP_CAPABILITY_BLOCKED. The unified chat send gated on `body.workspaceId`, but `resolveBranch` resolves a `workflowId` to the workflow's own workspace and ignores any `workspaceId` beside it. Sending only `workflowId` skipped the gate; pairing it with an unrestricted workspace aimed the gate elsewhere. Move the check after branch resolution and gate on the workspace the turn actually lands in — still ahead of the chat, the stream lock and the run, with the send claim released by the handler's `finally`. The tool-permission endpoint claims the decision row before reading `copilot.tool_auto_approval`. A rejected lookup answered 500 with the decision unpublished, and the retry lands on the already-answered branch, which does not republish — leaving the turn to wait out its permission timeout. Read a failed lookup as withheld: fail-closed for the capability, and the human's one-time answer still reaches the waiter. Also records why the per-turn `autoAllowPermitted` snapshot is not re-read mid-turn: `resolvePermissionGroupConfig` memoizes per request scope, so a re-read would answer with the value the turn opened with. --- .../api/copilot/tool-permission/route.test.ts | 26 ++++++++ .../app/api/copilot/tool-permission/route.ts | 20 +++++- apps/sim/app/api/v2/chat/route.test.ts | 60 ++++++++++++++++- apps/sim/app/api/v2/chat/route.ts | 26 ++++++++ apps/sim/lib/copilot/chat/post.test.ts | 64 +++++++++++++++++-- apps/sim/lib/copilot/chat/post.ts | 45 +++++++++---- .../lib/copilot/request/tools/permission.ts | 19 ++++-- 7 files changed, 235 insertions(+), 25 deletions(-) diff --git a/apps/sim/app/api/copilot/tool-permission/route.test.ts b/apps/sim/app/api/copilot/tool-permission/route.test.ts index f0508c3042c..b533477f309 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.test.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.test.ts @@ -175,6 +175,32 @@ describe('Copilot tool permission API', () => { } ) + /** + * The row is claimed before this lookup runs, so a rejection that escaped + * would answer 500 with the decision unpublished — and the retry lands on + * the already-answered branch, which does not republish, leaving the turn + * to wait out its permission timeout. + */ + it('answers the prompt when the lookup itself fails, remembering nothing', async () => { + getUserPermissionConfig.mockRejectedValue(new Error('permission group lookup failed')) + recordToolPermissionDecision.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: 'always_allow', + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + + const response = await POST(createRequest('always_allow')) + + expect(response.status).toBe(200) + expect(publishToolPermissionDecision).toHaveBeenCalledWith( + expect.objectContaining({ toolCallId: 'tool-1', decision: 'always_allow' }) + ) + expect(addAutoAllowedTool).not.toHaveBeenCalled() + }) + it('remembers it again once the group allows it', async () => { getUserPermissionConfig.mockResolvedValue({ disableToolAutoApproval: false }) recordToolPermissionDecision.mockResolvedValueOnce({ diff --git a/apps/sim/app/api/copilot/tool-permission/route.ts b/apps/sim/app/api/copilot/tool-permission/route.ts index 320e491531c..8c18edef37c 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.ts @@ -81,9 +81,27 @@ async function applyDecision( * runs the tool the user just allowed — only the memory of it is refused, so * the next call prompts again. Not a 403: the answer to *this* prompt was * legitimate, and failing the request would strand the waiting orchestrator. + * + * A failed lookup reads as withheld for the same reason, rather than throwing + * past the publish below. The row is already claimed by the time this runs, + * so an exception here answers 500 while leaving the decision unpublished — + * and the retry lands on the already-answered branch, which deliberately does + * not republish, so the turn waits out its permission timeout over a database + * hiccup. Withholding is also the fail-closed reading of the capability: the + * always-allow is simply not remembered, and the user is asked again. */ const mayRemember = run.workspaceId - ? !(await isWorkspaceCapabilityWithheld(userId, run.workspaceId, 'copilot.tool_auto_approval')) + ? !(await isWorkspaceCapabilityWithheld( + userId, + run.workspaceId, + 'copilot.tool_auto_approval' + ).catch((err) => { + logger.warn('Could not resolve the tool auto-approval capability; not remembering', { + toolCallId, + error: getErrorMessage(err), + }) + return true + })) : true // Best-effort: failing to remember the preference must not block the tool the diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index 945e2e8d564..b80cafb38c6 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -2,7 +2,11 @@ * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { + createMockRequest, + permissionGroupScopeMock, + permissionGroupScopeMockFns, +} from '@sim/testing' import { sleep } from '@sim/utils/helpers' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -116,6 +120,12 @@ vi.mock('@/lib/core/config/env-flags', () => ({ isDocSandboxEnabled: false, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +const mockResolvePermissionGroupConfig = + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { POST } from '@/app/api/v2/chat/route' const personalAuth = { @@ -243,6 +253,7 @@ describe('POST /api/v2/chat', () => { mockCheckPreAuthRate.mockResolvedValue({ allowed: true, remaining: 10, resetAt: new Date() }) mockCheckOperationRate.mockResolvedValue({ allowed: true, remaining: 10, resetAt: new Date() }) mockAssertActiveWorkspaceAccess.mockResolvedValue({ permission: 'admin' }) + mockResolvePermissionGroupConfig.mockResolvedValue(null) mockResolveBillingAttribution.mockResolvedValue(billingAttributionSnapshot) mockRequestExplicitStreamAbort.mockResolvedValue(undefined) mockPersistCopilotChatTurn.mockResolvedValue(undefined) @@ -296,6 +307,53 @@ describe('POST /api/v2/chat', () => { expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() }) + /** + * `admitV2Request` authenticates and rate-limits but never authorizes, so + * nothing but this route applies the capability `chat.send` declares. + */ + it('answers 403 when the permission group withholds copilot.use', async () => { + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCopilot: true, + }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: { + code: 'FORBIDDEN', + message: "Chat is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }, + }) + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + /** Workspace reach is decided first, so the refusal cannot name a group to an outsider. */ + it('refuses an inaccessible workspace before consulting a permission group', async () => { + mockAssertActiveWorkspaceAccess.mockRejectedValue(new MockWorkspaceAccessDeniedError('denied')) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCopilot: true, + }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(403) + expect(mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('runs one turn when a group governs the caller but withholds nothing', async () => { + mockResolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(200) + expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledTimes(1) + }) + it('runs one turn and answers the reply with a server-issued conversation id', async () => { const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index cc646312f44..d28bf49ac5f 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -39,6 +39,10 @@ import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { assertActiveWorkspaceAccess, isWorkspaceAccessDeniedError, @@ -165,6 +169,28 @@ export const POST = withRouteHandler( const workspaceAccess = await assertActiveWorkspaceAccess(workspaceId, userId) const userPermission = workspaceAccess.permission + /** + * permission-group-enforced: copilot.use — read off the operation so this + * route and the funnel can never name different capabilities. + * + * A raw special route: `admitV2Request` authenticates and rate-limits but + * never authorizes, so nothing else on this path applies the capability + * `chatOperations.send` declares. Checked after workspace access, for the + * reason the funnel gives — a caller with no reach into the workspace is + * refused first, so the refusal cannot report which capabilities an + * organization withholds to someone who is not in it — and before a + * conversation is minted or a turn is billed. + */ + const sendCapability = chatOperations.send.capability + if ( + sendCapability !== 'none' && + (await isWorkspaceCapabilityWithheld(userId, workspaceId, sendCapability)) + ) { + return v2Error('FORBIDDEN', capabilityRefusal(sendCapability), { + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + } + const conversationTitle = deriveConversationTitle(message) // A caller-supplied conversation id is a claim, not an identity: resolve diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 041c05240ab..4b8950a5140 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -964,8 +964,10 @@ describe('handleUnifiedChatPost copilot.use capability gate', () => { }) /** - * Refused before the send is claimed or a chat exists, so a refused request - * leaves nothing behind for a resume stream to replay. + * Refused before a chat exists or a run is created, so a refused request + * leaves nothing behind for a resume stream to replay. The send claim is + * taken first and released by the handler's `finally`, so a retry is free to + * start a turn. */ it('refuses the send when the group withholds copilot.use', async () => { resolvePermissionGroupConfig.mockResolvedValue({ @@ -977,9 +979,55 @@ describe('handleUnifiedChatPost copilot.use capability gate', () => { expect(response.status).toBe(403) await expect(response.json()).resolves.toEqual({ error: REFUSAL }) - expect(atomicallyClaimChatSend).not.toHaveBeenCalled() expect(resolveOrCreateChat).not.toHaveBeenCalled() expect(createSSEStream).not.toHaveBeenCalled() + expect(releaseChatSendClaim).toHaveBeenCalledTimes(1) + }) + + /** + * `workflowId` resolves the workflow's own workspace and ignores any + * `workspaceId` beside it, so gating on the request's copy would let a member + * skip the check entirely by simply not sending one. + */ + it('gates on the workflow workspace when the request names no workspace', async () => { + resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCopilot: true, + }) + + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ message: 'Hello', workflowId: 'wf-1', createNewChat: true }), + }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + expect(resolvePermissionGroupConfig).toHaveBeenCalledWith('user-1', 'ws-1', undefined) + expect(createSSEStream).not.toHaveBeenCalled() + }) + + /** The same escape aimed elsewhere: a workspace the chat never lands in. */ + it('ignores a workspaceId that disagrees with the resolved workflow workspace', async () => { + resolvePermissionGroupConfig.mockImplementation(async (_userId: string, workspaceId: string) => + workspaceId === 'ws-1' + ? { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCopilot: true } + : DEFAULT_PERMISSION_GROUP_CONFIG + ) + + const response = await handleUnifiedChatPost( + chatRequest({ workflowId: 'wf-1', workspaceId: 'ws-unrestricted', createNewChat: true }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + expect(resolvePermissionGroupConfig).not.toHaveBeenCalledWith( + 'user-1', + 'ws-unrestricted', + undefined + ) + expect(createSSEStream).not.toHaveBeenCalled() }) it('streams the send when a group governs the user but withholds nothing', async () => { @@ -1001,8 +1049,14 @@ describe('handleUnifiedChatPost copilot.use capability gate', () => { expect(createSSEStream).toHaveBeenCalledTimes(1) }) - /** A request naming no workspace is governed by no group, so the gate never asks. */ - it('does not consult a permission group when the request names no workspace', async () => { + /** A branch that lands in no workspace at all is governed by no group. */ + it('does not consult a permission group when the branch resolves no workspace', async () => { + resolveWorkflowIdForUser.mockResolvedValue({ + status: 'resolved', + workflowId: 'wf-1', + workspaceId: undefined, + workflowName: 'Workflow One', + }) resolvePermissionGroupConfig.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCopilot: true, diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 0e86482d107..1792bd0bc01 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -1060,20 +1060,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { const body = ChatMessageSchema.parse(await req.json()) - /** - * permission-group-enforced: copilot.use — Chat is a raw handler rather - * than a workspace operation, so the authorization funnel never sees it. - * Checked before the send is claimed or a run is created, which also - * settles the resume stream: with no run there is nothing to replay. A - * request naming no workspace is governed by no group. - */ - if ( - body.workspaceId && - (await isWorkspaceCapabilityWithheld(authenticatedUserId, body.workspaceId, 'copilot.use')) - ) { - return createForbiddenResponse(capabilityRefusal('copilot.use')) - } - const userMetadata = { ...(authenticatedUserName ? { name: authenticatedUserName } : {}), ...(authenticatedUserEmail ? { email: authenticatedUserEmail } : {}), @@ -1142,6 +1128,37 @@ export async function handleUnifiedChatPost(req: NextRequest) { return branch } + /** + * permission-group-enforced: copilot.use — Chat is a raw handler rather + * than a workspace operation, so the authorization funnel never sees it. + * + * Gated on the workspace the turn actually lands in, which is the one + * `resolveBranch` just resolved rather than the one the request asked + * for. A send naming `workflowId` resolves the workflow's own workspace + * and ignores any `workspaceId` beside it, so reading the request's copy + * would aim the check at a workspace the chat never touches — or, with + * no `workspaceId` sent at all, skip it entirely. A branch that resolves + * no workspace is governed by no group. + * + * Still ahead of everything durable: no chat is resolved, no pending + * stream lock is taken and no run is created, which also settles the + * resume stream — with no run there is nothing to replay. The send claim + * taken above is released by the `finally`, so a refused send leaves a + * later retry free to start a turn. + */ + if ( + branch.workspaceId && + (await isWorkspaceCapabilityWithheld( + authenticatedUserId, + branch.workspaceId, + 'copilot.use' + )) + ) { + activeOtelRoot.span.setAttribute(TraceAttr.HttpStatusCode, 403) + activeOtelRoot.finish('error') + return createForbiddenResponse(capabilityRefusal('copilot.use')) + } + let currentChat: ChatLoadResult['chat'] = null let conversationHistory: unknown[] = [] let chatIsNew = false diff --git a/apps/sim/lib/copilot/request/tools/permission.ts b/apps/sim/lib/copilot/request/tools/permission.ts index 2b10811a747..1e050b67e45 100644 --- a/apps/sim/lib/copilot/request/tools/permission.ts +++ b/apps/sim/lib/copilot/request/tools/permission.ts @@ -282,10 +282,21 @@ export function runGatedToolExecution( decisionSuppressesFuturePrompts(decision.decision) && context.toolPermissions.autoAllowPermitted ) { - // Same-turn effect: a second call to this tool later in the turn must - // not re-prompt. The durable write (chat row or user settings) happens - // in the endpoint, which refuses it under the same capability this - // guard reads — so the two cannot disagree within a turn. + /** + * Same-turn effect: a second call to this tool later in the turn must + * not re-prompt. The durable write (chat row or user settings) happens + * in the endpoint, which refuses it under the same capability. + * + * `autoAllowPermitted` is the snapshot the turn opened with, and it is + * deliberately not re-read here. Re-reading would not see a key revoked + * mid-turn anyway: `resolvePermissionGroupConfig` memoizes per request + * scope, so every read inside one turn answers with the value that + * scope resolved first. Revocation therefore takes effect on the next + * turn — seconds to minutes away — and in the meantime silences only + * prompts this user answered in person, never persisting one: the + * endpoint reads the capability on its own request and refuses the + * durable write. + */ context.toolPermissions.autoAllowed.add(toolName) } From 884fbe5af00af287d7242338f3a0371e14c72993 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:31:29 -0700 Subject: [PATCH 075/179] fix(permission-groups): close reactivation and creation gaps in the capability gates - POST /api/webhooks gated creation only, but its upsert always writes `isActive: true`, so re-saving a dormant webhook reactivated it under a group with `disableWebhookTriggers`. Gate creation and reactivation, matching the transition `PATCH /api/webhooks/[id]` already refuses; re-saving an already-active webhook stays open. - The two raw table import routes gated `tables.use`, so a group setting only `disableTableCreation` could still create a table by importing one. Gate `tables.create`, whose rule is denied by `disableTableCreation` OR `hideTablesTab` and so loses nothing. - `completeTableImportUseCase` authorized `tables.use` only, so an upload started before the group withheld creation still landed a table when it completed. Re-assert `tables.create` under the claimed session when the target is `new`. - `lockWorkspaceCreationContext` revalidated membership and entitlement but not `workspace.create`, so a create in flight when the group changed still committed. Re-read it under the lock and refuse with a distinct `WorkspaceCreationCapabilityWithheldError`, which the create route answers 403 rather than 409. --- .../app/api/table/import-async/route.test.ts | 56 ++++++++++++++- apps/sim/app/api/table/import-async/route.ts | 12 +++- .../app/api/table/import-csv/route.test.ts | 56 ++++++++++++++- apps/sim/app/api/table/import-csv/route.ts | 12 +++- apps/sim/app/api/webhooks/route.test.ts | 69 +++++++++++++++++- apps/sim/app/api/webhooks/route.ts | 32 ++++++--- apps/sim/app/api/workspaces/route.ts | 4 ++ .../sim/lib/table/application/imports.test.ts | 61 ++++++++++++++++ apps/sim/lib/table/application/imports.ts | 15 ++++ apps/sim/lib/workspaces/policy.test.ts | 70 +++++++++++++++++++ apps/sim/lib/workspaces/policy.ts | 42 ++++++++++- 11 files changed, 406 insertions(+), 23 deletions(-) diff --git a/apps/sim/app/api/table/import-async/route.test.ts b/apps/sim/app/api/table/import-async/route.test.ts index 4bb0b65bac2..e7b3fabf786 100644 --- a/apps/sim/app/api/table/import-async/route.test.ts +++ b/apps/sim/app/api/table/import-async/route.test.ts @@ -1,7 +1,14 @@ /** * @vitest-environment node */ -import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import { + hybridAuthMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + permissionsMock, + permissionsMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -48,7 +55,9 @@ vi.mock('@/lib/core/utils/background', () => ({ ), })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { POST } from '@/app/api/table/import-async/route' function makeRequest(body: unknown): NextRequest { @@ -68,6 +77,7 @@ const validBody = { describe('POST /api/table/import-async', () => { beforeEach(() => { vi.clearAllMocks() + resetPermissionGroupScopeMock() hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-1', @@ -153,4 +163,48 @@ describe('POST /api/table/import-async', () => { const response = await POST(makeRequest({ workspaceId: 'workspace-1' })) expect(response.status).toBe(400) }) + + /** + * An import is a table creation, so it is `tables.create` that governs it — + * not `tables.use`. `disableTableCreation` leaves Tables visible and usable, + * which is exactly the configuration a `tables.use` gate would let through. + */ + it('refuses the import when the group disables table creation', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableCreation: true, + }) + + const response = await POST(makeRequest(validBody)) + + expect(response.status).toBe(403) + expect((await response.json()).details).toEqual({ + code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }) + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('refuses the import when the group hides Tables entirely', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTablesTab: true, + }) + + const response = await POST(makeRequest(validBody)) + + expect(response.status).toBe(403) + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('lets the import through when the group withholds something else', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideKnowledgeBaseTab: true, + }) + + const response = await POST(makeRequest(validBody)) + + expect(response.status).toBe(200) + expect(mockCreateTable).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index cf095e12084..84bf6a27810 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -49,9 +49,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - // permission-group-enforced: tables.use — raw route that queries directly and predates the operation boundary - if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'tables.use')) { - return capabilityRefusalResponse('tables.use') + /** + * permission-group-enforced: tables.create — raw route that queries directly + * and predates the operation boundary. An import always ends in a new table, + * so it is creation, not ordinary use. `tables.create` subsumes `tables.use`: + * its rule is denied by `disableTableCreation` OR `hideTablesTab`, so gating + * on it still refuses a group that hides Tables entirely. + */ + if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'tables.create')) { + return capabilityRefusalResponse('tables.create') } // The fileKey is client-supplied — ensure it points at this workspace's storage prefix so a // caller can't import another workspace's uploaded object. diff --git a/apps/sim/app/api/table/import-csv/route.test.ts b/apps/sim/app/api/table/import-csv/route.test.ts index dea46a06a3a..ca30b15c8bb 100644 --- a/apps/sim/app/api/table/import-csv/route.test.ts +++ b/apps/sim/app/api/table/import-csv/route.test.ts @@ -1,7 +1,14 @@ /** * @vitest-environment node */ -import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import { + hybridAuthMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + permissionsMock, + permissionsMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' import type { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -63,8 +70,10 @@ vi.mock('@/app/api/table/utils', async () => { } }) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { TableLockedError } from '@/lib/table/mutation-locks' import { POST } from '@/app/api/table/import-csv/route' @@ -125,6 +134,7 @@ function uploadParts(csv: string): Part[] { describe('POST /api/table/import-csv', () => { beforeEach(() => { vi.clearAllMocks() + resetPermissionGroupScopeMock() hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-1', @@ -247,4 +257,48 @@ describe('POST /api/table/import-csv', () => { const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) expect(response.status).toBe(403) }) + + /** + * A CSV import creates a table, so `tables.create` governs it. A group that + * only sets `disableTableCreation` leaves Tables visible and usable, which is + * exactly the configuration a `tables.use` gate would let through. + */ + it('refuses the import when the group disables table creation', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableCreation: true, + }) + + const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) + + expect(response.status).toBe(403) + expect((await response.json()).details).toEqual({ + code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }) + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('refuses the import when the group hides Tables entirely', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTablesTab: true, + }) + + const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) + + expect(response.status).toBe(403) + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('lets the import through when the group withholds something else', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideKnowledgeBaseTab: true, + }) + + const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) + + expect(response.status).toBe(200) + expect(mockCreateTable).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index 65a804e9f1a..0d58c66d3e2 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -73,9 +73,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - // permission-group-enforced: tables.use — raw route that queries directly and predates the operation boundary - if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'tables.use')) { - return capabilityRefusalResponse('tables.use') + /** + * permission-group-enforced: tables.create — raw route that queries directly + * and predates the operation boundary. An import always ends in a new table, + * so it is creation, not ordinary use. `tables.create` subsumes `tables.use`: + * its rule is denied by `disableTableCreation` OR `hideTablesTab`, so gating + * on it still refuses a group that hides Tables entirely. + */ + if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'tables.create')) { + return capabilityRefusalResponse('tables.create') } let folderId: string | null = null diff --git a/apps/sim/app/api/webhooks/route.test.ts b/apps/sim/app/api/webhooks/route.test.ts index b580897ee78..85c318c1ca2 100644 --- a/apps/sim/app/api/webhooks/route.test.ts +++ b/apps/sim/app/api/webhooks/route.test.ts @@ -372,7 +372,7 @@ describe('POST /api/webhooks triggers.webhook gate', () => { mocks.findConflictingWebhookPathOwner.mockResolvedValue(null) mocks.resolveEnvVarsInObject.mockImplementation(async (config) => config) mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(false) - mocks.getProviderHandler.mockReturnValue(undefined) + mocks.getProviderHandler.mockReturnValue({}) mocks.createExternalWebhookSubscription.mockResolvedValue({ updatedProviderConfig: {}, externalSubscriptionCreated: false, @@ -420,4 +420,71 @@ describe('POST /api/webhooks triggers.webhook gate', () => { expect(response.status).not.toBe(403) expect(mocks.createExternalWebhookSubscription).toHaveBeenCalledTimes(1) }) + + /** The reads the update path makes: the path claim, then the existing row. */ + function queueUpdatePathRows(isActive: boolean): void { + queueTableRows(workflow, [{ id: 'workflow-1', userId: 'actor-1', workspaceId: 'workspace-1' }]) + queueTableRows(webhook, [{ id: 'webhook-1' }]) + queueTableRows(webhook, [ + { + id: 'webhook-1', + workflowId: 'workflow-1', + blockId: 'block-1', + path: 'inbound-orders', + provider: 'generic', + providerConfig: {}, + isActive, + }, + ]) + dbChainMockFns.returning.mockImplementationOnce(async () => [ + { id: 'webhook-1', workflowId: 'workflow-1', path: 'inbound-orders', isActive: true }, + ]) + } + + /** + * The upsert always writes `isActive: true`, so re-saving a dormant webhook is + * the same transition `PATCH /api/webhooks/[id]` gates — a workflow becoming + * reachable again — and has to be refused on the same terms. + */ + it('refuses to reactivate a dormant webhook when the group withholds webhook triggers', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableWebhookTriggers: true, + }) + queueUpdatePathRows(false) + + const response = await POST(upsertRequest()) + + expect(response.status).toBe(403) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) + + /** + * An already-active webhook is already reachable, so re-saving its config adds + * no exposure. Refusing it would strand a member unable to repair a live + * integration — the same reason inbound delivery is never gated. + */ + it('still lets an already-active webhook be reconfigured under the same group', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableWebhookTriggers: true, + }) + queueUpdatePathRows(true) + + const response = await POST(upsertRequest()) + + expect(response.status).toBe(200) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true, provider: 'generic' }) + ) + }) + + it('reactivates a dormant webhook when no group withholds the capability', async () => { + queueUpdatePathRows(false) + + const response = await POST(upsertRequest()) + + expect(response.status).toBe(200) + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ isActive: true })) + }) }) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index abd431b2ba5..e96cb78b084 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -406,14 +406,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { * application operation to declare the capability on, so it is asserted * here. * - * Creation only. An already-created webhook must keep firing: inbound - * delivery runs with no session to resolve a group against, and refusing - * there would silently break live integrations the moment an admin ticked - * the box, with the failure surfacing at the provider rather than in Sim. - * Withholding the capability stops new exposure; removing existing exposure - * is a deliberate act of deleting the webhook. + * Creation and reactivation, because both end with a workflow newly + * reachable from an inbound webhook: this upsert always writes + * `isActive: true`, so re-saving a dormant webhook turns it back on exactly + * as `PATCH /api/webhooks/[id]` would, and that route already gates the same + * transition. + * + * Re-saving an already-active webhook is not gated. It changes the config of + * an endpoint that is already reachable and adds no exposure, and refusing + * it would strand a member unable to repair a live integration — the same + * reason inbound delivery is never gated. Inbound delivery runs with no + * session to resolve a group against, and refusing there would break live + * integrations at the provider rather than in Sim. Removing existing + * exposure stays a deliberate act of deleting or deactivating the webhook. */ - if (!existingWebhook) { + if (!existingWebhook || existingWebhook.isActive === false) { const withheld = workflowRecord.workspaceId ? await isWorkspaceCapabilityWithheld( userId, @@ -422,10 +429,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) : false if (withheld) { - logger.warn(`[${requestId}] Webhook creation blocked by permission group`, { - userId, - workflowId, - }) + logger.warn( + `[${requestId}] Webhook ${existingWebhook ? 'reactivation' : 'creation'} blocked by permission group`, + { + userId, + workflowId, + } + ) return NextResponse.json({ error: capabilityRefusal('triggers.webhook') }, { status: 403 }) } } diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 37d15917946..9d501e4864b 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -15,6 +15,7 @@ import { createWorkspace } from '@/lib/workspaces/create' import { listWorkspacesForViewer } from '@/lib/workspaces/list' import { getWorkspaceCreationPolicy, + WorkspaceCreationCapabilityWithheldError, WorkspaceCreationContextChangedError, } from '@/lib/workspaces/policy' @@ -181,6 +182,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ workspace: newWorkspace }) } catch (error) { + if (error instanceof WorkspaceCreationCapabilityWithheldError) { + return NextResponse.json({ error: error.message }, { status: 403 }) + } if (error instanceof WorkspaceCreationContextChangedError) { return NextResponse.json( { diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts index 53fbf893a6c..ee5f0ac6def 100644 --- a/apps/sim/lib/table/application/imports.test.ts +++ b/apps/sim/lib/table/application/imports.test.ts @@ -509,5 +509,66 @@ describe('table import application use cases', () => { }) ).resolves.toEqual({ import: { record, upload: null } }) }) + + /** + * Creation and completion are separate requests, so a group that withholds + * creation between them has to be read again at completion — otherwise the + * upload started while it was allowed still lands a table. + */ + it('refuses to complete an upload that would create a table, and never starts the import', async () => { + await expect( + completeTableImportUseCase.execute({ + principal: reader, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + ).rejects.toMatchObject({ capability: 'tables.create' }) + + expect(mocks.startUploadedImport).not.toHaveBeenCalled() + }) + + it('still completes an upload targeting an existing table', async () => { + mocks.tableImportBodyFromUpload.mockReturnValue({ + workspaceId: 'workspace-1', + source: record.source, + target: { type: 'existing', tableId: 'table-1' }, + }) + + await expect( + completeTableImportUseCase.execute({ + principal: reader, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + ).resolves.toEqual({ import: { ...record, status: 'ready' } }) + + expect(mocks.startUploadedImport).toHaveBeenCalledTimes(1) + }) + + /** + * A workspace API key has no acting person, so there is no group to read — + * the same exemption `createTableImportUseCase` makes, and the reason the + * re-check must not become a blanket refusal on the completion leg. + */ + it('still completes an upload driven by a workspace key, which has no subject', async () => { + await expect( + completeTableImportUseCase.execute({ + principal: workspaceKey, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + ).resolves.toEqual({ import: { ...record, status: 'ready' } }) + + expect(mocks.startUploadedImport).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts index bc9786a8434..1ccd3407dca 100644 --- a/apps/sim/lib/table/application/imports.ts +++ b/apps/sim/lib/table/application/imports.ts @@ -291,6 +291,21 @@ export const completeTableImportUseCase = defineAuthorizedTableUseCase({ await authorizeWorkspaceOperation(principal, tableOperations.completeImport, context, { delegation: tableDelegationPolicy, }) + /** + * permission-group-enforced: tables.create — the same assertion + * `createTableImportUseCase` makes, repeated here because the two are + * separate requests: an upload started before the group withheld + * creation would otherwise still land a table when it completed. Read + * from the claimed session so the target is the one the upload was + * created for, and asserted for the acting person only — an actorless + * deployment run has no group, like everywhere else. + */ + if (tableImportBodyFromUpload(claimed).target.type === 'new') { + const actingUserId = resolvePrincipalSubjectUserId(principal) + if (actingUserId) { + await assertWorkspaceCapability(actingUserId, context.workspaceId, 'tables.create') + } + } return { value: null } }, }) diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index c77e274ee37..95d60d2f643 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -52,6 +52,7 @@ import { getWorkspaceInvitePolicy, lockWorkspaceCreationContext, WORKSPACE_MODE, + WorkspaceCreationCapabilityWithheldError, WorkspaceCreationContextChangedError, } from '@/lib/workspaces/policy' import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants' @@ -137,6 +138,75 @@ describe('lockWorkspaceCreationContext', () => { ) }) + /** + * The preflight in `getWorkspaceCreationPolicy` and the insert are separate + * requests. A group that withheld creation in between has to be caught under + * the lock, or the in-flight create lands a workspace that carries no + * `permissionGroupWorkspace` row to bring it back under the regime. + */ + it('rejects when the group withheld workspace creation after the preflight', async () => { + vi.clearAllMocks() + resetDbChainMock() + setEnvFlags({ isBillingEnabled: false }) + mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined) + mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1', role: 'admin' }) + mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + disableWorkspaceCreation: true, + }) + queueTableRows(member, [{ userId: 'owner-1' }]) + const tx = dbChainMock.db as unknown as DbOrTx + + await expect( + lockWorkspaceCreationContext(tx, { + userId: 'creator-1', + organizationId: 'org-1', + observedOrganizationId: 'org-1', + }) + ).rejects.toBeInstanceOf(WorkspaceCreationCapabilityWithheldError) + expect(mockGetUserPermissionConfigForOrganization).toHaveBeenCalledWith('org-1') + }) + + /** + * A personal workspace is precisely the escape from a scoped group, so the + * re-check reads the caller's membership organization even when the workspace + * being inserted carries none — the same organization the preflight used. + */ + it('rejects a personal workspace when the membership organization withheld creation', async () => { + vi.clearAllMocks() + resetDbChainMock() + mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined) + mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1', role: 'member' }) + mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + disableWorkspaceCreation: true, + }) + const tx = dbChainMock.db as unknown as DbOrTx + + await expect( + lockWorkspaceCreationContext(tx, { + userId: 'creator-1', + organizationId: null, + observedOrganizationId: 'org-1', + }) + ).rejects.toBeInstanceOf(WorkspaceCreationCapabilityWithheldError) + }) + + it('leaves an unaffiliated creator alone, with no group to read', async () => { + vi.clearAllMocks() + resetDbChainMock() + mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined) + mockGetUserOrganization.mockResolvedValue(null) + const tx = dbChainMock.db as unknown as DbOrTx + + await expect( + lockWorkspaceCreationContext(tx, { + userId: 'creator-1', + organizationId: null, + observedOrganizationId: null, + }) + ).resolves.toEqual({ billedAccountUserId: 'creator-1' }) + expect(mockGetUserPermissionConfigForOrganization).not.toHaveBeenCalled() + }) + it('rejects when the paid org entitlement disappeared before insertion', async () => { vi.clearAllMocks() resetDbChainMock() diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 3d56c7d0038..1b18a336358 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -14,7 +14,10 @@ import { getPlanType, isEnterprise, isMaxTier, isPro, isTeam } from '@/lib/billi import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/env-flags' import type { DbOrTx } from '@/lib/db/types' -import { isOrganizationCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { + capabilityRefusal, + isOrganizationCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { CONTACT_OWNER_TO_UPGRADE_REASON, UPGRADE_TO_INVITE_REASON, @@ -97,12 +100,27 @@ export interface WorkspaceCreationPolicy { } export class WorkspaceCreationContextChangedError extends Error { - constructor() { - super('Workspace creation context changed before the workspace was inserted') + constructor(message = 'Workspace creation context changed before the workspace was inserted') { + super(message) this.name = 'WorkspaceCreationContextChangedError' } } +/** + * The permission-group case of {@link WorkspaceCreationContextChangedError}. + * + * A subclass rather than a sibling so every caller that already treats a changed + * context as retryable keeps working unchanged, while a surface that wants to + * say *why* — the create route answers 403 with the capability refusal instead + * of 409 "membership changed" — can narrow to it. + */ +export class WorkspaceCreationCapabilityWithheldError extends WorkspaceCreationContextChangedError { + constructor() { + super(capabilityRefusal('workspace.create')) + this.name = 'WorkspaceCreationCapabilityWithheldError' + } +} + /** * Serializes the final creation-policy check with membership/ownership * mutations and row-locks the paid entitlement used by organization mode. @@ -133,6 +151,24 @@ export async function lockWorkspaceCreationContext( throw new WorkspaceCreationContextChangedError() } + /** + * permission-group-enforced: workspace.create — re-read under the lock because + * the preflight in `getWorkspaceCreationPolicy` and the insert are separate + * requests: a group that withheld creation in between would otherwise still + * let the in-flight create land, and a new workspace carries no + * `permissionGroupWorkspace` row to bring it back under the regime afterwards. + * Governed by the same organization the preflight used — the explicit one, or + * the caller's membership when the workspace would be personal — so a personal + * workspace stays as governed here as it is there. + */ + const governingOrganizationId = organizationId ?? currentMembership?.organizationId ?? null + if ( + governingOrganizationId && + (await isOrganizationCapabilityWithheld(governingOrganizationId, 'workspace.create')) + ) { + throw new WorkspaceCreationCapabilityWithheldError() + } + if (!organizationId) return { billedAccountUserId: userId } if (isBillingEnabled) { From 6cbebf125f9882cac4f8c9e61863051f629e79b3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:31:40 -0700 Subject: [PATCH 076/179] fix(permission-groups): exempt retired entry points and normalize allowlist ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A legacy starter block (`starter`, `manual_trigger`, `api_trigger`, `chat_trigger`) resolves to `start_trigger`, which the Access Control editor never offers as an allowlist row, so every active allowlist refused every saved workflow still carrying one. Exempt a retired block whose successor is the universal entry point. The policy list was compared raw against a successor-resolved block type, so a deployment whose `ALLOWED_INTEGRATIONS` names a retired id — the hand-written `slack` — refused every `slack_v2` in it. `toAccessControlAllowlist` normalizes the policy side the same way, and replaces `toAllowedIntegrationTypes` at every comparison site. --- .../access-control/utils/permission-check.ts | 7 +-- apps/sim/hooks/use-permission-config.ts | 22 ++++++-- .../catalog/application/catalog-context.ts | 7 ++- .../copilot/integration-tool-projection.ts | 14 ++--- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 12 ++--- apps/sim/lib/core/application/index.ts | 1 + .../application/workspace-authorization.ts | 33 ++++++++++++ .../integrations/principal-scope.server.ts | 3 +- .../permission-groups/block-access.test.ts | 54 +++++++++++++++++++ .../sim/lib/permission-groups/block-access.ts | 45 ++++++++++++++-- .../integration-allowlist.ts | 12 ----- .../persistence/block-access-guard.ts | 4 +- 12 files changed, 175 insertions(+), 39 deletions(-) diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 98973c439d8..1c8fdf8b43c 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -8,6 +8,7 @@ import { import { isBlockTypeAccessControlExempt, resolveAccessControlBlockType, + toAccessControlAllowlist, } from '@/lib/permission-groups/block-access' import { CAPABILITY_RULES, @@ -272,9 +273,9 @@ function assertBlockTypeAllowed( */ const allowlistType = resolveAccessControlBlockType(blockType).toLowerCase() - if (!config.allowedIntegrations.includes(allowlistType)) { - const envAllowlist = getAllowedIntegrationsFromEnv() - const blockedByEnv = envAllowlist !== null && !envAllowlist.includes(allowlistType) + if (!toAccessControlAllowlist(config.allowedIntegrations)?.has(allowlistType)) { + const envAllowlist = toAccessControlAllowlist(getAllowedIntegrationsFromEnv()) + const blockedByEnv = envAllowlist !== null && !envAllowlist.has(allowlistType) logger.warn( blockedByEnv ? 'Integration blocked by env allowlist' diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 3bfdd1d2f69..3cdd7f685eb 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -14,7 +14,11 @@ import { isDeploymentGatedIntegrationType, resolveIntegrationAvailabilityStateForVisibility, } from '@/lib/integrations/availability' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { + isBlockTypeAccessControlExempt, + resolveAccessControlBlockType, + toAccessControlAllowlist, +} from '@/lib/permission-groups/block-access' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, @@ -88,6 +92,16 @@ export function usePermissionConfig(): PermissionConfigResult { return intersectIntegrationAllowlists(config.allowedIntegrations, envAllowlist) }, [config.allowedIntegrations, envAllowlistData]) + /** + * Both sides of the membership test are judged as the current block, so a + * policy naming a retired id — `ALLOWED_INTEGRATIONS=slack` — still permits + * the successor the editor offers. + */ + const allowedAccessControlTypes = useMemo( + () => toAccessControlAllowlist(mergedAllowedIntegrations), + [mergedAllowedIntegrations] + ) + const integrationAvailability = useMemo(() => { const visibility = overlayVisibility() return new Map( @@ -116,10 +130,10 @@ export function usePermissionConfig(): PermissionConfigResult { return false } if (isBlockTypeAccessControlExempt(blockType)) return true - if (mergedAllowedIntegrations === null) return true - return mergedAllowedIntegrations.includes(normalizedBlockType) + if (allowedAccessControlTypes === null) return true + return allowedAccessControlTypes.has(resolveAccessControlBlockType(normalizedBlockType)) } - }, [hostContext?.features?.credentialGroups, integrationAvailability, mergedAllowedIntegrations]) + }, [hostContext?.features?.credentialGroups, integrationAvailability, allowedAccessControlTypes]) const isModelUsable = useMemo( () => diff --git a/apps/sim/lib/catalog/application/catalog-context.ts b/apps/sim/lib/catalog/application/catalog-context.ts index 859e76cb1a6..195558c5aa8 100644 --- a/apps/sim/lib/catalog/application/catalog-context.ts +++ b/apps/sim/lib/catalog/application/catalog-context.ts @@ -3,7 +3,10 @@ import { type BlockVisibilityState, getBlockVisibility } from '@/lib/core/config import { OrchestrationError } from '@/lib/core/orchestration/types' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { allowedIntegrationTypes, principalUserId } from '@/lib/integrations/principal-scope.server' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { + isBlockTypeAccessControlExempt, + resolveAccessControlBlockType, +} from '@/lib/permission-groups/block-access' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { type ActiveWorkspaceApplicationContext, @@ -74,7 +77,7 @@ export function isBlockVisibleToCaller(block: BlockConfig, gate: CatalogGate): b export function isBlockTypeAllowed(blockType: string, gate: CatalogGate): boolean { if (gate.allowedIntegrations === null) return true if (isBlockTypeAccessControlExempt(blockType)) return true - return gate.allowedIntegrations.has(blockType.toLowerCase()) + return gate.allowedIntegrations.has(resolveAccessControlBlockType(blockType).toLowerCase()) } /** diff --git a/apps/sim/lib/copilot/integration-tool-projection.ts b/apps/sim/lib/copilot/integration-tool-projection.ts index 69111c6e6bf..00a99a4703f 100644 --- a/apps/sim/lib/copilot/integration-tool-projection.ts +++ b/apps/sim/lib/copilot/integration-tool-projection.ts @@ -6,11 +6,12 @@ import { import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' -import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { - intersectIntegrationAllowlists, - toAllowedIntegrationTypes, -} from '@/lib/permission-groups/integration-allowlist' + resolveAccessControlBlockType, + toAccessControlAllowlist, +} from '@/lib/permission-groups/block-access' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { collectDeniedOperationIds, createToolAccessGate, @@ -54,7 +55,7 @@ export function projectIntegrationToolsForViewer( vis: BlockVisibilityState | null, permissionConfig: IntegrationGateConfig | null | undefined ): ViewerIntegrationProjection { - const allowedBlockTypes = toAllowedIntegrationTypes( + const allowedBlockTypes = toAccessControlAllowlist( intersectIntegrationAllowlists( permissionConfig?.allowedIntegrations ?? null, getAllowedIntegrationsFromEnv() @@ -67,7 +68,8 @@ export function projectIntegrationToolsForViewer( vis, (owner) => isIntegrationDeploymentAvailableForVisibility(owner.blockType, vis) && - (allowedBlockTypes === null || allowedBlockTypes.has(owner.blockType.toLowerCase())), + (allowedBlockTypes === null || + allowedBlockTypes.has(resolveAccessControlBlockType(owner.blockType).toLowerCase())), isToolAllowed ) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index e37bed18b95..519d203be73 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -141,13 +141,13 @@ import { listKnowledgeBases, } from '@/lib/knowledge/application/knowledge-bases' import { validateMermaidSource } from '@/lib/mermaid/validate' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { + isBlockTypeAccessControlExempt, + toAccessControlAllowlist, +} from '@/lib/permission-groups/block-access' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' -import { - intersectIntegrationAllowlists, - toAllowedIntegrationTypes, -} from '@/lib/permission-groups/integration-allowlist' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import type { IsToolAllowed } from '@/lib/permission-groups/operation-access' import { listOrganizationWorkspaceRefs, @@ -3180,7 +3180,7 @@ export class WorkspaceVFS { permissionConfigPromise, ]) const credentialVisibility = createIntegrationCredentialVisibility({ - allowedIntegrationTypes: toAllowedIntegrationTypes( + allowedIntegrationTypes: toAccessControlAllowlist( intersectIntegrationAllowlists( permissionConfig?.allowedIntegrations ?? null, getAllowedIntegrationsFromEnv() diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index cca5d4af452..c3f2b5a5c9f 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -32,6 +32,7 @@ export type { } from '@/lib/core/application/workspace-authorization' export { authorizeWorkspaceOperation, + capabilityGovernedPrincipalUserId, DelegatedServiceAuthorizationError, DelegatedWorkspaceAuthorizationError, InsufficientWorkspacePermissionsError, diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index ff225df1f66..11aa218aeb3 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -21,6 +21,39 @@ import { } from '@/lib/permission-groups/capability-assertions' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +/** + * The person whose permission group governs `principal`, or `null` when no + * group applies to it. + * + * THE one statement of that rule, for the checks that cannot ride on an + * operation's `capability` because they need the resource in hand — an import's + * block allowlist, a bulk download's item count. Those sites otherwise reach for + * whatever user id is nearest, and the nearest one is usually a bystander: a + * workspace key's creator, or the billing owner an attribution helper + * substituted. Both would apply a stranger's group to a caller the funnel + * deliberately passes ungated. + * + * Mirrors {@link authorizeWorkspaceOperation} exactly, including its executor + * exemption: a run carries the role of whoever triggered it but not their + * capabilities. + */ +export function capabilityGovernedPrincipalUserId(principal: Principal): string | null { + switch (principal.kind) { + case 'session': + case 'personal_api_key': + return principal.userId + case 'workspace_api_key': + case 'system': + case 'credential_group_enrollment': + return null + case 'delegated': { + if (principal.serviceId === 'executor') return null + const subject = resolvePrincipalSubject(principal) + return subject?.kind === 'sim_user' ? subject.userId : null + } + } +} + export interface WorkspaceAuthorizationContext { workspaceId: string workspaceOrganizationId: string | null diff --git a/apps/sim/lib/integrations/principal-scope.server.ts b/apps/sim/lib/integrations/principal-scope.server.ts index 1b28703a876..6b126b8be20 100644 --- a/apps/sim/lib/integrations/principal-scope.server.ts +++ b/apps/sim/lib/integrations/principal-scope.server.ts @@ -1,5 +1,6 @@ import type { Principal } from '@sim/auth/principal' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { toAccessControlAllowlist } from '@/lib/permission-groups/block-access' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' @@ -54,5 +55,5 @@ export async function allowedIntegrationTypes( permissionConfig?.allowedIntegrations ?? null, getAllowedIntegrationsFromEnv() ) - return integrations ? new Set(integrations.map((type) => type.toLowerCase())) : null + return toAccessControlAllowlist(integrations) } diff --git a/apps/sim/lib/permission-groups/block-access.test.ts b/apps/sim/lib/permission-groups/block-access.test.ts index 9f1a8433629..95d02200ee2 100644 --- a/apps/sim/lib/permission-groups/block-access.test.ts +++ b/apps/sim/lib/permission-groups/block-access.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { isBlockTypeAccessControlExempt, resolveAccessControlBlockType, + toAccessControlAllowlist, } from '@/lib/permission-groups/block-access' import { getBlock } from '@/blocks/registry' @@ -105,4 +106,57 @@ describe('isBlockTypeAccessControlExempt', () => { expect(isBlockTypeAccessControlExempt('slack_v2')).toBe(false) }) + + /** + * The editor never offers `start_trigger` as an allowlist row, so a retired + * entry point judged as its successor would be refused by every active + * allowlist — breaking every saved workflow that still carries one. + */ + it('exempts a retired entry point, whose successor is the universal one', () => { + registry({ + starter: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'start_trigger' } }, + manual_trigger: { + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'start_trigger' }, + }, + start_trigger: {}, + }) + + expect(isBlockTypeAccessControlExempt('starter')).toBe(true) + expect(isBlockTypeAccessControlExempt('manual_trigger')).toBe(true) + }) +}) + +describe('toAccessControlAllowlist', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('keeps an unrestricted allowlist unrestricted', () => { + registry({}) + + expect(toAccessControlAllowlist(null)).toBeNull() + }) + + /** + * `ALLOWED_INTEGRATIONS` is written by hand against whatever ids its author + * knows, so a deployment that permitted `slack` must not refuse `slack_v2`. + */ + it('judges a policy entry naming a retired id as its successor', () => { + registry({ + slack: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } }, + slack_v2: {}, + }) + + const allowlist = toAccessControlAllowlist(['Slack']) + + expect(allowlist?.has('slack_v2')).toBe(true) + expect(allowlist?.has('slack')).toBe(false) + }) + + it('denies everything for an empty allowlist', () => { + registry({ slack_v2: {} }) + + expect(toAccessControlAllowlist([])?.size).toBe(0) + }) }) diff --git a/apps/sim/lib/permission-groups/block-access.ts b/apps/sim/lib/permission-groups/block-access.ts index 88fdf1948d0..15d2877d6c7 100644 --- a/apps/sim/lib/permission-groups/block-access.ts +++ b/apps/sim/lib/permission-groups/block-access.ts @@ -1,15 +1,28 @@ import { getBlock } from '@/blocks/registry' +/** + * The universal workflow entry point. Every retired entry point resolves to it, + * and it is never an allowlist row, so both it and anything that resolves to it + * are exempt. + */ +const UNIVERSAL_ENTRY_POINT = 'start_trigger' + /** * Block types that bypass permission-group access control entirely. * - * Two kinds are exempt: + * Three kinds are exempt: * - `start_trigger`: the universal workflow entry point. A workflow must be * startable whatever the integration allowlist says. * - A retired block with no successor. It is hidden from the toolbar and from * the Access Control editor, so an admin has no row to permit it on and * nothing to permit it *as*; denying it would silently break the older * workflows still carrying it. + * - A retired entry point — `starter`, `manual_trigger`, `api_trigger`, + * `chat_trigger` — whose successor is `start_trigger`. It is judged as the + * universal entry point, and the universal entry point is exempt, so it must + * be too. The editor never offers `start_trigger` as an allowlist row, so + * without this every active allowlist refuses every workflow still carrying + * an old starter block. * * A *superseded* block is deliberately not exempt. Legacy `slack` talks to * Slack exactly as `slack_v2` does, so exempting it let an allowlist naming @@ -22,9 +35,11 @@ import { getBlock } from '@/blocks/registry' * set that is hidden and the set that is skipped cannot drift apart. */ export function isBlockTypeAccessControlExempt(blockType: string): boolean { - if (blockType === 'start_trigger') return true + if (blockType === UNIVERSAL_ENTRY_POINT) return true const block = getBlock(blockType) - return block?.hideFromToolbar === true && resolveAccessControlBlockType(blockType) === blockType + if (block?.hideFromToolbar !== true) return false + const successor = resolveAccessControlBlockType(blockType) + return successor === blockType || successor === UNIVERSAL_ENTRY_POINT } /** @@ -49,3 +64,27 @@ export function resolveAccessControlBlockType(blockType: string): string { current = successor } } + +/** + * The allowlist, indexed for membership tests against the block type an + * allowlist decision is made against. `null` stays `null` — unrestricted, not + * "nothing allowed". + * + * Both sides have to be normalized or they compare different vocabularies. A + * policy list can name a retired id: `ALLOWED_INTEGRATIONS` is written by hand + * against whatever ids the author knows, so `ALLOWED_INTEGRATIONS=slack` is the + * expected way to permit Slack. The checked type is always successor-resolved, + * so without normalizing the policy the deployment that permitted `slack` would + * refuse every `slack_v2` block in it. + */ +export function toAccessControlAllowlist( + allowedIntegrations: readonly string[] | null +): ReadonlySet | null { + return allowedIntegrations + ? new Set( + allowedIntegrations.map((integration) => + resolveAccessControlBlockType(integration.toLowerCase()).toLowerCase() + ) + ) + : null +} diff --git a/apps/sim/lib/permission-groups/integration-allowlist.ts b/apps/sim/lib/permission-groups/integration-allowlist.ts index dd38b4ce07d..3656ee60f6d 100644 --- a/apps/sim/lib/permission-groups/integration-allowlist.ts +++ b/apps/sim/lib/permission-groups/integration-allowlist.ts @@ -15,15 +15,3 @@ export function intersectIntegrationAllowlists( const secondSet = new Set(normalizedSecond) return normalizedFirst.filter((integration) => secondSet.has(integration)) } - -/** - * The lowercased block types an allowlist permits, indexed for membership tests. - * `null` stays `null` — unrestricted, not "nothing allowed". - */ -export function toAllowedIntegrationTypes( - allowedIntegrations: readonly string[] | null -): ReadonlySet | null { - return allowedIntegrations - ? new Set(allowedIntegrations.map((integration) => integration.toLowerCase())) - : null -} diff --git a/apps/sim/lib/workflows/persistence/block-access-guard.ts b/apps/sim/lib/workflows/persistence/block-access-guard.ts index 7d94aeb4b7c..4f4541caf1e 100644 --- a/apps/sim/lib/workflows/persistence/block-access-guard.ts +++ b/apps/sim/lib/workflows/persistence/block-access-guard.ts @@ -1,9 +1,9 @@ import { isBlockTypeAccessControlExempt, resolveAccessControlBlockType, + toAccessControlAllowlist, } from '@/lib/permission-groups/block-access' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' -import { toAllowedIntegrationTypes } from '@/lib/permission-groups/integration-allowlist' import { BlockType } from '@/executor/constants' /** @@ -44,7 +44,7 @@ export async function findWithheldBlockType(params: { params.workspaceId, undefined ) - const allowed = toAllowedIntegrationTypes(permissionConfig?.allowedIntegrations ?? null) + const allowed = toAccessControlAllowlist(permissionConfig?.allowedIntegrations ?? null) /** * Hoisted out of the loop: an unrestricted group is the common case, and every From 4b5192583e4b98abddd42197891e3bf77af9d901 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:32:39 -0700 Subject: [PATCH 077/179] fix(workflows): judge an import's blocks against the caller's own group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace API key has no user, so no permission group governs it, but the import pipeline read the allowlist from its attribution field — the billing owner on the application path, the key's creator on v1. Either is a bystander whose group would refuse an import `workflows.import` allows, and would break a shared key the moment that person's group changed. `capabilityUserId` carries the governed person separately from the attribution field, from the one funnel rule (`capabilityGovernedPrincipalUserId`, which mirrors `authorizeWorkspaceOperation` including its executor exemption). --- apps/sim/app/api/v1/workflows/import/route.ts | 2 ++ .../workflows/application/import-export.ts | 2 ++ .../operations/import-workflow.test.ts | 19 +++++++++++++ .../workflows/operations/import-workflow.ts | 28 +++++++++++++++---- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts index fc380ec3400..8a59f59a944 100644 --- a/apps/sim/app/api/v1/workflows/import/route.ts +++ b/apps/sim/app/api/v1/workflows/import/route.ts @@ -14,6 +14,7 @@ import { } from '@/lib/workflows/operations/import-workflow' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { + capabilityGovernedUserId, checkRateLimit, createRateLimitResponse, v1ValidationErrorResponse, @@ -78,6 +79,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { description, workflow: parsed.data.body.workflow, userId, + capabilityUserId: capabilityGovernedUserId(rateLimit), requestId, }) diff --git a/apps/sim/lib/workflows/application/import-export.ts b/apps/sim/lib/workflows/application/import-export.ts index 662b98542ee..d1cded44422 100644 --- a/apps/sim/lib/workflows/application/import-export.ts +++ b/apps/sim/lib/workflows/application/import-export.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' @@ -71,6 +72,7 @@ export const importWorkflow = defineAuthorizedWorkflowUseCase({ description: input.description, workflow: input.workflow, userId: attribution.attributedUserId, + capabilityUserId: capabilityGovernedPrincipalUserId(principal), requestId: generateRequestId(), }) if (!result.success) { diff --git a/apps/sim/lib/workflows/operations/import-workflow.test.ts b/apps/sim/lib/workflows/operations/import-workflow.test.ts index 857b22c9438..f4d3046d256 100644 --- a/apps/sim/lib/workflows/operations/import-workflow.test.ts +++ b/apps/sim/lib/workflows/operations/import-workflow.test.ts @@ -53,6 +53,7 @@ function params(workflowPayload: Record) { return { workspaceId: 'workspace-1', userId: 'user-1', + capabilityUserId: 'user-1', requestId: 'request-1', workflow: workflowPayload, } @@ -114,4 +115,22 @@ describe('importWorkflowIntoWorkspace block access', () => { expect(result.success).toBe(true) expect(mocks.performCreateWorkflow).toHaveBeenCalledOnce() }) + + /** + * `workflows.import` allows a workspace API key, which has no user and so no + * permission group. The attribution field still names someone — the billing + * owner, or the key's creator — and judging the payload against that + * bystander's allowlist is what this separates. + */ + it('judges no allowlist for a caller no permission group governs', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + + const result = await importWorkflowIntoWorkspace({ + ...params(payload(block('b1', 'gmail'))), + capabilityUserId: null, + }) + + expect(result.success).toBe(true) + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/workflows/operations/import-workflow.ts b/apps/sim/lib/workflows/operations/import-workflow.ts index 23913c5e81d..a169b5c4b67 100644 --- a/apps/sim/lib/workflows/operations/import-workflow.ts +++ b/apps/sim/lib/workflows/operations/import-workflow.ts @@ -63,7 +63,21 @@ export interface ImportWorkflowParams { description?: string /** Export envelope, bare state, or a JSON string of either. */ workflow: string | Record + /** Legacy attribution field: who the created workflow is recorded against. */ userId: string + /** + * The person whose permission group judges the payload's block types, or + * `null` when no group governs the caller — a workspace API key, which + * `workflows.import` allows and which has no user at all. + * + * Deliberately not {@link ImportWorkflowParams.userId}. That one is an + * attribution field: for a workspace key it holds the billing owner (the + * application path) or the key's creator (v1), and running either one's + * integration allowlist against a shared key's import would refuse it on a + * bystander's policy — and break the key outright once that person's group + * changed. + */ + capabilityUserId: string | null requestId: string } @@ -180,7 +194,7 @@ async function executeImportWorkflowIntoWorkspace( params: ImportWorkflowParams, createWorkflow: (params: PerformCreateWorkflowParams) => Promise ): Promise { - const { workspaceId, folderId, userId, requestId } = params + const { workspaceId, folderId, userId, capabilityUserId, requestId } = params const [workspaceData] = await db .select({ id: workspace.id }) @@ -266,11 +280,13 @@ async function executeImportWorkflowIntoWorkspace( * this is the only place the workspace's integration allowlist is consulted * before the graph becomes a stored workflow. */ - const withheldBlockType = await findWithheldBlockType({ - userId, - workspaceId, - blocks: Object.values(workflowState.blocks), - }) + const withheldBlockType = capabilityUserId + ? await findWithheldBlockType({ + userId: capabilityUserId, + workspaceId, + blocks: Object.values(workflowState.blocks), + }) + : null if (withheldBlockType) { return { success: false, From bd0d32d6def96e2cf0ca771286ef48cb5c97c154 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:33:58 -0700 Subject: [PATCH 078/179] fix(credentials): assert the personal-credential gate on the branch that is personal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A connection operation takes a target, not a scope: `providerId` connects a personal account, `credentialId` re-authorizes a credential the workspace already holds. Declaring `credentials.personal` on the operation refused both, so `disablePersonalCredentials` — which leaves members "only workspace-shared ones" — withheld the shared credentials it exists to mandate. And because an operation declares one capability, it replaced `integrations.manage`: a group that hid the whole Integrations module could still connect. The operations now declare `integrations.manage` like every other credential operation, and the narrower key is asserted on the connect branch — the same arrangement `credentials.create` already uses for its personal types. --- .../application/connection-target.test.ts | 27 +++++++++++++++++++ .../application/connection-target.ts | 24 +++++++++++++++++ .../application/credential-crud.test.ts | 14 +++++----- .../lib/credentials/application/operations.ts | 23 ++++++++-------- 4 files changed, 71 insertions(+), 17 deletions(-) diff --git a/apps/sim/lib/credentials/application/connection-target.test.ts b/apps/sim/lib/credentials/application/connection-target.test.ts index de2a9cd71c9..fc6cef25692 100644 --- a/apps/sim/lib/credentials/application/connection-target.test.ts +++ b/apps/sim/lib/credentials/application/connection-target.test.ts @@ -7,6 +7,11 @@ const mocks = vi.hoisted(() => ({ listCatalog: vi.fn(), getWorkspaceCredential: vi.fn(), getCredentialActorContext: vi.fn(), + assertWorkspaceCapability: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/capability-assertions', () => ({ + assertWorkspaceCapability: mocks.assertWorkspaceCapability, })) vi.mock('@/lib/credentials/application/provider-catalog', () => ({ @@ -85,6 +90,28 @@ describe('resolveCredentialConnectionTarget', () => { mocks.listCatalog.mockResolvedValue([salesforceProvider]) mocks.getWorkspaceCredential.mockResolvedValue(credential) mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: true }) + mocks.assertWorkspaceCapability.mockResolvedValue(undefined) + }) + + /** + * `disablePersonalCredentials` leaves members "only workspace-shared ones", so + * it withholds connecting an account and not re-authorizing a credential the + * workspace already holds. Declaring it on the operation refused both. + */ + it('asserts the personal-credential capability only when connecting an account', async () => { + await resolveCredentialConnectionTarget({ principal, context, providerId: 'salesforce' }) + + expect(mocks.assertWorkspaceCapability).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'credentials.personal', + null + ) + + mocks.assertWorkspaceCapability.mockClear() + await resolveCredentialConnectionTarget({ principal, context, credentialId: 'credential-1' }) + + expect(mocks.assertWorkspaceCapability).not.toHaveBeenCalled() }) it('accepts an exact authorization option for a new connection', async () => { diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts index 6c98ac540ce..c30d685d38a 100644 --- a/apps/sim/lib/credentials/application/connection-target.ts +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getCredentialActorContext } from '@/lib/credentials/access' @@ -10,6 +11,7 @@ import { } from '@/lib/credentials/application/provider-catalog' import { getWorkspaceCredential } from '@/lib/credentials/queries' import { credentialProviderMatchesService } from '@/lib/oauth/utils' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export interface ResolvedCredentialConnectionTarget { @@ -40,6 +42,28 @@ export async function resolveCredentialConnectionTarget(params: { const catalog = await listCredentialProviderCatalog(principal, context) if (providerId) { + /** + * permission-group-enforced: credentials.personal — scope is the request's + * target, not a property of the operation, exactly as it is for + * `credentials.create`. + * + * Only this branch connects a personal account. Reconnecting re-authorizes a + * credential the workspace already holds, which is the very thing + * `disablePersonalCredentials` leaves members ("leaving only workspace-shared + * ones"), so gating the reconnect on it would withhold the credentials that + * setting mandates. The operations declare `integrations.manage`, which + * governs both branches; this narrower one is asserted where the act + * actually is personal. + */ + const governedUserId = capabilityGovernedPrincipalUserId(principal) + if (governedUserId) { + await assertWorkspaceCapability( + governedUserId, + context.workspaceId, + 'credentials.personal', + context.workspaceOrganizationId + ) + } return { provider: requireAvailableOAuthCredentialProvider(catalog, providerId), providerId, diff --git a/apps/sim/lib/credentials/application/credential-crud.test.ts b/apps/sim/lib/credentials/application/credential-crud.test.ts index 2766c5dcc02..c90980159b9 100644 --- a/apps/sim/lib/credentials/application/credential-crud.test.ts +++ b/apps/sim/lib/credentials/application/credential-crud.test.ts @@ -332,15 +332,17 @@ describe('personal-credential capability', () => { }) /** - * The connection flow is personal by construction, so it can carry the - * capability on the operation itself. Pinned because the alternative — - * `integrations.manage`, which every other credential operation declares — - * compiles just as well and would silently gate on the wrong key. + * A connection operation takes a target, not a scope: the same operation + * connects an account and re-authorizes a workspace-shared credential. + * `credentials.personal` belongs to the first branch only and is asserted + * there; the operation carries the capability that governs both. Pinned + * because declaring the narrower one here compiles just as well, and it + * refused the shared credentials that setting exists to mandate. */ it.each(['createConnection', 'prepareConnection', 'launchConnection'] as const)( - 'declares the capability on %s, which can only produce a personal OAuth grant', + 'declares the capability on %s that governs both of its targets', (operationName) => { - expect(credentialOperations[operationName].capability).toBe('credentials.personal') + expect(credentialOperations[operationName].capability).toBe('integrations.manage') } ) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index bac88e64e37..cdada704975 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -56,27 +56,28 @@ export const credentialOperations = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), /** - * Every OAuth connection flow ends in a `type: 'oauth'` credential bound to the - * connecting user's own linked account — `resolveCredentialConnectionTarget` - * refuses any other credential type — so the whole flow is personal-scope by - * construction, not by a field the caller chooses. That makes - * `credentials.personal` an operation-level capability for these three, and it - * replaces `integrations.manage` rather than joining it: an operation declares - * one capability, and the narrower of the two is the one an organization that - * mandates workspace-shared credentials is actually setting. + * `integrations.manage`, like every other credential operation — these three + * take a *target*, not a scope: `providerId` connects a personal account, + * `credentialId` re-authorizes a credential the workspace already holds. Only + * the first is what `disablePersonalCredentials` withholds, so the narrower + * `credentials.personal` is asserted on that branch inside + * `resolveCredentialConnectionTarget` rather than declared here. Declaring it + * here withheld the reconnect too — refusing the workspace-shared credentials + * that same setting exists to mandate — and, because an operation declares one + * capability, let a group that hid the whole Integrations module still connect. */ createConnection: defineWorkspaceOperation({ id: 'credentials.connections.create', minimumRole: 'write', workspaceApiKey: 'deny', - capability: 'credentials.personal', + capability: 'integrations.manage', principalKinds: ['session', 'personal_api_key'], }), prepareConnection: defineWorkspaceOperation({ id: 'credentials.connections.prepare', minimumRole: 'write', workspaceApiKey: 'deny', - capability: 'credentials.personal', + capability: 'integrations.manage', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), @@ -170,7 +171,7 @@ export const credentialOperations = { id: 'credentials.connections.launch', minimumRole: 'write', workspaceApiKey: 'deny', - capability: 'credentials.personal', + capability: 'integrations.manage', principalKinds: ['session'], }), useManagedOAuth: defineWorkspaceOperation({ From 8b516d9c6780452bd13dabdc3908b6f6eca4850e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:35:55 -0700 Subject: [PATCH 079/179] fix(billing): apply the group's personal-key refusal to workspace billing reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The billing reads resolve their own workspace scope instead of running through `authorizeWorkspaceOperation`, and reproduced only the workspace column — so a personal key whose group sets `disablePersonalApiKeys` still read a workspace's plan, status and ledger through v2. `requirePersonalApiKeysAllowed` is now shared with the funnel rather than copied, and runs where the funnel runs it: after the role check. Also gates the OAuth credentials route on the credential's own workspace unconditionally. The asserted `workspaceId` is the caller's to choose, so gating on it alone let a caller who reaches two workspaces pair an ungoverned one with a credential from a workspace whose group withholds Integrations. --- .../api/auth/oauth/credentials/route.test.ts | 35 +++++++++++++++++++ .../app/api/auth/oauth/credentials/route.ts | 25 +++++++------ .../authorized-billing-read-use-case.ts | 14 +++++++- .../application/billing-use-cases.test.ts | 30 ++++++++++++++++ apps/sim/lib/core/application/index.ts | 1 + .../application/workspace-authorization.ts | 7 +++- 6 files changed, 99 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index f30f604e342..94f2cf1a7ab 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -255,5 +255,40 @@ describe('OAuth Credentials API Route', () => { undefined ) }) + + /** + * The asserted `workspaceId` is the caller's to choose. Pairing one their + * group leaves alone with a credential from one it governs must not read + * the credential out. + */ + it('refuses a credential whose own workspace is withheld, whatever workspace is asserted', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockImplementation( + async (_userId: string, workspaceId: string) => + workspaceId === 'workspace-1' ? INTEGRATIONS_WITHHELD : DEFAULT_PERMISSION_GROUP_CONFIG + ) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth', + displayName: 'Gmail', + providerId: 'google-email', + accountId: 'account-1', + updatedAt: new Date('2026-01-01T00:00:00Z'), + accountProviderId: 'google-email', + accountScope: 'email', + accountUpdatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]) + + const response = await GET( + createMockRequestWithQuery( + 'GET', + '?credentialId=credential-1&workspaceId=3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' + ) + ) + + expect(response.status).toBe(403) + }) }) }) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index cc9dfebf266..782730b836b 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -185,21 +185,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (platformCredential) { /** - * A `credentialId` lookup may arrive with neither `workflowId` nor - * `workspaceId`, in which case the workspace gate above never ran. The - * credential still names a workspace, and that is the scope whose group - * governs it. Asked after each branch's own access check, never before: - * a caller who may not reach this credential at all must not learn from - * the refusal wording that the workspace it belongs to is one their - * group governs. + * The credential names the workspace whose group governs it, and that is + * asked unconditionally — not only when the query named no workspace. + * The asserted `workspaceId` is the caller's to choose, so gating on it + * alone let a caller who reaches two workspaces pair the ungoverned one + * with a credential from the workspace whose group withholds + * Integrations, and read it. Both are checked; either withholding is a + * refusal, and the resolver memoizes the repeat when they are the same. + * + * Asked after each branch's own access check, never before: a caller who + * may not reach this credential at all must not learn from the refusal + * wording that the workspace it belongs to is one their group governs. */ - const credentialScopeWithheld = async () => - !effectiveWorkspaceId && - (await integrationsWithheldFromSession( + const credentialScopeWithheld = () => + integrationsWithheldFromSession( authResult.authType, requesterUserId, platformCredential.workspaceId - )) + ) if (platformCredential.type === 'service_account') { if ( diff --git a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts index 0d4cc811e62..1edd942ccb3 100644 --- a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts +++ b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts @@ -7,7 +7,7 @@ import type { BillingReadOperation, BillingReadPrincipal, } from '@/lib/billing/application/operations' -import type { OperationUseCase } from '@/lib/core/application' +import { type OperationUseCase, requirePersonalApiKeysAllowed } from '@/lib/core/application' import { InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, @@ -87,6 +87,18 @@ async function resolveBillingReadScope( if (!permissionSatisfies(permission, operation.workspaceMinimumRole)) { throw new InsufficientWorkspacePermissionsError() } + /** + * permission-group-enforced: personal_api_key.use — this path resolves its + * own workspace scope instead of running through + * `authorizeWorkspaceOperation`, so the funnel's personal-key refusal has to + * be repeated here or the same key the funnel refuses still reads billing. + * + * After the role check, like the funnel: it answers with a 403 naming how an + * organization configured one cohort, and running it ahead of the concealed + * no-access refusal would hand that to a caller with no reach into the + * workspace at all. + */ + await requirePersonalApiKeysAllowed(principal.userId, workspace) } return { kind: 'workspace', workspace } diff --git a/apps/sim/lib/billing/application/billing-use-cases.test.ts b/apps/sim/lib/billing/application/billing-use-cases.test.ts index 4d615a46ddd..18f7cf4dc89 100644 --- a/apps/sim/lib/billing/application/billing-use-cases.test.ts +++ b/apps/sim/lib/billing/application/billing-use-cases.test.ts @@ -2,8 +2,15 @@ * @vitest-environment node */ import type { SessionPrincipal } from '@sim/auth/principal' +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + const mocks = vi.hoisted(() => ({ loadWorkspace: vi.fn(), resolvePermission: vi.fn(), @@ -78,6 +85,8 @@ vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) import { getBillingStatus } from '@/lib/billing/application/get-billing-status' import { listBillingLogs } from '@/lib/billing/application/list-billing-logs' +import { PersonalApiKeysDisabledError } from '@/lib/core/application' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' const workspaceContext = { workspaceId: 'workspace-1', @@ -99,6 +108,7 @@ const workspacePrincipal = { describe('billing application use cases', () => { beforeEach(() => { vi.clearAllMocks() + resetPermissionGroupScopeMock() mocks.loadWorkspace.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('read') mocks.canUserManageWorkspaceBilling.mockResolvedValue(false) @@ -194,6 +204,26 @@ describe('billing application use cases', () => { expect(mocks.canUserManageWorkspaceBilling).not.toHaveBeenCalled() }) + /** + * The billing reads resolve their own workspace scope instead of running + * through `authorizeWorkspaceOperation`, so the funnel's personal-key refusal + * has to be repeated here — otherwise the same key v2 refuses everywhere else + * still reads a workspace's plan and ledger. + */ + it('refuses a personal key whose group withholds personal keys', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + + await expect( + getBillingStatus.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + }) + it('never reads the payer storage pool it may not disclose', async () => { await getBillingStatus.execute({ principal: workspacePrincipal, input: {} }) await getBillingStatus.execute({ diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index c3f2b5a5c9f..4992bbd3ea5 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -40,6 +40,7 @@ export { PersonalApiKeysDisabledError, PrincipalKindAuthorizationError, requireAllowedWorkspacePrincipal, + requirePersonalApiKeysAllowed, WorkspaceApiKeyAuthorizationError, WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application/workspace-authorization' diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index 11aa218aeb3..167a7972234 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -217,8 +217,13 @@ async function requireCapability( * Separate from {@link requireCapability} because it is not a property of the * operation: no operation opts into it, and every operation a personal key can * reach is subject to it. + * + * Exported for the one authorization path that does not run through + * {@link authorizeWorkspaceOperation} — the billing reads, which resolve their + * own workspace scope. One copy, or the same key the funnel refuses keeps + * working somewhere. */ -async function requirePersonalApiKeysAllowed( +export async function requirePersonalApiKeysAllowed( userId: string, context: WorkspaceAuthorizationContext ): Promise { From 50dc1dae1563b3c07f87ed310cd8175dbf5fdcc3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:36:24 -0700 Subject: [PATCH 080/179] fix(permission-groups): close the spend leaks the log projections left open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `withheldSpendData` stripped the spans and block executions but not the run's own roll-up. Every completed run carries `tokens` and `models` at the root of `execution_data`, and `models` is the per-model DOLLAR breakdown — finer-grained than the total the projection blanks — so a member whose group withholds spend read the itemization next to a nulled figure. Dropped with `cost`, which older rows carry inline. The same class one level down: `providerTiming.segments[*]` carry their own `cost` and `tokens`, the itemization behind a span's roll-up. Neither `stripSpanCosts` (which is what stands between a joined cross-workspace child run and the parent's reader) nor the display projection's `withoutSpend` reached them. `?minCost=` reached the v1 contract as `''`, `Number('')` is `0`, and the parameter arrived as a real zero — which read as a cost SELECTOR and refused an innocent request from a withheld member, while `maxCost=` silently narrowed the page to free runs. `optionalNumberQuerySchema` normalizes an empty value to omitted and preserves an explicit `0`. `checkWorkspaceScope` asked `personal_api_key.use` after verifying the caller's role at `read`, whatever the route went on to demand. The funnel asks it after `requireCurrentHumanRole(operation.minimumRole)`, so a read-only member on a write route is refused on role there; here the same person was told instead how their organization configured personal keys. The level is now the route's own. That refusal also carries its detail code, which is what separates it from the workspace-column refusal it shares a sentence with. The table-export and logs-stats refusals now render through `capabilityRefusalResponse`, so every capability 403 carries the same code. `listPublicLogs` materialized and secret-projected a whole page of payloads it was about to discard, when the group withholding execution detail had already turned both render flags off. It now skips the column read entirely. The checkpoint-revert route discarded the state write's status and reported every refusal as a 500, so a member whose group withholds a block type in the checkpoint was told the revert had crashed. --- .../copilot/checkpoints/revert/route.test.ts | 41 +++++++++++ .../api/copilot/checkpoints/revert/route.ts | 13 +++- apps/sim/app/api/logs/stats/route.test.ts | 5 +- apps/sim/app/api/logs/stats/route.ts | 8 +- .../api/table/[tableId]/export/route.test.ts | 1 + .../app/api/table/[tableId]/export/route.ts | 8 +- apps/sim/app/api/v1/files/route.ts | 2 +- apps/sim/app/api/v1/logs/projection.test.ts | 32 ++++++++ apps/sim/app/api/v1/middleware.test.ts | 45 ++++++++++++ apps/sim/app/api/v1/middleware.ts | 36 +++++++-- .../api/v1/tables/[tableId]/columns/route.ts | 6 +- apps/sim/app/api/v1/tables/[tableId]/route.ts | 2 +- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 4 +- .../app/api/v1/tables/[tableId]/rows/route.ts | 8 +- .../v1/tables/[tableId]/rows/upsert/route.ts | 2 +- apps/sim/lib/api/contracts/primitives.ts | 19 +++++ apps/sim/lib/api/contracts/v1/logs.ts | 14 ++-- .../lib/logs/application/list-public-logs.ts | 11 ++- .../application/public-log-projection.test.ts | 41 ++++++++++- .../lib/logs/execution/trace-store.test.ts | 73 +++++++++++++++++++ apps/sim/lib/logs/execution/trace-store.ts | 28 ++++++- apps/sim/lib/logs/fetch-log-detail.test.ts | 42 +++++++++++ apps/sim/lib/logs/fetch-log-detail.ts | 44 ++++++++++- 23 files changed, 439 insertions(+), 46 deletions(-) diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index 621256b8ac3..41c090b0185 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -510,6 +510,47 @@ describe('Copilot Checkpoints Revert API Route', () => { expect(responseData.error).toBe('Failed to revert workflow to checkpoint') }) + /** + * A checkpoint can carry a block whose integration the caller's permission + * group withholds; the write refuses that with a 403 naming the block type. + * Collapsed to a 500 with the generic sentence, the member was told the + * revert had crashed and had nothing to act on. + */ + it.each([ + [403, 'The Slack block is not available under your permission group'], + [409, 'Workflow is locked'], + ])( + 'passes a %i refusal from the state write through with its message', + async (status, error) => { + setAuthenticated() + + queueTableRows(schemaMock.workflowCheckpoints, [ + { + id: 'checkpoint-123', + workflowId: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2', + userId: 'user-123', + workflowState: { blocks: {}, edges: [] }, + }, + ]) + queueTableRows(schemaMock.workflow, [ + { id: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2', userId: 'user-123' }, + ]) + + mockSaveWorkflowNormalizedState.mockResolvedValueOnce({ success: false, status, error }) + + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'checkpoint-123' }), + }) + + const response = await POST(req) + + expect(response.status).toBe(status) + await expect(response.json()).resolves.toEqual({ error }) + } + ) + it('should return 500 when the checkpoint state fails validation', async () => { setAuthenticated() diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index 1543372f773..33327757691 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -144,11 +144,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { authorization, }) + /** + * The save's own refusals are the caller's answer, not a Sim fault. It + * classifies them itself — a withheld block type in the checkpoint is a + * 403, a locked workflow a 409 — and collapsing every one to a 500 told a + * member that reverting had crashed when their organization had simply + * withheld an integration the checkpoint uses. Only a genuine 5xx keeps the + * generic sentence; the rest carry the refusal's own status and message. + */ if (!saveResult.success) { + const { status } = saveResult logger.error(`[${tracker.requestId}] Failed to apply checkpoint state: ${saveResult.error}`) return NextResponse.json( - { error: 'Failed to revert workflow to checkpoint' }, - { status: 500 } + { error: status >= 500 ? 'Failed to revert workflow to checkpoint' : saveResult.error }, + { status } ) } diff --git a/apps/sim/app/api/logs/stats/route.test.ts b/apps/sim/app/api/logs/stats/route.test.ts index 25e13f2607d..b94c50dbc98 100644 --- a/apps/sim/app/api/logs/stats/route.test.ts +++ b/apps/sim/app/api/logs/stats/route.test.ts @@ -64,7 +64,10 @@ describe('GET /api/logs/stats', () => { const response = await GET(makeRequest('&costOperator=%3E&costValue=0.5')) expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: capabilityRefusal('logs.cost') }) + await expect(response.json()).resolves.toEqual({ + error: capabilityRefusal('logs.cost'), + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) expect(mocks.readLogStatsBounds).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index 712f8ae63c7..55533c6b603 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -12,10 +12,8 @@ import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' import { logQuerySelectsCost } from '@/lib/logs/log-projection' import { buildDashboardStats, resolveLogStatsWindow } from '@/lib/logs/stats' import { readLogStatsBounds, readLogStatsSegments } from '@/lib/logs/stats-queries' -import { - capabilityRefusal, - isWorkspaceCapabilityWithheld, -} from '@/lib/permission-groups/capability-assertions' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('LogsStatsAPI') @@ -79,7 +77,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 'logs.cost' ) if (hideCostInfo && logQuerySelectsCost(params)) { - return NextResponse.json({ error: capabilityRefusal('logs.cost') }, { status: 403 }) + return capabilityRefusalResponse('logs.cost') } const workspaceFilter = eq(workflowExecutionLogs.workspaceId, params.workspaceId) diff --git a/apps/sim/app/api/table/[tableId]/export/route.test.ts b/apps/sim/app/api/table/[tableId]/export/route.test.ts index 69f50d077fc..90d039f8715 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.test.ts @@ -101,6 +101,7 @@ describe('table export route — id→name translation', () => { expect(res.status).toBe(403) expect(await res.json()).toEqual({ error: "Exporting a table is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, }) }) }) diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index 018fbf00279..a9fee083ed4 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -5,10 +5,8 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - capabilityRefusal, - isWorkspaceCapabilityWithheld, -} from '@/lib/permission-groups/capability-assertions' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { captureServerEvent } from '@/lib/posthog/server' import { sanitizeExportFilename } from '@/lib/table/export-format' import { createTableExportStream, exportContentType } from '@/lib/table/export-stream' @@ -50,7 +48,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou table.workspaceId && (await isWorkspaceCapabilityWithheld(userId, table.workspaceId, 'tables.export')) ) { - return NextResponse.json({ error: capabilityRefusal('tables.export') }, { status: 403 }) + return capabilityRefusalResponse('tables.export') } // Audit before streaming: rows leave incrementally, so a mid-stream failure still exfiltrates partial data. diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index ccd823fa1b2..8d8b8659753 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -130,7 +130,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const { workspaceId } = formFieldsResult.data - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, workspaceId, 'write') if (scopeError) return scopeError if (!file) { diff --git a/apps/sim/app/api/v1/logs/projection.test.ts b/apps/sim/app/api/v1/logs/projection.test.ts index 97d7aa4f30a..fae245f7143 100644 --- a/apps/sim/app/api/v1/logs/projection.test.ts +++ b/apps/sim/app/api/v1/logs/projection.test.ts @@ -284,6 +284,38 @@ describe('GET /api/v1/logs cost-selective queries', () => { expect(mockListPublicWorkflowLogs).toHaveBeenCalled() }) + /** + * An unfilled form field is not a question about cost. `?minCost=` reaches + * the schema as `''`, and `z.coerce.number()` reads `Number('')` as a real + * zero — which made an innocent request look like a cost selector and refused + * it. Normalized to omitted before the assertion runs. + */ + it.each([['minCost='], ['maxCost='], ['minCost=&maxCost=']])( + 'answers %s for a group that withholds spend, because it selects nothing', + async (query) => { + governedBy({ hideCostInfo: true }) + + const response = await listFiltered(query) + + expect(response.status).toBe(200) + expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( + expect.objectContaining({ + filters: expect.objectContaining({ minCost: undefined, maxCost: undefined }), + }) + ) + } + ) + + /** An explicit zero is a bound the caller typed, and still selects on cost. */ + it('still refuses an explicit minCost=0', async () => { + governedBy({ hideCostInfo: true }) + + const response = await listFiltered('minCost=0') + + expect(response.status).toBe(403) + expect(mockListPublicWorkflowLogs).not.toHaveBeenCalled() + }) + /** Only the spend filter is refused; the rest of the query is unaffected. */ it('still answers a non-cost filter for a group that withholds spend', async () => { governedBy({ hideCostInfo: true }) diff --git a/apps/sim/app/api/v1/middleware.test.ts b/apps/sim/app/api/v1/middleware.test.ts index c9a2d822ed7..1ddc142f307 100644 --- a/apps/sim/app/api/v1/middleware.test.ts +++ b/apps/sim/app/api/v1/middleware.test.ts @@ -375,4 +375,49 @@ describe('checkWorkspaceScope', () => { error: expect.stringMatching(/personal API key/i), }) }) + + /** + * The two refusals share their sentence, so without the code a client cannot + * tell "the workspace switched personal keys off" from "your group did" — + * different settings, different people to ask. + */ + it('carries the detail code on the group refusal and not on the column one', async () => { + withholdsPersonalKeys() + const grouped = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID) + + await expect(grouped?.json()).resolves.toMatchObject({ + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }) + + resetPermissionGroupScopeMock() + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false }) + const column = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID) + + await expect(column?.json()).resolves.not.toHaveProperty('details') + }) + + /** + * The concealment ordering, one level in. The funnel asks this key only after + * `requireCurrentHumanRole(operation.minimumRole)`, so a read-only member on a + * write route is refused on role. Asked at `read` here, the same person was + * told instead how their organization configured personal keys. + */ + it('leaves a read-only member on a write route to the downstream role check', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + withholdsPersonalKeys() + + const response = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID, 'write') + + expect(response).toBeNull() + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('still refuses that member on a read route, where the role does reach', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + withholdsPersonalKeys() + + const response = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID, 'read') + + expect(response?.status).toBe(403) + }) }) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 57b95b43a8a..f1045dc42ee 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -401,22 +401,30 @@ export async function resolveWorkspaceScope( * `roleVerifiedFor` is the user id a caller has already checked, not a boolean, * so a caller that verified some OTHER subject's role cannot vouch for this * one. When it does not match, the role is resolved here instead, and a caller - * with no read access is handed back `null` so the surface's own role failure — - * the concealed one — is what it answers with. That second lookup is free: - * `getUserEntityPermissions` for a workspace goes through the request-scoped - * memo the role check itself uses. + * who does not reach `requiredLevel` is handed back `null` so the surface's own + * role failure — the concealed one — is what it answers with. That second + * lookup is free: `getUserEntityPermissions` for a workspace goes through the + * request-scoped memo the role check itself uses. + * + * `requiredLevel` is the level the SURFACE will demand, not a floor of `read`. + * The funnel runs this key after `requireCurrentHumanRole(operation.minimumRole)`, + * so a read-only member calling a write route is refused on role there. Checked + * at `read` here, the same person on the same route was told instead how their + * organization configured personal keys — the disclosure the ordering exists to + * prevent, one level in. */ async function resolvePersonalKeyGroupRefusal( rateLimit: RateLimitResult, workspaceId: string, - roleVerifiedFor: string | null + roleVerifiedFor: string | null, + requiredLevel: PermissionType = 'read' ): Promise { const governedUserId = capabilityGovernedUserId(rateLimit) if (!governedUserId) return null if (roleVerifiedFor !== governedUserId) { const permission = await getUserEntityPermissions(governedUserId, 'workspace', workspaceId) - if (!permissionSatisfies(permission, 'read')) return null + if (!permissionSatisfies(permission, requiredLevel)) return null } // permission-group-enforced: personal_api_key.use — v1 authorizes in this middleware, not through the funnel @@ -424,10 +432,17 @@ async function resolvePersonalKeyGroupRefusal( return null } + /** + * The detail code separates this from the workspace-column refusal above, + * which shares the sentence but is a different setting with a different + * remedy. Read off the rule rather than spelled out, exactly as + * {@link resolveCapabilityRefusal} does. + */ return { status: 403, code: 'FORBIDDEN', message: PERSONAL_KEY_DENIED, + details: { code: CAPABILITY_RULES['personal_api_key.use'].detailCode }, } } @@ -476,14 +491,19 @@ export async function resolveWorkspaceAccess( * module, not the key kind — so it is asked here, and * {@link resolvePersonalKeyGroupRefusal} resolves the caller's role itself * before answering rather than relying on a role check this wrapper never runs. + * + * `requiredLevel` must be the level the caller will hand `checkAccess` a moment + * later. Left at `read` on a write route, the group refusal answers a read-only + * member before the role failure that actually applies to them does. */ export async function checkWorkspaceScope( rateLimit: RateLimitResult, - requestedWorkspaceId: string + requestedWorkspaceId: string, + requiredLevel: PermissionType = 'read' ): Promise { const failure = (await resolveWorkspaceScope(rateLimit, requestedWorkspaceId)) ?? - (await resolvePersonalKeyGroupRefusal(rateLimit, requestedWorkspaceId, null)) + (await resolvePersonalKeyGroupRefusal(rateLimit, requestedWorkspaceId, null, requiredLevel)) return failure ? workspaceAccessErrorResponse(failure) : null } diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index 96c7ae38c1a..2037c566048 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -57,7 +57,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') @@ -123,7 +123,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') @@ -185,7 +185,7 @@ export const DELETE = withRouteHandler( const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index ab172723442..7c11deb75f6 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -130,7 +130,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab const { tableId } = parsed.data.params const { workspaceId } = parsed.data.query - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, workspaceId, 'write') if (scopeError) return scopeError const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 04b500897b8..05ddfb81b1b 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -132,7 +132,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR const { tableId, rowId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId) if (!actorUserId) { @@ -226,7 +226,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row const { tableId, rowId } = parsed.data.params const { workspaceId } = parsed.data.query - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, workspaceId, 'write') if (scopeError) return scopeError const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index ec8bf68249d..6e083e0a87b 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -237,7 +237,7 @@ export const POST = withRouteHandler( const { tableId } = parsed.data.params if ('rows' in parsed.data.body) { const batchValidated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, batchValidated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, batchValidated.workspaceId, 'write') if (scopeError) return scopeError const actorUserId = await resolveWorkspaceRequestActor( rateLimit, @@ -259,7 +259,7 @@ export const POST = withRouteHandler( const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId) if (!actorUserId) { @@ -342,7 +342,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId) if (!actorUserId) { @@ -437,7 +437,7 @@ export const DELETE = withRouteHandler( const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError const accessResult = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index 6e6c8f96d62..eb690199e93 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -52,7 +52,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId) if (!actorUserId) { diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index 21717e04184..81590fca6ef 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -555,3 +555,22 @@ export const booleanQueryFlagSchema = z.preprocess( }, z.boolean({ error: 'must be a boolean (true/false)' }) ) + +/** + * An optional numeric query parameter that treats a present-but-empty value as + * omitted. + * + * `z.coerce.number().optional()` does not: a query string carrying `?minCost=` + * reaches the schema as `''`, `Number('')` is `0`, and the parameter arrives as + * a real zero. That is wrong twice — `maxCost=` silently narrows the page to + * free runs, and `minCost=` reads as a cost *selector*, which is what + * `assertLogCostQueryAllowed` refuses for a member whose group withholds spend. + * An empty value is a caller sending an unfilled form field, not a question + * about cost. + * + * An explicit `0` is preserved: `?minCost=0` is a real bound the caller typed. + */ +export const optionalNumberQuerySchema = z.preprocess( + (value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), + z.coerce.number().optional() +) diff --git a/apps/sim/lib/api/contracts/v1/logs.ts b/apps/sim/lib/api/contracts/v1/logs.ts index 87279d8d72a..4597c1d60b8 100644 --- a/apps/sim/lib/api/contracts/v1/logs.ts +++ b/apps/sim/lib/api/contracts/v1/logs.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + booleanQueryFlagSchema, + optionalNumberQuerySchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' export const v1LogParamsSchema = z.object({ @@ -19,10 +23,10 @@ export const v1ListLogsQuerySchema = z.object({ startDate: z.string().optional(), endDate: z.string().optional(), executionId: z.string().optional(), - minDurationMs: z.coerce.number().optional(), - maxDurationMs: z.coerce.number().optional(), - minCost: z.coerce.number().optional(), - maxCost: z.coerce.number().optional(), + minDurationMs: optionalNumberQuerySchema, + maxDurationMs: optionalNumberQuerySchema, + minCost: optionalNumberQuerySchema, + maxCost: optionalNumberQuerySchema, model: z.string().optional(), details: z.enum(['basic', 'full']).optional().default('basic'), includeTraceSpans: booleanQueryFlagSchema.optional().default(false), diff --git a/apps/sim/lib/logs/application/list-public-logs.ts b/apps/sim/lib/logs/application/list-public-logs.ts index a9bba398a68..d6c119f0a37 100644 --- a/apps/sim/lib/logs/application/list-public-logs.ts +++ b/apps/sim/lib/logs/application/list-public-logs.ts @@ -113,7 +113,16 @@ export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ ? await resolveLogFolderScope(context.workspaceId, input.folderPaths) : undefined - const needsMaterialization = input.includeFinalOutput || input.includeTraceSpans + /** + * A group withholding execution detail turns both render flags off below, + * so every materialized payload would be projected and then dropped + * unread. Skipped here instead: materialization is an object-store read per + * row plus a secret projection over the whole trace, and paying for a page + * of them to discard the result is the most expensive way to withhold + * something. + */ + const needsMaterialization = + (input.includeFinalOutput || input.includeTraceSpans) && !projection.hideTraceSpans const { data, nextCursorKeys } = await readPublicLogPage({ filters: { ...input.filters, workspaceId: context.workspaceId }, limit: input.limit, diff --git a/apps/sim/lib/logs/application/public-log-projection.test.ts b/apps/sim/lib/logs/application/public-log-projection.test.ts index fc5165a1e49..6532246d979 100644 --- a/apps/sim/lib/logs/application/public-log-projection.test.ts +++ b/apps/sim/lib/logs/application/public-log-projection.test.ts @@ -83,6 +83,13 @@ const workspaceContext = { } const EXECUTION_DATA = { + /** + * The run-level roll-up every completed run carries. `models` is the + * per-model dollar breakdown, so a projection that blanks the total and + * leaves this published the finer figure it was hiding. + */ + tokens: { input: 60, output: 30, total: 90 }, + models: { 'gpt-4': { input: 0.4, output: 0.35, total: 0.75 } }, finalOutput: { answer: 'a customer address' }, workflowInput: { question: 'who?' }, blockInput: { prompt: 'who?' }, @@ -212,6 +219,7 @@ describe('listPublicLogs field projection', () => { }) const [span] = result.items[0].executionData?.traceSpans as Array> + expect(result.items[0].executionData).not.toHaveProperty('models') expect(span.name).toBe('agent') expect(span).not.toHaveProperty('cost') expect(span).not.toHaveProperty('tokens') @@ -234,10 +242,32 @@ describe('listPublicLogs field projection', () => { expect(result.includeTraceSpans).toBe(false) expect(result.includeFinalOutput).toBe(false) - expect(result.items[0].executionData).not.toHaveProperty('traceSpans') - expect(result.items[0].executionData).not.toHaveProperty('finalOutput') - expect(result.items[0].executionData).not.toHaveProperty('blockExecutions') - expect(result.items[0].executionData).not.toHaveProperty('workflowInput') + expect(result.items[0].executionData).toBeUndefined() + }) + + /** + * The page is withheld whole, so the object-store read and the secret + * projection behind every payload buy nothing. Asserted on the read itself, + * not only on `materializeExecutionDataForDisplay`, because the column is + * what the work hangs off. + */ + it('materializes nothing when the group withholds execution detail', async () => { + governedBy({ hideTraceSpans: true }) + + await listPublicLogs.execute({ principal: personalPrincipal, input: listInput() }) + + expect(mocks.materialize).not.toHaveBeenCalled() + expect(mocks.listLogs).toHaveBeenCalledWith( + expect.objectContaining({ includeExecutionData: false }) + ) + }) + + it('still materializes for a group that withholds only spend', async () => { + governedBy({ hideCostInfo: true }) + + await listPublicLogs.execute({ principal: personalPrincipal, input: listInput() }) + + expect(mocks.materialize).toHaveBeenCalledTimes(1) }) it('withholds nothing from a caller no group governs', async () => { @@ -347,6 +377,8 @@ describe('getPublicLog field projection', () => { expect( (result.executionData.blockExecutions as Array>)[0] ).not.toHaveProperty('tokens') + expect(result.executionData).not.toHaveProperty('tokens') + expect(result.executionData).not.toHaveProperty('models') }) it('withholds the execution payloads when the group hides trace spans', async () => { @@ -372,6 +404,7 @@ describe('getPublicLog field projection', () => { expect(result.log.costTotal).toBe('0.75') expect(result.costLedger).toEqual(COST_LEDGER) expect(result.executionData.finalOutput).toEqual(EXECUTION_DATA.finalOutput) + expect(result.executionData.models).toEqual(EXECUTION_DATA.models) }) it('withholds nothing from a workspace API key', async () => { diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index c73e65241a4..03d2f2015c1 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -31,6 +31,7 @@ import { projectExecutionDataForDisplay, RESOLVED_SECRET_PROVENANCE_KEY, SECRET_PROJECTION_VERSION, + stripSpanCosts, TRACE_STORE_REF_KEY, } from '@/lib/logs/execution/trace-store' @@ -772,3 +773,75 @@ describe('stored provenance display reporting', () => { ) }) }) + +/** + * `stripSpanCosts` is what stands between a joined cross-workspace child run and + * the parent's reader: the child's spend is billed to the SOURCE workspace and + * was never rolled into this run's total, so anything it leaves behind is spend + * the reader was never meant to see. + */ +describe('stripSpanCosts', () => { + function spanWithSpend() { + return [ + { + id: 'span-1', + name: 'agent', + cost: { total: 0.5 }, + tokens: { total: 900 }, + providerTiming: { + duration: 5, + segments: [ + { type: 'model', name: 'gpt-4', tokens: { total: 900 }, cost: { total: 0.5 } }, + { type: 'tool', name: 'search' }, + ], + }, + children: [ + { + id: 'span-2', + name: 'model', + cost: { total: 0.2 }, + tokens: { total: 400 }, + providerTiming: { segments: [{ type: 'model', tokens: { total: 400 } }] }, + }, + ], + }, + ] + } + + it('clears the span roll-up and the provider-timing segments that itemize it', () => { + const spans = spanWithSpend() + + stripSpanCosts(spans) + + expect(spans[0].cost).toBeUndefined() + expect(spans[0].tokens).toBeUndefined() + const [modelSegment, toolSegment] = spans[0].providerTiming.segments as Array< + Record + > + expect(modelSegment.tokens).toBeUndefined() + expect(modelSegment.cost).toBeUndefined() + // Structure and identity are what the waterfall renders; only spend goes. + expect(modelSegment).toMatchObject({ type: 'model', name: 'gpt-4' }) + expect(toolSegment).toMatchObject({ type: 'tool', name: 'search' }) + }) + + it('reaches the segments of nested children too', () => { + const spans = spanWithSpend() + + stripSpanCosts(spans) + + const child = spans[0].children[0] + expect(child.cost).toBeUndefined() + expect(child.tokens).toBeUndefined() + expect( + (child.providerTiming.segments as Array>)[0].tokens + ).toBeUndefined() + }) + + it('leaves a span with no provider timing alone', () => { + const spans = [{ id: 'span-1', name: 'api', cost: { total: 0.1 } }] + + expect(() => stripSpanCosts(spans)).not.toThrow() + expect(spans[0]).toMatchObject({ id: 'span-1', name: 'api' }) + }) +}) diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index e17eaceda8f..fef1d9149e9 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -109,7 +109,12 @@ export function stripSpanCosts(spans: unknown): void { if (!Array.isArray(spans)) return for (const span of spans) { if (!span || typeof span !== 'object') continue - const record = span as { cost?: unknown; tokens?: unknown; children?: unknown } + const record = span as { + cost?: unknown + tokens?: unknown + children?: unknown + providerTiming?: unknown + } if ('cost' in record) record.cost = undefined /** * Tokens as well as dollars: a span's token counts are the spend in another @@ -117,10 +122,31 @@ export function stripSpanCosts(spans: unknown): void { * knows the model's rate. */ if ('tokens' in record) record.tokens = undefined + stripProviderTimingSegmentCosts(record.providerTiming) if (Array.isArray(record.children)) stripSpanCosts(record.children) } } +/** + * The same removal one level down, in `providerTiming.segments`. + * + * A `ProviderTimingSegment` carries its own `tokens` and `cost` — the per-model + * iteration breakdown behind the span's roll-up — so clearing the span alone + * left the whole figure itemized underneath it, which is strictly more than the + * span published in the first place. + */ +function stripProviderTimingSegmentCosts(providerTiming: unknown): void { + if (!providerTiming || typeof providerTiming !== 'object') return + const segments = (providerTiming as { segments?: unknown }).segments + if (!Array.isArray(segments)) return + for (const segment of segments) { + if (!segment || typeof segment !== 'object') continue + const record = segment as { cost?: unknown; tokens?: unknown } + if ('cost' in record) record.cost = undefined + if ('tokens' in record) record.tokens = undefined + } +} + /** Creates a persistence-owned span tree with per-span cost fields removed. */ export function copyTraceSpansWithoutCosts(spans?: TraceSpan[]): TraceSpan[] | undefined { return spans?.map(({ cost: _cost, children, ...span }) => ({ diff --git a/apps/sim/lib/logs/fetch-log-detail.test.ts b/apps/sim/lib/logs/fetch-log-detail.test.ts index ce5d4be6b78..c4303867f52 100644 --- a/apps/sim/lib/logs/fetch-log-detail.test.ts +++ b/apps/sim/lib/logs/fetch-log-detail.test.ts @@ -62,6 +62,16 @@ function queueWorkflowLogRow(overrides: Record = {}): void { } const SPEND_BEARING_EXECUTION_DATA = { + /** + * The run-level roll-up `buildCompletedExecutionData` writes on every + * completed run. `models` is the per-model dollar breakdown itself, so it is + * finer-grained than the total the projection blanks. + */ + tokens: { input: 500, output: 400, total: 900 }, + models: { + 'gpt-4': { input: 0.4, output: 0.35, total: 0.75, tokens: { total: 900 } }, + }, + cost: { total: 0.75 }, traceSpans: [ { id: 'span-1', @@ -72,6 +82,23 @@ const SPEND_BEARING_EXECUTION_DATA = { endTime: '2026-01-01T00:00:00.005Z', cost: { total: 0.75 }, tokens: { total: 900 }, + /** A span's own itemization, one level below its roll-up. */ + providerTiming: { + duration: 5, + startTime: '2026-01-01T00:00:00.000Z', + endTime: '2026-01-01T00:00:00.005Z', + segments: [ + { + type: 'model', + name: 'gpt-4', + startTime: 0, + endTime: 5, + duration: 5, + tokens: { total: 900 }, + cost: { total: 0.75 }, + }, + ], + }, children: [ { id: 'span-2', @@ -264,6 +291,20 @@ describe('readLogDetail', () => { expect(span?.children?.[0]).not.toHaveProperty('cost') expect(result?.executionData.blockExecutions?.[0]).not.toHaveProperty('cost') + // The run's own roll-up. `models` is the per-model dollar breakdown, so + // leaving it published the finest figure of all next to a blanked total. + expect(result?.executionData).not.toHaveProperty('tokens') + expect(result?.executionData).not.toHaveProperty('models') + expect(result?.executionData).not.toHaveProperty('cost') + + // Provider-timing segments itemize the span's own roll-up, so stripping + // the span alone leaves the amount recoverable one level down. + const [segment] = (span as { providerTiming?: { segments?: unknown[] } })?.providerTiming + ?.segments as Array> + expect(segment).toMatchObject({ name: 'gpt-4' }) + expect(segment).not.toHaveProperty('cost') + expect(segment).not.toHaveProperty('tokens') + // Everything the restriction does not cover is untouched. expect(result).toMatchObject({ id: 'log-1', status: 'completed' }) expect(span).toMatchObject({ id: 'span-1', name: 'Agent 1' }) @@ -281,6 +322,7 @@ describe('readLogDetail', () => { expect(result?.cost).toEqual({ total: 1.25 }) expect(result?.executionData.traceSpans?.[0]).toHaveProperty('cost') + expect(result?.executionData).toHaveProperty('models') }) }) }) diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts index 4f0319f4d0c..b940734da66 100644 --- a/apps/sim/lib/logs/fetch-log-detail.ts +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -80,10 +80,43 @@ export function withheldExecutionData( return retained } +/** + * A `providerTiming` with the per-iteration spend stripped from its segments. + * + * A segment carries its own `cost` and `tokens` — the itemization behind the + * span's roll-up — so removing the span's fields alone leaves the finer + * breakdown in place, which withholds nothing. + */ +function withoutSegmentSpend(providerTiming: unknown): unknown { + if (!providerTiming || typeof providerTiming !== 'object' || Array.isArray(providerTiming)) { + return providerTiming + } + const record = providerTiming as Record + if (!Array.isArray(record.segments)) return providerTiming + return { + ...record, + segments: record.segments.map((segment) => { + if (!segment || typeof segment !== 'object' || Array.isArray(segment)) return segment + const { cost: _cost, tokens: _tokens, ...retained } = segment as Record + return retained + }), + } +} + /** A span or block execution with the spend fields stripped from it. */ function withoutSpend(entry: unknown): unknown { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry - const { cost: _cost, tokens: _tokens, children, ...retained } = entry as Record + const { + cost: _cost, + tokens: _tokens, + children, + providerTiming, + ...rest + } = entry as Record + const retained = + providerTiming === undefined + ? rest + : { ...rest, providerTiming: withoutSegmentSpend(providerTiming) } return Array.isArray(children) ? { ...retained, children: children.map(withoutSpend) } : retained } @@ -95,9 +128,16 @@ function withoutSpend(entry: unknown): unknown { * the spans has not been withheld anything. Deletes rather than relies on the * schema, because `executionDataDetailSchema` is a passthrough and a span's own * shape is a `catchall`, so a field left in place would survive validation. + * + * The run's own roll-up goes first. `buildCompletedExecutionData` writes + * `tokens` and `models` at the root of every completed run, and `models` is the + * per-model dollar breakdown itself — leaving it while stripping the spans + * published the finest-grained figure of all next to a blanked total. `cost` is + * dropped with them for the runs old enough to carry it inline. */ export function withheldSpendData(executionData: Record): Record { - const projected: Record = { ...executionData } + const { tokens: _tokens, models: _models, cost: _cost, ...retained } = executionData + const projected: Record = { ...retained } if (Array.isArray(projected.traceSpans)) { projected.traceSpans = projected.traceSpans.map(withoutSpend) } From eaf512d39291d70f903009122f3944716ad0fc39 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:38:36 -0700 Subject: [PATCH 081/179] fix(permission-groups): align the resource-level capability sites with the funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workspace-files: the bulk-download assertion read its subject straight off the principal, re-applying to a delegated executor exactly the capability `authorizeWorkspaceOperation` exempts a subject-bearing executor from. It now reads the funnel's own rule, and passes the organization the context loaded. - enrichments: `userId` is required and explicitly nullable. Omitting it skipped the per-tool `deniedTools` gate silently; the one genuinely actorless caller — a system-triggered table dispatch — now states that rather than falling into it by leaving a field off. - apply-workflow-operations: pass the loaded `workspaceOrganizationId` instead of `undefined`, which re-queried the workspace for every edit batch. --- .../background/workflow-column-execution.ts | 2 +- apps/sim/enrichments/run.test.ts | 2 +- apps/sim/enrichments/run.ts | 2 +- apps/sim/enrichments/types.ts | 15 ++++--- .../tools/server/enrichment/enrichment-run.ts | 2 +- .../workspace-authorization.test.ts | 45 +++++++++++++++++++ .../apply-workflow-operations.test.ts | 10 +++++ .../application/apply-workflow-operations.ts | 6 ++- .../download-workspace-file-items.test.ts | 23 ++++++++++ .../download-workspace-file-items.ts | 23 +++++++--- 10 files changed, 114 insertions(+), 16 deletions(-) diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index 91e6d782f38..67d7a2a8595 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -635,7 +635,7 @@ async function runWorkflowAndWriteTerminal( * Absent means no per-tool gate applies, which is the documented * behavior for an actorless run. */ - userId: payload.triggeredByUserId ?? undefined, + userId: payload.triggeredByUserId, signal: attemptSignal, resolvedSecretTraceRegistry: enrichmentRegistry, }) diff --git a/apps/sim/enrichments/run.test.ts b/apps/sim/enrichments/run.test.ts index 8b562c7ffb7..98314101304 100644 --- a/apps/sim/enrichments/run.test.ts +++ b/apps/sim/enrichments/run.test.ts @@ -43,7 +43,7 @@ function config(providers: EnrichmentProvider[]): EnrichmentConfig { } } -const ctx = { workspaceId: 'ws-1' } +const ctx = { workspaceId: 'ws-1', userId: null } beforeEach(() => { mockExecuteTool.mockReset() diff --git a/apps/sim/enrichments/run.ts b/apps/sim/enrichments/run.ts index af318838e74..93a1e6e7eac 100644 --- a/apps/sim/enrichments/run.ts +++ b/apps/sim/enrichments/run.ts @@ -112,7 +112,7 @@ export async function runEnrichment( try { const response = await executeTool( provider.toolId, - { ...params, _context: { workspaceId: ctx.workspaceId, userId: ctx.userId } }, + { ...params, _context: { workspaceId: ctx.workspaceId, userId: ctx.userId ?? undefined } }, { signal: ctx.signal, resolvedSecretTraceRegistry: ctx.resolvedSecretTraceRegistry, diff --git a/apps/sim/enrichments/types.ts b/apps/sim/enrichments/types.ts index 7974976e82a..eba5cba3fc0 100644 --- a/apps/sim/enrichments/types.ts +++ b/apps/sim/enrichments/types.ts @@ -31,12 +31,17 @@ export interface EnrichmentRunContext { rowId?: string workspaceId: string /** - * The user the run is attributed to. Load-bearing, not decorative: the - * per-tool permission gate is skipped entirely when a tool call carries no - * user, so an enrichment that omits this sends row data to its provider with - * the workspace's `deniedTools` denylist silently not applied. + * The person the run acts for, or `null` for a deliberately actorless run. + * + * Load-bearing, not decorative: the per-tool permission gate is skipped + * entirely when a tool call carries no user, so a run without one sends row + * data to its provider with the workspace's `deniedTools` denylist silently + * not applied. Required, and explicitly nullable, so that is a decision a + * caller states rather than one it falls into by leaving a field off — the + * only actorless caller is a system-triggered table dispatch, which has no + * person to name and must not borrow the billing owner instead. */ - userId?: string + userId: string | null signal?: AbortSignal /** Isolated provenance for the exact mapped row inputs used by this run. */ resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry diff --git a/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts b/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts index 10479b04ed9..822059863c5 100644 --- a/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts +++ b/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts @@ -44,7 +44,7 @@ export const enrichmentRunServerTool: BaseServerTool permissionGroupScop import { authorizeWorkspaceOperation, + capabilityGovernedPrincipalUserId, defineWorkspaceOperation, InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, @@ -607,3 +608,47 @@ describe('authorizeWorkspaceOperation personal API key policy', () => { ).resolves.toBeUndefined() }) }) + +/** + * The sites that cannot ride on an operation's `capability` — they need the + * resource in hand — read the governed person from here. Pinned against the + * funnel above, because the tempting alternatives are all bystanders: an + * attribution helper's billing owner, a workspace key's creator, or the subject + * of an executor run the funnel exempts. + */ +describe('capabilityGovernedPrincipalUserId', () => { + it('names the person for a session and a personal key', () => { + expect(capabilityGovernedPrincipalUserId(principal)).toBe('user-1') + expect( + capabilityGovernedPrincipalUserId({ + kind: 'personal_api_key', + userId: 'user-2', + keyId: 'key-1', + }) + ).toBe('user-2') + }) + + it('names nobody for a workspace key, which has no user', () => { + expect(capabilityGovernedPrincipalUserId(workspaceKeyPrincipal)).toBeNull() + }) + + it('names nobody for an executor run, subject or not', () => { + expect(capabilityGovernedPrincipalUserId(executorPrincipal(undefined))).toBeNull() + expect( + capabilityGovernedPrincipalUserId({ + ...executorPrincipal(undefined), + subjectUserId: 'user-1', + }) + ).toBeNull() + }) + + it('names the person a non-executor delegation acts as', () => { + expect( + capabilityGovernedPrincipalUserId({ + ...executorPrincipal(undefined), + serviceId: 'copilot', + subjectUserId: 'user-3', + }) + ).toBe('user-3') + }) +}) diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts index 83203422e03..a88a6d652b4 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -87,6 +87,16 @@ vi.mock('@/lib/billing/core/subscription', () => ({ vi.mock('@/lib/core/config/block-visibility', () => ({ getBlockVisibility: mocks.blockVisibility })) vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.permissionConfig, + /** + * The use case passes the organization it already loaded, so the resolver + * takes its verified-context branch rather than looking the workspace up + * again. + */ + resolveVerifiedUserAccessControlContext: async ( + userId: string, + workspaceId: string, + _organizationId: string | null + ) => ({ config: await mocks.permissionConfig(userId, workspaceId) }), })) vi.mock('@/blocks/visibility/server-context', () => ({ withBlockVisibility: (_state: unknown, run: () => unknown) => run(), diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.ts index f070935fc10..2c8bff0815b 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.ts @@ -274,7 +274,11 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ const baseGraph = await resolveBaseGraph(principal, input, context) const [permissionConfig, blockVisibility] = await Promise.all([ - resolvePermissionGroupConfig(subjectUserId, context.workspaceId, undefined), + resolvePermissionGroupConfig( + subjectUserId, + context.workspaceId, + context.workspaceOrganizationId + ), getBlockVisibility({ userId: subjectUserId, orgId: context.workspaceOrganizationId }), ]) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts index 28702436eef..e8374108526 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts @@ -31,6 +31,10 @@ const { vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, + /** The use case passes the organization the authorized context already loaded. */ + resolveVerifiedUserAccessControlContext: async (userId: string, workspaceId: string) => ({ + config: await mockGetUserPermissionConfig(userId, workspaceId), + }), })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ @@ -306,6 +310,25 @@ describe('downloadWorkspaceFileItems', () => { ).rejects.toMatchObject({ capability: 'files.bulk_download' }) }) + /** + * A run carries the role of whoever triggered it but not their capabilities + * — `authorizeWorkspaceOperation` exempts a subject-bearing executor — and + * an assertion that read the subject straight off the principal re-applied + * here exactly what the funnel exempts. + */ + it('does not apply the capability to a delegated executor carrying a subject', async () => { + await expect( + downloadWorkspaceFileItems.execute({ + principal: { + ...delegatedPrincipal, + serviceId: 'executor' as const, + resourceScope: {}, + }, + input: { workspaceId: 'ws-1', fileIds: [], folderIds: ['folder-1'] }, + }) + ).resolves.toMatchObject({ filesToZip: [expect.objectContaining({ id: 'f2' })] }) + }) + it('still allows downloading a single named file, which the key does not withhold', async () => { await expect( downloadWorkspaceFileItems.execute({ diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts index c6857716c55..011dabaf0c7 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts @@ -1,6 +1,8 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' -import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { + type AuthorizedWorkspaceUseCaseContext, + capabilityGovernedPrincipalUserId, +} from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { parseFolderPath } from '@/lib/folders/paths' @@ -126,13 +128,22 @@ async function executeDownloadWorkspaceFileItems({ * withholds; declaring the capability on `files.download` would take away * saving one file too. `context.fileId` is the same single-file predicate the * resource authorization already resolved, reused so the two cannot drift. - * Asserted for the acting person only: an actorless deployment run has no - * permission group. + * Asserted against whoever the funnel would have judged, from its own rule — + * nobody, for a workspace key or an executor run. A run carries the role of + * whoever triggered it but not their capabilities, and reading the subject + * straight off the principal would have re-applied here exactly the + * capability `authorizeWorkspaceOperation` exempts a subject-bearing executor + * from. */ if (context.fileId === undefined) { - const actingUserId = resolvePrincipalSubjectUserId(principal) + const actingUserId = capabilityGovernedPrincipalUserId(principal) if (actingUserId) { - await assertWorkspaceCapability(actingUserId, context.workspaceId, 'files.bulk_download') + await assertWorkspaceCapability( + actingUserId, + context.workspaceId, + 'files.bulk_download', + context.workspaceOrganizationId + ) } } From 3576df4cebd030621c49fddc0c095ae0f2697911 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:41:03 -0700 Subject: [PATCH 082/179] fix(access-control): correct three affordance and documentation defects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - An empty allow-list denies every option, but the multi-select's default empty label reads 'All' — the opposite of what the server enforces. - The policy-read retry predicate excluded only 4xx, so a 2xx body that fails contract validation (an `ApiClientError` carrying status 200) was asked three more times for the same deterministic answer. - Two comments stated the opposite of the code: `audit_logs.list` declares `capability: 'none'` rather than omitting the field, and `ApiKeys` does ship outside the workspace panel — the account plane renders it as `scope='personal'`, which is what the `!workspaceId` arm covers. - The skill's leak grep excluded the directory holding `capabilities.ts`, hiding the authoritative `CAPABILITY_RULES`/`deniedBy` reads it asks for. --- .../validate-permission-group-item/SKILL.md | 5 +++-- apps/sim/app/api/v1/audit-logs/route.ts | 8 ++++---- .../settings/components/api-keys/api-keys.tsx | 18 +++++++++++------- .../components/group-detail.tsx | 3 +++ .../hooks/permission-groups.test.tsx | 19 +++++++++++++++++++ .../access-control/hooks/permission-groups.ts | 7 ++++++- 6 files changed, 46 insertions(+), 14 deletions(-) diff --git a/.agents/skills/validate-permission-group-item/SKILL.md b/.agents/skills/validate-permission-group-item/SKILL.md index acece4284f2..85416831b92 100644 --- a/.agents/skills/validate-permission-group-item/SKILL.md +++ b/.agents/skills/validate-permission-group-item/SKILL.md @@ -28,10 +28,11 @@ Record the builder, the `enforcement`, and the position. All derived by `collectFieldProperty` from the same registry. **Do not hand-verify them.** Verify nothing bypasses the derivation: ```bash -grep -rn "" apps/sim --include='*.ts' --include='*.tsx' | grep -v 'lib/permission-groups/' +grep -rn "" apps/sim --include='*.ts' --include='*.tsx' \ + | grep -vE 'lib/permission-groups/(fields|resolve\.server|config-scope\.server)\.ts' ``` -Every hit should be a rule's `deniedBy`, an enforcement site, a UI binding, or a test. A route restating the key, a client re-deriving a default, or a second coercion path is a leak. Specifically: +Only the registry and the resolvers are excluded, so `capabilities.ts` stays in the output — its `CAPABILITY_RULES` entry and `deniedBy` are the authoritative reads this step exists to check. Every hit should be a rule's `deniedBy`, an enforcement site, a UI binding, or a test. A route restating the key, a client re-deriving a default, or a second coercion path is a leak. Specifically: - **`z.array(...).catch(...)` anywhere on this key's path** — whole-value tolerant, so one bad member discards every good one, and on an allowlist the `null` fallback means unrestricted. That is fail-**open**. `tolerantArray` filters element-wise. Rank a regression here with the enforcement findings. - **`?? []` applied to an allowlist** — collapses "allows everything" into "allows nothing". diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index 36dfb466b08..e1ddc69d9b1 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -49,10 +49,10 @@ export const revalidate = 0 /** * GET /api/v1/audit-logs — List an organization's audit log. * - * permission-group-exempt: none — the counterpart `audit_logs.list` is an - * organization-admin operation with no `capability` field at all, because the - * surface is already restricted to organization admins and owners, who sit - * above every permission group. There is nothing here for a group to withhold. + * permission-group-exempt: none — the counterpart `audit_logs.list` declares + * `capability: 'none'` explicitly, because the surface is already restricted to + * organization admins and owners, who sit above every permission group. There + * is nothing here for a group to withhold. */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index 8fb672b1950..891a41dea86 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -127,13 +127,17 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { * refetch on focus outside the desktop app. `useUserPermissionConfig` raises * both, which is what makes this gate self-healing rather than sticky. * - * The `!workspaceId` arm is defense, not a live case: this component ships - * only from the workspace settings panel, always as `scope='combined'`, and - * `workspaceId` is that route's own param, so the query always runs. It - * covers the `|| ''` fallback above — a render outside the route would - * disable the hook, and `isSuccess` on a query that never runs is false - * forever, which would present as a dead button rather than a refusal. The - * server is the enforcement either way; this gate is the affordance. + * The `!workspaceId` arm covers the account plane, which renders this + * component as `scope='personal'` outside `/workspace/[workspaceId]`: the + * `|| ''` fallback above disables the hook, and `isSuccess` on a query that + * never runs is false forever, so failing closed there would present as a + * dead button rather than a refusal. Nothing reads the result on that plane — + * `createButtonDisabled` has a workspace arm and a combined arm and no + * personal one, because a personal key is not a workspace's to withhold. + * `api_keys.manage` is, and it is user-global: `/api/users/me/api-keys` + * refuses on it, and this page has no workspace to read the governing group + * through. The server is the enforcement; this gate is only the affordance, + * and it is deliberately not claiming to be one here. */ const permissionPolicyReady = !workspaceId || permissionConfigQuery.isSuccess diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 57e668a3102..8e7cae0d1a6 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -185,6 +185,9 @@ function AllowlistField({ label, value, onChange, options, disabled }: Allowlist onChange={onChange} options={options} disabled={disabled} + // An empty allow-list denies every option, so the multi-select's default + // empty label — 'All' — states the opposite of what the server enforces. + allLabel='None allowed' matchTriggerWidth={false} className='w-[200px]' /> diff --git a/apps/sim/ee/access-control/hooks/permission-groups.test.tsx b/apps/sim/ee/access-control/hooks/permission-groups.test.tsx index 040667724bd..aac6df3f7cd 100644 --- a/apps/sim/ee/access-control/hooks/permission-groups.test.tsx +++ b/apps/sim/ee/access-control/hooks/permission-groups.test.tsx @@ -151,6 +151,25 @@ describe('useUserPermissionConfig retry policy', () => { unmount() }) + /** + * `requestJson` raises an `ApiClientError` carrying the response status when a + * 2xx body fails contract validation, so the failure arrives as a `200`. + * Asking again produces the same body; a "not a 4xx" test sent four. + */ + it('does not retry a contract-validation failure, which arrives as a 200', async () => { + mockRequestJson.mockRejectedValue( + new ApiClientError({ status: 200, message: 'Invalid response' }) + ) + + const { mount } = makeHarness() + const { result, unmount } = mount(() => useUserPermissionConfig(WORKSPACE_ID)) + await settle(() => result().isError) + + expect(mockRequestJson).toHaveBeenCalledTimes(1) + + unmount() + }) + it('retries again on remount, so reopening settings is a real retry', async () => { mockRequestJson.mockRejectedValue(refusal()) diff --git a/apps/sim/ee/access-control/hooks/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts index 93d9d9dd6d8..cef9d86278e 100644 --- a/apps/sim/ee/access-control/hooks/permission-groups.ts +++ b/apps/sim/ee/access-control/hooks/permission-groups.ts @@ -118,9 +118,14 @@ const USER_PERMISSION_CONFIG_RETRIES = 3 * A refusal will not heal by asking again: the caller's session or membership * is what the server disagrees with, and three more requests spend latency to * arrive at the same 4xx. Only a transport failure or a 5xx is worth a retry. + * + * Stated as "a 5xx and nothing else" rather than "not a 4xx". `requestJson` + * raises an `ApiClientError` carrying the response status for a body that fails + * contract validation too, and that status is a `200` — deterministic, and + * outside the 4xx band the narrower test would have let through. */ function retryUserPermissionConfig(failureCount: number, error: Error): boolean { - if (isApiClientError(error) && error.status >= 400 && error.status < 500) return false + if (isApiClientError(error) && (error.status < 500 || error.status >= 600)) return false return failureCount < USER_PERMISSION_CONFIG_RETRIES } From 1822355230628fea62a8138fde0c5a8878e72075 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:43:01 -0700 Subject: [PATCH 083/179] fix(audits): close two renames that made the graph and subject audits no-ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-application-graph walked past `import '@/lib/x'` entirely — its pattern required a `from` — so the heaviest edge of all, a module loaded purely to run, was invisible to the guard. The clause pattern is also narrowed to non-quote characters so it cannot swallow a preceding side-effect import and report one edge for two. check-capability-subject followed neither rename that defeats a source-text match: an import alias left the sink's own name on the import line and nowhere else, and a local `capabilityGovernedUserId` made every governed subject unverifiable. Aliases are folded into the sink table; a local of that name is refused. --- scripts/check-application-graph.test.ts | 16 +++++++- scripts/check-application-graph.ts | 19 +++++++++- scripts/check-capability-subject.test.ts | 47 ++++++++++++++++++++++++ scripts/check-capability-subject.ts | 39 +++++++++++++++++++- 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/scripts/check-application-graph.test.ts b/scripts/check-application-graph.test.ts index a535a5e300b..0e6d3f209b1 100644 --- a/scripts/check-application-graph.test.ts +++ b/scripts/check-application-graph.test.ts @@ -13,7 +13,21 @@ describe('runtimeSpecifiers', () => { runtimeSpecifiers( "import { a } from '@/lib/a'\nexport { b } from '@/lib/b'\nimport '@/lib/c'\n" ) - ).toEqual(['@/lib/a', '@/lib/b']) + ).toEqual(['@/lib/a', '@/lib/b', '@/lib/c']) + }) + + /** + * The heaviest edge of all — the module is loaded purely to run — and the one + * nothing in the importing file names, so it was walked straight past. + */ + it('collects a side-effect import, in source order', () => { + expect( + runtimeSpecifiers("import '@/lib/uploads/core/setup.server'\nimport { a } from '@/lib/a'\n") + ).toEqual(['@/lib/uploads/core/setup.server', '@/lib/a']) + }) + + it('ignores a dynamic import, which is a call rather than a load', () => { + expect(runtimeSpecifiers("const a = await import('@/lib/a')\n")).toEqual([]) }) it('ignores type-only statements, which the compiler erases', () => { diff --git a/scripts/check-application-graph.ts b/scripts/check-application-graph.ts index d56dd085b1f..11767991f8f 100644 --- a/scripts/check-application-graph.ts +++ b/scripts/check-application-graph.ts @@ -98,9 +98,22 @@ export const GUARDED_ROOTS: readonly GuardedRoot[] = [ * The negative lookahead drops `import type {` and `import type X`, which the * compiler erases; `import { type A }` still counts, because that statement * emits a runtime require for the module. + * + * The clause between the keyword and `from` never contains a quote, so matching + * only non-quote characters there keeps this from swallowing a side-effect + * import that precedes a clause import and reporting one edge for two. */ const IMPORT_PATTERN = - /(?:^|\n)\s*(?:import|export)\s+(?!type[\s{])[\s\S]*?\s*from\s*['"]([^'"]+)['"]/g + /(?:^|\n)\s*(?:import|export)\s+(?!type[\s{])[^'"]*?\s*from\s*['"]([^'"]+)['"]/g + +/** + * Matches a side-effect import — `import '…'`, with no clause and so no + * `from`. It is the heaviest edge of all: the module is loaded purely to run, + * and nothing in the importing file names it, so it is also the easiest one to + * miss by eye. Dynamic `import(…)` is not matched: the quote must follow the + * keyword directly, and a call opens a parenthesis first. + */ +const SIDE_EFFECT_IMPORT_PATTERN = /(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g /** Resolves an `@/`- or relative specifier to a file under `apps/sim`, or null. */ export function resolveSpecifier(specifier: string, fromFile: string): string | null { @@ -119,7 +132,9 @@ export function resolveSpecifier(specifier: string, fromFile: string): string | /** The runtime specifiers `source` imports, in source order. */ export function runtimeSpecifiers(source: string): string[] { - return [...source.matchAll(IMPORT_PATTERN)].map((match) => match[1]) + return [...source.matchAll(IMPORT_PATTERN), ...source.matchAll(SIDE_EFFECT_IMPORT_PATTERN)] + .sort((first, second) => (first.index ?? 0) - (second.index ?? 0)) + .map((match) => match[1]) } export interface GraphViolation { diff --git a/scripts/check-capability-subject.test.ts b/scripts/check-capability-subject.test.ts index cca011a97d6..761a91e5e39 100644 --- a/scripts/check-capability-subject.test.ts +++ b/scripts/check-capability-subject.test.ts @@ -69,6 +69,53 @@ describe('assertion C — the subject came from capabilityGovernedUserId', () => }) }) +describe('assertion C — the two renames that made it a no-op', () => { + /** + * The alias leaves the sink's own name on the import line and nowhere else, + * so the audit read a file full of ungoverned calls as a file with none. + */ + it('follows an import alias to the call it renamed', () => { + const { findings, sinks } = auditSource( + MIDDLEWARE, + [ + "import { assertWorkspaceCapability as assertCap } from '@/lib/permission-groups/capability-assertions'", + "await assertCap(rateLimit.userId, workspaceId, 'tables.use')", + ].join('\n') + ) + + expect(sinks).toBe(0) + expect(findings).toHaveLength(1) + expect(findings[0].message).toContain('rateLimit.userId') + }) + + it('accepts an aliased call whose subject is still governed', () => { + const { findings, sinks } = auditSource( + 'apps/sim/app/api/v1/logs/route.ts', + [ + 'import { resolveLogFieldProjection as project } from "@/lib/logs/log-projection"', + 'const governed = capabilityGovernedUserId(rateLimit)', + 'await project(governed, workspaceId)', + ].join('\n') + ) + + expect(findings).toEqual([]) + expect(sinks).toBe(1) + }) + + it('refuses a route that declares the governed-subject name for itself', () => { + const { findings } = auditSource( + 'apps/sim/app/api/v1/logs/route.ts', + [ + 'function capabilityGovernedUserId(rateLimit) { return rateLimit.userId }', + "await isWorkspaceCapabilityWithheld(capabilityGovernedUserId(rateLimit), ws, 'tables.use')", + ].join('\n') + ) + + expect(findings).toHaveLength(1) + expect(findings[0].message).toContain('shadowing') + }) +}) + describe('assertion A — the name the audit is written in terms of', () => { it('reports a middleware that no longer exports it', () => { expect(auditMiddlewareExport('export function someOtherName() {}')).toHaveLength(1) diff --git a/scripts/check-capability-subject.ts b/scripts/check-capability-subject.ts index 61966c1b88e..2de20f115d3 100644 --- a/scripts/check-capability-subject.ts +++ b/scripts/check-capability-subject.ts @@ -31,6 +31,9 @@ * C every call to a capability sink passes a subject that came from * `capabilityGovernedUserId` — the call expression inline, or a local bound * to it. An id read off `rateLimit`/`auth` is the failure this exists for. + * Import aliases (`sink as local`) are folded in, and a local of the + * audit's own name is refused outright: either one turns a source-text + * match into a no-op that still passes. * D at least one governed sink call was actually found. The assertions are * source-text matches, so a refactor into a form the parser cannot follow * would otherwise be indistinguishable from a clean tree. @@ -185,7 +188,41 @@ export function auditSource(file: string, source: string): { findings: Finding[] governedLocals.add(match[1]) } - for (const [sink, subjectIndex] of Object.entries(CAPABILITY_SINKS)) { + /** + * An import alias is a rename the source-text match cannot see: + * `import { assertWorkspaceCapability as assertCap }` leaves every later call + * spelled `assertCap(...)`, and the sink's own name still appears once — on + * the import line — so the audit stayed green with a governed-call count that + * never moved. Aliases are folded into the sink table for this file. + */ + const sinks_ = { ...CAPABILITY_SINKS } + for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)/g)) { + const subjectIndex = CAPABILITY_SINKS[match[1]] + if (subjectIndex !== undefined) sinks_[match[2]] = subjectIndex + } + + /** + * A local of the audit's own name defeats every assertion below at once: the + * governed-locals scan matches `= capabilityGovernedUserId(` whatever that + * name now resolves to. Refused outright rather than resolved, because the + * name has exactly one legitimate meaning here — the middleware's export. + */ + if (file !== MIDDLEWARE) { + for (const match of source.matchAll( + new RegExp(`(?:(?:const|let|var|function)\\s+${GOVERNED}\\b|\\bas\\s+${GOVERNED}\\b)`, 'g') + )) { + findings.push({ + file, + line: lineOf(source, match.index), + message: + `declares its own \`${GOVERNED}\`, shadowing the middleware's. Every ` + + 'assertion here is written in terms of that name, so a local one makes a ' + + 'governed subject unverifiable. Import it from the middleware instead.', + }) + } + } + + for (const [sink, subjectIndex] of Object.entries(sinks_)) { for (const match of source.matchAll(new RegExp(`\\b${sink}\\s*\\(`, 'g'))) { const openIndex = match.index + match[0].length - 1 /** A declaration or an import of the sink, not a call into it. */ From 3345f271045e899a2510859bfc2bce8dc3bdbb66 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:45:01 -0700 Subject: [PATCH 084/179] fix(enrichments): state the actorless dispatch's null subject explicitly --- apps/sim/background/workflow-column-execution.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index 67d7a2a8595..a6a809e10ac 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -632,10 +632,11 @@ async function runWorkflowAndWriteTerminal( * running a member's tool denylist against a bystander is wrong in * both directions: it fails cells nobody meant to govern, and it * skips the denylist for the person who actually triggered one. - * Absent means no per-tool gate applies, which is the documented - * behavior for an actorless run. + * `null` means no per-tool gate applies, which is the documented + * behavior for an actorless run — stated, because the field is + * required precisely so it cannot be skipped by omission. */ - userId: payload.triggeredByUserId, + userId: payload.triggeredByUserId ?? null, signal: attemptSignal, resolvedSecretTraceRegistry: enrichmentRegistry, }) From 2c2c815823147ef4565e91f54d6ffee53edc9426 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:52:18 -0700 Subject: [PATCH 085/179] test(v1): teach the import suite's middleware mock the governed-subject helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import route now resolves its capability subject through capabilityGovernedUserId; a module-factory mock omitting a newly-used export yields undefined and a 500 on every test. The stub mirrors the real helper's keyType branch — only a personal key carries a governed subject. --- apps/sim/app/api/v1/workflows/import/route.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/sim/app/api/v1/workflows/import/route.test.ts b/apps/sim/app/api/v1/workflows/import/route.test.ts index ef3ef7a6fab..0c85588eda9 100644 --- a/apps/sim/app/api/v1/workflows/import/route.test.ts +++ b/apps/sim/app/api/v1/workflows/import/route.test.ts @@ -40,6 +40,9 @@ const { })) vi.mock('@/app/api/v1/middleware', () => ({ + /** Mirrors the real helper: only a personal key or session carries a governed subject. */ + capabilityGovernedUserId: (rateLimit: { keyType?: string; userId?: string }) => + rateLimit.keyType === 'personal' ? (rateLimit.userId ?? null) : null, checkRateLimit: mockCheckRateLimit, createRateLimitResponse: vi.fn(() => NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) From bdeb07a4d91eb88a820588b8f656fd2c0de673d8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 17:13:51 -0700 Subject: [PATCH 086/179] feat(permission-groups): declare a capability on the organization billing summary read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge brought one new operation; the required field refused it until answered. It is an organization-admin surface — admins and owners sit above every group — so it is exempt with its reason on record, and its factory now carries the same definition-time guard the other operation factories do. --- .../application/organization-billing-summary/operations.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/sim/lib/billing/application/organization-billing-summary/operations.ts b/apps/sim/lib/billing/application/organization-billing-summary/operations.ts index 745f56b40fd..890da903e0b 100644 --- a/apps/sim/lib/billing/application/organization-billing-summary/operations.ts +++ b/apps/sim/lib/billing/application/organization-billing-summary/operations.ts @@ -1,5 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability } from '@/lib/core/application' export type OrganizationBillingSummaryPrincipal = Extract @@ -13,16 +14,19 @@ export interface OrganizationBillingSummaryOperation function defineOrganizationBillingSummaryOperation( operation: OrganizationBillingSummaryOperation ): OrganizationBillingSummaryOperation { + assertOperationCapability(operation) Object.freeze(operation.organizationRoles) Object.freeze(operation.principalKinds) return Object.freeze(operation) } export const organizationBillingSummaryOperations = { + // permission-group-exempt: an organization-admin surface — admins and owners sit above every group, and no group key names organization billing read: defineOrganizationBillingSummaryOperation({ id: 'organization_billing.summary.read', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'none', }), } as const From 1a387ed0230418e7d060718cc1f79002c829b563 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 17:39:16 -0700 Subject: [PATCH 087/179] fix(permission-groups): gate selector execution on allowedIntegrations, apply the personal-key policy on v2 chat Three cubic findings on the enforcement PR. `POST /api/selectors/execute` reaches a provider's API with the caller's credential, so it is a use of the integration and not a neutral picker. The authorization funnel cannot apply `allowedIntegrations` because it never sees which integration a selector key stands for, so the decision is asserted from the use case, after credential binding and ahead of the provider call. The integration identity comes from the selector attachment's declared OAuth services, narrowed by the resolved credential's provider id so a two-service selector is judged as the half the caller actually reaches. `/api/v2/chat` only ever runs for a personal API key, and `admitV2Request` authenticates without authorizing, so neither half of the funnel's personal-key policy applied there. Both now run after the workspace access check, the group half through the shared `requirePersonalApiKeysAllowed`. The raw copilot chat route's `copilot.use` refusal now renders through `capabilityRefusalResponse`, so it carries the same detail code as every other capability refusal. --- .../app/api/selectors/execute/route.test.ts | 17 +++ apps/sim/app/api/selectors/execute/route.ts | 12 ++ apps/sim/app/api/v2/chat/route.test.ts | 76 ++++++++++- apps/sim/app/api/v2/chat/route.ts | 61 ++++++++- apps/sim/lib/copilot/chat/post.test.ts | 15 ++- apps/sim/lib/copilot/chat/post.ts | 14 +- .../application/execute-selector.test.ts | 121 ++++++++++++++++++ .../selectors/application/execute-selector.ts | 25 +++- .../lib/selectors/application/operations.ts | 2 +- .../selectors/server/integration-access.ts | 99 ++++++++++++++ 10 files changed, 425 insertions(+), 17 deletions(-) create mode 100644 apps/sim/lib/selectors/server/integration-access.ts diff --git a/apps/sim/app/api/selectors/execute/route.test.ts b/apps/sim/app/api/selectors/execute/route.test.ts index 1b0ba3713f7..dc6b53e3aeb 100644 --- a/apps/sim/app/api/selectors/execute/route.test.ts +++ b/apps/sim/app/api/selectors/execute/route.test.ts @@ -77,6 +77,7 @@ import { SelectorOptionsUnavailableError, } from '@/lib/selectors/server/errors' import { POST } from '@/app/api/selectors/execute/route' +import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' function project(error: unknown) { const result = mocks.errorPolicy?.project(error) @@ -126,6 +127,22 @@ describe('POST /api/selectors/execute', () => { }) }) + /** + * The one selector failure that names itself. The other three are normalized + * so a caller cannot probe a scope or a credential through them; this one + * reports the caller's own permission group against their own workspace and + * names the remedy, which "Connection unavailable" would hide. + */ + it('projects an integration-allowlist refusal as its own 403', () => { + expect(project(new IntegrationNotAllowedError('gmail_v2'))).toEqual({ + status: 403, + body: { + error: 'Integration "gmail_v2" is not allowed based on your permission group settings', + }, + headers: { 'Cache-Control': 'private, no-store' }, + }) + }) + it('preserves same-workspace forbidden errors', () => { expect( project(new OrchestrationError('forbidden', 'Insufficient workspace permissions')) diff --git a/apps/sim/app/api/selectors/execute/route.ts b/apps/sim/app/api/selectors/execute/route.ts index ff4e9364c07..4c2657eb787 100644 --- a/apps/sim/app/api/selectors/execute/route.ts +++ b/apps/sim/app/api/selectors/execute/route.ts @@ -17,6 +17,7 @@ import { SelectorContextUnavailableError, SelectorOptionsUnavailableError, } from '@/lib/selectors/server/errors' +import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const const SELECTOR_SCOPE_NOT_FOUND = 'Selector scope not found' @@ -34,6 +35,17 @@ const selectorOperationErrorPolicy = extendInternalErrorPolicy( PRIVATE_NO_STORE ) } + /** + * The integration allowlist refusal, which is deliberately the one selector + * failure that names itself. The other three are normalized so a caller + * cannot probe a scope or a credential through them; this one reports the + * caller's OWN permission group against their own workspace, tells them the + * remedy is an admin changing the allowlist rather than a broken connection, + * and reveals nothing they could not read off the block toolbar. + */ + if (error instanceof IntegrationNotAllowedError) { + return internalErrorResponse(403, { error: error.message }, PRIVATE_NO_STORE) + } if (error instanceof SelectorOptionsUnavailableError) { return internalErrorResponse( error.status, diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index b80cafb38c6..cd1ea42cd08 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -252,7 +252,10 @@ describe('POST /api/v2/chat', () => { mockAuthenticateV2ApiKey.mockResolvedValue(personalAuth) mockCheckPreAuthRate.mockResolvedValue({ allowed: true, remaining: 10, resetAt: new Date() }) mockCheckOperationRate.mockResolvedValue({ allowed: true, remaining: 10, resetAt: new Date() }) - mockAssertActiveWorkspaceAccess.mockResolvedValue({ permission: 'admin' }) + mockAssertActiveWorkspaceAccess.mockResolvedValue({ + permission: 'admin', + workspace: { organizationId: null, allowPersonalApiKeys: true }, + }) mockResolvePermissionGroupConfig.mockResolvedValue(null) mockResolveBillingAttribution.mockResolvedValue(billingAttributionSnapshot) mockRequestExplicitStreamAbort.mockResolvedValue(undefined) @@ -331,6 +334,77 @@ describe('POST /api/v2/chat', () => { expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() }) + /** + * The route only ever runs for a personal API key, and `admitV2Request` never + * authorizes, so both halves of the funnel's personal-key policy have to be + * repeated here. The workspace column is the first half. + */ + it('answers 403 when the workspace has switched personal API keys off', async () => { + mockAssertActiveWorkspaceAccess.mockResolvedValue({ + permission: 'admin', + workspace: { organizationId: null, allowPersonalApiKeys: false }, + }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }, + }) + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + /** + * The group half. The column and the key combine with AND, so a workspace + * that allows personal keys still refuses the cohort whose group withholds + * them — the case `copilot.use` alone could never see. + */ + it('answers 403 when the permission group withholds personal_api_key.use', async () => { + mockAssertActiveWorkspaceAccess.mockResolvedValue({ + permission: 'admin', + workspace: { organizationId: 'org-1', allowPersonalApiKeys: true }, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }, + }) + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + /** A workspace with no organization resolves no group, so the key passes. */ + it('runs one turn for a personal key in a workspace no group governs', async () => { + mockAssertActiveWorkspaceAccess.mockResolvedValue({ + permission: 'admin', + workspace: { organizationId: null, allowPersonalApiKeys: true }, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(200) + expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledTimes(1) + }) + /** Workspace reach is decided first, so the refusal cannot name a group to an outsider. */ it('refuses an inaccessible workspace before consulting a permission group', async () => { mockAssertActiveWorkspaceAccess.mockRejectedValue(new MockWorkspaceAccessDeniedError('denied')) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index d28bf49ac5f..35da036b1df 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { truncate } from '@sim/utils/string' -import type { NextRequest } from 'next/server' +import type { NextRequest, NextResponse } from 'next/server' import { v2ChatContract } from '@/lib/api/contracts/v2/chat' import { parseRequest } from '@/lib/api/server' import { @@ -36,6 +36,12 @@ import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/hea import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' import type { OrchestratorResult, StreamEvent } from '@/lib/copilot/request/types' import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' +import { + forbiddenErrorDetails, + PersonalApiKeysDisabledError, + requirePersonalApiKeysAllowed, + type WorkspaceAuthorizationContext, +} from '@/lib/core/application' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' @@ -79,6 +85,34 @@ function deriveConversationTitle(message: string): string | undefined { return truncate(normalized, CHAT_TITLE_MAX_LENGTH) } +/** + * The two personal-API-key checks `authorizeWorkspaceOperation` applies, for the + * one route that never reaches it, or `null` when the key may proceed. + * + * The group half runs through the same {@link requirePersonalApiKeysAllowed} the + * funnel and the billing reads call, so a third wording of the same refusal + * cannot drift in. Its error is projected rather than thrown because this route + * renders its own v2 envelope, and the detail code is read off the error so the + * column refusal and the group refusal answer with one code. + */ +async function personalApiKeyPolicyRefusal( + userId: string, + context: WorkspaceAuthorizationContext +): Promise { + const refuse = (error: PersonalApiKeysDisabledError) => + v2Error('FORBIDDEN', error.message, { details: forbiddenErrorDetails(error) }) + + if (!context.allowPersonalApiKeys) return refuse(new PersonalApiKeysDisabledError()) + + try { + await requirePersonalApiKeysAllowed(userId, context) + } catch (error) { + if (error instanceof PersonalApiKeysDisabledError) return refuse(error) + throw error + } + return null +} + function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError' } @@ -169,6 +203,31 @@ export const POST = withRouteHandler( const workspaceAccess = await assertActiveWorkspaceAccess(workspaceId, userId) const userPermission = workspaceAccess.permission + /** + * permission-group-enforced: personal_api_key.use — this route only ever + * runs for a personal API key, and `admitV2Request` authenticates one + * without authorizing it, so the funnel's personal-key policy has to be + * repeated here or the same key `authorizeWorkspaceOperation` refuses + * still starts a chat turn. + * + * Both halves, because they combine with AND: the workspace column is the + * coarse switch every workspace has, and the group key narrows it further + * for one cohort inside an enterprise organization. Either one saying no + * is a no, and checking only `copilot.use` applied neither. + * + * Both run after workspace access rather than before it, unlike the + * funnel, which can afford to check the column first because its caller + * has already loaded the workspace. Here the access check is what loads + * it, and answering later only ever conceals more: a caller with no reach + * into the workspace is refused without learning how it is configured. + */ + const personalKeyRefusal = await personalApiKeyPolicyRefusal(userId, { + workspaceId, + workspaceOrganizationId: workspaceAccess.workspace?.organizationId ?? null, + allowPersonalApiKeys: workspaceAccess.workspace?.allowPersonalApiKeys ?? false, + }) + if (personalKeyRefusal) return personalKeyRefusal + /** * permission-group-enforced: copilot.use — read off the operation so this * route and the funnel can never name different capabilities. diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 4b8950a5140..809420f07da 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -911,6 +911,15 @@ describe('handleUnifiedChatPost', () => { describe('handleUnifiedChatPost copilot.use capability gate', () => { const REFUSAL = "Chat is not available under your organization's permission group" + /** + * The body every raw capability refusal renders, detail code included. This + * route builds it through the shared `capabilityRefusalResponse`, so a client + * cannot tell a group refusal here apart from one raised by the funnel. + */ + const REFUSAL_BODY = { + error: REFUSAL, + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + } function chatRequest(body: Record = {}) { return new NextRequest('http://localhost/api/copilot/chat', { @@ -978,7 +987,7 @@ describe('handleUnifiedChatPost copilot.use capability gate', () => { const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true })) expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + await expect(response.json()).resolves.toEqual(REFUSAL_BODY) expect(resolveOrCreateChat).not.toHaveBeenCalled() expect(createSSEStream).not.toHaveBeenCalled() expect(releaseChatSendClaim).toHaveBeenCalledTimes(1) @@ -1003,7 +1012,7 @@ describe('handleUnifiedChatPost copilot.use capability gate', () => { ) expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + await expect(response.json()).resolves.toEqual(REFUSAL_BODY) expect(resolvePermissionGroupConfig).toHaveBeenCalledWith('user-1', 'ws-1', undefined) expect(createSSEStream).not.toHaveBeenCalled() }) @@ -1021,7 +1030,7 @@ describe('handleUnifiedChatPost copilot.use capability gate', () => { ) expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + await expect(response.json()).resolves.toEqual(REFUSAL_BODY) expect(resolvePermissionGroupConfig).not.toHaveBeenCalledWith( 'user-1', 'ws-unrestricted', diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 1792bd0bc01..be12cec3faf 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -46,11 +46,7 @@ import { import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1' -import { - createBadRequestResponse, - createForbiddenResponse, - createUnauthorizedResponse, -} from '@/lib/copilot/request/http' +import { createBadRequestResponse, createUnauthorizedResponse } from '@/lib/copilot/request/http' import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/lifecycle/start' import { startCopilotOtelRoot, withCopilotSpan } from '@/lib/copilot/request/otel' import { @@ -68,10 +64,8 @@ import { import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import type { AtomicClaimResult } from '@/lib/core/idempotency' import { chatSendIdempotency } from '@/lib/core/idempotency' -import { - capabilityRefusal, - isWorkspaceCapabilityWithheld, -} from '@/lib/permission-groups/capability-assertions' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { captureServerEvent } from '@/lib/posthog/server' import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { @@ -1156,7 +1150,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { ) { activeOtelRoot.span.setAttribute(TraceAttr.HttpStatusCode, 403) activeOtelRoot.finish('error') - return createForbiddenResponse(capabilityRefusal('copilot.use')) + return capabilityRefusalResponse('copilot.use') } let currentChat: ChatLoadResult['chat'] = null diff --git a/apps/sim/lib/selectors/application/execute-selector.test.ts b/apps/sim/lib/selectors/application/execute-selector.test.ts index 2259c03021c..ff2e0716d3e 100644 --- a/apps/sim/lib/selectors/application/execute-selector.test.ts +++ b/apps/sim/lib/selectors/application/execute-selector.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -61,7 +62,13 @@ vi.mock('@/lib/selectors/server/sanitize', () => ({ sanitizeSelectorResult: mocks.sanitize, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +const mockResolvePermissionGroupConfig = + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + import { selectorScopeSchema } from '@/lib/api/contracts/selectors/execute' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { executeSelector } from '@/lib/selectors/application/execute-selector' import { getSelectorManifestEntry } from '@/lib/selectors/manifest' import { @@ -69,6 +76,7 @@ import { SelectorOptionsUnavailableError, } from '@/lib/selectors/server/errors' import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' +import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } const scope = { kind: 'workspace' as const, workspaceId: 'workspace-1' } @@ -130,6 +138,7 @@ describe('executeSelector', () => { mocks.events.push('sanitization') return result }) + mockResolvePermissionGroupConfig.mockResolvedValue(null) }) it('authorizes canonical scope before references, credentials, and provider execution', async () => { @@ -148,6 +157,118 @@ describe('executeSelector', () => { ]) }) + /** + * The picker is a use of the integration, not a neutral list: it reaches the + * provider's API with the caller's credential. The authorization funnel never + * sees which integration a selector key stands for, so the allowlist decision + * is asserted from the use case instead. + */ + it('refuses a selector whose integration the permission group excludes', async () => { + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['slack_v2'], + }) + + await expect(execute()).rejects.toBeInstanceOf(IntegrationNotAllowedError) + + expect(mocks.events).toEqual([ + 'canonical-scope', + 'workspace-authorization', + 'reference-resolution', + 'credential-authorization', + ]) + }) + + it('executes a selector whose integration the permission group names', async () => { + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['gmail_v2'], + }) + + await expect(execute()).resolves.toMatchObject({ kind: 'list' }) + expect(mocks.executeAttachment).toHaveBeenCalledTimes(1) + }) + + /** + * A selector accepting two services would pass a check that asks whether ANY + * declared service is allowed. The resolved credential's provider id is the + * server-trusted narrowing, so the pair is judged as the half the caller is + * really reaching. + */ + it('narrows a two-service selector to the resolved credential provider', async () => { + mocks.authorizeCredential.mockImplementation(async () => { + mocks.events.push('credential-authorization') + return { suppliedId: 'credential-1', providerId: 'sharepoint' } + }) + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['sharepoint', 'microsoft-excel'], + }, + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['microsoft_excel_v2'], + }) + + await expect(execute()).rejects.toBeInstanceOf(IntegrationNotAllowedError) + expect(mocks.executeAttachment).not.toHaveBeenCalled() + }) + + /** The same pair, reached through the credential the allowlist does name. */ + it('allows a two-service selector reached through the permitted provider', async () => { + mocks.authorizeCredential.mockImplementation(async () => { + mocks.events.push('credential-authorization') + return { suppliedId: 'credential-1', providerId: 'microsoft-excel' } + }) + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['sharepoint', 'microsoft-excel'], + }, + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['microsoft_excel_v2'], + }) + + await expect(execute()).resolves.toMatchObject({ kind: 'list' }) + expect(mocks.executeAttachment).toHaveBeenCalledTimes(1) + }) + + /** + * A selector with no integration identity is not an integration: an internal + * selector declares no credential policy at all, so an allowlist that names + * nothing still leaves workspace files and knowledge bases pickable. + */ + it('passes through a selector that carries no credential policy', async () => { + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: [], + }) + + await expect(execute()).resolves.toMatchObject({ kind: 'list' }) + expect(mocks.executeAttachment).toHaveBeenCalledTimes(1) + }) + + /** No group governs the caller, so nothing narrows the allowlist. */ + it('executes when no permission group governs the caller', async () => { + mockResolvePermissionGroupConfig.mockResolvedValue(null) + + await expect(execute()).resolves.toMatchObject({ kind: 'list' }) + expect(mocks.executeAttachment).toHaveBeenCalledTimes(1) + }) + it('prepares non-fixed destinations after credential authorization and before provider execution', async () => { const prepare = vi.fn(async () => { mocks.events.push('destination-preparation') diff --git a/apps/sim/lib/selectors/application/execute-selector.ts b/apps/sim/lib/selectors/application/execute-selector.ts index 82b25dacbbb..05ce441825f 100644 --- a/apps/sim/lib/selectors/application/execute-selector.ts +++ b/apps/sim/lib/selectors/application/execute-selector.ts @@ -18,12 +18,14 @@ import { SelectorContextUnavailableError, SelectorOptionsUnavailableError, } from '@/lib/selectors/server/errors' +import { assertSelectorIntegrationAllowed } from '@/lib/selectors/server/integration-access' import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' import { resolveSelectorReferences } from '@/lib/selectors/server/references' import { getServerSelectorAttachment } from '@/lib/selectors/server/registry' import { sanitizeSelectorResult } from '@/lib/selectors/server/sanitize' import type { ResolvedSelectorReference } from '@/lib/selectors/server/types' import type { SelectorExecutionResult, SelectorRequest } from '@/lib/selectors/types' +import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' const logger = createLogger('ExecuteSelector') @@ -157,6 +159,24 @@ async function executeAuthorizedSelector(args: { }) : undefined + /** + * Enforces the permission group's `allowedIntegrations` decision, which the + * funnel cannot apply because it never sees which integration a selector + * reaches. Not a `permission-group-enforced:` annotation because that names + * a capability, and this key's enforcement mechanism is `executor`, not + * `capability`. + * + * Placed after credential binding so it can be made against the resolved + * credential's provider rather than the declaration alone, and before the + * provider call so a denied integration is never reached. + */ + await assertSelectorIntegrationAllowed({ + principal: args.principal, + workspaceId: args.context.workspaceId, + serviceIds: attachment.credential?.serviceIds ?? [], + ...(credential?.providerId ? { providerId: credential.providerId } : {}), + }) + const credentialAccess = credential?.access let credentialUseRecorded = false const recordCredentialUse = @@ -240,7 +260,10 @@ async function executeAuthorizedSelector(args: { if ( error instanceof SelectorContextUnavailableError || error instanceof SelectorConnectionUnavailableError || - error instanceof SelectorOptionsUnavailableError + error instanceof SelectorOptionsUnavailableError || + // A refusal, not a provider failure: it reaches the caller as its own 403 + // rather than being folded into "Options unavailable". + error instanceof IntegrationNotAllowedError ) { throw error } diff --git a/apps/sim/lib/selectors/application/operations.ts b/apps/sim/lib/selectors/application/operations.ts index 1abd11fbae4..a6f055010fe 100644 --- a/apps/sim/lib/selectors/application/operations.ts +++ b/apps/sim/lib/selectors/application/operations.ts @@ -1,7 +1,7 @@ import { defineWorkspaceOperation } from '@/lib/core/application' export const selectorOperations = { - // permission-group-exempt: no static capability names selector browsing — credential access is authorized per credential, and per-integration denial is the parameterized allowedIntegrations key, enforced today at workflow save and execution. Gating the picker itself needs a selector-key-to-block-type mapping and is tracked as a follow-up, not silently covered here. + // permission-group-exempt: no static capability names selector browsing — credential access is authorized per credential, and per-integration denial is the parameterized allowedIntegrations key, which the funnel cannot apply because it never sees which integration a selector reaches. That decision is now enforced from the use case by assertSelectorIntegrationAllowed, against the resolved credential's provider, ahead of the provider call. execute: defineWorkspaceOperation({ id: 'selectors.execute', minimumRole: 'read', diff --git a/apps/sim/lib/selectors/server/integration-access.ts b/apps/sim/lib/selectors/server/integration-access.ts new file mode 100644 index 00000000000..fd2e90515f6 --- /dev/null +++ b/apps/sim/lib/selectors/server/integration-access.ts @@ -0,0 +1,99 @@ +import type { Principal } from '@sim/auth/principal' +import { getIntegrationTypesForOAuthServiceId } from '@sim/deployment-config/integration-availability' +import { createLogger } from '@sim/logger' +import { allowedIntegrationTypes } from '@/lib/integrations/principal-scope.server' +import { credentialProviderMatchesService, getServiceConfigByServiceId } from '@/lib/oauth/utils' +import { + isBlockTypeAccessControlExempt, + resolveAccessControlBlockType, +} from '@/lib/permission-groups/block-access' +import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('SelectorIntegrationAccess') + +/** + * The OAuth services this execution actually stands for. + * + * A selector declares the services whose credentials it accepts, and most + * declare exactly one. A few accept two — `sharepoint`/`microsoft-excel`, + * `onedrive`/`microsoft-word` — and there the declaration alone is too wide: a + * member permitted only one of the pair would pass a check that asks whether + * *any* declared service is allowed. The resolved credential's provider id is + * the server-trusted narrowing, loaded during credential binding from the + * stored row rather than taken from the request, so it names which of the pair + * the caller is really reaching. + * + * Falls back to the full declaration when there is no provider id (a fixed + * token carries none) or when it matches none of them, which keeps the check + * from silently widening to "no integration identity" on a shape it cannot + * narrow. + */ +function resolveBoundServiceIds( + serviceIds: readonly string[], + providerId: string | undefined +): readonly string[] { + if (!providerId) return serviceIds + const bound = serviceIds.filter((serviceId) => { + const service = getServiceConfigByServiceId(serviceId) + return service ? credentialProviderMatchesService(providerId, service) : false + }) + return bound.length > 0 ? bound : serviceIds +} + +/** + * Refuses a selector execution whose integration the caller's permission group + * does not permit. + * + * `POST /api/selectors/execute` reaches a provider's API with the caller's + * credential, so it is a use of the integration and not merely a picker. The + * authorization funnel cannot apply the rule: `allowedIntegrations` is a + * parameterized decision about *which* integration, and the funnel knows only + * the principal, the workspace and the operation. Hence the assertion here, + * ahead of the provider call, exactly as `knowledge.connectors` is asserted + * ahead of the connector write. + * + * The decision is the one the block-access path makes. `allowedIntegrationTypes` + * is the shared gate — it intersects the caller's permission group with the + * deployment's `ALLOWED_INTEGRATIONS`, contributes no group half for a principal + * that stands for no person, and normalizes the policy side through + * `toAccessControlAllowlist`. The checked side is successor-resolved the same + * way, so a group naming `slack` and a selector bound to `slack_v2` match. + * + * A `null` allowlist, a caller no group governs, and a selector with no + * integration identity all pass through. The last covers two real shapes: an + * internal selector (workspace files, knowledge bases) declares no credential + * policy at all, and an API-key integration — Snowflake, NetSuite, Harmonic — + * owns no OAuth entry in the deployment integration catalog and therefore maps + * to no block type. Treating an unmapped service as allowed is deliberate and + * is what the credential catalog already does; see + * `isOAuthServiceAllowedByIntegrationTypes`. + */ +export async function assertSelectorIntegrationAllowed(input: { + principal: Principal + workspaceId: string + serviceIds: readonly string[] + providerId?: string +}): Promise { + if (input.serviceIds.length === 0) return + + const allowlist = await allowedIntegrationTypes(input.principal, input.workspaceId) + if (allowlist === null) return + + const blockTypes = resolveBoundServiceIds(input.serviceIds, input.providerId).flatMap( + (serviceId) => getIntegrationTypesForOAuthServiceId(serviceId) + ) + if (blockTypes.length === 0) return + + const allowed = blockTypes.some( + (blockType) => + isBlockTypeAccessControlExempt(blockType) || + allowlist.has(resolveAccessControlBlockType(blockType).toLowerCase()) + ) + if (allowed) return + + logger.warn('Selector integration blocked by integration allowlist', { + workspaceId: input.workspaceId, + blockTypes, + }) + throw new IntegrationNotAllowedError(blockTypes[0]) +} From 45b4bedd5f715e992610272b6a4c35497ce0e6fd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 17:41:52 -0700 Subject: [PATCH 088/179] fix(permission-groups): key the table-import capability re-check on the governed subject `resolvePrincipalSubjectUserId` answers with the subject of an executor delegation too, so the create- and completion-time `tables.create` assertions re-applied a capability the authorization funnel deliberately exempts runs from. `capabilityGovernedPrincipalUserId` is the canonical statement of that rule: null for an executor delegation, still the subject for a copilot one, which is the intended asymmetry. --- .../sim/lib/table/application/imports.test.ts | 40 +++++++++++++++++++ apps/sim/lib/table/application/imports.ts | 26 ++++++------ 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts index ee5f0ac6def..162f762fe2f 100644 --- a/apps/sim/lib/table/application/imports.test.ts +++ b/apps/sim/lib/table/application/imports.test.ts @@ -494,6 +494,46 @@ describe('table import application use cases', () => { expect(mocks.createResource).not.toHaveBeenCalled() }) + /** + * A run carries the role of whoever triggered it but not their + * capabilities — the same exemption `authorizeWorkspaceOperation` applies. + * Keying this check on the raw subject instead would re-apply a capability + * the funnel deliberately passed, failing an executor import under a group + * that withholds table creation from the person who started the workflow. + */ + it('exempts an executor delegation carrying a subject, as the funnel does', async () => { + await expect( + createTableImportUseCase.execute({ + principal: executor, + input: { + body: { + workspaceId: 'workspace-1', + source: record.source, + target: { type: 'new', name: 'People' }, + }, + }, + request: new Request('http://localhost:3000/api/table/imports', { method: 'POST' }), + }) + ).resolves.toBeDefined() + + expect(mocks.createResource).toHaveBeenCalled() + }) + + it('exempts an executor delegation at completion too', async () => { + await expect( + completeTableImportUseCase.execute({ + principal: executor, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + ).resolves.toBeDefined() + + expect(mocks.startUploadedImport).toHaveBeenCalled() + }) + it('still allows importing into an existing table, which creates nothing', async () => { await expect( createTableImportUseCase.execute({ diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts index 1ccd3407dca..ba596f1dfb1 100644 --- a/apps/sim/lib/table/application/imports.ts +++ b/apps/sim/lib/table/application/imports.ts @@ -1,10 +1,9 @@ -import { - type Principal, - resolvePrincipalAttribution, - resolvePrincipalSubjectUserId, -} from '@sim/auth/principal' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' -import { authorizeWorkspaceOperation } from '@/lib/core/application' +import { + authorizeWorkspaceOperation, + capabilityGovernedPrincipalUserId, +} from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { withFolderTreeLock } from '@/lib/folders/locks' @@ -177,11 +176,13 @@ export const createTableImportUseCase = defineAuthorizedTableUseCase({ * permission-group-enforced: tables.create — an import targeting `new` * creates a table, but one targeting `existing` only fills one, and the * operation cannot tell them apart: the target is request input the - * authorization funnel never sees. Asserted for the acting person only; - * an actorless deployment run has no group, like everywhere else. + * authorization funnel never sees. Keyed to the governed subject, which + * names nobody for an actorless run and nobody for an executor delegation — + * the funnel exempts a run from capabilities even when it carries the + * subject of whoever triggered it. A copilot delegation stays governed. */ if (input.body.target.type === 'new') { - const actingUserId = resolvePrincipalSubjectUserId(principal) + const actingUserId = capabilityGovernedPrincipalUserId(principal) if (actingUserId) { await assertWorkspaceCapability(actingUserId, context.workspaceId, 'tables.create') } @@ -297,11 +298,12 @@ export const completeTableImportUseCase = defineAuthorizedTableUseCase({ * separate requests: an upload started before the group withheld * creation would otherwise still land a table when it completed. Read * from the claimed session so the target is the one the upload was - * created for, and asserted for the acting person only — an actorless - * deployment run has no group, like everywhere else. + * created for, and keyed to the governed subject for the reason the + * create path is: an actorless run and an executor delegation are both + * ungoverned, a copilot delegation is not. */ if (tableImportBodyFromUpload(claimed).target.type === 'new') { - const actingUserId = resolvePrincipalSubjectUserId(principal) + const actingUserId = capabilityGovernedPrincipalUserId(principal) if (actingUserId) { await assertWorkspaceCapability(actingUserId, context.workspaceId, 'tables.create') } From f73c01982c8b127c093924648fcd3c00eb4fea39 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 17:42:02 -0700 Subject: [PATCH 089/179] fix(permission-groups): gate table enrichments on the acting person, not the billing owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `triggeredByUserId` is an attribution: for a workspace-API-key run it names the workspace billed account, so passing it as the enrichment gate ran a bystander's tool denylist against an actorless request — failing cells nobody meant to govern, and skipping the denylist for whoever actually triggered one. The dispatch now carries the governed subject alongside the trigger actor and the worker gates on that. `insertDispatch` defaults it to the trigger actor so producers that cannot tell an acting person from an attribution fallback keep today's behavior; the use cases holding a principal pass it explicitly, null included. The column is nullable and purely additive — existing rows read null, which is the answer those runs already get. --- .../background/workflow-column-execution.ts | 20 +- apps/sim/lib/table/application/groups.test.ts | 1 + apps/sim/lib/table/application/groups.ts | 42 +- apps/sim/lib/table/application/runs.test.ts | 28 + apps/sim/lib/table/application/runs.ts | 11 + apps/sim/lib/table/dispatcher.ts | 26 +- apps/sim/lib/table/workflow-columns.ts | 12 + ...able_dispatch_capability_governed_user.sql | 7 + .../db/migrations/meta/0315_snapshot.json | 20802 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 10 + 11 files changed, 20953 insertions(+), 13 deletions(-) create mode 100644 packages/db/migrations/0315_table_dispatch_capability_governed_user.sql create mode 100644 packages/db/migrations/meta/0315_snapshot.json diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index a6a809e10ac..532651889c4 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -627,16 +627,18 @@ async function runWorkflowAndWriteTerminal( rowId, workspaceId, /** - * The person who asked, not who pays. For a system-triggered cell - * the billing attribution names the workspace's billing owner, and - * running a member's tool denylist against a bystander is wrong in - * both directions: it fails cells nobody meant to govern, and it - * skips the denylist for the person who actually triggered one. - * `null` means no per-tool gate applies, which is the documented - * behavior for an actorless run — stated, because the field is - * required precisely so it cannot be skipped by omission. + * The person who asked, not who pays. `triggeredByUserId` is an + * attribution: for a workspace-API-key run it names the workspace's + * billing owner, and running that bystander's tool denylist against + * an actorless request is wrong in both directions — it fails cells + * nobody meant to govern, and it skips the denylist for the person + * who actually triggered one. The governed subject is carried + * separately from the dispatch. `null` means no per-tool gate + * applies, which is the documented behavior for an actorless run — + * stated, because the field is required precisely so it cannot be + * skipped by omission. */ - userId: payload.triggeredByUserId ?? null, + userId: payload.capabilityGovernedUserId ?? null, signal: attemptSignal, resolvedSecretTraceRegistry: enrichmentRegistry, }) diff --git a/apps/sim/lib/table/application/groups.test.ts b/apps/sim/lib/table/application/groups.test.ts index 59f3135caa7..f881b4c1287 100644 --- a/apps/sim/lib/table/application/groups.test.ts +++ b/apps/sim/lib/table/application/groups.test.ts @@ -333,6 +333,7 @@ describe('workflow and enrichment Table application commands', () => { isManualRun: false, requestId: 'request-1', triggeredByUserId: 'user-1', + capabilityGovernedUserId: 'user-1', }) }) diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index 485c960b78e..53019edf0e0 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -3,6 +3,7 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import type { V2AddWorkflowGroupBody } from '@/lib/api/contracts/v2/tables' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' @@ -182,6 +183,13 @@ function dispatchGroupAutoRun(params: { workspaceId: string groupId: string actorUserId: string + /** + * The gate's subject, which is not the meter's. `actorUserId` is an + * attribution and names the workspace billed account when the credential + * names no human, so passing it as the gate would run that bystander's tool + * denylist against an actorless run. Null means no acting person. + */ + capabilityGovernedUserId: string | null label: string }): void { runDetached(params.label, async () => { @@ -193,6 +201,7 @@ function dispatchGroupAutoRun(params: { isManualRun: false, requestId: generateRequestId(), triggeredByUserId: params.actorUserId, + capabilityGovernedUserId: params.capabilityGovernedUserId, }) logger.info('Started table group auto-run', { tableId: params.tableId, @@ -274,6 +283,7 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ } const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const capabilityGovernedUserId = capabilityGovernedPrincipalUserId(principal) const groupId = input.group.id ?? generateId() /** * The public surface lets an `enrichment` group omit `workflowId`, so the @@ -305,7 +315,12 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ }, generateRequestId() ) - return { table, group: groupFromTable(table, groupId), actorUserId } + return { + table, + group: groupFromTable(table, groupId), + actorUserId, + capabilityGovernedUserId, + } }, projectAudit({ result }) { return { @@ -325,6 +340,7 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, groupId: result.group.id, actorUserId: result.actorUserId, + capabilityGovernedUserId: result.capabilityGovernedUserId, label: 'table-group-create-auto-run', }) } @@ -411,6 +427,7 @@ export const createWorkflowTableGroup = defineAuthorizedTableUseCase({ outputs, } const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const capabilityGovernedUserId = capabilityGovernedPrincipalUserId(principal) const table = await addWorkflowGroup( { tableId: context.tableId, @@ -423,7 +440,12 @@ export const createWorkflowTableGroup = defineAuthorizedTableUseCase({ }, generateRequestId() ) - return { table, group: groupFromTable(table, groupId), actorUserId } + return { + table, + group: groupFromTable(table, groupId), + actorUserId, + capabilityGovernedUserId, + } }, projectAudit({ result }) { return { @@ -443,6 +465,7 @@ export const createWorkflowTableGroup = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, groupId: result.group.id, actorUserId: result.actorUserId, + capabilityGovernedUserId: result.capabilityGovernedUserId, label: 'table-workflow-group-create-auto-run', }) } @@ -550,6 +573,7 @@ export const createTableEnrichmentGroup = defineAuthorizedTableUseCase({ autoRun: input.autoRun ?? false, } const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const capabilityGovernedUserId = capabilityGovernedPrincipalUserId(principal) const table = await addWorkflowGroup( { tableId: context.tableId, @@ -562,7 +586,12 @@ export const createTableEnrichmentGroup = defineAuthorizedTableUseCase({ }, generateRequestId() ) - return { table, group: groupFromTable(table, groupId), actorUserId } + return { + table, + group: groupFromTable(table, groupId), + actorUserId, + capabilityGovernedUserId, + } }, projectAudit({ result }) { return { @@ -586,6 +615,7 @@ export const createTableEnrichmentGroup = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, groupId: result.group.id, actorUserId: result.actorUserId, + capabilityGovernedUserId: result.capabilityGovernedUserId, label: 'table-enrichment-group-create-auto-run', }) } @@ -735,6 +765,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ } } const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const capabilityGovernedUserId = capabilityGovernedPrincipalUserId(principal) const hasMappingUpdates = Boolean(input.mappingUpdates && input.mappingUpdates.length > 0) if (hasMappingUpdates && !resolvedWorkflow) { throw new Error('Workflow metadata is required for workflow group mapping updates') @@ -795,6 +826,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ JSON.stringify(context.table.metadata) !== JSON.stringify(table.metadata), startAutoRun: previousGroup?.autoRun === false && input.autoRun === true, actorUserId, + capabilityGovernedUserId, } }, projectAudit({ result }) { @@ -816,6 +848,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, groupId: result.group.id, actorUserId: result.actorUserId, + capabilityGovernedUserId: result.capabilityGovernedUserId, label: 'table-group-update-auto-run', }) } @@ -956,6 +989,7 @@ export const updateWorkflowTableGroup = defineAuthorizedTableUseCase({ } const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const capabilityGovernedUserId = capabilityGovernedPrincipalUserId(principal) const table = await updateWorkflowGroup( { tableId: context.tableId, @@ -984,6 +1018,7 @@ export const updateWorkflowTableGroup = defineAuthorizedTableUseCase({ JSON.stringify(context.table.metadata) !== JSON.stringify(table.metadata), startAutoRun: previousGroup.autoRun === false && input.autoRun === true, actorUserId, + capabilityGovernedUserId, } }, projectAudit({ result }) { @@ -1005,6 +1040,7 @@ export const updateWorkflowTableGroup = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, groupId: result.group.id, actorUserId: result.actorUserId, + capabilityGovernedUserId: result.capabilityGovernedUserId, label: 'table-workflow-group-update-auto-run', }) } diff --git a/apps/sim/lib/table/application/runs.test.ts b/apps/sim/lib/table/application/runs.test.ts index 93a1675e4ef..e2735125059 100644 --- a/apps/sim/lib/table/application/runs.test.ts +++ b/apps/sim/lib/table/application/runs.test.ts @@ -159,11 +159,39 @@ describe('table run application use cases', () => { mode: 'all', requestId: 'request-1', triggeredByUserId: PRINCIPAL.userId, + capabilityGovernedUserId: PRINCIPAL.userId, }) expect(result.dispatchId).toBe('dispatch-1') expect(mockSignalRowsChanged).toHaveBeenCalledWith(TABLE.id) }) + /** + * A workspace key names no human, so its run is ungoverned. The meter still + * needs someone, and attribution answers with the workspace billed account — + * a bystander whose tool denylist must not reach the run's cells. The two + * subjects are carried separately precisely so this case can differ. + */ + it('carries the billed account as the meter but nobody as the gate for a workspace key', async () => { + await startTableRun.execute({ + principal: { kind: 'workspace_api_key', workspaceId: TABLE.workspaceId, keyId: 'key-1' }, + input: { + kind: 'row_enrichment', + tableId: TABLE.id, + assertedWorkspaceId: TABLE.workspaceId, + rowId: 'row-1', + groupId: 'group-1', + requestId: 'request-1', + }, + }) + + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + triggeredByUserId: 'billing-owner-1', + capabilityGovernedUserId: null, + }) + ) + }) + it('rejects missing canonical groups and rows without dispatching', async () => { await expect( startTableRun.execute({ diff --git a/apps/sim/lib/table/application/runs.ts b/apps/sim/lib/table/application/runs.ts index 7b9725f9414..6480304b46c 100644 --- a/apps/sim/lib/table/application/runs.ts +++ b/apps/sim/lib/table/application/runs.ts @@ -1,6 +1,7 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { getRequestContext } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { DEFAULT_TABLE_PLAN_LIMITS, @@ -96,6 +97,14 @@ export const startTableRun = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: StartTableRunInput }) => resolveActiveTableContext(input), async execute({ principal, input, context }): Promise { const triggeredByUserId = actorUserId(principal, context.billedAccountUserId) + /** + * The gate's subject, which is not the meter's. `actorUserId` substitutes + * the workspace billed account when the credential names no human, so a + * workspace-API-key run would otherwise carry that bystander into the + * cells' tool denylist. Null here means no acting person and no per-tool + * gate — the same answer an executor delegation gets from the funnel. + */ + const capabilityGovernedUserId = capabilityGovernedPrincipalUserId(principal) if (input.kind === 'row_enrichment') { requireCanonicalGroups(context.table, [input.groupId]) const row = await getRowById(context.tableId, input.rowId, context.workspaceId) @@ -108,6 +117,7 @@ export const startTableRun = defineAuthorizedTableUseCase({ mode: 'all', requestId: requestId(input), triggeredByUserId, + capabilityGovernedUserId, }) return { table: context.table, @@ -165,6 +175,7 @@ export const startTableRun = defineAuthorizedTableUseCase({ limit: input.limit, requestId: requestId(input), triggeredByUserId, + capabilityGovernedUserId, }) return { table: context.table, diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index c20d1d837a5..dbe9a1ceb56 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -98,6 +98,10 @@ export interface DispatchRow { isManualRun: boolean /** User who triggered the run (for usage attribution); null for auto-fire. */ triggeredByUserId: string | null + /** Person whose permission group gates this run's cells; null when the run + * has no acting person. Deliberately not `triggeredByUserId` — see the + * column comment on `table_run_dispatches`. */ + capabilityGovernedUserId: string | null requestedAt: Date /** Set when the dispatch reached `complete`; null while it is still active. */ completedAt: Date | null @@ -248,6 +252,7 @@ export async function insertDispatch(input: { limit?: DispatchLimit | null isManualRun: boolean triggeredByUserId?: string | null + capabilityGovernedUserId?: string | null }): Promise { const id = `tdsp_${generateId().replace(/-/g, '')}` await db.insert(tableRunDispatches).values({ @@ -265,6 +270,16 @@ export async function insertDispatch(input: { cursor: -1, isManualRun: input.isManualRun, triggeredByUserId: input.triggeredByUserId ?? null, + /** + * Defaults to the trigger actor so every producer that has not been taught + * the distinction keeps the gating it has today. Only a producer holding + * the principal can tell an acting person from an attribution fallback, and + * those pass the governed subject explicitly — `null` included. + */ + capabilityGovernedUserId: + input.capabilityGovernedUserId !== undefined + ? input.capabilityGovernedUserId + : (input.triggeredByUserId ?? null), }) return id } @@ -349,6 +364,7 @@ export async function listActiveDispatches(tableId: string): Promise ({ ...p, dispatchId, triggeredByUserId: dispatch.triggeredByUserId ?? undefined })) + }).map((p) => ({ + ...p, + dispatchId, + triggeredByUserId: dispatch.triggeredByUserId ?? undefined, + capabilityGovernedUserId: dispatch.capabilityGovernedUserId, + })) // Cursor advances to the last position in this chunk regardless of // eligibility — otherwise a window full of skipped cells loops forever. @@ -1026,6 +1048,7 @@ export async function cancelStaleDispatches( processedCount: row.processedCount, isManualRun: row.isManualRun, triggeredByUserId: row.triggeredByUserId, + capabilityGovernedUserId: row.capabilityGovernedUserId, requestedAt: row.requestedAt, completedAt: row.completedAt, cancelledAt: row.cancelledAt, @@ -1109,6 +1132,7 @@ export async function markActiveDispatchesCancelled( processedCount: row.processedCount, isManualRun: row.isManualRun, triggeredByUserId: row.triggeredByUserId, + capabilityGovernedUserId: row.capabilityGovernedUserId, requestedAt: row.requestedAt, completedAt: row.completedAt, cancelledAt: row.cancelledAt, diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 76d9bbafa50..cfbe8f3ba74 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -449,6 +449,11 @@ export interface WorkflowGroupCellPayload { * auto-fire (row writes, CSV import) → billing falls back to the workspace * billed account. */ triggeredByUserId?: string + /** Person whose permission group gates this cell's tools. Null/absent means + * no acting person, so no per-tool gate applies. Not `triggeredByUserId`: + * that is an attribution and names the workspace billed account when the + * credential names no human, which would run a bystander's denylist. */ + capabilityGovernedUserId?: string | null } export type QueuedWorkflowGroupCellPayload = Omit< @@ -865,6 +870,11 @@ export async function runWorkflowColumn(opts: { * callers (row writes, CSV import) → falls back to the workspace billed * account at billing time. */ triggeredByUserId?: string | null + /** Person whose permission group gates the run's cells. Omitted by producers + * that cannot tell an acting person from an attribution fallback; those + * default to `triggeredByUserId` in `insertDispatch`. Pass it explicitly — + * `null` included — wherever the principal is in hand. */ + capabilityGovernedUserId?: string | null }): Promise<{ dispatchId: string | null; shouldSignalRowsChanged: boolean }> { const { tableId, @@ -877,6 +887,7 @@ export async function runWorkflowColumn(opts: { excludeRowIds, limit, triggeredByUserId, + capabilityGovernedUserId, } = opts const isManualRun = opts.isManualRun ?? true // Empty `rowIds` array means "scope explicitly empty" — auto-fire callers @@ -945,6 +956,7 @@ export async function runWorkflowColumn(opts: { limit, isManualRun, triggeredByUserId, + ...('capabilityGovernedUserId' in opts ? { capabilityGovernedUserId } : {}), }) try { diff --git a/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql b/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql new file mode 100644 index 00000000000..50ddf2285f1 --- /dev/null +++ b/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql @@ -0,0 +1,7 @@ +-- Adds the permission-group subject a table run's cells are gated against, separate from +-- `triggered_by_user_id` (an attribution that substitutes the workspace billed account when the +-- credential names no human). Purely additive and nullable: every existing row reads NULL, which +-- means "no acting person, no per-tool gate" — the same answer those runs already get today. +ALTER TABLE "table_run_dispatches" ADD COLUMN "capability_governed_user_id" text;--> statement-breakpoint +ALTER TABLE "table_run_dispatches" ADD CONSTRAINT "table_run_dispatches_capability_governed_user_id_user_id_fk" FOREIGN KEY ("capability_governed_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action NOT VALID;--> statement-breakpoint +ALTER TABLE "table_run_dispatches" VALIDATE CONSTRAINT "table_run_dispatches_capability_governed_user_id_user_id_fk"; diff --git a/packages/db/migrations/meta/0315_snapshot.json b/packages/db/migrations/meta/0315_snapshot.json new file mode 100644 index 00000000000..8da654ca8c1 --- /dev/null +++ b/packages/db/migrations/meta/0315_snapshot.json @@ -0,0 +1,20802 @@ +{ + "id": "1c5e9470-9b1b-4290-9952-7adfaac8ae89", + "prevId": "71728d90-1e30-40bf-aa5e-edc370e34999", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_status_idx": { + "name": "credential_group_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_name_unique": { + "name": "credential_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "managed_oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index b0baf0d54d6..84d8c86a129 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2199,6 +2199,13 @@ "when": 1788208209301, "tag": "0314_superb_daimon_hellstrom", "breakpoints": true + }, + { + "idx": 315, + "version": "7", + "when": 1788222849643, + "tag": "0315_table_dispatch_capability_governed_user", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index b8e07056576..e7aa9dd8f19 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -5071,6 +5071,16 @@ export const tableRunDispatches = pgTable( triggeredByUserId: text('triggered_by_user_id').references(() => user.id, { onDelete: 'set null', }), + /** The person whose permission group governs what this run's cells may do. + * Distinct from `triggered_by_user_id`, which is an *attribution* and + * substitutes the workspace billed account when the credential names no + * human — right for a meter, wrong for a gate, since it would run a + * bystander's tool denylist against an actorless request. Null when the + * run has no acting person (workspace API key, schedule, auto-fire), which + * means no per-tool gate applies. */ + capabilityGovernedUserId: text('capability_governed_user_id').references(() => user.id, { + onDelete: 'set null', + }), requestedAt: timestamp('requested_at').notNull().defaultNow(), /** Last time the dispatcher loop made progress on this dispatch. Stamped by * the same per-window writes that advance `cursor` and `processed_count`, From 24e5d6be8ef4eaa4daf7e44d7609334162525fed Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 17:42:10 -0700 Subject: [PATCH 090/179] fix(v1): attribute a workspace-key table delete to the system actor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace key names no human, so `rateLimit.userId` is the key's creator and audit/analytics recorded the deletion against a bystander. `resolveWorkspaceRequestActor` is the established primitive — the row routes on this same table already use it — and substitutes the billed account as the explicit system actor while keeping a personal key's owner. --- .../app/api/v1/tables/[tableId]/route.test.ts | 9 +++++++++ apps/sim/app/api/v1/tables/[tableId]/route.ts | 20 +++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.test.ts b/apps/sim/app/api/v1/tables/[tableId]/route.test.ts index 3f1d4c0cb7b..6060a1c1333 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.test.ts @@ -19,12 +19,14 @@ const { mockGetTableById, mockGetUserEntityPermissions, mockPerformDeleteTable, + mockResolveWorkspaceRequestActor, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockCheckWorkspaceScope: vi.fn(), mockGetTableById: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockPerformDeleteTable: vi.fn(), + mockResolveWorkspaceRequestActor: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -41,6 +43,12 @@ vi.mock('@/app/api/v1/middleware', () => ({ rateLimit.keyType === 'personal' ? { kind: 'user', userId: rateLimit.userId } : { kind: 'workspace_api_key', keyCreatorUserId: rateLimit.userId }, + /** + * Mirrors the real resolver: a workspace key names no human, so the billed + * account stands in as the explicit system actor; anything else keeps its + * owner. + */ + resolveWorkspaceRequestActor: mockResolveWorkspaceRequestActor, })) vi.mock('@/lib/table', () => ({ @@ -92,6 +100,7 @@ describe('DELETE /api/v1/tables/[tableId] — orchestration failure projection', vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1', keyType: 'personal' }) mockCheckWorkspaceScope.mockResolvedValue(null) + mockResolveWorkspaceRequestActor.mockResolvedValue('user-1') mockGetTableById.mockResolvedValue({ id: TABLE_ID, name: 'Table', diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index 7c11deb75f6..82ca23dc8e4 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -17,6 +17,7 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + resolveWorkspaceRequestActor, tableAccessPrincipal, } from '@/app/api/v1/middleware' @@ -111,7 +112,6 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1DeleteTableContract, request, context, { validationErrorResponse: (error) => { const hasInvalidTableId = error.issues.some((issue) => issue.path.includes('tableId')) @@ -133,6 +133,17 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab const scopeError = await checkWorkspaceScope(rateLimit, workspaceId, 'write') if (scopeError) return scopeError + /** + * A workspace key names no human, so its creator must not be attributed the + * deletion in audit and analytics. The shared resolver substitutes the + * explicit system actor for a workspace key and keeps the owner for a + * personal one, exactly as the row routes on this table already do. + */ + const actorUserId = await resolveWorkspaceRequestActor(rateLimit, workspaceId) + if (!actorUserId) { + throw new Error(`Unable to resolve system actor for workspace ${workspaceId}`) + } + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -140,7 +151,12 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) + const outcome = await performDeleteTable({ + table: result.table, + userId: actorUserId, + requestId, + request, + }) if (!outcome.success) { return orchestrationOutcomeErrorResponse(outcome, 'Failed to delete table') } From f47a9e13a9e9aaed31f84d710d05d6ad82632f73 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 17:42:11 -0700 Subject: [PATCH 091/179] fix(permission-groups): carry the capability detail code on the workspace.create race refusal The insert-time refusal rendered a bare message, so a client could not tell a permission-group block from any other 403. `capabilityRefusalResponse` is the one builder for that body and reads the detail code off the rule. --- apps/sim/app/api/workspaces/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 9d501e4864b..6b44b6c60bf 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -10,6 +10,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { captureServerEvent } from '@/lib/posthog/server' import { createWorkspace } from '@/lib/workspaces/create' import { listWorkspacesForViewer } from '@/lib/workspaces/list' @@ -183,7 +184,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ workspace: newWorkspace }) } catch (error) { if (error instanceof WorkspaceCreationCapabilityWithheldError) { - return NextResponse.json({ error: error.message }, { status: 403 }) + return capabilityRefusalResponse('workspace.create') } if (error instanceof WorkspaceCreationContextChangedError) { return NextResponse.json( From 4413130ae6f5feed25b09a44b38cc50cee20b273 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 17:42:23 -0700 Subject: [PATCH 092/179] fix(permission-groups): canonicalize each integration policy before intersecting them `intersectIntegrationAllowlists` case-folds but does not successor-resolve, so a group naming `slack` and an env allowlist naming `slack_v2` intersected to nothing textually and the hook hid an integration both policies allow. Resolving each side first puts them in one vocabulary before the intersection. --- apps/sim/hooks/use-permission-config.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 3cdd7f685eb..fb1bca798ee 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -96,11 +96,20 @@ export function usePermissionConfig(): PermissionConfigResult { * Both sides of the membership test are judged as the current block, so a * policy naming a retired id — `ALLOWED_INTEGRATIONS=slack` — still permits * the successor the editor offers. + * + * Each policy is canonicalized *before* the two are intersected, not after. + * `intersectIntegrationAllowlists` case-folds but does not successor-resolve, + * so a group naming `slack` and an env allowlist naming `slack_v2` intersect + * to nothing textually — hiding an integration both policies allow. Resolving + * first puts them in one vocabulary, and the intersection is then exact. */ - const allowedAccessControlTypes = useMemo( - () => toAccessControlAllowlist(mergedAllowedIntegrations), - [mergedAllowedIntegrations] - ) + const allowedAccessControlTypes = useMemo(() => { + const groupAllowlist = toAccessControlAllowlist(config.allowedIntegrations) + const envAllowlist = toAccessControlAllowlist(envAllowlistData?.allowedIntegrations ?? null) + if (groupAllowlist === null) return envAllowlist + if (envAllowlist === null) return groupAllowlist + return new Set([...groupAllowlist].filter((type) => envAllowlist.has(type))) + }, [config.allowedIntegrations, envAllowlistData]) const integrationAvailability = useMemo(() => { const visibility = overlayVisibility() From 87eb783b7a0d331ecbc027b513bd0277ae7b13d0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 17:42:24 -0700 Subject: [PATCH 093/179] docs(skills): chain the permission-group verification commands after one cd Run as one block, the first `cd apps/sim` persisted and the second command targeted `apps/sim/apps/sim`, so Vitest never ran. --- .agents/skills/validate-permission-group-item/SKILL.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.agents/skills/validate-permission-group-item/SKILL.md b/.agents/skills/validate-permission-group-item/SKILL.md index 85416831b92..45d83c59c37 100644 --- a/.agents/skills/validate-permission-group-item/SKILL.md +++ b/.agents/skills/validate-permission-group-item/SKILL.md @@ -106,8 +106,7 @@ For an allowlist the three states must be tested separately — `null` permits e bun run check:permission-group-enforcement bun run check:application-graph bun run check:capability-subject -cd apps/sim && bun run type-check -cd apps/sim && bunx vitest run lib/permission-groups +cd apps/sim && bun run type-check && bunx vitest run lib/permission-groups ``` All three are inside `check:audits`, which derives its list from the `check:*` scripts in `package.json` — a new audit is opted *out* deliberately. Read the output, not the exit codes. Reference success lines (counts grow): From 33982570d305fb346d9f0dcbfa68a26fad2f4a27 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 17:42:24 -0700 Subject: [PATCH 094/179] fix(access-control): normalize block-type casing before resolving its successor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry keys are lowercase, so `getBlock('Slack')` misses and the successor lookup answers `Slack` — compared as `slack` against an allowlist holding `slack_v2`, refusing a block both policies allow. `blockType` reaches here from persisted workflow state and from an agent block's `tool.type`, neither case-normalized upstream; the pre-existing check lowercased it for this reason. --- apps/sim/ee/access-control/utils/permission-check.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 1c8fdf8b43c..633df84aa48 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -270,8 +270,16 @@ function assertBlockTypeAllowed( * current block covers every retired version of the same integration. The * editor only offers current ids, so without this an admin could not deny a * legacy block even knowing it existed. + * + * Lowercased *before* resolving, not after: registry keys are lowercase, so + * `getBlock('Slack')` misses and the successor lookup answers `Slack` — which + * then compares as `slack` against an allowlist holding `slack_v2` and + * refuses a block both policies allow. `blockType` reaches here from + * persisted workflow state and from an agent block's `tool.type`, neither of + * which is case-normalized upstream. `toAccessControlAllowlist` normalizes + * the policy side the same way. */ - const allowlistType = resolveAccessControlBlockType(blockType).toLowerCase() + const allowlistType = resolveAccessControlBlockType(blockType.toLowerCase()) if (!toAccessControlAllowlist(config.allowedIntegrations)?.has(allowlistType)) { const envAllowlist = toAccessControlAllowlist(getAllowedIntegrationsFromEnv()) From f34dae81f5e8c70d18671c5fd6396c22224d37cc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 17:42:24 -0700 Subject: [PATCH 095/179] perf(logs): resolve the permission-group config only for cost-selective stats queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lookup re-reads workspace and organization/group state on every stats request, including unfiltered dashboard loads that can never be refused. Gating it behind `logQuerySelectsCost` is behavior-identical — the refusal already required both conditions. --- apps/sim/app/api/logs/stats/route.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index 55533c6b603..a5689d63028 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -71,12 +71,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { * workspace access check above has already passed, so the caller is a * member learning about their own group. */ - const hideCostInfo = await isWorkspaceCapabilityWithheld( - userId, - params.workspaceId, - 'logs.cost' - ) - if (hideCostInfo && logQuerySelectsCost(params)) { + if ( + logQuerySelectsCost(params) && + (await isWorkspaceCapabilityWithheld(userId, params.workspaceId, 'logs.cost')) + ) { return capabilityRefusalResponse('logs.cost') } From c1a060228246699bdb97a2e28e6bc72397618407 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 17:45:20 -0700 Subject: [PATCH 096/179] refactor(permission-groups): name the canonicalize-then-intersect rule once The hook built the resolved intersection inline, where the ordering that makes it correct was invisible and untestable. `intersectAccessControlAllowlists` states it in the module that owns the vocabulary, next to the canonicalizer it composes, and is pinned against the retired-id-versus-successor case. --- apps/sim/hooks/use-permission-config.ts | 17 +++---- .../permission-groups/block-access.test.ts | 46 +++++++++++++++++++ .../sim/lib/permission-groups/block-access.ts | 20 ++++++++ 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index fb1bca798ee..c4e45bd3a06 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -15,9 +15,9 @@ import { resolveIntegrationAvailabilityStateForVisibility, } from '@/lib/integrations/availability' import { + intersectAccessControlAllowlists, isBlockTypeAccessControlExempt, resolveAccessControlBlockType, - toAccessControlAllowlist, } from '@/lib/permission-groups/block-access' import { DEFAULT_PERMISSION_GROUP_CONFIG, @@ -103,13 +103,14 @@ export function usePermissionConfig(): PermissionConfigResult { * to nothing textually — hiding an integration both policies allow. Resolving * first puts them in one vocabulary, and the intersection is then exact. */ - const allowedAccessControlTypes = useMemo(() => { - const groupAllowlist = toAccessControlAllowlist(config.allowedIntegrations) - const envAllowlist = toAccessControlAllowlist(envAllowlistData?.allowedIntegrations ?? null) - if (groupAllowlist === null) return envAllowlist - if (envAllowlist === null) return groupAllowlist - return new Set([...groupAllowlist].filter((type) => envAllowlist.has(type))) - }, [config.allowedIntegrations, envAllowlistData]) + const allowedAccessControlTypes = useMemo( + () => + intersectAccessControlAllowlists( + config.allowedIntegrations, + envAllowlistData?.allowedIntegrations ?? null + ), + [config.allowedIntegrations, envAllowlistData] + ) const integrationAvailability = useMemo(() => { const visibility = overlayVisibility() diff --git a/apps/sim/lib/permission-groups/block-access.test.ts b/apps/sim/lib/permission-groups/block-access.test.ts index 95d02200ee2..c4ed31deb04 100644 --- a/apps/sim/lib/permission-groups/block-access.test.ts +++ b/apps/sim/lib/permission-groups/block-access.test.ts @@ -3,6 +3,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + intersectAccessControlAllowlists, isBlockTypeAccessControlExempt, resolveAccessControlBlockType, toAccessControlAllowlist, @@ -127,6 +128,51 @@ describe('isBlockTypeAccessControlExempt', () => { }) }) +describe('intersectAccessControlAllowlists', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + /** + * The two policies are written independently — `ALLOWED_INTEGRATIONS` by hand + * against whatever ids its author knew, the group through an editor that only + * offers current ones — so they routinely name the same integration by + * different vintages. Intersecting before resolving leaves those disjoint. + */ + it('intersects a retired id against its successor', () => { + registry({ + slack: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } }, + slack_v2: {}, + }) + + expect([...(intersectAccessControlAllowlists(['slack'], ['slack_v2']) ?? [])]).toEqual([ + 'slack_v2', + ]) + }) + + it('keeps either side null as unrestricted', () => { + registry({}) + + expect([...(intersectAccessControlAllowlists(null, ['notion']) ?? [])]).toEqual(['notion']) + expect([...(intersectAccessControlAllowlists(['notion'], null) ?? [])]).toEqual(['notion']) + expect(intersectAccessControlAllowlists(null, null)).toBeNull() + }) + + it('keeps an empty policy denying everything', () => { + registry({}) + + expect(intersectAccessControlAllowlists([], ['notion'])?.size).toBe(0) + }) + + it('drops an integration only one policy names', () => { + registry({}) + + expect([...(intersectAccessControlAllowlists(['notion', 'gmail'], ['gmail']) ?? [])]).toEqual([ + 'gmail', + ]) + }) +}) + describe('toAccessControlAllowlist', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/permission-groups/block-access.ts b/apps/sim/lib/permission-groups/block-access.ts index 15d2877d6c7..4dc13a6818e 100644 --- a/apps/sim/lib/permission-groups/block-access.ts +++ b/apps/sim/lib/permission-groups/block-access.ts @@ -77,6 +77,26 @@ export function resolveAccessControlBlockType(blockType: string): string { * so without normalizing the policy the deployment that permitted `slack` would * refuse every `slack_v2` block in it. */ +/** + * Intersects two independent integration policies in the *resolved* vocabulary. + * + * Each side is canonicalized before the intersection, not after. A policy list + * can name a retired id while the other names its successor — + * `ALLOWED_INTEGRATIONS=slack` against a group naming `slack_v2` — and folding + * only case leaves those two ids disjoint, intersecting to nothing and hiding + * an integration both policies allow. `null` stays unrestricted on either side. + */ +export function intersectAccessControlAllowlists( + first: readonly string[] | null, + second: readonly string[] | null +): ReadonlySet | null { + const resolvedFirst = toAccessControlAllowlist(first) + const resolvedSecond = toAccessControlAllowlist(second) + if (resolvedFirst === null) return resolvedSecond + if (resolvedSecond === null) return resolvedFirst + return new Set([...resolvedFirst].filter((type) => resolvedSecond.has(type))) +} + export function toAccessControlAllowlist( allowedIntegrations: readonly string[] | null ): ReadonlySet | null { From 0b7fc4be4030e4cb87e99d708a506786945766f1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 17:45:21 -0700 Subject: [PATCH 097/179] test(permission-groups): pin block-type casing and the stats config-lookup gate Both fail against the prior implementations: the casing case resolves before folding and refuses a permitted successor, and the stats case consults the group on a read that can never be refused. --- apps/sim/app/api/logs/stats/route.test.ts | 14 +++++++++++++ .../utils/permission-check.test.ts | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/apps/sim/app/api/logs/stats/route.test.ts b/apps/sim/app/api/logs/stats/route.test.ts index b94c50dbc98..669cb2af758 100644 --- a/apps/sim/app/api/logs/stats/route.test.ts +++ b/apps/sim/app/api/logs/stats/route.test.ts @@ -80,6 +80,20 @@ describe('GET /api/logs/stats', () => { expect(mocks.readLogStatsBounds).toHaveBeenCalled() }) + /** + * The refusal needs both conditions, so an unfiltered read can never be + * refused and the config lookup — which re-reads workspace and + * organization/group state — is pure cost on the dashboard's common path. + */ + it('does not consult the group for an unfiltered read', async () => { + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + const response = await GET(makeRequest()) + + expect(response.status).toBe(200) + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) + it('answers the same cost-filtered read when no group withholds spend', async () => { const response = await GET(makeRequest('&costOperator=%3E&costValue=0.5')) 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 b14c3922260..86feaf0ccc2 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -445,6 +445,26 @@ describe('validateBlockType', () => { await validateBlockType('user-123', 'workspace-1', 'slack') }) + /** + * Registry keys are lowercase, so a mixed-case block type must be folded + * *before* the successor lookup. Resolving first makes `getBlock('Slack')` + * miss, the successor answer `Slack`, and the comparison fall back to + * `slack` — refusing a block the allowlist permits as `slack_v2`. + */ + it('resolves a superseded block supplied with different casing', async () => { + setEnterpriseOrgWorkspace() + mockGetBlock.mockImplementation((type: string) => + type === 'slack' + ? { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } } + : type === 'slack_v2' + ? {} + : undefined + ) + queueGroupResolution([{ config: { allowedIntegrations: ['slack_v2'] } }]) + + await validateBlockType('user-123', 'workspace-1', 'Slack') + }) + it('still rejects a block absent from a mixed-case stored allowlist', async () => { setEnterpriseOrgWorkspace() queueGroupResolution([{ config: { allowedIntegrations: ['Slack'] } }]) From 4ff4ceddc18e24ec7ea55ab1e258f35dba0e3de9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 17:47:13 -0700 Subject: [PATCH 098/179] docs(db): state the deploy-window semantics of the governed-subject column honestly --- .../0315_table_dispatch_capability_governed_user.sql | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql b/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql index 50ddf2285f1..93636679add 100644 --- a/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql +++ b/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql @@ -1,7 +1,9 @@ -- Adds the permission-group subject a table run's cells are gated against, separate from -- `triggered_by_user_id` (an attribution that substitutes the workspace billed account when the -- credential names no human). Purely additive and nullable: every existing row reads NULL, which --- means "no acting person, no per-tool gate" — the same answer those runs already get today. +-- means "no acting person, no per-tool gate". For rows a manual run had already queued when this +-- deploys, that is briefly weaker than the old triggered-by gate — dispatch rows live seconds, so +-- the window closes with the queue. ALTER TABLE "table_run_dispatches" ADD COLUMN "capability_governed_user_id" text;--> statement-breakpoint ALTER TABLE "table_run_dispatches" ADD CONSTRAINT "table_run_dispatches_capability_governed_user_id_user_id_fk" FOREIGN KEY ("capability_governed_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action NOT VALID;--> statement-breakpoint ALTER TABLE "table_run_dispatches" VALIDATE CONSTRAINT "table_run_dispatches_capability_governed_user_id_user_id_fk"; From 964bb6d7670876a457815e358c8c8b726cb4f719 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 18:21:12 -0700 Subject: [PATCH 099/179] fix(permission-groups): require the governed subject on every table dispatch producer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making `capabilityGovernedUserId` optional on `insertDispatch` let it fall back to `triggeredByUserId` — the attribution that names the workspace billed account when the credential names no human — for any producer that had not been taught the distinction. That is the bystander substitution the column exists to remove, reintroduced by omission. The field is now required with an explicit `null` (the shape the secret- provenance write already uses) on `insertDispatch`, `runWorkflowColumn`, and every row-write and group-write payload that can auto-fire one, so a producer cannot land the wrong subject by forgetting the field. Surfaces that hold a principal thread `capabilityGovernedPrincipalUserId`; v1 routes thread the `keyType`-derived helper; auto-fire and internal state patches pass `null`. --- .../api/table/[tableId]/columns/run/route.ts | 9 +++ .../v1/tables/[tableId]/rows/[rowId]/route.ts | 2 + .../app/api/v1/tables/[tableId]/rows/route.ts | 11 +++- .../v1/tables/[tableId]/rows/upsert/route.ts | 2 + .../background/workflow-column-execution.ts | 2 + .../table/application/copilot-bulk-rows.ts | 2 + apps/sim/lib/table/application/groups.ts | 11 +++- apps/sim/lib/table/application/rows.ts | 7 +++ .../application/workspace-file-imports.ts | 7 +++ apps/sim/lib/table/backfill-runner.ts | 6 ++ apps/sim/lib/table/cell-write.ts | 6 ++ apps/sim/lib/table/dispatcher.ts | 25 ++++---- apps/sim/lib/table/import-data.ts | 2 + apps/sim/lib/table/orchestration/import.ts | 5 +- apps/sim/lib/table/rows/service.ts | 24 ++++++-- apps/sim/lib/table/types.ts | 58 ++++++++++++++++++- apps/sim/lib/table/workflow-columns.ts | 16 +++-- apps/sim/lib/table/workflow-groups/service.ts | 2 + ...able_dispatch_capability_governed_user.sql | 20 +++++-- 19 files changed, 185 insertions(+), 32 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts index dd6dffd890d..5375e160fb4 100644 --- a/apps/sim/app/api/table/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { runColumnContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { TableQueryValidationError } from '@/lib/table/errors' @@ -63,6 +64,14 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro limit, requestId, triggeredByUserId: auth.userId, + /** + * The gate's subject, not the meter's. An internal JWT resolves to the + * executor principal, which carries a role but no capabilities, so it + * governs nothing — only a session caller does. + */ + capabilityGovernedUserId: auth.principal + ? capabilityGovernedPrincipalUserId(auth.principal) + : null, }) // Starting a run clears the target group's cells to pending (`bulkClearWorkflowGroupCells`) — a DB diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 05ddfb81b1b..6aba9855227 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -26,6 +26,7 @@ import { tableLockErrorResponse, } from '@/app/api/table/utils' import { + capabilityGovernedUserId, checkRateLimit, checkWorkspaceScope, createRateLimitResponse, @@ -158,6 +159,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR data: patchData, workspaceId: validated.workspaceId, actorUserId, + capabilityGovernedUserId: capabilityGovernedUserId(rateLimit), secretProvenance: createExactEmptyTableRowSecretProvenance(patchData), }, table, diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index 6e083e0a87b..6de18e89a8c 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -40,6 +40,7 @@ import { type TableAccessPrincipal, } from '@/app/api/table/utils' import { + capabilityGovernedUserId, checkRateLimit, checkWorkspaceScope, createRateLimitResponse, @@ -63,7 +64,9 @@ async function handleBatchInsert( tableId: string, validated: V1BatchInsertTableRowsBody, principal: TableAccessPrincipal, - actorUserId: string + actorUserId: string, + /** The gate's subject; see {@link BatchInsertData.capabilityGovernedUserId}. */ + governedUserId: string | null ): Promise { const accessResult = await checkAccess(tableId, principal, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) @@ -93,6 +96,7 @@ async function handleBatchInsert( rows, workspaceId: validated.workspaceId, userId: actorUserId, + capabilityGovernedUserId: governedUserId, secretProvenance: rows.map(createExactEmptyTableRowSecretProvenance), }, table, @@ -253,7 +257,8 @@ export const POST = withRouteHandler( tableId, batchValidated, tableAccessPrincipal(rateLimit), - actorUserId + actorUserId, + capabilityGovernedUserId(rateLimit) ) } @@ -292,6 +297,7 @@ export const POST = withRouteHandler( data: rowData, workspaceId: validated.workspaceId, userId: actorUserId, + capabilityGovernedUserId: capabilityGovernedUserId(rateLimit), secretProvenance: createExactEmptyTableRowSecretProvenance(rowData), }, table, @@ -379,6 +385,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR data: patchData, limit: validated.limit, actorUserId, + capabilityGovernedUserId: capabilityGovernedUserId(rateLimit), secretProvenance: createExactEmptyTableRowSecretProvenance(patchData), }, requestId diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index eb690199e93..76bee437366 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -17,6 +17,7 @@ import { tableLockErrorResponse, } from '@/app/api/table/utils' import { + capabilityGovernedUserId, checkRateLimit, checkWorkspaceScope, createRateLimitResponse, @@ -77,6 +78,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser workspaceId: validated.workspaceId, data: rowData, userId: actorUserId, + capabilityGovernedUserId: capabilityGovernedUserId(rateLimit), conflictTarget: validated.conflictTarget, secretProvenance: createExactEmptyTableRowSecretProvenance(rowData), }, diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index 532651889c4..cf6816d2386 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -229,6 +229,8 @@ export function buildTableUsageLimitClear(args: { secretProvenance: undefined, workspaceId, executionsPatch: { [groupId]: null }, + /** Clearing a pre-stamp writes no cell values and fires no enrichment. */ + capabilityGovernedUserId: null, cancellationGuard: { groupId, executionId }, } } diff --git a/apps/sim/lib/table/application/copilot-bulk-rows.ts b/apps/sim/lib/table/application/copilot-bulk-rows.ts index 76c55db7350..0c29ed56b06 100644 --- a/apps/sim/lib/table/application/copilot-bulk-rows.ts +++ b/apps/sim/lib/table/application/copilot-bulk-rows.ts @@ -3,6 +3,7 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' @@ -255,6 +256,7 @@ export const copilotUpdateRowsByFilter = defineAuthorizedTableUseCase({ actorUserId: resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }).attributedUserId, + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), secretProvenance: createExactEmptyTableRowSecretProvenance(idData), }, requestId() diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index 53019edf0e0..1887b6840ae 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -312,6 +312,7 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ autoRun: input.autoRun ?? false, suppressAutoRunDispatch: true, actorUserId, + capabilityGovernedUserId, }, generateRequestId() ) @@ -437,6 +438,7 @@ export const createWorkflowTableGroup = defineAuthorizedTableUseCase({ autoRun: input.autoRun ?? false, suppressAutoRunDispatch: true, actorUserId, + capabilityGovernedUserId, }, generateRequestId() ) @@ -583,6 +585,7 @@ export const createTableEnrichmentGroup = defineAuthorizedTableUseCase({ autoRun: input.autoRun ?? false, suppressAutoRunDispatch: true, actorUserId, + capabilityGovernedUserId, }, generateRequestId() ) @@ -626,7 +629,11 @@ export interface UpdateTableGroupInput extends TableGroupInput, Omit< UpdateWorkflowGroupData, - 'tableId' | 'workspaceId' | 'actorUserId' | 'suppressAutoRunDispatch' + | 'tableId' + | 'workspaceId' + | 'actorUserId' + | 'capabilityGovernedUserId' + | 'suppressAutoRunDispatch' > {} export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ @@ -795,6 +802,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, groupId: input.groupId, actorUserId, + capabilityGovernedUserId, suppressAutoRunDispatch: true, ...(input.workflowId !== undefined ? { workflowId: input.workflowId } : {}), ...(input.name !== undefined ? { name: input.name } : {}), @@ -996,6 +1004,7 @@ export const updateWorkflowTableGroup = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, groupId: input.groupId, actorUserId, + capabilityGovernedUserId, suppressAutoRunDispatch: true, ...(input.workflowId !== undefined ? { workflowId: input.workflowId } : {}), ...(input.name !== undefined ? { name: input.name } : {}), diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index 4ca671cdc4b..4bce8ce9ad5 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -9,6 +9,7 @@ import { db } from '@sim/db' import { getRequestContext } from '@sim/logger' import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { isPrivateSecretProvenanceScopeCompatible } from '@/lib/execution/durable-secret-provenance' @@ -788,6 +789,7 @@ export const createTableRows = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, data, userId, + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), position: input.position, afterRowId: input.afterRowId, beforeRowId: input.beforeRowId, @@ -847,6 +849,7 @@ export const createTableRows = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, rows, userId, + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), orderKeys: input.orderKeys, secretProvenance, }, @@ -1151,6 +1154,7 @@ export const updateTableRow = defineAuthorizedTableUseCase({ rowId: input.rowId, data, actorUserId: actorUserId(principal, context.billedAccountUserId), + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), secretProvenance, }, context.table, @@ -1213,6 +1217,7 @@ export const updateTableRows = defineAuthorizedTableUseCase({ data, limit: input.limit, actorUserId: actorUserId(principal, context.billedAccountUserId), + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), secretProvenance, }, requestId(input), @@ -1305,6 +1310,7 @@ export const batchUpdateTableRows = defineAuthorizedTableUseCase({ updates, workspaceId: context.workspaceId, actorUserId: actorUserId(principal, context.billedAccountUserId), + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), secretProvenanceByRowId: Object.fromEntries( updates.flatMap((update, index) => { const stamp = secretProvenance[index] @@ -1472,6 +1478,7 @@ export const upsertTableRow = defineAuthorizedTableUseCase({ data, conflictTarget, userId: actorUserId(principal, context.billedAccountUserId), + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), secretProvenance, }, context.table, diff --git a/apps/sim/lib/table/application/workspace-file-imports.ts b/apps/sim/lib/table/application/workspace-file-imports.ts index 25917059fc5..22febc596f5 100644 --- a/apps/sim/lib/table/application/workspace-file-imports.ts +++ b/apps/sim/lib/table/application/workspace-file-imports.ts @@ -3,6 +3,7 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' @@ -226,6 +227,9 @@ async function batchInsertAll(params: { rows: RowData[] workspaceId: string userId: string + /** The gate's subject for enrichment the landed rows auto-fire; see + * {@link BatchInsertData.capabilityGovernedUserId}. */ + capabilityGovernedUserId: string | null assertNotAborted?: () => void }): Promise { let inserted = 0 @@ -238,6 +242,7 @@ async function batchInsertAll(params: { rows: batch, workspaceId: params.workspaceId, userId: params.userId, + capabilityGovernedUserId: params.capabilityGovernedUserId, secretProvenance: batch.map(createExactEmptyTableRowSecretProvenance), }, { ...params.table, rowCount: params.table.rowCount + inserted }, @@ -401,6 +406,7 @@ export const createTableFromWorkspaceFile = defineAuthorizedTableUseCase({ }), workspaceId: context.workspaceId, userId, + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), assertNotAborted: input.assertNotAborted, }) const summary = summarizeRejections(rejections, cellsRejected, sourceFile) @@ -563,6 +569,7 @@ export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({ rows, workspaceId: context.workspaceId, userId, + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), assertNotAborted: input.assertNotAborted, }) return { diff --git a/apps/sim/lib/table/backfill-runner.ts b/apps/sim/lib/table/backfill-runner.ts index 685bd43740b..43ecb11ddea 100644 --- a/apps/sim/lib/table/backfill-runner.ts +++ b/apps/sim/lib/table/backfill-runner.ts @@ -224,6 +224,12 @@ async function processBackfillPage(opts: { updates, workspaceId: table.workspaceId, actorUserId, + /** + * A backfill replays values already produced by earlier runs; it starts + * no enrichment of its own and carries no acting person into this + * background pass. + */ + capabilityGovernedUserId: null, secretProvenanceByRowId, }, table, diff --git a/apps/sim/lib/table/cell-write.ts b/apps/sim/lib/table/cell-write.ts index 789d8702b56..72b5f7968bd 100644 --- a/apps/sim/lib/table/cell-write.ts +++ b/apps/sim/lib/table/cell-write.ts @@ -93,6 +93,12 @@ export async function writeWorkflowGroupState( executionsPatch, cancellationGuard, secretProvenance: payload.secretProvenance, + /** + * A cell result carries no acting person down to this layer — the + * write has no `actorUserId` either, so any cascade it fires is + * already actorless on both the meter and the gate. + */ + capabilityGovernedUserId: null, }, table, requestId, diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index dbe9a1ceb56..9b37a8888eb 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -252,7 +252,19 @@ export async function insertDispatch(input: { limit?: DispatchLimit | null isManualRun: boolean triggeredByUserId?: string | null - capabilityGovernedUserId?: string | null + /** + * The person whose permission group gates this run's cells, or `null` when + * the run has no acting person (workspace key, schedule, auto-fire). + * + * Required with an explicit `null` rather than optional: the only way to get + * this wrong is to not think about it, and an optional field with a fallback + * let every producer that had not been taught the distinction silently + * inherit `triggeredByUserId` — an *attribution* that names the workspace + * billed account when the credential names no human. Making omission a + * compile error is what stops the next producer from re-introducing that + * bystander substitution. + */ + capabilityGovernedUserId: string | null }): Promise { const id = `tdsp_${generateId().replace(/-/g, '')}` await db.insert(tableRunDispatches).values({ @@ -270,16 +282,7 @@ export async function insertDispatch(input: { cursor: -1, isManualRun: input.isManualRun, triggeredByUserId: input.triggeredByUserId ?? null, - /** - * Defaults to the trigger actor so every producer that has not been taught - * the distinction keeps the gating it has today. Only a producer holding - * the principal can tell an acting person from an attribution fallback, and - * those pass the governed subject explicitly — `null` included. - */ - capabilityGovernedUserId: - input.capabilityGovernedUserId !== undefined - ? input.capabilityGovernedUserId - : (input.triggeredByUserId ?? null), + capabilityGovernedUserId: input.capabilityGovernedUserId, }) return id } diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 808a4108f5d..50bca67c255 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -294,6 +294,8 @@ export async function importAppendRows( rows: batch, workspaceId: ctx.workspaceId, userId: ctx.userId, + /** CSV import is auto-fire: no acting person governs the rows it lands. */ + capabilityGovernedUserId: null, secretProvenance: batch.map(createExactEmptyTableRowSecretProvenance), }, working, diff --git a/apps/sim/lib/table/orchestration/import.ts b/apps/sim/lib/table/orchestration/import.ts index bc9c0d69a5a..f736496f313 100644 --- a/apps/sim/lib/table/orchestration/import.ts +++ b/apps/sim/lib/table/orchestration/import.ts @@ -370,7 +370,8 @@ export async function performTableCsvImport( }) // Fire trigger + scheduler AFTER the tx commits — both read through the // global db connection and would otherwise see no rows. - dispatchAfterBatchInsert(finalTable, inserted, requestId, userId) + /** CSV import is auto-fire: no acting person governs the rows it lands. */ + dispatchAfterBatchInsert(finalTable, inserted, requestId, userId, null) logger.info(`[${requestId}] Append CSV imported`, { tableId: table.id, @@ -507,6 +508,8 @@ export async function performCreateTableFromCsv( rows: coerced as RowData[], workspaceId, userId, + /** CSV import is auto-fire: no acting person governs the rows it lands. */ + capabilityGovernedUserId: null, secretProvenance: coerced.map(createExactEmptyTableRowSecretProvenance), }, // The created table's rowCount is frozen at 0; pass the running total so the diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index f036f1efce0..ebed42b9c74 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -242,6 +242,7 @@ export async function insertRow( isManualRun: false, requestId, triggeredByUserId: data.userId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (insertRow) failed:`, err)) return insertedRow @@ -279,7 +280,7 @@ export async function batchInsertRows( addedRows: result.length, limit: rowLimit, }) - dispatchAfterBatchInsert(table, result, requestId, data.userId) + dispatchAfterBatchInsert(table, result, requestId, data.userId, data.capabilityGovernedUserId) return result } @@ -401,7 +402,9 @@ export function dispatchAfterBatchInsert( table: TableDefinition, result: TableRow[], requestId: string, - actorUserId?: string | null + actorUserId: string | null | undefined, + /** The gate's subject for the auto-fire pass; see {@link InsertRowData.capabilityGovernedUserId}. */ + capabilityGovernedUserId: string | null ): void { void fireTableTrigger( table.id, @@ -425,6 +428,7 @@ export function dispatchAfterBatchInsert( isManualRun: false, requestId, triggeredByUserId: actorUserId, + capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchInsertRows) failed:`, err)) } @@ -923,6 +927,7 @@ export async function upsertRow( isManualRun: false, requestId, triggeredByUserId: data.userId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (upsertRow) failed:`, err)) return result @@ -1875,6 +1880,7 @@ export async function updateRow( groupIds: inFlightDownstreamGroups, requestId, triggeredByUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }) } catch (err) { logger.error(`[${requestId}] cancel+rerun for in-flight downstream groups failed:`, err) @@ -1889,6 +1895,7 @@ export async function updateRow( isManualRun: false, requestId, triggeredByUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateRow) failed:`, err)) return updatedRow @@ -2077,7 +2084,9 @@ function dispatchBulkUpdateEffects( patch: RowData, now: Date, requestId: string, - actorUserId: BulkUpdateData['actorUserId'] + actorUserId: BulkUpdateData['actorUserId'], + /** The gate's subject for the auto-fire pass; see {@link BulkUpdateData.capabilityGovernedUserId}. */ + capabilityGovernedUserId: string | null ): void { const affectedRowIdSet = new Set(affectedRowIds) const affectedRows = rows.filter((row) => affectedRowIdSet.has(row.id)) @@ -2110,6 +2119,7 @@ function dispatchBulkUpdateEffects( isManualRun: false, requestId, triggeredByUserId: actorUserId, + capabilityGovernedUserId, }).catch((error) => logger.error(`[${requestId}] auto-dispatch (updateRowsByFilter) failed:`, error) ) @@ -2241,7 +2251,8 @@ export async function updateRowsByFilter( data.data, now, requestId, - data.actorUserId + data.actorUserId, + data.capabilityGovernedUserId ) afterId = nextAfterId if (batchRows.length < TABLE_LIMITS.UPDATE_BATCH_SIZE) break @@ -2311,7 +2322,8 @@ export async function updateRowsByFilter( data.data, now, requestId, - data.actorUserId + data.actorUserId, + data.capabilityGovernedUserId ) return { @@ -2550,6 +2562,7 @@ export async function batchUpdateRows( groupIds: inFlightDownstreamGroups, requestId, triggeredByUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }) } } catch (err) { @@ -2569,6 +2582,7 @@ export async function batchUpdateRows( isManualRun: false, requestId, triggeredByUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchUpdateRows) failed:`, err)) } diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 9319e794c10..51ca2821a55 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -703,6 +703,13 @@ export interface InsertRowData { * unstamped write. */ secretProvenance: TableRowSecretProvenanceWrite | undefined + /** The person whose permission group gates any enrichment this write + * auto-fires; `null` when the write has no acting person (workspace API key, + * schedule, internal state patch). Required with an explicit `null` — + * deliberately not the attribution field beside it, which names the + * workspace billed account when the credential names no human, and would run + * that bystander's tool denylist against an actorless run. */ + capabilityGovernedUserId: string | null } export interface BatchInsertData { @@ -717,6 +724,13 @@ export interface BatchInsertData { orderKeys?: string[] /** Encrypted provenance for the values in `rows`, positionally aligned. Required; see {@link InsertRowData.secretProvenance}. */ secretProvenance: Array | undefined + /** The person whose permission group gates any enrichment this write + * auto-fires; `null` when the write has no acting person (workspace API key, + * schedule, internal state patch). Required with an explicit `null` — + * deliberately not the attribution field beside it, which names the + * workspace billed account when the credential names no human, and would run + * that bystander's tool denylist against an actorless run. */ + capabilityGovernedUserId: string | null } export interface UpsertRowData { @@ -728,6 +742,13 @@ export interface UpsertRowData { conflictTarget?: string /** Encrypted provenance for the values in `data`. Required; see {@link InsertRowData.secretProvenance}. */ secretProvenance: TableRowSecretProvenanceWrite | undefined + /** The person whose permission group gates any enrichment this write + * auto-fires; `null` when the write has no acting person (workspace API key, + * schedule, internal state patch). Required with an explicit `null` — + * deliberately not the attribution field beside it, which names the + * workspace billed account when the credential names no human, and would run + * that bystander's tool denylist against an actorless run. */ + capabilityGovernedUserId: string | null } export interface UpsertResult { @@ -776,6 +797,13 @@ export interface UpdateRowData { actorUserId?: string | null /** Encrypted provenance for the values in this partial patch. Required; see {@link InsertRowData.secretProvenance}. */ secretProvenance: TableRowSecretProvenanceWrite | undefined + /** The person whose permission group gates any enrichment this write + * auto-fires; `null` when the write has no acting person (workspace API key, + * schedule, internal state patch). Required with an explicit `null` — + * deliberately not the attribution field beside it, which names the + * workspace billed account when the credential names no human, and would run + * that bystander's tool denylist against an actorless run. */ + capabilityGovernedUserId: string | null } export interface BulkUpdateData { @@ -786,6 +814,13 @@ export interface BulkUpdateData { actorUserId?: string | null /** Encrypted provenance for the values in this partial patch. Required; see {@link InsertRowData.secretProvenance}. */ secretProvenance: TableRowSecretProvenanceWrite | undefined + /** The person whose permission group gates any enrichment this write + * auto-fires; `null` when the write has no acting person (workspace API key, + * schedule, internal state patch). Required with an explicit `null` — + * deliberately not the attribution field beside it, which names the + * workspace billed account when the credential names no human, and would run + * that bystander's tool denylist against an actorless run. */ + capabilityGovernedUserId: string | null } export interface BatchUpdateByIdData { @@ -800,6 +835,13 @@ export interface BatchUpdateByIdData { actorUserId?: string | null /** Encrypted provenance for the values in all partial patches; omitted by legacy callers. */ secretProvenanceByRowId?: Record + /** The person whose permission group gates any enrichment this write + * auto-fires; `null` when the write has no acting person (workspace API key, + * schedule, internal state patch). Required with an explicit `null` — + * deliberately not the attribution field beside it, which names the + * workspace billed account when the credential names no human, and would run + * that bystander's tool denylist against an actorless run. */ + capabilityGovernedUserId: string | null } export interface BulkDeleteData { @@ -937,8 +979,14 @@ export interface AddWorkflowGroupData { autoRun?: boolean /** Persist auto-run state without dispatching through the primitive. */ suppressAutoRunDispatch?: boolean - /** The member adding the group — billed/gated for the auto-run enrichment pass. */ + /** The member adding the group — billed for the auto-run enrichment pass. */ actorUserId?: string | null + /** The person whose permission group gates the auto-run pass this write can + * start; `null` when the write has no acting person (workspace key, system). + * Required with an explicit `null` — deliberately not `actorUserId`, which + * is an attribution and names the workspace billed account when the + * credential names no human. */ + capabilityGovernedUserId: string | null } /** Payload for `updateWorkflowGroup` — diffs outputs and writes columns. */ @@ -976,8 +1024,14 @@ export interface UpdateWorkflowGroupData { autoRun?: boolean /** Skip primitive dispatch when an authorized caller will start the run itself. */ suppressAutoRunDispatch?: boolean - /** The member updating the group — billed/gated for any triggered re-run. */ + /** The member updating the group — billed for any triggered re-run. */ actorUserId?: string | null + /** The person whose permission group gates the auto-run pass this write can + * start; `null` when the write has no acting person (workspace key, system). + * Required with an explicit `null` — deliberately not `actorUserId`, which + * is an attribution and names the workspace billed account when the + * credential names no human. */ + capabilityGovernedUserId: string | null } export interface DeleteWorkflowGroupData { diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index cfbe8f3ba74..48338a46fd6 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -735,6 +735,8 @@ export async function cancelWorkflowGroupRuns( secretProvenance: undefined, workspaceId: table.workspaceId, executionsPatch: mutation.executionsPatch, + /** A cancellation stamp writes no cell values and fires no enrichment. */ + capabilityGovernedUserId: null, }, table, `wfgrp-cancel-${mutation.rowId}` @@ -870,11 +872,13 @@ export async function runWorkflowColumn(opts: { * callers (row writes, CSV import) → falls back to the workspace billed * account at billing time. */ triggeredByUserId?: string | null - /** Person whose permission group gates the run's cells. Omitted by producers - * that cannot tell an acting person from an attribution fallback; those - * default to `triggeredByUserId` in `insertDispatch`. Pass it explicitly — - * `null` included — wherever the principal is in hand. */ - capabilityGovernedUserId?: string | null + /** Person whose permission group gates the run's cells; `null` when the run + * has no acting person (workspace key, schedule, auto-fire). Required with + * an explicit `null` — never defaulted from `triggeredByUserId`, which is an + * attribution and names the workspace billed account when the credential + * names no human. Producers that sit below the principal take it from the + * surface that holds one rather than re-deriving it here. */ + capabilityGovernedUserId: string | null }): Promise<{ dispatchId: string | null; shouldSignalRowsChanged: boolean }> { const { tableId, @@ -956,7 +960,7 @@ export async function runWorkflowColumn(opts: { limit, isManualRun, triggeredByUserId, - ...('capabilityGovernedUserId' in opts ? { capabilityGovernedUserId } : {}), + capabilityGovernedUserId, }) try { diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index 056f10dd316..2f432efcc71 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -246,6 +246,7 @@ export async function addWorkflowGroup( groupIds: [data.group.id], requestId, triggeredByUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (addWorkflowGroup) failed:`, err)) } @@ -609,6 +610,7 @@ export async function updateWorkflowGroup( groupIds: [data.groupId], requestId, triggeredByUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateWorkflowGroup autoRun=true) failed:`, err) ) diff --git a/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql b/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql index 93636679add..2e0462d2453 100644 --- a/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql +++ b/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql @@ -1,9 +1,21 @@ -- Adds the permission-group subject a table run's cells are gated against, separate from -- `triggered_by_user_id` (an attribution that substitutes the workspace billed account when the --- credential names no human). Purely additive and nullable: every existing row reads NULL, which --- means "no acting person, no per-tool gate". For rows a manual run had already queued when this --- deploys, that is briefly weaker than the old triggered-by gate — dispatch rows live seconds, so --- the window closes with the queue. +-- credential names no human). Additive and nullable: NULL means "no acting person, no per-tool +-- gate". +-- +-- Rows written before this column existed would all read NULL, which silently drops the +-- triggered-by gate they were running under. That window is NOT short: a dispatch has no time +-- ceiling on the in-process path (`runDispatcherToCompletion` loops until the scope is exhausted, +-- `lib/table/dispatcher.ts`), and the trigger.dev path allows up to 90 minutes, so a queued +-- dispatch can outlive the deploy by hours. The backfill below therefore gives every non-terminal +-- pre-migration row exactly the subject it was already gated on — `triggered_by_user_id` — while +-- rows written by the new code carry the corrected acting-person/attribution distinction. ALTER TABLE "table_run_dispatches" ADD COLUMN "capability_governed_user_id" text;--> statement-breakpoint +-- migration-safe: bounded backfill over non-terminal dispatches only (status pending/dispatching — a few rows at any instant, indexed by `table_run_dispatches_active_idx`), idempotent under the IS NULL guard, and it preserves rather than changes the gate those rows already had. A replay after the new writers are live could only re-gate a still-queued actorless row, which fails closed. +UPDATE "table_run_dispatches" +SET "capability_governed_user_id" = "triggered_by_user_id" +WHERE "status" IN ('pending', 'dispatching') + AND "capability_governed_user_id" IS NULL + AND "triggered_by_user_id" IS NOT NULL;--> statement-breakpoint ALTER TABLE "table_run_dispatches" ADD CONSTRAINT "table_run_dispatches_capability_governed_user_id_user_id_fk" FOREIGN KEY ("capability_governed_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action NOT VALID;--> statement-breakpoint ALTER TABLE "table_run_dispatches" VALIDATE CONSTRAINT "table_run_dispatches_capability_governed_user_id_user_id_fk"; From eacf218acb6e024a536e11a21a2e62d46123c924 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 18:21:49 -0700 Subject: [PATCH 100/179] fix(permission-groups): stop a deleted account's table runs instead of un-gating them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capability_governed_user_id` is `ON DELETE SET NULL`, so deleting the governed user turned every dispatch they still had queued actorless — and the worker reads an actorless dispatch as "no per-tool gate applies". The two cases are indistinguishable at read time, and the in-process dispatcher has no time ceiling, so a surviving dispatch could keep running ungated for as long as its scope took. `deleteUserAccount` now cancels the account's pending/dispatching rows in the same transaction that removes the user, so the nulled subject is only ever observed on a terminal row. `RESTRICT` was the alternative and is worse: it blocks account deletion behind background work, the way the billed-account foreign key already does. --- apps/sim/lib/users/account-deletion.ts | 25 +++++++++++++++++++++++++ packages/db/schema.ts | 10 +++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts index 98c063edc0f..6a44092271a 100644 --- a/apps/sim/lib/users/account-deletion.ts +++ b/apps/sim/lib/users/account-deletion.ts @@ -6,6 +6,7 @@ import { member, organization, permissions, + tableRunDispatches, user, workspaceFile, workspaceFiles, @@ -604,6 +605,30 @@ export async function deleteUserAccount(userId: string): Promise user.id, { onDelete: 'set null', }), From 2f8d0641efa21b7c846b888d2435ae5ae082b6f0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 18:28:37 -0700 Subject: [PATCH 101/179] test(permission-groups): pin the governed subject end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four seams, each mutation-verified against the fallback it replaces: `insertDispatch` and `runWorkflowColumn` store and forward the subject verbatim (an explicit null survives a non-null attribution); the enrichment cell gates on the payload subject, so a workspace-key dispatch runs ungated and a pre-0315-shaped payload — subject null, attribution intact — stays ungated rather than reconstructing a gate from the payer; and `deleteUserAccount` cancels the account's non-terminal dispatches ahead of the user delete. The 0315 backfill itself is SQL and is covered by review, not by a test. --- .../enrichment-capability-subject.test.ts | 157 ++++++++++++++++++ .../workflow-column-execution.test.ts | 2 + apps/sim/lib/table/cell-write.test.ts | 2 + .../table/dispatch-governed-subject.test.ts | 94 +++++++++++ .../table/run-column-governed-subject.test.ts | 86 ++++++++++ .../account-deletion-dispatch-cancel.test.ts | 70 ++++++++ 6 files changed, 411 insertions(+) create mode 100644 apps/sim/background/enrichment-capability-subject.test.ts create mode 100644 apps/sim/lib/table/dispatch-governed-subject.test.ts create mode 100644 apps/sim/lib/table/run-column-governed-subject.test.ts create mode 100644 apps/sim/lib/users/account-deletion-dispatch-cancel.test.ts diff --git a/apps/sim/background/enrichment-capability-subject.test.ts b/apps/sim/background/enrichment-capability-subject.test.ts new file mode 100644 index 00000000000..330c61cfb12 --- /dev/null +++ b/apps/sim/background/enrichment-capability-subject.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + */ +import { resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getTableById: vi.fn(), + getRowById: vi.fn(), + updateRow: vi.fn(), + pickNextEligibleGroupForRow: vi.fn(), + stashCellContextForResume: vi.fn(), + writeWorkflowGroupState: vi.fn(async () => 'wrote'), + markWorkflowGroupPickedUp: vi.fn(async () => 'wrote'), + createWorkflowCellProgressWriter: vi.fn(), + buildCancelledExecution: vi.fn(), + classifyWorkflowCellTerminalResult: vi.fn(), + getEnrichment: vi.fn(), + runEnrichment: vi.fn(), + skippedEnrichmentDetail: vi.fn(() => ({})), + checkAttributedUsageLimits: vi.fn(async () => ({ isExceeded: false })), + loadTableRowSecretProvenance: vi.fn(async () => ({ scope: null, entries: [] })), +})) + +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/rows/service', () => ({ + getRowById: mocks.getRowById, + updateRow: mocks.updateRow, +})) +vi.mock('@/lib/table/cell-write', () => ({ + writeWorkflowGroupState: mocks.writeWorkflowGroupState, + markWorkflowGroupPickedUp: mocks.markWorkflowGroupPickedUp, + createWorkflowCellProgressWriter: mocks.createWorkflowCellProgressWriter, + buildCancelledExecution: mocks.buildCancelledExecution, +})) +vi.mock('@/lib/table/workflow-cell-result', () => ({ + classifyWorkflowCellTerminalResult: mocks.classifyWorkflowCellTerminalResult, +})) +vi.mock('@/enrichments/registry', () => ({ getEnrichment: mocks.getEnrichment })) +vi.mock('@/enrichments/run', () => ({ + runEnrichment: mocks.runEnrichment, + skippedEnrichmentDetail: mocks.skippedEnrichmentDetail, +})) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + assertBillingAttributionSnapshot: vi.fn((value) => value), + checkAttributedUsageLimits: mocks.checkAttributedUsageLimits, + toBillingContext: vi.fn(() => ({})), +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: vi.fn(() => undefined), + createTableRowSecretProvenanceFromRegistry: vi.fn(() => undefined), + loadTableRowSecretProvenance: mocks.loadTableRowSecretProvenance, +})) +vi.mock('@/executor/utils/resolved-secret-trace-registry', () => ({ + ResolvedSecretTraceRegistry: class { + async importCrossingProvenance() {} + }, +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) + +import { runRowCascadeLoop } from '@/background/workflow-column-execution' + +const GROUP = { + id: 'group-1', + type: 'enrichment' as const, + enrichmentId: 'company-lookup', + workflowId: '', + outputs: [{ columnName: 'col-out', blockId: '', path: '' }], + inputMappings: [{ columnName: 'col-in', inputName: 'domain' }], +} + +const TABLE = { + id: 'table-1', + workspaceId: 'workspace-1', + schema: { columns: [{ id: 'col-in', name: 'Domain', type: 'string' }], workflowGroups: [GROUP] }, +} + +function payload(capabilityGovernedUserId: string | null, triggeredByUserId?: string) { + return { + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workflowId: '', + workspaceId: 'workspace-1', + executionId: 'exec-1', + capabilityGovernedUserId, + ...(triggeredByUserId ? { triggeredByUserId } : {}), + billingAttribution: { + /** The meter's subject: the payer a workspace-key run attributes to. */ + actorUserId: triggeredByUserId ?? 'billing-owner', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'billing-owner', + billingEntity: { type: 'user' as const, id: 'billing-owner' }, + billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + payerSubscription: null, + }, + } +} + +/** The `userId` the cell handed the enrichment run — the per-tool gate subject. */ +function gatedUserId(): unknown { + expect(mocks.runEnrichment).toHaveBeenCalledTimes(1) + return (mocks.runEnrichment.mock.calls[0][2] as { userId?: unknown }).userId +} + +describe('enrichment cell capability subject', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getTableById.mockResolvedValue(TABLE) + mocks.getRowById.mockResolvedValue({ + id: 'row-1', + data: { 'col-in': 'example.com' }, + executions: {}, + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + mocks.checkAttributedUsageLimits.mockResolvedValue({ isExceeded: false }) + mocks.markWorkflowGroupPickedUp.mockResolvedValue('wrote') + mocks.writeWorkflowGroupState.mockResolvedValue('wrote') + mocks.pickNextEligibleGroupForRow.mockResolvedValue(null) + mocks.getEnrichment.mockReturnValue({ + id: 'company-lookup', + inputs: [{ id: 'domain', required: true }], + providers: [], + }) + mocks.runEnrichment.mockResolvedValue({ result: {}, cost: 0, detail: {} }) + }) + + /** + * A workspace-key write is actorless: nobody's permission group governs it, + * and the billing owner beside it on the payload is a bystander. Handing that + * bystander to the enrichment would run their tool denylist against a request + * they never made. + */ + it('runs a workspace-key dispatch ungated even though the payload names a payer', async () => { + await runRowCascadeLoop(payload(null, 'billing-owner') as never) + expect(gatedUserId()).toBeNull() + }) + + it('governs a session-triggered dispatch by the acting person', async () => { + await runRowCascadeLoop(payload('acting-user', 'acting-user') as never) + expect(gatedUserId()).toBe('acting-user') + }) + + /** + * The shape a pre-0315 dispatch row has after the column is added: no governed + * subject, attribution intact. New code reads that as actorless, which is why + * the migration backfills the legacy subject onto non-terminal old rows rather + * than letting the reader reconstruct it here. + */ + it('does not fall back to the attribution when the governed subject is absent', async () => { + await runRowCascadeLoop(payload(null, 'legacy-trigger-user') as never) + expect(gatedUserId()).toBeNull() + }) +}) diff --git a/apps/sim/background/workflow-column-execution.test.ts b/apps/sim/background/workflow-column-execution.test.ts index 306e925133f..43c14535b7f 100644 --- a/apps/sim/background/workflow-column-execution.test.ts +++ b/apps/sim/background/workflow-column-execution.test.ts @@ -283,6 +283,8 @@ describe('table workflow usage-limit clear', () => { data: {}, workspaceId: 'workspace-1', executionsPatch: { 'group-1': null }, + /** Clearing a pre-stamp writes no values, so no acting person governs it. */ + capabilityGovernedUserId: null, cancellationGuard: { groupId: 'group-1', executionId: 'execution-1' }, }) }) diff --git a/apps/sim/lib/table/cell-write.test.ts b/apps/sim/lib/table/cell-write.test.ts index 61fbbaee7b7..c49bf6675eb 100644 --- a/apps/sim/lib/table/cell-write.test.ts +++ b/apps/sim/lib/table/cell-write.test.ts @@ -157,6 +157,8 @@ describe('writeWorkflowGroupState', () => { workspaceId: TABLE.workspaceId, executionsPatch: { [GROUP.id]: RUNNING_STATE }, cancellationGuard: { groupId: GROUP.id, executionId: CONTEXT.executionId }, + /** A cell result carries no acting person down to the write layer. */ + capabilityGovernedUserId: null, secretProvenance, }, TABLE, diff --git a/apps/sim/lib/table/dispatch-governed-subject.test.ts b/apps/sim/lib/table/dispatch-governed-subject.test.ts new file mode 100644 index 00000000000..b1d4937cef3 --- /dev/null +++ b/apps/sim/lib/table/dispatch-governed-subject.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/table/events', () => ({ + appendTableEvent: vi.fn(), +})) +vi.mock('@/lib/table/service', () => ({ + getTableById: vi.fn(), +})) + +import { insertDispatch } from '@/lib/table/dispatcher' + +const BASE = { + tableId: 'table-1', + workspaceId: 'workspace-1', + requestId: 'req-1', + mode: 'all' as const, + scope: { groupIds: ['group-1'] }, + isManualRun: true, +} + +/** The values `insertDispatch` handed to the single `db.insert(...).values(...)`. */ +function insertedRow(): Record { + expect(dbChainMockFns.values).toHaveBeenCalledTimes(1) + return dbChainMockFns.values.mock.calls[0][0] as Record +} + +describe('insertDispatch governed subject', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + /** + * The bug this replaces: an optional field defaulting to `triggeredByUserId` + * meant a workspace-key auto-dispatch stored the workspace billed account as + * its gate subject — a bystander whose tool denylist would then run against + * a request nobody meant to govern. + */ + it('stores null for an actorless run even when the attribution names a user', async () => { + await insertDispatch({ + ...BASE, + triggeredByUserId: 'billing-owner', + capabilityGovernedUserId: null, + }) + const row = insertedRow() + expect(row.triggeredByUserId).toBe('billing-owner') + expect(row.capabilityGovernedUserId).toBeNull() + }) + + it('stores the acting person for a session-triggered run', async () => { + await insertDispatch({ + ...BASE, + triggeredByUserId: 'user-1', + capabilityGovernedUserId: 'user-1', + }) + const row = insertedRow() + expect(row.capabilityGovernedUserId).toBe('user-1') + }) + + /** + * The two fields are independent: a delegated run can be metered to the payer + * while staying governed by the person who asked for it. + */ + it('keeps the gate subject independent of the meter subject', async () => { + await insertDispatch({ + ...BASE, + triggeredByUserId: 'billing-owner', + capabilityGovernedUserId: 'requesting-user', + }) + const row = insertedRow() + expect(row.triggeredByUserId).toBe('billing-owner') + expect(row.capabilityGovernedUserId).toBe('requesting-user') + }) + + /** + * A row written before the column existed reads `capability_governed_user_id` + * as NULL with `triggered_by_user_id` still set. Under the new semantics that + * shape means "actorless, ungated" — which is why the 0315 migration + * backfills the legacy subject onto non-terminal pre-migration rows rather + * than letting them fall through to it. + */ + it('never reconstructs the gate subject from the attribution', async () => { + await insertDispatch({ + ...BASE, + triggeredByUserId: 'user-1', + capabilityGovernedUserId: null, + }) + expect(insertedRow().capabilityGovernedUserId).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/run-column-governed-subject.test.ts b/apps/sim/lib/table/run-column-governed-subject.test.ts new file mode 100644 index 00000000000..86c3ca3be52 --- /dev/null +++ b/apps/sim/lib/table/run-column-governed-subject.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getTableById: vi.fn(), + insertDispatch: vi.fn(async () => 'tdsp_1'), + readDispatch: vi.fn(async () => null), + cancelDispatchById: vi.fn(), + bulkClearWorkflowGroupCells: vi.fn(async () => false), + runDispatcherToCompletion: vi.fn(), + resolveTableDispatchConcurrency: vi.fn(async () => 5), +})) + +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/dispatcher', () => ({ + bulkClearWorkflowGroupCells: mocks.bulkClearWorkflowGroupCells, + cancelDispatchById: mocks.cancelDispatchById, + insertDispatch: mocks.insertDispatch, + readDispatch: mocks.readDispatch, + runDispatcherToCompletion: mocks.runDispatcherToCompletion, +})) +vi.mock('@/lib/table/dispatch-concurrency', () => ({ + resolveTableDispatchConcurrency: mocks.resolveTableDispatchConcurrency, +})) + +import { runWorkflowColumn } from '@/lib/table/workflow-columns' + +const TABLE = { + id: 'table-1', + workspaceId: 'workspace-1', + schema: { columns: [], workflowGroups: [{ id: 'group-1', outputs: [] }] }, +} + +const BASE = { + tableId: 'table-1', + workspaceId: 'workspace-1', + groupIds: ['group-1'], + mode: 'new' as const, + isManualRun: false, + requestId: 'req-1', +} + +/** The dispatch row `runWorkflowColumn` asked the dispatcher to insert. */ +function inserted(): Record { + expect(mocks.insertDispatch).toHaveBeenCalledTimes(1) + return mocks.insertDispatch.mock.calls[0][0] as Record +} + +describe('runWorkflowColumn governed subject', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getTableById.mockResolvedValue(TABLE) + mocks.insertDispatch.mockResolvedValue('tdsp_1') + mocks.readDispatch.mockResolvedValue(null) + mocks.bulkClearWorkflowGroupCells.mockResolvedValue(false) + mocks.resolveTableDispatchConcurrency.mockResolvedValue(5) + }) + + /** + * The row-write auto-fire case: a workspace API key wrote the row, so the + * attribution names the workspace billed account. Forwarding that as the gate + * subject — which an optional field with a fallback did — puts a bystander's + * tool denylist on a run nobody governs. + */ + it('forwards an explicit null past a non-null attribution', async () => { + await runWorkflowColumn({ + ...BASE, + triggeredByUserId: 'billing-owner', + capabilityGovernedUserId: null, + }) + const row = inserted() + expect(row.triggeredByUserId).toBe('billing-owner') + expect(row.capabilityGovernedUserId).toBeNull() + }) + + it('forwards the acting person for a session-initiated run', async () => { + await runWorkflowColumn({ + ...BASE, + triggeredByUserId: 'user-1', + capabilityGovernedUserId: 'user-1', + }) + expect(inserted().capabilityGovernedUserId).toBe('user-1') + }) +}) diff --git a/apps/sim/lib/users/account-deletion-dispatch-cancel.test.ts b/apps/sim/lib/users/account-deletion-dispatch-cancel.test.ts new file mode 100644 index 00000000000..cc9b03f00c6 --- /dev/null +++ b/apps/sim/lib/users/account-deletion-dispatch-cancel.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsSoleOwnerOfPaidOrganization, mockGetPersonalSubscription, mockIsUsingCloudStorage } = + vi.hoisted(() => ({ + mockIsSoleOwnerOfPaidOrganization: vi.fn(), + mockGetPersonalSubscription: vi.fn(), + mockIsUsingCloudStorage: vi.fn(), + })) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + isSoleOwnerOfPaidOrganization: mockIsSoleOwnerOfPaidOrganization, +})) +vi.mock('@/lib/billing/core/plan', () => ({ + getHighestPriorityPersonalSubscription: mockGetPersonalSubscription, +})) +vi.mock('@/lib/uploads', () => ({ + isUsingCloudStorage: mockIsUsingCloudStorage, + StorageService: { deleteFiles: vi.fn(async () => ({ failed: [] })) }, +})) +vi.mock('@/lib/workspaces/utils', () => ({ + reassignBilledAccountForUser: vi.fn(async () => ({ unresolved: [] })), + reassignOwnedWorkspacesForUser: vi.fn(async () => ({ unresolved: [] })), +})) + +import { deleteUserAccount } from '@/lib/users/account-deletion' + +describe('deleteUserAccount and the governed-subject foreign key', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsSoleOwnerOfPaidOrganization.mockResolvedValue({ isSoleOwner: false, name: null }) + mockGetPersonalSubscription.mockResolvedValue(null) + mockIsUsingCloudStorage.mockReturnValue(false) + }) + + /** + * `capability_governed_user_id` is `ON DELETE SET NULL`, and a subject the + * database erased reads exactly like a run that never had one — so a dispatch + * that outlived its governor would keep executing its remaining windows with + * no per-tool gate at all. The in-process dispatcher has no time ceiling, so + * that is not a short window. Going terminal first is what keeps the nulled + * row unreachable. + */ + it('cancels the account’s still-queued dispatches before deleting the user row', async () => { + await deleteUserAccount('user-1') + + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.tableRunDispatches) + const cancelled = dbChainMockFns.set.mock.calls.find( + ([patch]) => (patch as { status?: string }).status === 'cancelled' + ) + expect(cancelled).toBeDefined() + expect((cancelled?.[0] as { cancelledAt?: Date }).cancelledAt).toBeInstanceOf(Date) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.user) + }) + + /** The cancel must precede the delete, or the FK has already nulled the subject. */ + it('orders the cancel ahead of the user delete', async () => { + await deleteUserAccount('user-1') + + const cancelOrder = dbChainMockFns.update.mock.invocationCallOrder.at(-1) + const deleteOrder = dbChainMockFns.delete.mock.invocationCallOrder.at(-1) + expect(cancelOrder).toBeDefined() + expect(deleteOrder).toBeDefined() + expect(cancelOrder as number).toBeLessThan(deleteOrder as number) + }) +}) From 954db6efe8de5230d16f47d09e044797f483667a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 18:36:18 -0700 Subject: [PATCH 102/179] docs(skills): fix two checklist semantics bugs in the permission-group item skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The featureExtras rule falsely flagged top-level lists — allowedIntegrations, allowedModelProviders, deniedModels and deniedTools render from dedicated Providers and Blocks sections, not from featureExtras, so the check reported missing admin UI for every one of them. Restrict it to nested platform-feature controls and point the reader at the sections those lists actually use. The picker rule applied allowlist semantics to denylists. Refusing an empty selection and collapsing a full one back to null are allowlist behaviours; a denylist must let an admin clear every entry, which is how it denies nothing. --- .agents/skills/validate-permission-group-item/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/validate-permission-group-item/SKILL.md b/.agents/skills/validate-permission-group-item/SKILL.md index 45d83c59c37..a75133baeb3 100644 --- a/.agents/skills/validate-permission-group-item/SKILL.md +++ b/.agents/skills/validate-permission-group-item/SKILL.md @@ -48,8 +48,8 @@ Confirm the assertions at the bottom of `fields.ts` still name a field of this k ## Step 3: Admin UI (`ee/access-control/components/group-detail.tsx`) - **Boolean:** appears automatically via `PLATFORM_FEATURES`. Confirm its `category` is in `PLATFORM_CATEGORY_ORDER`; an unlisted one renders after every ordered section. -- **Allowlist / denylist:** renders **nothing** unless it is in the `featureExtras` map — keyed by the *parent boolean's feature id*, not the config key. No picker and no bespoke section means no admin can ever set it. Report it. -- For a picker, check both behaviors: refuses an empty selection (`if (values.length === 0) return`) and collapses a full one back to `null` (otherwise the allowlist freezes at today's members). +- **Nested allowlist / denylist** (one that qualifies a platform-feature boolean): renders **nothing** unless it is in the `featureExtras` map — keyed by the *parent boolean's feature id*, not the config key. No picker there means no admin can ever set it. Report it. Top-level lists — `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, `deniedTools` — are not in `featureExtras` and must not be reported for it; they render from the dedicated Providers and Blocks sections, so check them there. +- For an **allowlist** picker, check both behaviors: refuses an empty selection (`if (values.length === 0) return`) and collapses a full one back to `null` (otherwise the allowlist freezes at today's members). A **denylist** picker must do neither: clearing every entry is how an admin denies nothing, and a full selection is a real state that denies everything. - Check the parent is the right one (`allowedKnowledgeConnectors` under `hide-knowledge-base`, not `disable-knowledge-base-creation`). ## Step 4: Capability rule From bfd0398f34dc8834802e50155122e72d05286d12 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 18:36:33 -0700 Subject: [PATCH 103/179] feat(permission-groups): generate the access-control successor map from the block registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The successor question — which block type is an allowlist decision about this id really made against — was answerable only through getBlock, and scripts/check-application-graph.ts forbids lib/permission-groups/ from importing blocks/: the authorization funnel would pull every block definition into every surface that authorizes anything. So mergeEnvAllowlist intersected the group allowlist with ALLOWED_INTEGRATIONS textually. Project the registry into a checked-in Record instead. Entries are flattened to the terminal successor, reproducing the transitive walk the runtime performed and both of its stopping rules: a cycle stops at the last id visited, and an edge naming an unregistered block is not followed. The generator refuses to emit a map that is not closed, and check:block-successors fails when the map drifts from the registry in either direction. --- .../block-successors.generated.ts | 50 ++++++ package.json | 4 +- scripts/generate-block-successors.test.ts | 43 +++++ scripts/generate-block-successors.ts | 161 ++++++++++++++++++ 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/permission-groups/block-successors.generated.ts create mode 100644 scripts/generate-block-successors.test.ts create mode 100644 scripts/generate-block-successors.ts diff --git a/apps/sim/lib/permission-groups/block-successors.generated.ts b/apps/sim/lib/permission-groups/block-successors.generated.ts new file mode 100644 index 00000000000..8f7cb1fb395 --- /dev/null +++ b/apps/sim/lib/permission-groups/block-successors.generated.ts @@ -0,0 +1,50 @@ +/** + * Generated by `bun run generate:block-successors` from the block registry. + * Do not edit this file directly. + * + * Maps a retired block type to the *terminal* type an access-control decision + * about it is made against — `sunset.replacedBy`, followed transitively. It + * exists as a generated projection because `lib/permission-groups/` may not + * import `blocks/`; see `scripts/generate-block-successors.ts`. + */ +export const BLOCK_ACCESS_SUCCESSORS: Record = { + api_trigger: 'start_trigger', + chat_trigger: 'start_trigger', + confluence: 'confluence_v2', + cursor: 'cursor_v2', + extend: 'extend_v2', + file: 'file_v5', + file_v2: 'file_v5', + file_v3: 'file_v5', + file_v4: 'file_v5', + fireflies: 'fireflies_v2', + github: 'github_v2', + gmail: 'gmail_v2', + google_calendar: 'google_calendar_v2', + google_sheets: 'google_sheets_v2', + google_slides: 'google_slides_v2', + grain: 'grain_v2', + image_generator: 'image_generator_v2', + input_trigger: 'start_trigger', + intercom: 'intercom_v2', + kalshi: 'kalshi_v2', + linear: 'linear_v2', + logs: 'logs_v2', + manual_trigger: 'start_trigger', + microsoft_excel: 'microsoft_excel_v2', + mistral_parse: 'mistral_parse_v3', + mistral_parse_v2: 'mistral_parse_v3', + notion: 'notion_v2', + openai: 'embeddings', + pulse: 'pulse_v2', + reducto: 'reducto_v2', + router: 'router_v2', + sharepoint: 'sharepoint_v2', + slack: 'slack_v2', + starter: 'start_trigger', + stt: 'stt_v2', + textract: 'textract_v2', + video_generator: 'video_generator_v3', + video_generator_v2: 'video_generator_v3', + workflow: 'workflow_input', +} diff --git a/package.json b/package.json index 7d428a9f8fa..bab4d22fb09 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:capability-subject": "bunx vitest run scripts/check-capability-subject.test.ts", "test:application-graph": "bunx vitest run scripts/check-application-graph.test.ts", "test:migrations-safety": "bunx vitest run scripts/check-migrations-safety.test.ts", - "test:generators": "bunx vitest run scripts/generate-v2-cli-api.test.ts scripts/generate-cli-docs.test.ts scripts/generate-docs.test.ts", + "test:generators": "bunx vitest run scripts/generate-v2-cli-api.test.ts scripts/generate-cli-docs.test.ts scripts/generate-docs.test.ts scripts/generate-block-successors.test.ts", "format": "turbo run format", "format:check": "turbo run format:check", "lint": "turbo run lint", @@ -53,6 +53,8 @@ "check:permission-group-enforcement": "bun run scripts/check-permission-group-enforcement.ts", "check:capability-subject": "bun run scripts/check-capability-subject.ts", "check:application-graph": "bun run scripts/check-application-graph.ts", + "generate:block-successors": "bun run scripts/generate-block-successors.ts", + "check:block-successors": "bun run scripts/generate-block-successors.ts --check", "check:tool-registry-boundary": "bun run scripts/check-tool-registry-boundary.ts --check", "check:trigger-block-cycle": "bun run scripts/check-trigger-block-cycle.ts", "check:import-specifiers": "bun run scripts/check-import-specifiers.ts", diff --git a/scripts/generate-block-successors.test.ts b/scripts/generate-block-successors.test.ts new file mode 100644 index 00000000000..3a6d40e581a --- /dev/null +++ b/scripts/generate-block-successors.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { flattenSuccessors } from './generate-block-successors' + +const registered = (blocks: readonly string[]) => (blockType: string) => blocks.includes(blockType) + +describe('flattenSuccessors', () => { + it('follows a chain to the current version so one lookup answers it', () => { + const map = flattenSuccessors({ a: 'b', b: 'c' }, registered(['a', 'b', 'c'])) + + expect(map.get('a')).toBe('c') + expect(map.get('b')).toBe('c') + }) + + it('stops rather than looping when successors point at each other', () => { + const map = flattenSuccessors({ a: 'b', b: 'a' }, registered(['a', 'b'])) + + expect(map.get('a')).toBe('b') + expect(map.get('b')).toBe('a') + }) + + /** + * A `replacedBy` naming a block that was never registered — a typo, or a + * successor removed later — must leave the retired id as its own answer. The + * editor still offers it as an allowlist row under that id, so resolving it to + * a type nothing can be permitted as would deny it with no row to fix it. + */ + it('keeps its own identity when the named successor is not registered', () => { + const map = flattenSuccessors({ a: 'gone' }, registered(['a'])) + + expect(map.has('a')).toBe(false) + }) + + it('emits nothing for a block that is already current', () => { + expect(flattenSuccessors({}, registered(['slack_v2'])).size).toBe(0) + }) + + /** No key may also be a value, or the runtime's single lookup would be short. */ + it('produces a closed map', () => { + const map = flattenSuccessors({ a: 'b', b: 'c' }, registered(['a', 'b', 'c'])) + + for (const successor of map.values()) expect(map.has(successor)).toBe(false) + }) +}) diff --git a/scripts/generate-block-successors.ts b/scripts/generate-block-successors.ts new file mode 100644 index 00000000000..2eb36b3ff66 --- /dev/null +++ b/scripts/generate-block-successors.ts @@ -0,0 +1,161 @@ +#!/usr/bin/env bun +/** + * Generates the access-control successor map from the block registry. + * + * The map answers one question — "which block type is an allowlist decision + * about this id really made against?" — and it has to be answerable from + * `lib/permission-groups/`, which `scripts/check-application-graph.ts` forbids + * from importing `blocks/`: the authorization funnel would pull every block + * definition into every surface that authorizes anything. Before this file the + * answer was reachable only through `getBlock`, so the env allowlist was + * intersected with the group allowlist *textually*, and a deployment naming + * `slack` against a group naming `slack_v2` intersected to nothing — refusing an + * integration both policies allow. + * + * Entries are flattened to the terminal successor, reproducing the transitive + * walk `resolveAccessControlBlockType` used to perform against the registry, + * including its two stopping rules: a cycle stops at the last id visited, and a + * `replacedBy` naming an unregistered block leaves the id as its own answer. + * Only ids whose answer differs from themselves are emitted. + * + * Usage: + * bun run scripts/generate-block-successors.ts + * bun run scripts/generate-block-successors.ts --check + */ +import { readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { formatGeneratedSource } from './format-generated-source' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/permission-groups/block-successors.generated.ts') +const CHECK_MODE = process.argv.includes('--check') + +interface SunsetBlock { + sunset?: { status: string; replacedBy?: string } +} + +/** + * The block registry, loaded lazily. + * + * A static import would run on every import of this module, including the unit + * test for {@link flattenSuccessors}, which needs no registry and cannot + * resolve the `@/` specifiers every block file uses from the repo-root vitest + * project. + */ +async function loadRegistry(): Promise> { + const { BLOCK_REGISTRY } = await import('../apps/sim/blocks/registry-maps') + return BLOCK_REGISTRY as unknown as Record +} + +/** + * Flattens one-hop `replacedBy` edges to terminal successors. + * + * Reproduces the walk the runtime used to perform against the registry, with + * both of its stopping rules: a cycle stops at the last id visited rather than + * spinning, and an edge naming an unregistered block is not followed, leaving + * the id as its own answer. Only ids whose answer differs from themselves are + * returned, so a lookup that misses is a block with no successor. + */ +export function flattenSuccessors( + directSuccessors: Readonly>, + isRegistered: (blockType: string) => boolean +): ReadonlyMap { + const terminal = (blockType: string): string => { + const seen = new Set([blockType]) + let current = blockType + + while (true) { + const successor = directSuccessors[current] + if (!successor || seen.has(successor) || !isRegistered(successor)) return current + seen.add(successor) + current = successor + } + } + + const successors = new Map() + for (const blockType of Object.keys(directSuccessors).sort()) { + const resolved = terminal(blockType) + if (resolved !== blockType) successors.set(blockType, resolved) + } + return successors +} + +export async function buildBlockSuccessors(): Promise> { + const registry = await loadRegistry() + + /** `getBlock`'s own-key lookup, with its dash-to-underscore normalization. */ + const lookup = (type: string): SunsetBlock | undefined => { + if (Object.hasOwn(registry, type)) return registry[type] + const normalized = type.replace(/-/g, '_') + return Object.hasOwn(registry, normalized) ? registry[normalized] : undefined + } + + const directSuccessors: Record = {} + for (const blockType of Object.keys(registry)) { + const successor = registry[blockType]?.sunset?.replacedBy + if (successor) directSuccessors[blockType] = successor + } + return flattenSuccessors(directSuccessors, (blockType) => lookup(blockType) !== undefined) +} + +function render(successors: ReadonlyMap): string { + const quote = (value: string) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` + const entries = [...successors] + .map( + ([blockType, successor]) => + ` ${/^[A-Za-z_$][\w$]*$/.test(blockType) ? blockType : quote(blockType)}: ${quote(successor)},` + ) + .join('\n') + + return `/** + * Generated by \`bun run generate:block-successors\` from the block registry. + * Do not edit this file directly. + * + * Maps a retired block type to the *terminal* type an access-control decision + * about it is made against — \`sunset.replacedBy\`, followed transitively. It + * exists as a generated projection because \`lib/permission-groups/\` may not + * import \`blocks/\`; see \`scripts/generate-block-successors.ts\`. + */ +export const BLOCK_ACCESS_SUCCESSORS: Record = { +${entries} +} +` +} + +async function main(): Promise { + const successors = await buildBlockSuccessors() + + /** + * The map must be closed: no key may also be a value, or a lookup would need + * a second hop and the runtime does exactly one. Flattening guarantees it, so + * a violation means the flattening itself regressed. + */ + for (const successor of successors.values()) { + if (successors.has(successor)) { + throw new Error( + `Block successor map is not flattened: '${successor}' is both a successor and a retired id.` + ) + } + } + + const generated = formatGeneratedSource(render(successors), OUTPUT_PATH, ROOT) + + if (CHECK_MODE) { + const current = await readFile(OUTPUT_PATH, 'utf8').catch(() => '') + if (current !== generated) { + console.error( + 'Block successor map is stale. Run `bun run generate:block-successors` and commit the result.' + ) + process.exit(1) + } + process.stdout.write('Block successor map is current.\n') + return + } + + await writeFile(OUTPUT_PATH, generated) + process.stdout.write(`Generated ${OUTPUT_PATH}\n`) +} + +if (import.meta.main) await main() From 3b3666739152c317752ee57215aefa893dd41f7c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 18:36:48 -0700 Subject: [PATCH 104/179] fix(permission-groups): canonicalize both policy layers before intersecting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group allowlist and ALLOWED_INTEGRATIONS are written independently — the editor only offers current ids, the env list is hand-written against whatever ids its author knew — so they routinely name the same integration by different vintages. Intersecting them textually made slack against slack_v2 disjoint, so the merged allowlist was empty and every consumer refused an integration both policies allow: the client hook, the server catalog, the block gate and the new selector gate each disagreed with the others depending on where in the pipeline the successor resolution happened. Move resolveAccessControlBlockType, toAccessControlAllowlist and the two intersect helpers into integration-allowlist.ts, backed by the generated successor map, so the module under the authorization funnel can canonicalize without reaching the block registry. block-access.ts re-exports them and keeps only the exemption rule, which genuinely needs hideFromToolbar. intersectIntegrationAllowlists now resolves each side before intersecting, so every caller of the merged config gets one vocabulary. The copilot surfaces that tested a raw block id against that list now resolve the checked side too, which is what the block path always did. --- .../utils/permission-check.test.ts | 30 +++- apps/sim/hooks/use-permission-config.ts | 32 ++-- .../catalog/application/catalog-reads.test.ts | 7 +- apps/sim/lib/copilot/chat/process-contents.ts | 7 +- .../integration-tool-projection.test.ts | 3 +- .../server/blocks/get-blocks-metadata-tool.ts | 7 +- .../tools/server/blocks/get-trigger-blocks.ts | 7 +- .../tools/server/user/get-credentials.test.ts | 18 ++- .../application/provider-catalog.test.ts | 26 +++- .../integrations/principal-scope.server.ts | 10 +- .../permission-groups/block-access.test.ts | 137 +----------------- .../sim/lib/permission-groups/block-access.ts | 74 +--------- .../integration-allowlist.test.ts | 113 ++++++++++++++- .../integration-allowlist.ts | 87 +++++++++-- .../lib/permission-groups/resolve.server.ts | 12 +- 15 files changed, 315 insertions(+), 255 deletions(-) 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 86feaf0ccc2..e38273f660b 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -147,13 +147,18 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { expect(mockIsOrganizationOnEnterprisePlan).not.toHaveBeenCalled() }) + /** + * The env list is written by hand against whatever ids its author knew, so it + * is canonicalized on the way in: `slack` and `slack_v2` are the same policy, + * and the merged config carries the id every gate resolves a block type to. + */ it('still applies the env allowlist on a no-org workspace', async () => { mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: null }) mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack']) const config = await getUserPermissionConfig('user-123', 'workspace-1') - expect(config?.allowedIntegrations).toEqual(['slack']) + expect(config?.allowedIntegrations).toEqual(['slack_v2']) }) it('returns null when the organization is not on an enterprise plan', async () => { @@ -313,6 +318,12 @@ describe('access control context resolution', () => { }) }) + /** + * The group and the deployment name the same integrations by different + * vintages — the editor only offers current ids, `ALLOWED_INTEGRATIONS` is + * hand-written. Intersecting them textually left Slack out of a policy both + * layers permit, so both sides are successor-resolved first. + */ it('identifies the default group and preserves the environment allowlist', async () => { mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack']) @@ -322,7 +333,7 @@ describe('access control context resolution', () => { { id: 'group-default', name: 'Organization default', - config: { allowedIntegrations: ['slack', 'github'] }, + config: { allowedIntegrations: ['slack_v2', 'github'] }, }, ] ) @@ -338,7 +349,7 @@ describe('access control context resolution', () => { name: 'Organization default', resolution: 'default', }) - expect(context.config?.allowedIntegrations).toEqual(['slack']) + expect(context.config?.allowedIntegrations).toEqual(['slack_v2']) }) }) @@ -500,12 +511,17 @@ describe('validateBlockType', () => { await validateBlockType(undefined, undefined, 'start_trigger') }) + /** + * `thinking` is a real retired block with no successor: it has no editor row + * and nothing to be permitted *as*, so it is exempt. A retired block that + * does have one — `notion` — is judged as `notion_v2` instead and is not. + */ it('always allows legacy blocks hidden from the toolbar', async () => { mockGetBlock.mockImplementation((type) => - type === 'notion' ? { hideFromToolbar: true } : undefined + type === 'thinking' ? { hideFromToolbar: true } : undefined ) - await validateBlockType(undefined, undefined, 'notion') + await validateBlockType(undefined, undefined, 'thinking') }) it('does NOT treat preview blocks as exempt — preview is not legacy', async () => { @@ -769,13 +785,13 @@ describe('assertPermissionsAllowed', () => { it('exempts legacy blocks from the integration allowlist', async () => { queueGroupResolution([{ config: { allowedIntegrations: ['slack'] } }]) mockGetBlock.mockImplementation((type) => - type === 'notion' ? { hideFromToolbar: true } : undefined + type === 'thinking' ? { hideFromToolbar: true } : undefined ) await assertPermissionsAllowed({ userId: 'user-123', workspaceId: 'workspace-1', - blockType: 'notion', + blockType: 'thinking', }) }) diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index c4e45bd3a06..36dc856180b 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -14,16 +14,15 @@ import { isDeploymentGatedIntegrationType, resolveIntegrationAvailabilityStateForVisibility, } from '@/lib/integrations/availability' -import { - intersectAccessControlAllowlists, - isBlockTypeAccessControlExempt, - resolveAccessControlBlockType, -} from '@/lib/permission-groups/block-access' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, } from '@/lib/permission-groups/fields' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { + intersectAccessControlAllowlists, + resolveAccessControlBlockType, +} from '@/lib/permission-groups/integration-allowlist' import { createModelAccessGate } from '@/lib/permission-groups/model-access' import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' @@ -87,21 +86,17 @@ export function usePermissionConfig(): PermissionConfigResult { const isInPermissionGroup = !!permissionData?.permissionGroupId - const mergedAllowedIntegrations = useMemo(() => { - const envAllowlist = envAllowlistData?.allowedIntegrations ?? null - return intersectIntegrationAllowlists(config.allowedIntegrations, envAllowlist) - }, [config.allowedIntegrations, envAllowlistData]) - /** * Both sides of the membership test are judged as the current block, so a * policy naming a retired id — `ALLOWED_INTEGRATIONS=slack` — still permits * the successor the editor offers. * - * Each policy is canonicalized *before* the two are intersected, not after. - * `intersectIntegrationAllowlists` case-folds but does not successor-resolve, - * so a group naming `slack` and an env allowlist naming `slack_v2` intersect - * to nothing textually — hiding an integration both policies allow. Resolving - * first puts them in one vocabulary, and the intersection is then exact. + * Each policy is canonicalized *before* the two are intersected, not after: a + * group naming `slack` and an env allowlist naming `slack_v2` intersect to + * nothing textually, hiding an integration both policies allow. This is the + * same helper the server gates use — `mergeEnvAllowlist` for the config the + * catalog reads, `allowedIntegrationTypes` for the block and selector gates — + * so what this hook shows and what the server permits cannot disagree. */ const allowedAccessControlTypes = useMemo( () => @@ -112,6 +107,11 @@ export function usePermissionConfig(): PermissionConfigResult { [config.allowedIntegrations, envAllowlistData] ) + const mergedAllowedIntegrations = useMemo( + () => (allowedAccessControlTypes === null ? null : [...allowedAccessControlTypes]), + [allowedAccessControlTypes] + ) + const integrationAvailability = useMemo(() => { const visibility = overlayVisibility() return new Map( diff --git a/apps/sim/lib/catalog/application/catalog-reads.test.ts b/apps/sim/lib/catalog/application/catalog-reads.test.ts index 74a4e73e0a1..fa9b0b6f346 100644 --- a/apps/sim/lib/catalog/application/catalog-reads.test.ts +++ b/apps/sim/lib/catalog/application/catalog-reads.test.ts @@ -378,8 +378,13 @@ describe('catalog block and tool reads', () => { ).rejects.toMatchObject({ code: 'not_found', message: 'Block not found' }) }) + /** + * The gate answers in the resolved vocabulary — `slack` is judged as + * `slack_v2` on both sides — so the allowlist naming the successor is what + * keeps the legacy block visible. + */ it('drops a block the permission-group allowlist excludes, from list and detail alike', async () => { - mocks.allowedIntegrationTypes.mockResolvedValue(new Set(['slack'])) + mocks.allowedIntegrationTypes.mockResolvedValue(new Set(['slack_v2'])) const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) expect(result.entries.map((entry) => entry.id)).toEqual(['slack']) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index cb734df50ae..6b4207c6fc7 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -43,7 +43,10 @@ import { mcpService } from '@/lib/mcp/service' import { createMcpToolId } from '@/lib/mcp/utils' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { + intersectIntegrationAllowlists, + resolveAccessControlBlockType, +} from '@/lib/permission-groups/integration-allowlist' import { getColumnId } from '@/lib/table/column-keys' import { getRowsByIds } from '@/lib/table/rows/service' import { getTableById } from '@/lib/table/service' @@ -619,7 +622,7 @@ async function processBlockMetadata( if ( allowedIntegrations != null && !isBlockTypeAccessControlExempt(blockId) && - !allowedIntegrations.includes(blockId.toLowerCase()) + !allowedIntegrations.includes(resolveAccessControlBlockType(blockId.toLowerCase())) ) { logger.debug('Block not allowed by integration allowlist', { blockId, userId }) return null diff --git a/apps/sim/lib/copilot/integration-tool-projection.test.ts b/apps/sim/lib/copilot/integration-tool-projection.test.ts index c12eb30732f..1276bbb3341 100644 --- a/apps/sim/lib/copilot/integration-tool-projection.test.ts +++ b/apps/sim/lib/copilot/integration-tool-projection.test.ts @@ -108,7 +108,8 @@ describe('projectIntegrationToolsForViewer', () => { deniedTools: ['slack_canvas_v1'], }) - expect(projection.allowedBlockTypes).toEqual(new Set(['slack'])) + /** The set is in the resolved vocabulary, which is what the gate compares against. */ + expect(projection.allowedBlockTypes).toEqual(new Set(['slack_v2'])) expect(projection.isToolAllowed('slack_canvas_v1')).toBe(false) expect(projection.isToolAllowed('slack_message_v1')).toBe(true) }) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 81f9e12b3f7..0c0796bc55e 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -19,7 +19,10 @@ import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integration import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { + intersectIntegrationAllowlists, + resolveAccessControlBlockType, +} from '@/lib/permission-groups/integration-allowlist' import { collectDeniedOperationIds, createToolAccessGate, @@ -213,7 +216,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< allowedIntegrations != null && !specialBlock && !isBlockTypeAccessControlExempt(blockId) && - !allowedIntegrations.includes(blockId.toLowerCase()) + !allowedIntegrations.includes(resolveAccessControlBlockType(blockId.toLowerCase())) ) { logger.debug('Block not allowed by permission group', { blockId }) continue diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts index 11a0c6d562a..389dbc334d2 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts @@ -5,7 +5,10 @@ import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { + intersectIntegrationAllowlists, + resolveAccessControlBlockType, +} from '@/lib/permission-groups/integration-allowlist' import { getAllBlocks } from '@/blocks/registry' import { overlayVisibility } from '@/blocks/visibility/context' @@ -44,7 +47,7 @@ export const getTriggerBlocksServerTool: BaseServerTool< if ( allowedIntegrations != null && !isBlockTypeAccessControlExempt(blockType) && - !allowedIntegrations.includes(blockType.toLowerCase()) + !allowedIntegrations.includes(resolveAccessControlBlockType(blockType.toLowerCase())) ) continue diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts index 83da859ade1..2b8aebdf221 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts @@ -6,6 +6,7 @@ */ import { account, user } from '@sim/db/schema' +import { getIntegrationTypesForOAuthServiceId } from '@sim/deployment-config/integration-availability' import { dbChainMockFns, environmentUtilsMockFns, @@ -186,8 +187,21 @@ describe('getCredentialsServerTool', () => { checkWorkspaceAccessMock.mockResolvedValue({ canAdmin: false }) createIntegrationCredentialVisibilityMock.mockImplementation( ({ allowedIntegrationTypes, oauthServices }) => { - const isAllowed = (service: { serviceId: string }) => - allowedIntegrationTypes === null || allowedIntegrationTypes.has(service.serviceId) + /** + * Mirrors `isOAuthServiceAllowedByIntegrationTypes`: the gate holds + * *block types*, so the service is mapped through the deployment + * catalog rather than compared to its own id. A service the catalog + * does not name maps to no block type and stays visible, as it does in + * production. + */ + const isAllowed = (service: { serviceId: string }) => { + if (allowedIntegrationTypes === null) return true + const blockTypes = getIntegrationTypesForOAuthServiceId(service.serviceId) + return ( + blockTypes.length === 0 || + blockTypes.some((blockType) => allowedIntegrationTypes.has(blockType)) + ) + } const isOAuthServiceVisible = (service: { serviceId: string; providerId: string }) => isAllowed(service) && isOAuthServiceDeploymentAvailableMock(service.providerId) return { diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index 6dbb3cf0c23..73fc59914fc 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -24,16 +24,34 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.getUserPermissionConfig, })) -vi.mock('@/lib/permission-groups/integration-allowlist', () => ({ - intersectIntegrationAllowlists: ( +/** + * The real helpers canonicalize each side through the generated successor map; + * this stub keeps the intersection semantics without the map, because the ids + * used here are fixtures rather than real block types. + */ +vi.mock('@/lib/permission-groups/integration-allowlist', () => { + const intersect = ( permissionGroup: readonly string[] | null, deployment: readonly string[] | null ) => { if (!permissionGroup) return deployment if (!deployment) return permissionGroup return permissionGroup.filter((type) => deployment.includes(type)) - }, -})) + } + return { + intersectIntegrationAllowlists: intersect, + intersectAccessControlAllowlists: ( + permissionGroup: readonly string[] | null, + deployment: readonly string[] | null + ) => { + const result = intersect(permissionGroup, deployment) + return result === null ? null : new Set(result) + }, + resolveAccessControlBlockType: (blockType: string) => blockType, + toAccessControlAllowlist: (allowlist: readonly string[] | null) => + allowlist ? new Set(allowlist) : null, + } +}) vi.mock('@/lib/integrations/credential-visibility.server', () => ({ createIntegrationCredentialVisibility: mocks.createVisibility, diff --git a/apps/sim/lib/integrations/principal-scope.server.ts b/apps/sim/lib/integrations/principal-scope.server.ts index 6b126b8be20..3e239092b15 100644 --- a/apps/sim/lib/integrations/principal-scope.server.ts +++ b/apps/sim/lib/integrations/principal-scope.server.ts @@ -1,8 +1,7 @@ import type { Principal } from '@sim/auth/principal' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' -import { toAccessControlAllowlist } from '@/lib/permission-groups/block-access' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { intersectAccessControlAllowlists } from '@/lib/permission-groups/integration-allowlist' /** * The workspace integration gate, shared by every catalog that projects @@ -42,6 +41,10 @@ export function principalUserId(principal: Principal): string | undefined { * The intersection of the caller's permission-group allowlist with the * deployment's `ALLOWED_INTEGRATIONS`. A principal with no user contributes no * permission-group half, leaving the deployment allowlist alone. + * + * Each half is successor-resolved *before* the intersection, so a group naming + * `slack_v2` and a deployment naming `slack` still meet. Callers resolve the + * type they test the same way. */ export async function allowedIntegrationTypes( principal: Principal, @@ -51,9 +54,8 @@ export async function allowedIntegrationTypes( const permissionConfig = userId ? await resolvePermissionGroupConfig(userId, workspaceId, undefined) : null - const integrations = intersectIntegrationAllowlists( + return intersectAccessControlAllowlists( permissionConfig?.allowedIntegrations ?? null, getAllowedIntegrationsFromEnv() ) - return toAccessControlAllowlist(integrations) } diff --git a/apps/sim/lib/permission-groups/block-access.test.ts b/apps/sim/lib/permission-groups/block-access.test.ts index c4ed31deb04..ec2db3f6b7f 100644 --- a/apps/sim/lib/permission-groups/block-access.test.ts +++ b/apps/sim/lib/permission-groups/block-access.test.ts @@ -2,12 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - intersectAccessControlAllowlists, - isBlockTypeAccessControlExempt, - resolveAccessControlBlockType, - toAccessControlAllowlist, -} from '@/lib/permission-groups/block-access' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { getBlock } from '@/blocks/registry' const mockGetBlock = getBlock as unknown as ReturnType @@ -17,56 +12,15 @@ interface FakeBlock { sunset?: { status: 'legacy' | 'deprecated'; replacedBy?: string } } +/** + * Only `hideFromToolbar` is read from here: the successor half of the decision + * comes from the generated map, so every id used below is a real one whose real + * successor the assertion depends on. + */ function registry(blocks: Record) { mockGetBlock.mockImplementation((type: string) => blocks[type]) } -describe('resolveAccessControlBlockType', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('judges a superseded block as its successor', () => { - registry({ - slack: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } }, - slack_v2: {}, - }) - - expect(resolveAccessControlBlockType('slack')).toBe('slack_v2') - }) - - it('follows a chain of successors to the current version', () => { - registry({ - a: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'b' } }, - b: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'c' } }, - c: {}, - }) - - expect(resolveAccessControlBlockType('a')).toBe('c') - }) - - it('stops rather than looping when successors point at each other', () => { - registry({ - a: { sunset: { status: 'legacy', replacedBy: 'b' } }, - b: { sunset: { status: 'legacy', replacedBy: 'a' } }, - }) - - expect(resolveAccessControlBlockType('a')).toBe('b') - }) - - it('keeps its own identity when the named successor is not registered', () => { - registry({ a: { sunset: { status: 'legacy', replacedBy: 'gone' } } }) - - expect(resolveAccessControlBlockType('a')).toBe('a') - }) - - it('leaves a current block alone', () => { - registry({ slack_v2: {} }) - - expect(resolveAccessControlBlockType('slack_v2')).toBe('slack_v2') - }) -}) - describe('isBlockTypeAccessControlExempt', () => { beforeEach(() => { vi.clearAllMocks() @@ -127,82 +81,3 @@ describe('isBlockTypeAccessControlExempt', () => { expect(isBlockTypeAccessControlExempt('manual_trigger')).toBe(true) }) }) - -describe('intersectAccessControlAllowlists', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - /** - * The two policies are written independently — `ALLOWED_INTEGRATIONS` by hand - * against whatever ids its author knew, the group through an editor that only - * offers current ones — so they routinely name the same integration by - * different vintages. Intersecting before resolving leaves those disjoint. - */ - it('intersects a retired id against its successor', () => { - registry({ - slack: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } }, - slack_v2: {}, - }) - - expect([...(intersectAccessControlAllowlists(['slack'], ['slack_v2']) ?? [])]).toEqual([ - 'slack_v2', - ]) - }) - - it('keeps either side null as unrestricted', () => { - registry({}) - - expect([...(intersectAccessControlAllowlists(null, ['notion']) ?? [])]).toEqual(['notion']) - expect([...(intersectAccessControlAllowlists(['notion'], null) ?? [])]).toEqual(['notion']) - expect(intersectAccessControlAllowlists(null, null)).toBeNull() - }) - - it('keeps an empty policy denying everything', () => { - registry({}) - - expect(intersectAccessControlAllowlists([], ['notion'])?.size).toBe(0) - }) - - it('drops an integration only one policy names', () => { - registry({}) - - expect([...(intersectAccessControlAllowlists(['notion', 'gmail'], ['gmail']) ?? [])]).toEqual([ - 'gmail', - ]) - }) -}) - -describe('toAccessControlAllowlist', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('keeps an unrestricted allowlist unrestricted', () => { - registry({}) - - expect(toAccessControlAllowlist(null)).toBeNull() - }) - - /** - * `ALLOWED_INTEGRATIONS` is written by hand against whatever ids its author - * knows, so a deployment that permitted `slack` must not refuse `slack_v2`. - */ - it('judges a policy entry naming a retired id as its successor', () => { - registry({ - slack: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } }, - slack_v2: {}, - }) - - const allowlist = toAccessControlAllowlist(['Slack']) - - expect(allowlist?.has('slack_v2')).toBe(true) - expect(allowlist?.has('slack')).toBe(false) - }) - - it('denies everything for an empty allowlist', () => { - registry({ slack_v2: {} }) - - expect(toAccessControlAllowlist([])?.size).toBe(0) - }) -}) diff --git a/apps/sim/lib/permission-groups/block-access.ts b/apps/sim/lib/permission-groups/block-access.ts index 4dc13a6818e..f82103fb71d 100644 --- a/apps/sim/lib/permission-groups/block-access.ts +++ b/apps/sim/lib/permission-groups/block-access.ts @@ -1,5 +1,12 @@ +import { + intersectAccessControlAllowlists, + resolveAccessControlBlockType, + toAccessControlAllowlist, +} from '@/lib/permission-groups/integration-allowlist' import { getBlock } from '@/blocks/registry' +export { intersectAccessControlAllowlists, resolveAccessControlBlockType, toAccessControlAllowlist } + /** * The universal workflow entry point. Every retired entry point resolves to it, * and it is never an allowlist row, so both it and anything that resolves to it @@ -41,70 +48,3 @@ export function isBlockTypeAccessControlExempt(blockType: string): boolean { const successor = resolveAccessControlBlockType(blockType) return successor === blockType || successor === UNIVERSAL_ENTRY_POINT } - -/** - * The block type an allowlist decision should be made against. - * - * A superseded version resolves to the successor its `sunset.replacedBy` names, - * transitively, so allowing or denying an integration covers every version of - * it. Without this an admin would have to know each retired id and deny it - * individually — and could not, since the editor only offers the current ones. - * - * A retired block with no successor keeps its own identity and appears in the - * editor under it, which is the only way an admin can decide about it at all. - */ -export function resolveAccessControlBlockType(blockType: string): string { - const seen = new Set([blockType]) - let current = blockType - - while (true) { - const successor = getBlock(current)?.sunset?.replacedBy - if (!successor || seen.has(successor) || !getBlock(successor)) return current - seen.add(successor) - current = successor - } -} - -/** - * The allowlist, indexed for membership tests against the block type an - * allowlist decision is made against. `null` stays `null` — unrestricted, not - * "nothing allowed". - * - * Both sides have to be normalized or they compare different vocabularies. A - * policy list can name a retired id: `ALLOWED_INTEGRATIONS` is written by hand - * against whatever ids the author knows, so `ALLOWED_INTEGRATIONS=slack` is the - * expected way to permit Slack. The checked type is always successor-resolved, - * so without normalizing the policy the deployment that permitted `slack` would - * refuse every `slack_v2` block in it. - */ -/** - * Intersects two independent integration policies in the *resolved* vocabulary. - * - * Each side is canonicalized before the intersection, not after. A policy list - * can name a retired id while the other names its successor — - * `ALLOWED_INTEGRATIONS=slack` against a group naming `slack_v2` — and folding - * only case leaves those two ids disjoint, intersecting to nothing and hiding - * an integration both policies allow. `null` stays unrestricted on either side. - */ -export function intersectAccessControlAllowlists( - first: readonly string[] | null, - second: readonly string[] | null -): ReadonlySet | null { - const resolvedFirst = toAccessControlAllowlist(first) - const resolvedSecond = toAccessControlAllowlist(second) - if (resolvedFirst === null) return resolvedSecond - if (resolvedSecond === null) return resolvedFirst - return new Set([...resolvedFirst].filter((type) => resolvedSecond.has(type))) -} - -export function toAccessControlAllowlist( - allowedIntegrations: readonly string[] | null -): ReadonlySet | null { - return allowedIntegrations - ? new Set( - allowedIntegrations.map((integration) => - resolveAccessControlBlockType(integration.toLowerCase()).toLowerCase() - ) - ) - : null -} diff --git a/apps/sim/lib/permission-groups/integration-allowlist.test.ts b/apps/sim/lib/permission-groups/integration-allowlist.test.ts index 314bdf9c86e..26e64bbdece 100644 --- a/apps/sim/lib/permission-groups/integration-allowlist.test.ts +++ b/apps/sim/lib/permission-groups/integration-allowlist.test.ts @@ -1,16 +1,121 @@ +/** + * @vitest-environment node + * + * These helpers read the generated successor map rather than the block + * registry, so every id below is a real one and the assertions are about the + * repository's actual lifecycle facts: `slack` was replaced by `slack_v2`, + * `notion` by `notion_v2`, `file` by `file_v5`. + */ import { describe, expect, it } from 'vitest' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { + intersectAccessControlAllowlists, + intersectIntegrationAllowlists, + resolveAccessControlBlockType, + toAccessControlAllowlist, +} from '@/lib/permission-groups/integration-allowlist' + +describe('resolveAccessControlBlockType', () => { + it('judges a superseded block as its successor', () => { + expect(resolveAccessControlBlockType('slack')).toBe('slack_v2') + }) + + /** The map is flattened, so a chain costs one lookup and never a partial hop. */ + it('answers the terminal version of a chain, not an intermediate one', () => { + expect(resolveAccessControlBlockType('file')).toBe('file_v5') + expect(resolveAccessControlBlockType('file_v3')).toBe('file_v5') + }) + + it('leaves a current block alone', () => { + expect(resolveAccessControlBlockType('slack_v2')).toBe('slack_v2') + }) + + /** + * A retired block with no successor keeps its own identity, which is the only + * id an admin can permit it under. + */ + it('keeps its own identity when nothing replaced it', () => { + expect(resolveAccessControlBlockType('thinking')).toBe('thinking') + }) + + it('accepts the dashed spelling the registry also normalizes', () => { + expect(resolveAccessControlBlockType('google-sheets')).toBe('google_sheets_v2') + }) +}) + +describe('toAccessControlAllowlist', () => { + it('keeps an unrestricted allowlist unrestricted', () => { + expect(toAccessControlAllowlist(null)).toBeNull() + }) + + /** + * `ALLOWED_INTEGRATIONS` is written by hand against whatever ids its author + * knows, so a deployment that permitted `slack` must not refuse `slack_v2`. + */ + it('judges a policy entry naming a retired id as its successor', () => { + const allowlist = toAccessControlAllowlist(['Slack']) + + expect(allowlist?.has('slack_v2')).toBe(true) + expect(allowlist?.has('slack')).toBe(false) + }) + + it('denies everything for an empty allowlist', () => { + expect(toAccessControlAllowlist([])?.size).toBe(0) + }) +}) + +describe('intersectAccessControlAllowlists', () => { + /** + * The two policies are written independently — `ALLOWED_INTEGRATIONS` by hand + * against whatever ids its author knew, the group through an editor that only + * offers current ones — so they routinely name the same integration by + * different vintages. Intersecting before resolving leaves those disjoint, + * which refuses an integration both policies allow. + */ + it('intersects a retired id against its successor', () => { + expect([...(intersectAccessControlAllowlists(['slack'], ['slack_v2']) ?? [])]).toEqual([ + 'slack_v2', + ]) + expect([...(intersectAccessControlAllowlists(['slack_v2'], ['slack']) ?? [])]).toEqual([ + 'slack_v2', + ]) + }) + + it('keeps either side null as unrestricted', () => { + expect([...(intersectAccessControlAllowlists(null, ['notion']) ?? [])]).toEqual(['notion_v2']) + expect([...(intersectAccessControlAllowlists(['notion'], null) ?? [])]).toEqual(['notion_v2']) + expect(intersectAccessControlAllowlists(null, null)).toBeNull() + }) + + it('keeps an empty policy denying everything', () => { + expect(intersectAccessControlAllowlists([], ['notion'])?.size).toBe(0) + }) + + it('drops an integration only one policy names', () => { + expect([...(intersectAccessControlAllowlists(['notion', 'gmail'], ['gmail']) ?? [])]).toEqual([ + 'gmail_v2', + ]) + }) +}) describe('intersectIntegrationAllowlists', () => { it('uses the configured list when the other policy is unrestricted', () => { - expect(intersectIntegrationAllowlists(null, ['Slack'])).toEqual(['slack']) - expect(intersectIntegrationAllowlists(['Notion'], null)).toEqual(['notion']) + expect(intersectIntegrationAllowlists(null, ['Slack'])).toEqual(['slack_v2']) + expect(intersectIntegrationAllowlists(['Notion'], null)).toEqual(['notion_v2']) expect(intersectIntegrationAllowlists(null, null)).toBeNull() }) + /** + * The list form must canonicalize identically to the set form, or the config + * the catalogs carry and the gate the block path applies would disagree about + * the same two policies. + */ + it('keeps a mixed-vintage integration both policies allow', () => { + expect(intersectIntegrationAllowlists(['slack'], ['slack_v2'])).toEqual(['slack_v2']) + }) + it('keeps only integrations allowed by both policies', () => { expect(intersectIntegrationAllowlists(['Slack', 'Notion'], ['notion', 'gmail'])).toEqual([ - 'notion', + 'notion_v2', ]) }) diff --git a/apps/sim/lib/permission-groups/integration-allowlist.ts b/apps/sim/lib/permission-groups/integration-allowlist.ts index 3656ee60f6d..cf78d778e9b 100644 --- a/apps/sim/lib/permission-groups/integration-allowlist.ts +++ b/apps/sim/lib/permission-groups/integration-allowlist.ts @@ -1,17 +1,86 @@ +import { BLOCK_ACCESS_SUCCESSORS } from '@/lib/permission-groups/block-successors.generated' + +/** + * The block type an allowlist decision about `blockType` is really made against. + * + * A superseded version resolves to the successor its `sunset.replacedBy` names, + * transitively, so allowing or denying an integration covers every version of + * it. Without this an admin would have to know each retired id and deny it + * individually — and could not, since the editor only offers the current ones. + * + * A retired block with no successor keeps its own identity and appears in the + * editor under it, which is the only way an admin can decide about it at all. + * + * Reads the generated projection of the registry rather than the registry + * itself: this module sits under the authorization funnel, which + * `scripts/check-application-graph.ts` forbids from importing `blocks/`. + * `check:block-successors` fails the build when the projection drifts. + */ +export function resolveAccessControlBlockType(blockType: string): string { + return ( + BLOCK_ACCESS_SUCCESSORS[blockType] ?? + BLOCK_ACCESS_SUCCESSORS[blockType.replace(/-/g, '_')] ?? + blockType + ) +} + +/** + * The allowlist, indexed for membership tests against the block type an + * allowlist decision is made against. `null` stays `null` — unrestricted, not + * "nothing allowed". + * + * Both sides have to be normalized or they compare different vocabularies. A + * policy list can name a retired id: `ALLOWED_INTEGRATIONS` is written by hand + * against whatever ids the author knows, so `ALLOWED_INTEGRATIONS=slack` is the + * expected way to permit Slack. The checked type is always successor-resolved, + * so without normalizing the policy the deployment that permitted `slack` would + * refuse every `slack_v2` block in it. + */ +export function toAccessControlAllowlist( + allowedIntegrations: readonly string[] | null +): ReadonlySet | null { + return allowedIntegrations + ? new Set( + allowedIntegrations.map((integration) => + resolveAccessControlBlockType(integration.toLowerCase()).toLowerCase() + ) + ) + : null +} + /** - * Intersects integration allowlists from independent policy layers. + * Intersects two independent integration policies in the *resolved* vocabulary. + * + * Each side is canonicalized before the intersection, not after. A policy list + * can name a retired id while the other names its successor — + * `ALLOWED_INTEGRATIONS=slack` against a group naming `slack_v2` — and folding + * only case leaves those two ids disjoint, intersecting to nothing and hiding + * an integration both policies allow. `null` stays unrestricted on either side. + */ +export function intersectAccessControlAllowlists( + first: readonly string[] | null, + second: readonly string[] | null +): ReadonlySet | null { + const resolvedFirst = toAccessControlAllowlist(first) + const resolvedSecond = toAccessControlAllowlist(second) + if (resolvedFirst === null) return resolvedSecond + if (resolvedSecond === null) return resolvedFirst + return new Set([...resolvedFirst].filter((type) => resolvedSecond.has(type))) +} + +/** + * Intersects integration allowlists from independent policy layers, as a list. * `null` means unrestricted, while an empty array denies every integration. + * + * The list form of {@link intersectAccessControlAllowlists}, for the callers + * that carry the effective policy on a `PermissionGroupConfig`. It canonicalizes + * for the same reason and the result is in the same resolved vocabulary, so + * callers must successor-resolve the type they check against it. */ export function intersectIntegrationAllowlists( first: readonly string[] | null, second: readonly string[] | null ): string[] | null { - const normalizedFirst = first?.map((integration) => integration.toLowerCase()) ?? null - const normalizedSecond = second?.map((integration) => integration.toLowerCase()) ?? null - - if (normalizedFirst === null) return normalizedSecond - if (normalizedSecond === null) return normalizedFirst - - const secondSet = new Set(normalizedSecond) - return normalizedFirst.filter((integration) => secondSet.has(integration)) + const intersection = intersectAccessControlAllowlists(first, second) + return intersection === null ? null : [...intersection] } diff --git a/apps/sim/lib/permission-groups/resolve.server.ts b/apps/sim/lib/permission-groups/resolve.server.ts index 2575b04514a..b254e7d3e2a 100644 --- a/apps/sim/lib/permission-groups/resolve.server.ts +++ b/apps/sim/lib/permission-groups/resolve.server.ts @@ -35,9 +35,15 @@ import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' * * Returns null only when neither layer restricts anything. Otherwise the group's * own allowlist is intersected with the env one by - * {@link intersectIntegrationAllowlists}, which case-folds both sides — callers - * compare against a lowercased block type, and a stored config reaches here - * straight off the wire, where the contract permits any casing. + * {@link intersectIntegrationAllowlists}, which canonicalizes both sides — case + * *and* successor — before intersecting. Both matter here: a stored config + * reaches this function straight off the wire, where the contract permits any + * casing, and the two layers are written independently, so one can name a + * retired id (`ALLOWED_INTEGRATIONS=slack`) while the other names its successor + * (`slack_v2`). Intersecting those textually yields the empty allowlist, which + * refuses an integration both layers allow. The result is therefore in the + * resolved vocabulary, and callers must judge a block type through + * `resolveAccessControlBlockType` before testing membership. */ export function mergeEnvAllowlist( config: PermissionGroupConfig | null From ad317b598116858e66a4d5c0e87459215e9a1cec Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 18:36:57 -0700 Subject: [PATCH 105/179] fix(selectors): gate a selector on the resource it reaches, not the credentials it accepts serviceIds names which credentials a selector accepts, which is a different question from which resource it reads, and the two diverge whenever one provider API is reachable with several sibling connections. google.drive accepts a Drive, Docs, Sheets or Forms credential because all four carry Drive scope, and sharepoint.sites accepts a SharePoint or an Excel one. The gate asked whether ANY accepted service was allowed, so a group that permitted google_sheets_v2 and excluded google_drive could still read Drive through google.drive. The declaration now names its own resource, and that is what the allowlist judges. resourceServiceId is required whenever serviceIds names more than one service and must be one of them; manifest.test.ts pins both, so a new multi-service selector cannot reintroduce the fan-out. The bound credential's provider id is no longer consulted: it describes the key, not the API the selector calls, and narrowing by it is what gated Drive reads on whether a Sheets connection was permitted. --- .../application/execute-selector.test.ts | 57 ++++++++++++++---- .../selectors/application/execute-selector.ts | 14 +++-- apps/sim/lib/selectors/manifest.test.ts | 29 +++++++++ .../selectors/server/integration-access.ts | 59 +++++++++---------- .../lib/selectors/server/providers/google.ts | 7 ++- .../selectors/server/providers/microsoft.ts | 1 + .../selectors/server/providers/sharepoint.ts | 1 + apps/sim/lib/selectors/server/types.ts | 16 +++++ 8 files changed, 135 insertions(+), 49 deletions(-) diff --git a/apps/sim/lib/selectors/application/execute-selector.test.ts b/apps/sim/lib/selectors/application/execute-selector.test.ts index ff2e0716d3e..9448f7359bc 100644 --- a/apps/sim/lib/selectors/application/execute-selector.test.ts +++ b/apps/sim/lib/selectors/application/execute-selector.test.ts @@ -190,36 +190,68 @@ describe('executeSelector', () => { }) /** - * A selector accepting two services would pass a check that asks whether ANY - * declared service is allowed. The resolved credential's provider id is the - * server-trusted narrowing, so the pair is judged as the half the caller is - * really reaching. + * `serviceIds` names which credentials a selector accepts, not which resource + * it reads. `google.drive` accepts a Drive, Docs, Sheets or Forms connection + * because all four carry Drive scope, but it only ever calls the Drive API. + * Judging the accepted set let a group that permits `google_sheets_v2` and + * excludes `google_drive` read Drive through it. */ - it('narrows a two-service selector to the resolved credential provider', async () => { + it('refuses a multi-service selector whose own resource is excluded', async () => { mocks.authorizeCredential.mockImplementation(async () => { mocks.events.push('credential-authorization') - return { suppliedId: 'credential-1', providerId: 'sharepoint' } + return { suppliedId: 'credential-1', providerId: 'google-sheets' } }) mocks.getAttachment.mockReturnValue({ destination: 'fixed', credential: { kind: 'stored', field: 'oauthCredential', - serviceIds: ['sharepoint', 'microsoft-excel'], + serviceIds: ['google-drive', 'google-docs', 'google-sheets', 'google-forms'], + resourceServiceId: 'google-drive', }, execute: mocks.executeAttachment, }) mockResolvePermissionGroupConfig.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, - allowedIntegrations: ['microsoft_excel_v2'], + allowedIntegrations: ['google_sheets_v2'], }) await expect(execute()).rejects.toBeInstanceOf(IntegrationNotAllowedError) expect(mocks.executeAttachment).not.toHaveBeenCalled() }) - /** The same pair, reached through the credential the allowlist does name. */ - it('allows a two-service selector reached through the permitted provider', async () => { + /** + * The same selector, with its own resource permitted. The credential is a + * Sheets one and `google_sheets_v2` is *not* allowed, which is deliberate: + * the credential narrows nothing, because the API the selector reaches is the + * only thing the allowlist has an opinion about. + */ + it('allows a multi-service selector whose own resource is permitted', async () => { + mocks.authorizeCredential.mockImplementation(async () => { + mocks.events.push('credential-authorization') + return { suppliedId: 'credential-1', providerId: 'google-sheets' } + }) + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['google-drive', 'google-docs', 'google-sheets', 'google-forms'], + resourceServiceId: 'google-drive', + }, + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['google_drive'], + }) + + await expect(execute()).resolves.toMatchObject({ kind: 'list' }) + expect(mocks.executeAttachment).toHaveBeenCalledTimes(1) + }) + + /** The SharePoint/Excel pair reads SharePoint, whatever credential opened it. */ + it('refuses a sharepoint selector when only the excel half is allowed', async () => { mocks.authorizeCredential.mockImplementation(async () => { mocks.events.push('credential-authorization') return { suppliedId: 'credential-1', providerId: 'microsoft-excel' } @@ -230,6 +262,7 @@ describe('executeSelector', () => { kind: 'stored', field: 'oauthCredential', serviceIds: ['sharepoint', 'microsoft-excel'], + resourceServiceId: 'sharepoint', }, execute: mocks.executeAttachment, }) @@ -238,8 +271,8 @@ describe('executeSelector', () => { allowedIntegrations: ['microsoft_excel_v2'], }) - await expect(execute()).resolves.toMatchObject({ kind: 'list' }) - expect(mocks.executeAttachment).toHaveBeenCalledTimes(1) + await expect(execute()).rejects.toBeInstanceOf(IntegrationNotAllowedError) + expect(mocks.executeAttachment).not.toHaveBeenCalled() }) /** diff --git a/apps/sim/lib/selectors/application/execute-selector.ts b/apps/sim/lib/selectors/application/execute-selector.ts index 05ce441825f..75661e08726 100644 --- a/apps/sim/lib/selectors/application/execute-selector.ts +++ b/apps/sim/lib/selectors/application/execute-selector.ts @@ -18,7 +18,10 @@ import { SelectorContextUnavailableError, SelectorOptionsUnavailableError, } from '@/lib/selectors/server/errors' -import { assertSelectorIntegrationAllowed } from '@/lib/selectors/server/integration-access' +import { + assertSelectorIntegrationAllowed, + selectorResourceServiceIds, +} from '@/lib/selectors/server/integration-access' import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' import { resolveSelectorReferences } from '@/lib/selectors/server/references' import { getServerSelectorAttachment } from '@/lib/selectors/server/registry' @@ -166,15 +169,14 @@ async function executeAuthorizedSelector(args: { * a capability, and this key's enforcement mechanism is `executor`, not * `capability`. * - * Placed after credential binding so it can be made against the resolved - * credential's provider rather than the declaration alone, and before the - * provider call so a denied integration is never reached. + * Judged against the selector's own resource — the API it calls — not the + * set of credentials it accepts, and not the bound credential's provider. + * Placed before the provider call so a denied integration is never reached. */ await assertSelectorIntegrationAllowed({ principal: args.principal, workspaceId: args.context.workspaceId, - serviceIds: attachment.credential?.serviceIds ?? [], - ...(credential?.providerId ? { providerId: credential.providerId } : {}), + serviceIds: attachment.credential ? selectorResourceServiceIds(attachment.credential) : [], }) const credentialAccess = credential?.access diff --git a/apps/sim/lib/selectors/manifest.test.ts b/apps/sim/lib/selectors/manifest.test.ts index 4263ab3734d..1595c5c09b5 100644 --- a/apps/sim/lib/selectors/manifest.test.ts +++ b/apps/sim/lib/selectors/manifest.test.ts @@ -59,6 +59,35 @@ describe('selector manifest', () => { ]) }) + /** + * `serviceIds` names which credentials a selector accepts; the integration + * allowlist has to judge which resource it *reaches*, and for a shared + * provider API those differ. A multi-service declaration that named no + * resource fell back to "any accepted service is allowed", which let a group + * permitting `google_sheets_v2` read Drive through `google.drive`. + */ + it('makes every multi-service selector name the resource it reaches', () => { + for (const [key, attachment] of Object.entries(serverSelectorRegistry)) { + const credential = attachment.credential + if (!credential || credential.serviceIds.length < 2) continue + + expect(credential.resourceServiceId, `${key} declares no resourceServiceId`).toBeDefined() + expect(credential.serviceIds).toContain(credential.resourceServiceId) + } + }) + + it('pins the resource each shared-provider selector reaches', () => { + expect(serverSelectorRegistry['google.drive'].credential?.resourceServiceId).toBe( + 'google-drive' + ) + expect(serverSelectorRegistry['onedrive.folders'].credential?.resourceServiceId).toBe( + 'onedrive' + ) + expect(serverSelectorRegistry['sharepoint.sites'].credential?.resourceServiceId).toBe( + 'sharepoint' + ) + }) + it('requires executable preparation for every non-fixed destination', () => { const preparedDestinations = Object.values(serverSelectorRegistry).filter( (attachment) => attachment.destination !== 'fixed' diff --git a/apps/sim/lib/selectors/server/integration-access.ts b/apps/sim/lib/selectors/server/integration-access.ts index fd2e90515f6..35a72f78262 100644 --- a/apps/sim/lib/selectors/server/integration-access.ts +++ b/apps/sim/lib/selectors/server/integration-access.ts @@ -2,42 +2,34 @@ import type { Principal } from '@sim/auth/principal' import { getIntegrationTypesForOAuthServiceId } from '@sim/deployment-config/integration-availability' import { createLogger } from '@sim/logger' import { allowedIntegrationTypes } from '@/lib/integrations/principal-scope.server' -import { credentialProviderMatchesService, getServiceConfigByServiceId } from '@/lib/oauth/utils' import { isBlockTypeAccessControlExempt, resolveAccessControlBlockType, } from '@/lib/permission-groups/block-access' +import type { SelectorCredentialPolicy } from '@/lib/selectors/server/types' import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' const logger = createLogger('SelectorIntegrationAccess') /** - * The OAuth services this execution actually stands for. + * The OAuth services a selector execution actually reaches. * - * A selector declares the services whose credentials it accepts, and most - * declare exactly one. A few accept two — `sharepoint`/`microsoft-excel`, - * `onedrive`/`microsoft-word` — and there the declaration alone is too wide: a - * member permitted only one of the pair would pass a check that asks whether - * *any* declared service is allowed. The resolved credential's provider id is - * the server-trusted narrowing, loaded during credential binding from the - * stored row rather than taken from the request, so it names which of the pair - * the caller is really reaching. + * `serviceIds` answers a different question — which *credentials* the selector + * accepts. The two diverge whenever one provider API is reachable with several + * of its sibling connections: `google.drive` accepts a Drive, Docs, Sheets or + * Forms credential because all four carry Drive scope, and `sharepoint.sites` + * accepts a SharePoint or an Excel one. Gating on the accepted set asked + * whether *any* of them was permitted, so a group that allowed + * `google_sheets_v2` and excluded `google_drive` could still read Drive through + * `google.drive`. * - * Falls back to the full declaration when there is no provider id (a fixed - * token carries none) or when it matches none of them, which keeps the check - * from silently widening to "no integration identity" on a shape it cannot - * narrow. + * The declaration therefore names its own resource, and that is what the + * allowlist judges. The credential's provider id is deliberately not consulted: + * it describes the key, not the API the selector calls, and narrowing by it + * gated Drive reads on whether a Sheets connection was permitted. */ -function resolveBoundServiceIds( - serviceIds: readonly string[], - providerId: string | undefined -): readonly string[] { - if (!providerId) return serviceIds - const bound = serviceIds.filter((serviceId) => { - const service = getServiceConfigByServiceId(serviceId) - return service ? credentialProviderMatchesService(providerId, service) : false - }) - return bound.length > 0 ? bound : serviceIds +export function selectorResourceServiceIds(policy: SelectorCredentialPolicy): readonly string[] { + return policy.resourceServiceId ? [policy.resourceServiceId] : policy.serviceIds } /** @@ -55,9 +47,11 @@ function resolveBoundServiceIds( * The decision is the one the block-access path makes. `allowedIntegrationTypes` * is the shared gate — it intersects the caller's permission group with the * deployment's `ALLOWED_INTEGRATIONS`, contributes no group half for a principal - * that stands for no person, and normalizes the policy side through - * `toAccessControlAllowlist`. The checked side is successor-resolved the same - * way, so a group naming `slack` and a selector bound to `slack_v2` match. + * that stands for no person, and canonicalizes each half through + * `resolveAccessControlBlockType` *before* intersecting, so a group naming + * `slack_v2` and a deployment naming `slack` still meet. The checked side is + * successor-resolved the same way, so a group naming `slack` and a selector + * bound to `slack_v2` match. * * A `null` allowlist, a caller no group governs, and a selector with no * integration identity all pass through. The last covers two real shapes: an @@ -67,20 +61,25 @@ function resolveBoundServiceIds( * to no block type. Treating an unmapped service as allowed is deliberate and * is what the credential catalog already does; see * `isOAuthServiceAllowedByIntegrationTypes`. + * + * One service can still map to several block types — the `google-drive` entry + * authenticates both `google_drive` and `google_slides_v2` — and any of them + * satisfies the check. That is the catalog's own shared-service convention and + * not a widening: both block types hold the same Drive scope on the same + * credential, so permitting either already grants the access. */ export async function assertSelectorIntegrationAllowed(input: { principal: Principal workspaceId: string serviceIds: readonly string[] - providerId?: string }): Promise { if (input.serviceIds.length === 0) return const allowlist = await allowedIntegrationTypes(input.principal, input.workspaceId) if (allowlist === null) return - const blockTypes = resolveBoundServiceIds(input.serviceIds, input.providerId).flatMap( - (serviceId) => getIntegrationTypesForOAuthServiceId(serviceId) + const blockTypes = input.serviceIds.flatMap((serviceId) => + getIntegrationTypesForOAuthServiceId(serviceId) ) if (blockTypes.length === 0) return diff --git a/apps/sim/lib/selectors/server/providers/google.ts b/apps/sim/lib/selectors/server/providers/google.ts index da478ecba83..418df995295 100644 --- a/apps/sim/lib/selectors/server/providers/google.ts +++ b/apps/sim/lib/selectors/server/providers/google.ts @@ -422,7 +422,12 @@ export const googleSelectorAttachments = { execute: executeCalendars, }, 'google.drive': { - credential: storedCredential(['google-drive', 'google-docs', 'google-sheets', 'google-forms']), + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['google-drive', 'google-docs', 'google-sheets', 'google-forms'], + resourceServiceId: 'google-drive', + }, destination: 'fixed', execute: executeDrive, }, diff --git a/apps/sim/lib/selectors/server/providers/microsoft.ts b/apps/sim/lib/selectors/server/providers/microsoft.ts index 228a85d6e5e..b2984dde819 100644 --- a/apps/sim/lib/selectors/server/providers/microsoft.ts +++ b/apps/sim/lib/selectors/server/providers/microsoft.ts @@ -604,6 +604,7 @@ const oneDriveFolderCredential: SelectorCredentialPolicy = { kind: 'stored', field: 'oauthCredential', serviceIds: ['onedrive', 'microsoft-word'], + resourceServiceId: 'onedrive', } const excelCredential = microsoftCredential('microsoft-excel') const wordCredential = microsoftCredential('microsoft-word') diff --git a/apps/sim/lib/selectors/server/providers/sharepoint.ts b/apps/sim/lib/selectors/server/providers/sharepoint.ts index fa5a3f86fbc..a0df47220ba 100644 --- a/apps/sim/lib/selectors/server/providers/sharepoint.ts +++ b/apps/sim/lib/selectors/server/providers/sharepoint.ts @@ -33,6 +33,7 @@ const siteCredential = { kind: 'stored', field: 'oauthCredential', serviceIds: ['sharepoint', 'microsoft-excel'], + resourceServiceId: 'sharepoint', } as const async function graphToken(args: ExecuteServerSelectorArgs): Promise { diff --git a/apps/sim/lib/selectors/server/types.ts b/apps/sim/lib/selectors/server/types.ts index ee43e62876c..0373704d29a 100644 --- a/apps/sim/lib/selectors/server/types.ts +++ b/apps/sim/lib/selectors/server/types.ts @@ -14,17 +14,33 @@ export type SelectorDestinationPolicy = 'fixed' | 'credential-bound' | 'user-con export type SelectorProtectedValueKind = 'secret' | 'reference' +/** + * The service whose API a selector actually reaches. + * + * `serviceIds` names which *credentials* a selector accepts, which is not the + * same question as which *resource* it reads. `google.drive` accepts a Drive, + * Docs, Sheets or Forms connection because all four carry Drive scope, but it + * only ever calls the Drive API. Judging the integration allowlist against the + * accepted set let a group that permits `google_sheets_v2` and excludes + * `google_drive` read Drive through it. + * + * Required whenever `serviceIds` names more than one service, and must be one + * of them; `lib/selectors/manifest.test.ts` pins both. A single-service + * declaration is its own resource and omits it. + */ export type SelectorCredentialPolicy = | { kind: 'stored' field: 'oauthCredential' serviceIds: readonly string[] + resourceServiceId?: string } | { kind: 'stored-or-fixed-token' field: 'oauthCredential' serviceIds: readonly string[] tokenPrefixes: readonly string[] + resourceServiceId?: string } export interface AuthorizedSelectorCredential { From 6e1b053a7781610fb73352a7d1658f72436b61fd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 18:37:08 -0700 Subject: [PATCH 106/179] refactor(copilot): read the chat capability off its operation declaration The raw chat handler restated copilot.use in both the assertion and the refusal, so changing chatOperations.send's policy would leave the route over- or under-gated and answering the wrong refusal code. Read the declaration once instead, including its 'none' case, where a declarative surface asserts nothing and so does this. --- apps/sim/lib/copilot/chat/post.test.ts | 10 ++++++++++ apps/sim/lib/copilot/chat/post.ts | 11 +++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 809420f07da..7eebf488605 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -141,6 +141,7 @@ vi.mock('@/lib/copilot/chat-status', () => ({ }, })) +import { chatOperations } from '@/lib/copilot/application/operations' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { handleUnifiedChatPost } from './post' @@ -978,6 +979,15 @@ describe('handleUnifiedChatPost copilot.use capability gate', () => { * taken first and released by the handler's `finally`, so a retry is free to * start a turn. */ + /** + * The capability this raw handler asserts is the one `chatOperations.send` + * declares, not a literal restated beside it — a declarative surface would + * enforce the declaration, and this one must agree with it. + */ + it('enforces the capability the chat operation declares', () => { + expect(chatOperations.send.capability).toBe('copilot.use') + }) + it('refuses the send when the group withholds copilot.use', async () => { resolvePermissionGroupConfig.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index be12cec3faf..fbcb1309a90 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -10,6 +10,7 @@ import { z } from 'zod' import { isZodError, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { chatOperations } from '@/lib/copilot/application/operations' import { DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH, DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH, @@ -1125,6 +1126,10 @@ export async function handleUnifiedChatPost(req: NextRequest) { /** * permission-group-enforced: copilot.use — Chat is a raw handler rather * than a workspace operation, so the authorization funnel never sees it. + * The capability is read off `chatOperations.send` rather than restated, + * so the assertion and the refusal cannot drift from the declaration a + * declarative surface would enforce — including the `'none'` case, where + * a declarative surface asserts nothing and so does this. * * Gated on the workspace the turn actually lands in, which is the one * `resolveBranch` just resolved rather than the one the request asked @@ -1140,17 +1145,19 @@ export async function handleUnifiedChatPost(req: NextRequest) { * taken above is released by the `finally`, so a refused send leaves a * later retry free to start a turn. */ + const chatCapability = chatOperations.send.capability if ( branch.workspaceId && + chatCapability !== 'none' && (await isWorkspaceCapabilityWithheld( authenticatedUserId, branch.workspaceId, - 'copilot.use' + chatCapability )) ) { activeOtelRoot.span.setAttribute(TraceAttr.HttpStatusCode, 403) activeOtelRoot.finish('error') - return capabilityRefusalResponse('copilot.use') + return capabilityRefusalResponse(chatCapability) } let currentChat: ChatLoadResult['chat'] = null From 2d20245ef6bb77be82efbddbd659a57bfcb8be6c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 18:37:09 -0700 Subject: [PATCH 107/179] fix(api): answer an unresolvable workspace actor and a denied workspace creation with controlled bodies resolveWorkspaceRequestActor returns null for a reachable request about an unreachable workspace: an authenticated workspace key whose workspace has been archived has no billed account to stand in as its system actor. All five v1 table call sites threw on that, which the routes' catch-all reported as a generic 500. requireWorkspaceRequestActor projects it onto the 400 those routes already use for a workspace mismatch, from one place rather than five copies. POST /api/workspaces refused a permission-group denial with two different bodies. The revocation race the insert detects carried details.code PERMISSION_GROUP_CAPABILITY_BLOCKED; the far more common preflight denial answered a bare { error }, so a client keying off the code saw the capability refusal only in the rarer case. Both now render through capabilityRefusalResponse. --- apps/sim/app/api/v1/middleware.ts | 24 ++++ .../app/api/v1/tables/[tableId]/route.test.ts | 30 ++++- apps/sim/app/api/v1/tables/[tableId]/route.ts | 14 ++- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 9 +- .../app/api/v1/tables/[tableId]/rows/route.ts | 28 ++--- .../v1/tables/[tableId]/rows/upsert/route.ts | 9 +- apps/sim/app/api/workspaces/route.test.ts | 119 ++++++++++++++++++ apps/sim/app/api/workspaces/route.ts | 12 ++ 8 files changed, 210 insertions(+), 35 deletions(-) create mode 100644 apps/sim/app/api/workspaces/route.test.ts diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index f1045dc42ee..10999de3610 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -532,6 +532,30 @@ export async function resolveWorkspaceRequestActor( return rateLimit.userId ?? null } +/** + * {@link resolveWorkspaceRequestActor} as a route-ready result. + * + * The resolver answers `null` for a real, reachable request: an authenticated + * workspace key whose workspace has since been archived or deleted has no + * billed account to stand in as its system actor. Every call site used to + * `throw` on that, which the route's catch-all turned into a generic 500 — an + * unreachable workspace reported as a server fault. It is the same condition + * the routes already report as a 400 `Invalid workspace ID` when the addressed + * table belongs to another workspace, so it is reported the same way, from one + * place, rather than five copies of a throw. + */ +export async function requireWorkspaceRequestActor( + rateLimit: RateLimitResult, + workspaceId: string +): Promise<{ ok: true; actorUserId: string } | { ok: false; response: NextResponse }> { + const actorUserId = await resolveWorkspaceRequestActor(rateLimit, workspaceId) + if (actorUserId) return { ok: true, actorUserId } + return { + ok: false, + response: NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }), + } +} + /** * v1 wrapper: renders {@link resolveWorkspaceAccess} as the v1 `{ error }` body. * Returns null on success, NextResponse on failure. diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.test.ts b/apps/sim/app/api/v1/tables/[tableId]/route.test.ts index 6060a1c1333..d649908cb40 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.test.ts @@ -46,9 +46,20 @@ vi.mock('@/app/api/v1/middleware', () => ({ /** * Mirrors the real resolver: a workspace key names no human, so the billed * account stands in as the explicit system actor; anything else keeps its - * owner. + * owner. The route reads it through `requireWorkspaceRequestActor`, which + * projects an unresolvable actor onto a 400 instead of throwing, so the mock + * reproduces that projection rather than only the raw resolver. */ resolveWorkspaceRequestActor: mockResolveWorkspaceRequestActor, + requireWorkspaceRequestActor: async (rateLimit: unknown, workspaceId: string) => { + const actorUserId = await mockResolveWorkspaceRequestActor(rateLimit, workspaceId) + return actorUserId + ? { ok: true, actorUserId } + : { + ok: false, + response: NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }), + } + }, })) vi.mock('@/lib/table', () => ({ @@ -109,6 +120,23 @@ describe('DELETE /api/v1/tables/[tableId] — orchestration failure projection', mockGetUserEntityPermissions.mockResolvedValue('admin') }) + /** + * A workspace key whose workspace has since been archived resolves no billed + * account, so there is no system actor to attribute the deletion to. That is + * a reachable request about an unreachable workspace, not a server fault: it + * used to `throw`, and the catch-all reported it as a 500. + */ + it('reports an unresolvable workspace actor as a 400, not a 500', async () => { + mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1', keyType: 'workspace' }) + mockResolveWorkspaceRequestActor.mockResolvedValue(null) + + const response = await DELETE(makeRequest(), makeContext()) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ error: 'Invalid workspace ID' }) + expect(mockPerformDeleteTable).not.toHaveBeenCalled() + }) + it('renders an unclassified internal failure as a fixed generic message', async () => { mockPerformDeleteTable.mockResolvedValue({ success: false, diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index 82ca23dc8e4..c9dcd9daae3 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -17,7 +17,7 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, - resolveWorkspaceRequestActor, + requireWorkspaceRequestActor, tableAccessPrincipal, } from '@/app/api/v1/middleware' @@ -137,12 +137,14 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab * A workspace key names no human, so its creator must not be attributed the * deletion in audit and analytics. The shared resolver substitutes the * explicit system actor for a workspace key and keeps the owner for a - * personal one, exactly as the row routes on this table already do. + * personal one, exactly as the row routes on this table already do. An + * archived or deleted workspace has no billed account to stand in, which is + * a controlled 400 rather than an uncaught throw the catch-all would report + * as a 500. */ - const actorUserId = await resolveWorkspaceRequestActor(rateLimit, workspaceId) - if (!actorUserId) { - throw new Error(`Unable to resolve system actor for workspace ${workspaceId}`) - } + const actor = await requireWorkspaceRequestActor(rateLimit, workspaceId) + if (!actor.ok) return actor.response + const actorUserId = actor.actorUserId const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 05ddfb81b1b..03cf31f3b5a 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -29,7 +29,7 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, - resolveWorkspaceRequestActor, + requireWorkspaceRequestActor, tableAccessPrincipal, v1ValidationErrorResponse, v1ValidationErrorResponseFromError, @@ -134,10 +134,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError - const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId) - if (!actorUserId) { - throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`) - } + const actor = await requireWorkspaceRequestActor(rateLimit, validated.workspaceId) + if (!actor.ok) return actor.response + const actorUserId = actor.actorUserId const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index 6e083e0a87b..30c55eb3156 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -43,7 +43,7 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, - resolveWorkspaceRequestActor, + requireWorkspaceRequestActor, tableAccessPrincipal, v1ValidationErrorResponse, v1ValidationErrorResponseFromError, @@ -239,15 +239,9 @@ export const POST = withRouteHandler( const batchValidated = parsed.data.body const scopeError = await checkWorkspaceScope(rateLimit, batchValidated.workspaceId, 'write') if (scopeError) return scopeError - const actorUserId = await resolveWorkspaceRequestActor( - rateLimit, - batchValidated.workspaceId - ) - if (!actorUserId) { - throw new Error( - `Unable to resolve system actor for workspace ${batchValidated.workspaceId}` - ) - } + const batchActor = await requireWorkspaceRequestActor(rateLimit, batchValidated.workspaceId) + if (!batchActor.ok) return batchActor.response + const actorUserId = batchActor.actorUserId return handleBatchInsert( requestId, tableId, @@ -261,10 +255,9 @@ export const POST = withRouteHandler( const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError - const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId) - if (!actorUserId) { - throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`) - } + const actor = await requireWorkspaceRequestActor(rateLimit, validated.workspaceId) + if (!actor.ok) return actor.response + const actorUserId = actor.actorUserId const accessResult = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) @@ -344,10 +337,9 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError - const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId) - if (!actorUserId) { - throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`) - } + const actor = await requireWorkspaceRequestActor(rateLimit, validated.workspaceId) + if (!actor.ok) return actor.response + const actorUserId = actor.actorUserId const accessResult = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index eb690199e93..a8424bde9b7 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -20,7 +20,7 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, - resolveWorkspaceRequestActor, + requireWorkspaceRequestActor, tableAccessPrincipal, v1ValidationErrorResponse, v1ValidationErrorResponseFromError, @@ -54,10 +54,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError - const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId) - if (!actorUserId) { - throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`) - } + const actor = await requireWorkspaceRequestActor(rateLimit, validated.workspaceId) + if (!actor.ok) return actor.response + const actorUserId = actor.actorUserId const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) diff --git a/apps/sim/app/api/workspaces/route.test.ts b/apps/sim/app/api/workspaces/route.test.ts new file mode 100644 index 00000000000..54fca6aa265 --- /dev/null +++ b/apps/sim/app/api/workspaces/route.test.ts @@ -0,0 +1,119 @@ +/** + * @vitest-environment node + * + * POST /api/workspaces refuses a workspace-creation-denied group at two + * moments: the preflight policy read, and the revocation race the insert + * detects. Both are the same decision, so both must produce the same body — + * the preflight one used to answer a bare `{ error }` with no + * `details.code`, so a client keying off the code saw the capability refusal + * only in the rarer case. + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockGetWorkspaceCreationPolicy, mockCreateWorkspace } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetWorkspaceCreationPolicy: vi.fn(), + mockCreateWorkspace: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@/lib/auth/session-response', () => ({ + getActiveOrganizationId: () => null, +})) + +vi.mock('@/lib/workspaces/create', () => ({ + createWorkspace: mockCreateWorkspace, +})) + +vi.mock('@/lib/workspaces/list', () => ({ + listWorkspacesForViewer: vi.fn(), +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + AuditAction: { WORKSPACE_CREATED: 'workspace.created' }, + AuditResourceType: { WORKSPACE: 'workspace' }, +})) + +vi.mock('@/lib/workspaces/policy', async () => { + class WorkspaceCreationCapabilityWithheldError extends Error {} + class WorkspaceCreationContextChangedError extends Error {} + return { + getWorkspaceCreationPolicy: mockGetWorkspaceCreationPolicy, + WorkspaceCreationCapabilityWithheldError, + WorkspaceCreationContextChangedError, + } +}) + +import { WorkspaceCreationCapabilityWithheldError } from '@/lib/workspaces/policy' +import { POST } from '@/app/api/workspaces/route' + +function createRequest() { + return createMockRequest('POST', { name: 'New workspace' }) +} + +const deniedPolicy = { + canCreate: false, + status: 403, + reason: 'Your permission group does not allow creating workspaces.', + blockedReasonCode: 'permission-group-denied', +} + +describe('POST /api/workspaces capability refusal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'A', email: 'a@example.com' }, + }) + }) + + it('answers the preflight denial with the capability refusal envelope', async () => { + mockGetWorkspaceCreationPolicy.mockResolvedValue(deniedPolicy) + + const response = await POST(createRequest()) + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + expect(mockCreateWorkspace).not.toHaveBeenCalled() + }) + + it('answers the revocation race with the same envelope', async () => { + mockGetWorkspaceCreationPolicy.mockResolvedValue({ canCreate: true, status: 200 }) + mockCreateWorkspace.mockRejectedValue(new WorkspaceCreationCapabilityWithheldError()) + + const response = await POST(createRequest()) + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + }) + + /** A non-capability block keeps its own reason and status. */ + it('leaves an unrelated policy refusal alone', async () => { + mockGetWorkspaceCreationPolicy.mockResolvedValue({ + canCreate: false, + status: 402, + reason: 'Your organization subscription is inactive.', + blockedReasonCode: 'organization-subscription-inactive', + }) + + const response = await POST(createRequest()) + + expect(response.status).toBe(402) + const body = await response.json() + expect(body.error).toBe('Your organization subscription is inactive.') + expect(body.details).toBeUndefined() + }) +}) diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 6b44b6c60bf..7c5c1ebecd5 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -130,6 +130,18 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) if (!creationPolicy.canCreate) { + /** + * The preflight refusal and the revocation-race refusal are the same + * decision reached at two moments, so they must be the same body. Without + * this branch the common path — the group already denied `workspace.create` + * when the policy was read — answered a bare `{ error }`, while only the + * race the `catch` below handles carried + * `details.code: PERMISSION_GROUP_CAPABILITY_BLOCKED`. A client that keys + * off the code then saw the capability refusal in the rarer case only. + */ + if (creationPolicy.blockedReasonCode === 'permission-group-denied') { + return capabilityRefusalResponse('workspace.create') + } return NextResponse.json( { error: creationPolicy.reason || 'Workspace creation is not available.' }, { status: creationPolicy.status } From bf9cdd7b6dd267af22226ea51b9eb81c22e98a70 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 18:39:40 -0700 Subject: [PATCH 108/179] test(v1): pin the workspace-actor projection requireWorkspaceRequestActor is the only place the 'no billed account for this workspace' case is turned into a response, and the table route tests mock the middleware wholesale, so the projection itself needs its own pin. --- apps/sim/app/api/v1/middleware.test.ts | 49 +++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/v1/middleware.test.ts b/apps/sim/app/api/v1/middleware.test.ts index 1ddc142f307..3c92fdb58cd 100644 --- a/apps/sim/app/api/v1/middleware.test.ts +++ b/apps/sim/app/api/v1/middleware.test.ts @@ -29,6 +29,7 @@ const { mockGetRateLimit, mockGetUserEntityPermissions, mockGetWorkspaceBillingSettings, + mockGetWorkspaceBilledAccountUserId, } = vi.hoisted(() => ({ mockAuthenticateV1Request: vi.fn(), mockGetSubscription: vi.fn(), @@ -36,6 +37,7 @@ const { mockGetRateLimit: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceBillingSettings: vi.fn(), + mockGetWorkspaceBilledAccountUserId: vi.fn(), })) vi.mock('@/app/api/v1/auth', () => ({ @@ -61,7 +63,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, - getWorkspaceBilledAccountUserId: vi.fn(async () => 'billed-user'), + getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId, })) import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' @@ -70,6 +72,7 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + requireWorkspaceRequestActor, v1ValidationErrorResponse, } from '@/app/api/v1/middleware' @@ -421,3 +424,47 @@ describe('checkWorkspaceScope', () => { expect(response?.status).toBe(403) }) }) + +describe('requireWorkspaceRequestActor', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-user') + }) + + it('substitutes the billed account as the system actor for a workspace key', async () => { + const actor = await requireWorkspaceRequestActor( + { allowed: true, keyType: 'workspace', userId: 'key-creator' } as never, + 'workspace-1' + ) + + expect(actor).toEqual({ ok: true, actorUserId: 'billed-user' }) + }) + + it('keeps the owner for a personal key', async () => { + const actor = await requireWorkspaceRequestActor( + { allowed: true, keyType: 'personal', userId: 'user-1' } as never, + 'workspace-1' + ) + + expect(actor).toEqual({ ok: true, actorUserId: 'user-1' }) + }) + + /** + * An archived or deleted workspace has no billed account to stand in. That is + * a reachable request about an unreachable workspace, not a server fault: the + * call sites used to throw, and the routes' catch-all reported it as a 500. + */ + it('projects an unresolvable actor onto a 400 rather than throwing', async () => { + mockGetWorkspaceBilledAccountUserId.mockResolvedValue(null) + + const actor = await requireWorkspaceRequestActor( + { allowed: true, keyType: 'workspace', userId: 'key-creator' } as never, + 'workspace-gone' + ) + + expect(actor.ok).toBe(false) + if (actor.ok) throw new Error('expected a refusal') + expect(actor.response.status).toBe(400) + await expect(actor.response.json()).resolves.toEqual({ error: 'Invalid workspace ID' }) + }) +}) From e84519e185a1cd26eff312eba12a8bc454fa70cb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 19:12:07 -0700 Subject: [PATCH 109/179] fix(permission-groups): read the successor map by its own keys only --- .../integration-allowlist.test.ts | 24 +++++++++++++++++++ .../integration-allowlist.ts | 24 +++++++++++++++---- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/permission-groups/integration-allowlist.test.ts b/apps/sim/lib/permission-groups/integration-allowlist.test.ts index 26e64bbdece..fd183581ac3 100644 --- a/apps/sim/lib/permission-groups/integration-allowlist.test.ts +++ b/apps/sim/lib/permission-groups/integration-allowlist.test.ts @@ -40,6 +40,18 @@ describe('resolveAccessControlBlockType', () => { it('accepts the dashed spelling the registry also normalizes', () => { expect(resolveAccessControlBlockType('google-sheets')).toBe('google_sheets_v2') }) + + /** + * `allowedIntegrations` is admin-supplied jsonb and `ALLOWED_INTEGRATIONS` is + * hand-written, so an arbitrary string reaches the successor map. An + * inherited key must stay an ordinary unresolved id rather than answering + * with `Object.prototype`'s function. + */ + it('leaves an object-prototype key alone', () => { + expect(resolveAccessControlBlockType('constructor')).toBe('constructor') + expect(resolveAccessControlBlockType('toString')).toBe('toString') + expect(resolveAccessControlBlockType('__proto__')).toBe('__proto__') + }) }) describe('toAccessControlAllowlist', () => { @@ -61,6 +73,18 @@ describe('toAccessControlAllowlist', () => { it('denies everything for an empty allowlist', () => { expect(toAccessControlAllowlist([])?.size).toBe(0) }) + + /** + * A prototype key used to resolve to an inherited function and throw on + * `.toLowerCase()`, turning one configured string into a 500 on every + * enforcement path that read the group. + */ + it('indexes an object-prototype entry as an ordinary block type', () => { + const allowlist = toAccessControlAllowlist(['constructor', 'slack']) + + expect(allowlist?.has('constructor')).toBe(true) + expect(allowlist?.has('slack_v2')).toBe(true) + }) }) describe('intersectAccessControlAllowlists', () => { diff --git a/apps/sim/lib/permission-groups/integration-allowlist.ts b/apps/sim/lib/permission-groups/integration-allowlist.ts index cf78d778e9b..8f1789c48ef 100644 --- a/apps/sim/lib/permission-groups/integration-allowlist.ts +++ b/apps/sim/lib/permission-groups/integration-allowlist.ts @@ -17,11 +17,25 @@ import { BLOCK_ACCESS_SUCCESSORS } from '@/lib/permission-groups/block-successor * `check:block-successors` fails the build when the projection drifts. */ export function resolveAccessControlBlockType(blockType: string): string { - return ( - BLOCK_ACCESS_SUCCESSORS[blockType] ?? - BLOCK_ACCESS_SUCCESSORS[blockType.replace(/-/g, '_')] ?? - blockType - ) + return ownSuccessor(blockType) ?? ownSuccessor(blockType.replace(/-/g, '_')) ?? blockType +} + +/** + * Reads the successor map by its own keys only. + * + * The generated map is an object literal with an intact prototype, so a bare + * bracket lookup answers `constructor`, `toString`, `valueOf` and friends with + * an inherited function. The ids reaching here come from admin-supplied jsonb + * (`allowedIntegrations`) and from `ALLOWED_INTEGRATIONS`, so a group naming + * `constructor` made {@link toAccessControlAllowlist} call `.toLowerCase()` on + * a function and throw — an unclassified 500 on every enforcement path that + * read that group. `getBlock` guards the registry the same way for the same + * reason. + */ +function ownSuccessor(blockType: string): string | undefined { + return Object.hasOwn(BLOCK_ACCESS_SUCCESSORS, blockType) + ? BLOCK_ACCESS_SUCCESSORS[blockType] + : undefined } /** From ee405935db31578a68294edde6d9fbd4110ebc49 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 19:14:03 -0700 Subject: [PATCH 110/179] fix(table): govern CSV-import auto-fire by the person the route already gated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A synchronous CSV import passed an explicit null governed subject, so the workflow and enrichment cells the appended rows auto-fire ran with no per-tool gate — including when a session member with a restricting permission group started the import. The subject now comes from the same `TableAccessPrincipal` `checkAccess` gated `tables.use` against, so a route cannot gate one subject and dispatch under another. --- .../app/api/table/[tableId]/import/route.ts | 16 ++++-- apps/sim/app/api/table/import-csv/route.ts | 8 +++ apps/sim/app/api/table/utils.ts | 8 ++- apps/sim/lib/table/import-data.ts | 12 +++-- .../lib/table/orchestration/import.test.ts | 39 ++++++++++++++ apps/sim/lib/table/orchestration/import.ts | 51 ++++++++++++++++--- 6 files changed, 117 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 8161813ea47..13b3ab17d15 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -21,9 +21,11 @@ import { performTableCsvImport } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { accessError, + capabilityGovernedUserId, checkAccess, csvProxyBodyCapResponse, multipartErrorResponse, + type TableAccessPrincipal, } from '@/app/api/table/utils' const logger = createLogger('TableImportCSVExisting') @@ -96,11 +98,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro ) } - const accessResult = await checkAccess( - tableId, - { kind: 'user', userId: authResult.userId }, - 'write' - ) + const principal: TableAccessPrincipal = { kind: 'user', userId: authResult.userId } + const accessResult = await checkAccess(tableId, principal, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult @@ -160,6 +159,13 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro createColumns, timezone, requestId, + /** + * An append starts the table's workflow columns on every row it lands, so + * those cells are governed by the same person `checkAccess` just gated — + * not left ungoverned, which would let an import run tools this member's + * permission group withholds. + */ + capabilityGovernedUserId: capabilityGovernedUserId(principal), }) if (!outcome.success) { diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index 0d58c66d3e2..e757c8b325c 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -131,6 +131,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { folderId, timezone, requestId, + /** + * The session or internal-JWT person this route already gated + * `tables.create` against. A table created here has no workflow columns + * yet, so nothing auto-fires today; naming the subject anyway keeps the + * rule "the id the surface gated is the id the write dispatches under" + * with no producer-specific exception to re-argue. + */ + capabilityGovernedUserId: userId, }) if (!outcome.success) { diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index a460f1e458d..5d2a604ddda 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -268,8 +268,14 @@ function roleSubjectUserId(principal: TableAccessPrincipal): string { /** * The id whose permission group governs the request, or `null` when no group * does. Only a `user` principal has one — see {@link TableAccessPrincipal}. + * + * Exported because the gate is not the only thing that needs the subject: a + * write that lands rows auto-fires the table's workflow and enrichment cells, + * and those cells must run under the same person this check just gated, not + * under whatever id the surface had nearest. One statement of the rule, so a + * route cannot gate one subject and dispatch another. */ -function capabilityGovernedUserId(principal: TableAccessPrincipal): string | null { +export function capabilityGovernedUserId(principal: TableAccessPrincipal): string | null { return principal.kind === 'user' ? principal.userId : null } diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 50bca67c255..e1da840c599 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -265,7 +265,14 @@ export async function importAppendRows( table: TableDefinition, additions: { id?: string; name: string; type: string; required?: boolean; unique?: boolean }[], rows: RowData[], - ctx: { workspaceId: string; userId?: string; requestId: string } + ctx: { + workspaceId: string + userId?: string + requestId: string + /** Gate subject for cells the appended rows auto-fire — the subject the + * importing surface resolved from its principal, or `null` for none. */ + capabilityGovernedUserId: string | null + } ): Promise<{ inserted: TableRow[]; table: TableDefinition }> { // Gate capacity before opening the tx — the lookup is a separate pool read. const rowLimit = await assertRowCapacity({ @@ -294,8 +301,7 @@ export async function importAppendRows( rows: batch, workspaceId: ctx.workspaceId, userId: ctx.userId, - /** CSV import is auto-fire: no acting person governs the rows it lands. */ - capabilityGovernedUserId: null, + capabilityGovernedUserId: ctx.capabilityGovernedUserId, secretProvenance: batch.map(createExactEmptyTableRowSecretProvenance), }, working, diff --git a/apps/sim/lib/table/orchestration/import.test.ts b/apps/sim/lib/table/orchestration/import.test.ts index dfac5d90e8d..1212f5795e6 100644 --- a/apps/sim/lib/table/orchestration/import.test.ts +++ b/apps/sim/lib/table/orchestration/import.test.ts @@ -91,6 +91,7 @@ function importParams(overrides: Record = {}) { mode: 'append' as const, timezone: 'UTC', requestId: 'req-1', + capabilityGovernedUserId: 'user-1' as string | null, ...overrides, } } @@ -133,6 +134,43 @@ describe('performTableCsvImport', () => { expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') }) + /** + * The rows an import lands start the table's workflow columns, and those + * cells gate their tools on the governed subject. Dropping it here would run + * the importing member's cells with no per-tool gate at all — the one thing + * `null` means on this field. + */ + it('dispatches the auto-fired cells under the importing person', async () => { + await performTableCsvImport(importParams({ capabilityGovernedUserId: 'user-9' })) + + expect(mockDispatchAfterBatchInsert).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'req-1', + 'user-1', + 'user-9' + ) + expect(mockImportAppendRows).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ capabilityGovernedUserId: 'user-9' }) + ) + }) + + /** An actorless import still says so explicitly rather than by omission. */ + it('carries a null subject through unchanged', async () => { + await performTableCsvImport(importParams({ capabilityGovernedUserId: null })) + + expect(mockDispatchAfterBatchInsert).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'req-1', + 'user-1', + null + ) + }) + it('reports the deleted count on a replace', async () => { const result = await performTableCsvImport(importParams({ mode: 'replace' })) @@ -344,6 +382,7 @@ describe('performCreateTableFromCsv', () => { folderId: null, timezone: 'UTC', requestId: 'req-1', + capabilityGovernedUserId: 'user-1', } } diff --git a/apps/sim/lib/table/orchestration/import.ts b/apps/sim/lib/table/orchestration/import.ts index f736496f313..9b42a52eca3 100644 --- a/apps/sim/lib/table/orchestration/import.ts +++ b/apps/sim/lib/table/orchestration/import.ts @@ -234,6 +234,15 @@ export interface PerformTableCsvImportParams { /** IANA zone used to read naive datetimes (Excel/Sheets exports carry no offset). */ timezone: string requestId?: string + /** + * The person whose permission group gates any cell this import auto-fires, + * or `null` when no person is behind it. Required with an explicit `null` + * rather than optional, matching `insertDispatch`: an import lands rows, and + * landing rows starts workflow and enrichment cells on the table's workflow + * columns. Threaded from the surface that holds the principal rather than + * re-derived here — the route has already gated the same subject. + */ + capabilityGovernedUserId: string | null } export interface TableCsvImportData extends ImportRejectionFields { @@ -271,8 +280,17 @@ export interface PerformTableCsvImportResult { export async function performTableCsvImport( params: PerformTableCsvImportParams ): Promise { - const { table, workspaceId, userId, fileStream, fileName, fallbackDelimiter, mode, timezone } = - params + const { + table, + workspaceId, + userId, + fileStream, + fileName, + fallbackDelimiter, + mode, + timezone, + capabilityGovernedUserId, + } = params const requestId = params.requestId ?? generateRequestId() if (table.archivedAt) return fail('Cannot import into an archived table', 'validation') @@ -367,11 +385,11 @@ export async function performTableCsvImport( workspaceId, userId, requestId, + capabilityGovernedUserId, }) // Fire trigger + scheduler AFTER the tx commits — both read through the // global db connection and would otherwise see no rows. - /** CSV import is auto-fire: no acting person governs the rows it lands. */ - dispatchAfterBatchInsert(finalTable, inserted, requestId, userId, null) + dispatchAfterBatchInsert(finalTable, inserted, requestId, userId, capabilityGovernedUserId) logger.info(`[${requestId}] Append CSV imported`, { tableId: table.id, @@ -419,6 +437,16 @@ export async function performTableCsvImport( export interface PerformCreateTableFromCsvParams { workspaceId: string userId: string + /** + * The person whose permission group gates any cell this import auto-fires, + * or `null` when no person is behind it. Required with an explicit `null` + * rather than optional, matching `insertDispatch`: an import lands rows, and + * landing rows starts workflow and enrichment cells on the table's workflow + * columns. Threaded from the surface that holds the principal rather than + * re-derived here — the route has already gated the same subject. + */ + capabilityGovernedUserId: string | null + /** Multipart file stream. The caller still owns destroying it. */ fileStream: Readable fileName: string @@ -462,8 +490,16 @@ export interface PerformCreateTableFromCsvResult { export async function performCreateTableFromCsv( params: PerformCreateTableFromCsvParams ): Promise { - const { workspaceId, userId, fileStream, fileName, fallbackDelimiter, folderId, timezone } = - params + const { + workspaceId, + userId, + fileStream, + fileName, + fallbackDelimiter, + folderId, + timezone, + capabilityGovernedUserId, + } = params const requestId = params.requestId ?? generateRequestId() const { delimiter, stream } = await sniffCsvDelimiterFromStream(fileStream, fallbackDelimiter) @@ -508,8 +544,7 @@ export async function performCreateTableFromCsv( rows: coerced as RowData[], workspaceId, userId, - /** CSV import is auto-fire: no acting person governs the rows it lands. */ - capabilityGovernedUserId: null, + capabilityGovernedUserId, secretProvenance: coerced.map(createExactEmptyTableRowSecretProvenance), }, // The created table's rowCount is frozen at 0; pass the running total so the From 66056ad28d08179a9d7be362549679b2fe550bed Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 19:15:23 -0700 Subject: [PATCH 111/179] fix(access-control): keep superseded blocks out of the editor's allowlist universe --- .../components/group-detail.tsx | 55 ++++++------ .../utils/integration-allowlist-rows.test.ts | 87 +++++++++++++++++++ .../utils/integration-allowlist-rows.ts | 54 ++++++++++++ .../permission-groups/block-access.test.ts | 42 ++++++++- .../sim/lib/permission-groups/block-access.ts | 22 +++++ 5 files changed, 230 insertions(+), 30 deletions(-) create mode 100644 apps/sim/ee/access-control/utils/integration-allowlist-rows.test.ts create mode 100644 apps/sim/ee/access-control/utils/integration-allowlist-rows.ts diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 8e7cae0d1a6..c803d242f96 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -30,7 +30,7 @@ import { formatDate } from '@sim/utils/formatting' 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 { isAccessControlAllowlistRow } from '@/lib/permission-groups/block-access' import { PLATFORM_CATEGORY_ORDER, PLATFORM_FEATURES } from '@/lib/permission-groups/features' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' @@ -65,6 +65,11 @@ import { useRemovePermissionGroupMember, useUpdatePermissionGroup, } from '@/ee/access-control/hooks/permission-groups' +import { + allowlistRowsFromStored, + toggleAllowlistRow, + withAllowlistRows, +} from '@/ee/access-control/utils/integration-allowlist-rows' import { SettingRow } from '@/ee/components/setting-row' import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers' import { useOrganizationRoster } from '@/hooks/queries/organization' @@ -789,9 +794,15 @@ export function GroupDetail({ * otherwise a null→partial transition by a non-revealed admin would silently * drop a preview block from the stored allowlist and deny it to revealed * users already running it. + * + * EXCLUDES superseded blocks. They are hidden and so are never rendered, but + * they used to be materialized into the allowlist all the same — so an admin + * narrowing a previously-unrestricted allowlist by unchecking `slack_v2` wrote + * `slack` into it, which the runtime resolves back to `slack_v2` and allows. + * A decision about a retired version is made on its successor's row. */ const allBlocks = useMemo(() => { - const blocks = getAllBlocks().filter((b) => !isBlockTypeAccessControlExempt(b.type)) + const blocks = getAllBlocks().filter((b) => isAccessControlAllowlistRow(b.type)) return blocks.sort((a, b) => { const catA = BLOCK_CATEGORY_ORDER[a.category] ?? 3 const catB = BLOCK_CATEGORY_ORDER[b.category] ?? 3 @@ -881,17 +892,16 @@ export function GroupDetail({ const guard = useSettingsUnsavedGuard({ isDirty: hasChanges }) + const allBlockTypes = useMemo(() => allBlocks.map((b) => b.type), [allBlocks]) + /** * `null` means "everything allowed". Indexing the allow-lists once keeps the * per-row membership checks O(1) — they run for every one of the ~200 block * rows on each render, and again in the section-wide `every(...)` scans. */ const allowedIntegrationSet = useMemo( - () => - editingConfig.allowedIntegrations === null - ? null - : new Set(editingConfig.allowedIntegrations), - [editingConfig.allowedIntegrations] + () => allowlistRowsFromStored(allBlockTypes, editingConfig.allowedIntegrations), + [allBlockTypes, editingConfig.allowedIntegrations] ) const allowedProviderSet = useMemo( @@ -994,17 +1004,7 @@ export function GroupDetail({ const toggleIntegration = useCallback( (blockType: string) => { setEditingConfig((prev) => { - const current = prev.allowedIntegrations - let nextAllowed: string[] | null - if (current === null) { - nextAllowed = allBlocks.map((b) => b.type).filter((t) => t !== blockType) - } else if (current.includes(blockType)) { - const updated = current.filter((t) => t !== blockType) - nextAllowed = updated.length === allBlocks.length ? null : updated - } else { - const updated = [...current, blockType] - nextAllowed = updated.length === allBlocks.length ? null : updated - } + const nextAllowed = toggleAllowlistRow(allBlockTypes, prev.allowedIntegrations, blockType) return { ...prev, allowedIntegrations: nextAllowed, @@ -1012,22 +1012,19 @@ export function GroupDetail({ } }) }, - [allBlocks, pruneDeniedTools] + [allBlockTypes, pruneDeniedTools] ) /** Allow or deny a whole section's blocks at once, respecting the active filter. */ const setBlocksAllowed = useCallback( (blocks: BlockConfig[], allowed: boolean) => { setEditingConfig((prev) => { - const allTypes = allBlocks.map((b) => b.type) - const current = - prev.allowedIntegrations === null ? new Set(allTypes) : new Set(prev.allowedIntegrations) - for (const block of blocks) { - if (allowed) current.add(block.type) - else current.delete(block.type) - } - const nextArr = allTypes.filter((t) => current.has(t)) - const nextAllowed = nextArr.length === allTypes.length ? null : nextArr + const nextAllowed = withAllowlistRows( + allBlockTypes, + prev.allowedIntegrations, + blocks.map((block) => block.type), + allowed + ) return { ...prev, allowedIntegrations: nextAllowed, @@ -1035,7 +1032,7 @@ export function GroupDetail({ } }) }, - [allBlocks, pruneDeniedTools] + [allBlockTypes, pruneDeniedTools] ) const isToolAllowed = useCallback( diff --git a/apps/sim/ee/access-control/utils/integration-allowlist-rows.test.ts b/apps/sim/ee/access-control/utils/integration-allowlist-rows.test.ts new file mode 100644 index 00000000000..ec834ae696f --- /dev/null +++ b/apps/sim/ee/access-control/utils/integration-allowlist-rows.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + * + * The universe below is the editor's row set, which excludes superseded blocks + * (`isAccessControlAllowlistRow`). Every id is a real one, so the assertions + * rest on the repository's own lifecycle facts: `slack` was replaced by + * `slack_v2`. + */ +import { describe, expect, it } from 'vitest' +import { + allowlistRowsFromStored, + toggleAllowlistRow, + withAllowlistRows, +} from '@/ee/access-control/utils/integration-allowlist-rows' + +const UNIVERSE = ['agent', 'notion_v2', 'slack_v2'] as const + +describe('allowlistRowsFromStored', () => { + it('keeps an unrestricted allowlist unrestricted', () => { + expect(allowlistRowsFromStored(UNIVERSE, null)).toBeNull() + }) + + /** + * The runtime resolves a stored `slack` to `slack_v2` and allows it, so the + * row has to render checked or the editor is lying about what is permitted. + */ + it('reads a stored retired id as the row it actually governs', () => { + const rows = allowlistRowsFromStored(UNIVERSE, ['slack']) + + expect(rows?.has('slack_v2')).toBe(true) + expect(rows?.has('slack')).toBe(false) + }) + + it('drops an id no row corresponds to', () => { + expect([...(allowlistRowsFromStored(UNIVERSE, ['agent', 'retired_thing']) ?? [])]).toEqual([ + 'agent', + ]) + }) +}) + +describe('toggleAllowlistRow', () => { + /** + * The bug this closes. The editor renders only current blocks, so narrowing a + * previously-unrestricted allowlist used to materialize the hidden `slack` + * alongside the rows — and the runtime resolves `slack` back to `slack_v2`, + * re-allowing the integration the admin had just denied. + */ + it('does not leave a superseded id behind when a row is denied', () => { + const next = toggleAllowlistRow(UNIVERSE, null, 'slack_v2') + + expect(next).toEqual(['agent', 'notion_v2']) + expect(allowlistRowsFromStored(UNIVERSE, next)?.has('slack_v2')).toBe(false) + }) + + /** A stored retired id must follow its successor's fate, not outlive it. */ + it('denies a row a stored retired id was granting', () => { + const next = toggleAllowlistRow(UNIVERSE, ['agent', 'slack'], 'slack_v2') + + expect(next).toEqual(['agent']) + }) + + it('grants a row that was not allowed', () => { + expect(toggleAllowlistRow(UNIVERSE, ['agent'], 'notion_v2')).toEqual(['agent', 'notion_v2']) + }) + + /** Permitting everything is stored as "no restriction", not as a frozen list. */ + it('collapses back to unrestricted when the last row is granted', () => { + expect(toggleAllowlistRow(UNIVERSE, ['agent', 'notion_v2'], 'slack_v2')).toBeNull() + }) +}) + +describe('withAllowlistRows', () => { + it('denies a whole section at once', () => { + expect(withAllowlistRows(UNIVERSE, null, ['notion_v2', 'slack_v2'], false)).toEqual(['agent']) + }) + + it('emits rows in universe order however the section is ordered', () => { + expect(withAllowlistRows(UNIVERSE, [], ['slack_v2', 'agent'], true)).toEqual([ + 'agent', + 'slack_v2', + ]) + }) + + it('collapses to unrestricted when a section grant covers every row', () => { + expect(withAllowlistRows(UNIVERSE, ['agent'], [...UNIVERSE], true)).toBeNull() + }) +}) diff --git a/apps/sim/ee/access-control/utils/integration-allowlist-rows.ts b/apps/sim/ee/access-control/utils/integration-allowlist-rows.ts new file mode 100644 index 00000000000..818289aa20c --- /dev/null +++ b/apps/sim/ee/access-control/utils/integration-allowlist-rows.ts @@ -0,0 +1,54 @@ +import { toAccessControlAllowlist } from '@/lib/permission-groups/block-access' + +/** + * The stored `allowedIntegrations` re-expressed as editor rows: successor- + * resolved the way the runtime resolves it, then projected onto the universe of + * rows the editor actually offers. `null` stays `null` — everything allowed. + * + * A stored list can name a retired id (written before the universe excluded + * superseded blocks, or through the API directly). The runtime resolves it to + * its successor, so a stored `slack` allows `slack_v2`; reading the raw strings + * would render that row unchecked, and toggling it would "enable" an + * integration that was already permitted. Resolving first makes the checkbox + * tell the truth, and the projection drops the stale id on the next write. + */ +export function allowlistRowsFromStored( + universe: readonly string[], + stored: readonly string[] | null +): Set | null { + const resolved = toAccessControlAllowlist(stored) + return resolved === null ? null : new Set(universe.filter((type) => resolved.has(type))) +} + +/** + * The stored allowlist after a set of rows is allowed or denied. + * + * Always emitted in universe order and collapsed back to `null` — unrestricted + * — when every row survives, so a group that ends up permitting everything is + * stored as "no restriction" rather than as a list that silently freezes out + * every integration added later. + */ +export function withAllowlistRows( + universe: readonly string[], + stored: readonly string[] | null, + blockTypes: readonly string[], + allowed: boolean +): string[] | null { + const rows = allowlistRowsFromStored(universe, stored) ?? new Set(universe) + for (const blockType of blockTypes) { + if (allowed) rows.add(blockType) + else rows.delete(blockType) + } + const next = universe.filter((type) => rows.has(type)) + return next.length === universe.length ? null : next +} + +/** {@link withAllowlistRows} for one row, flipping whatever it is now. */ +export function toggleAllowlistRow( + universe: readonly string[], + stored: readonly string[] | null, + blockType: string +): string[] | null { + const rows = allowlistRowsFromStored(universe, stored) + return withAllowlistRows(universe, stored, [blockType], !(rows === null || rows.has(blockType))) +} diff --git a/apps/sim/lib/permission-groups/block-access.test.ts b/apps/sim/lib/permission-groups/block-access.test.ts index ec2db3f6b7f..88514b10888 100644 --- a/apps/sim/lib/permission-groups/block-access.test.ts +++ b/apps/sim/lib/permission-groups/block-access.test.ts @@ -2,7 +2,10 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { + isAccessControlAllowlistRow, + isBlockTypeAccessControlExempt, +} from '@/lib/permission-groups/block-access' import { getBlock } from '@/blocks/registry' const mockGetBlock = getBlock as unknown as ReturnType @@ -81,3 +84,40 @@ describe('isBlockTypeAccessControlExempt', () => { expect(isBlockTypeAccessControlExempt('manual_trigger')).toBe(true) }) }) + +describe('isAccessControlAllowlistRow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + /** + * The bug this closes: the editor renders only visible blocks but used to + * materialize an allowlist from every non-exempt one. Unchecking `slack_v2` + * on a previously-unrestricted group therefore wrote `slack` into the stored + * list, and the runtime resolves `slack` to `slack_v2` — re-allowing exactly + * the integration the admin had just denied. + */ + it('is not a row for a superseded block, which has no row of its own', () => { + registry({ + slack: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } }, + slack_v2: {}, + }) + + expect(isAccessControlAllowlistRow('slack')).toBe(false) + expect(isBlockTypeAccessControlExempt('slack')).toBe(false) + }) + + it('is a row for a current block', () => { + registry({ slack_v2: {} }) + + expect(isAccessControlAllowlistRow('slack_v2')).toBe(true) + }) + + /** Exempt block types are decided by no row at all. */ + it('is not a row for an exempt block', () => { + registry({ thinking: { hideFromToolbar: true }, start_trigger: {} }) + + expect(isAccessControlAllowlistRow('thinking')).toBe(false) + expect(isAccessControlAllowlistRow('start_trigger')).toBe(false) + }) +}) diff --git a/apps/sim/lib/permission-groups/block-access.ts b/apps/sim/lib/permission-groups/block-access.ts index f82103fb71d..5e7f1e01d03 100644 --- a/apps/sim/lib/permission-groups/block-access.ts +++ b/apps/sim/lib/permission-groups/block-access.ts @@ -48,3 +48,25 @@ export function isBlockTypeAccessControlExempt(blockType: string): boolean { const successor = resolveAccessControlBlockType(blockType) return successor === blockType || successor === UNIVERSAL_ENTRY_POINT } + +/** + * Whether `blockType` is a row in the Access Control editor's allowlist + * universe — the set the editor materializes an allowlist from and compares + * against to collapse one back to `null`. + * + * Narrower than {@link isBlockTypeAccessControlExempt} on purpose. A superseded + * block must stay non-exempt at runtime (legacy `slack` reaches Slack and is + * judged as `slack_v2`), but it must not be an editor row: the editor renders + * only visible blocks, so an admin narrowing a previously-unrestricted + * allowlist by unchecking `slack_v2` would still write the hidden `slack` into + * it — and canonical resolution then reads that entry as `slack_v2` and allows + * the very integration the admin just denied. + * + * Viewer-independent, like the exemption: it reads the pure registry and the + * generated successor map, never the visibility projection, so a preview block + * gated for the acting admin stays in the universe and keeps its stored grant. + */ +export function isAccessControlAllowlistRow(blockType: string): boolean { + if (isBlockTypeAccessControlExempt(blockType)) return false + return resolveAccessControlBlockType(blockType) === blockType +} From 8a3c289491383df40fb57d4ea11979a93fb803bb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 19:17:41 -0700 Subject: [PATCH 112/179] fix(table): carry the acting person into the backfill's downstream cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An output backfill writes cells that satisfy downstream groups' deps, and `batchUpdateRows` starts those groups. The write passed an explicit null governed subject, so a cascade started by a person's schema change ran its tools ungated. The subject now rides the backfill payload beside `actorUserId` — a billing attribution that names the workspace billed account when the change carried no human. --- apps/sim/lib/table/application/groups.test.ts | 23 ++++ apps/sim/lib/table/application/groups.ts | 1 + .../table/backfill-governed-subject.test.ts | 112 ++++++++++++++++++ apps/sim/lib/table/backfill-runner.ts | 44 +++++-- apps/sim/lib/table/workflow-groups/service.ts | 9 +- 5 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 apps/sim/lib/table/backfill-governed-subject.test.ts diff --git a/apps/sim/lib/table/application/groups.test.ts b/apps/sim/lib/table/application/groups.test.ts index f881b4c1287..5c8aa4d46cc 100644 --- a/apps/sim/lib/table/application/groups.test.ts +++ b/apps/sim/lib/table/application/groups.test.ts @@ -290,6 +290,29 @@ describe('workflow and enrichment Table application commands', () => { expect(mocks.signal).toHaveBeenCalledWith(table.id) }) + /** + * Adding an output backfills it from saved runs, and a backfilled cell can + * satisfy a downstream group's deps and start it. That cascade is gated on + * the acting person, which is not the billing attribution beside it. + */ + it('names the acting person, not the billing actor, as the backfill cascade subject', async () => { + await addWorkflowTableGroupOutput.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + blockId: 'block-2', + path: 'score', + }, + }) + + expect(mocks.addOutput).toHaveBeenCalledWith( + expect.objectContaining({ capabilityGovernedUserId: 'user-1' }), + 'request-1' + ) + }) + it('persists disabled auto-run on a newly created workflow group', async () => { const result = await createWorkflowTableGroup.execute({ principal, diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index 1887b6840ae..b35eaf6e25b 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -1149,6 +1149,7 @@ export const addWorkflowTableGroupOutput = defineAuthorizedTableUseCase({ actorUserId: resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }).attributedUserId, + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), resolvedOutput: { workflowId: resolvedWorkflow.workflowId, columnType: columnTypeForLeaf(output.leafType), diff --git a/apps/sim/lib/table/backfill-governed-subject.test.ts b/apps/sim/lib/table/backfill-governed-subject.test.ts new file mode 100644 index 00000000000..04e3553da16 --- /dev/null +++ b/apps/sim/lib/table/backfill-governed-subject.test.ts @@ -0,0 +1,112 @@ +/** + * @vitest-environment node + */ + +import { tableRowExecutions, userTableRows, workflowExecutionLogs } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { mockBatchUpdateRows, mockMaterializeExecutionData, mockGetFunctionalBlockOutput } = + vi.hoisted(() => ({ + mockBatchUpdateRows: vi.fn(), + mockMaterializeExecutionData: vi.fn(), + mockGetFunctionalBlockOutput: vi.fn(), + })) + +vi.mock('@/lib/table/rows/service', () => ({ + batchUpdateRows: mockBatchUpdateRows, +})) +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionData: mockMaterializeExecutionData, +})) +vi.mock('@/lib/logs/execution/functional-outputs', () => ({ + getFunctionalBlockOutput: mockGetFunctionalBlockOutput, +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createTableRowSecretProvenanceFromRegistry: () => ({ complete: true, columns: {} }), +})) + +import { maybeBackfillGroupOutputs } from '@/lib/table/backfill-runner' + +const TABLE = { + id: 'table-1', + workspaceId: 'workspace-1', + schema: { columns: [], workflowGroups: [] }, +} as unknown as TableDefinition + +/** Queues the four reads one inline backfill page makes, in the order it makes them. */ +function queueOnePage(): void { + queueTableRows(tableRowExecutions, [{ count: 1 }]) + queueTableRows(tableRowExecutions, [{ rowId: 'row-1', executionId: 'execution-1' }]) + queueTableRows(userTableRows, [{ id: 'row-1', data: {} }]) + queueTableRows(workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionData: {}, + }, + ]) + queueTableRows(tableRowExecutions, []) +} + +describe('backfill cascade governance', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockMaterializeExecutionData.mockResolvedValue({}) + mockGetFunctionalBlockOutput.mockReturnValue({ value: 'filled' }) + mockBatchUpdateRows.mockResolvedValue({ affectedCount: 1, affectedRowIds: ['row-1'] }) + }) + + /** + * A backfilled cell is a dependency: `batchUpdateRows` starts every downstream + * group whose deps it just satisfied. Passing no subject there ran those + * cells with no per-tool gate, which is what `null` means on this field. + */ + it('cascades under the person who made the schema change', async () => { + queueOnePage() + + await maybeBackfillGroupOutputs({ + table: TABLE, + groupId: 'group-1', + outputs: [{ blockId: 'block-1', path: 'value', columnName: 'value' }], + overwrite: true, + requestId: 'request-1', + actorUserId: 'billed-owner', + capabilityGovernedUserId: 'member-1', + }) + + expect(mockBatchUpdateRows).toHaveBeenCalledWith( + expect.objectContaining({ + actorUserId: 'billed-owner', + capabilityGovernedUserId: 'member-1', + }), + expect.anything(), + expect.anything(), + expect.anything() + ) + }) + + /** A change with no acting person still names one explicitly. */ + it('keeps an absent subject null rather than borrowing the billing actor', async () => { + queueOnePage() + + await maybeBackfillGroupOutputs({ + table: TABLE, + groupId: 'group-1', + outputs: [{ blockId: 'block-1', path: 'value', columnName: 'value' }], + overwrite: true, + requestId: 'request-1', + actorUserId: 'billed-owner', + }) + + expect(mockBatchUpdateRows).toHaveBeenCalledWith( + expect.objectContaining({ capabilityGovernedUserId: null }), + expect.anything(), + expect.anything(), + expect.anything() + ) + }) +}) diff --git a/apps/sim/lib/table/backfill-runner.ts b/apps/sim/lib/table/backfill-runner.ts index 43ecb11ddea..1ed604e5ddd 100644 --- a/apps/sim/lib/table/backfill-runner.ts +++ b/apps/sim/lib/table/backfill-runner.ts @@ -56,6 +56,14 @@ export interface TableBackfillPayload { overwrite: boolean /** User who triggered the schema change, for usage attribution on the row writes. */ actorUserId?: string | null + /** + * Person whose permission group gates any cell the backfill's writes cascade + * into. Separate from `actorUserId`, which is a billing attribution and names + * the workspace billed account when the schema change carried no human. Null + * when the change had no acting person; absent on payloads enqueued before + * this field existed, which read as null — the pre-existing behavior. + */ + capabilityGovernedUserId?: string | null } /** @@ -136,8 +144,11 @@ async function processBackfillPage(opts: { execs: Array<{ rowId: string; executionId: string | null }> requestId: string actorUserId?: string | null + /** See {@link TableBackfillPayload.capabilityGovernedUserId}. */ + capabilityGovernedUserId?: string | null }): Promise { - const { table, outputs, overwrite, execs, requestId, actorUserId } = opts + const { table, outputs, overwrite, execs, requestId, actorUserId, capabilityGovernedUserId } = + opts const executionIdsByRow = new Map() for (const e of execs) { @@ -225,11 +236,14 @@ async function processBackfillPage(opts: { workspaceId: table.workspaceId, actorUserId, /** - * A backfill replays values already produced by earlier runs; it starts - * no enrichment of its own and carries no acting person into this - * background pass. + * A backfill replays values already produced by earlier runs, but the + * cells it fills are dependencies: `batchUpdateRows` starts every + * downstream group whose deps just became satisfied. Those cells are + * governed by whoever made the schema change, carried separately from + * `actorUserId` — an attribution that names the workspace billed account + * when the change carried no human, whose denylist is nobody's to run. */ - capabilityGovernedUserId: null, + capabilityGovernedUserId: capabilityGovernedUserId ?? null, secretProvenanceByRowId, }, table, @@ -248,7 +262,8 @@ async function processBackfillPage(opts: { * passes skip already-filled cells). */ export async function runTableBackfill(payload: TableBackfillPayload): Promise { - const { jobId, tableId, groupId, outputs, overwrite, actorUserId } = payload + const { jobId, tableId, groupId, outputs, overwrite, actorUserId, capabilityGovernedUserId } = + payload const requestId = generateId().slice(0, 8) try { @@ -274,6 +289,7 @@ export async function runTableBackfill(payload: TableBackfillPayload): Promise { - const { table, groupId, outputs, overwrite, requestId, actorUserId } = opts + const { table, groupId, outputs, overwrite, requestId, actorUserId, capabilityGovernedUserId } = + opts if (outputs.length === 0) return const [{ count: completedCount }] = await db @@ -353,7 +372,15 @@ export async function maybeBackfillGroupOutputs(opts: { const execs = await selectCompletedExecPage(table.id, groupId, afterRowId, BACKFILL_PAGE_SIZE) if (execs.length === 0) break afterRowId = execs[execs.length - 1].rowId - await processBackfillPage({ table, outputs, overwrite, execs, requestId, actorUserId }) + await processBackfillPage({ + table, + outputs, + overwrite, + execs, + requestId, + actorUserId, + capabilityGovernedUserId, + }) } return } @@ -376,6 +403,7 @@ export async function maybeBackfillGroupOutputs(opts: { outputs, overwrite, actorUserId, + capabilityGovernedUserId, } if (isTriggerDevEnabled) { try { diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index 2f432efcc71..2fd6f96d8b7 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -571,6 +571,7 @@ export async function updateWorkflowGroup( overwrite: false, requestId, actorUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }) } catch (err) { logger.warn( @@ -589,6 +590,7 @@ export async function updateWorkflowGroup( overwrite: true, requestId, actorUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }) } catch (err) { logger.warn( @@ -636,8 +638,12 @@ export async function addWorkflowGroupOutput( path: string /** Optional override; defaults to a slug derived from `path`. */ columnName?: string - /** The member adding the output — billed/gated for any backfill-triggered re-run. */ + /** The member adding the output — the billing attribution for the backfill's + * row writes. Not the gate: see `capabilityGovernedUserId`. */ actorUserId?: string | null + /** Person whose permission group gates any cell the backfill's writes + * cascade into; null when the change has no acting person. */ + capabilityGovernedUserId?: string | null resolvedOutput: { workflowId: string columnType: ColumnDefinition['type'] @@ -869,6 +875,7 @@ export async function addWorkflowGroupOutput( overwrite: false, requestId, actorUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }) } catch (err) { logger.warn( From ae8785bdb754046d260c00f864702238defdd1bf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 19:18:13 -0700 Subject: [PATCH 113/179] fix(copilot): capture prompt telemetry only once the turn is allowed to run --- apps/sim/lib/copilot/chat/post.test.ts | 67 ++++++++++++++++++++++++++ apps/sim/lib/copilot/chat/post.ts | 16 ++++-- apps/sim/lib/copilot/request/otel.ts | 21 +++++--- 3 files changed, 92 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 7eebf488605..7e178dfe1e8 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -61,6 +61,41 @@ const { releaseChatSendClaim: vi.fn(), })) +/** + * The root span, captured so a test can assert what a refused turn exported. + * `withCopilotSpan` is a pass-through here — the nesting it provides is not + * under test and a real tracer would need an exporter to observe. + */ +const { setInputMessages, setUserMessagePreview, startCopilotOtelRoot } = vi.hoisted(() => ({ + setInputMessages: vi.fn(), + setUserMessagePreview: vi.fn(), + startCopilotOtelRoot: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/otel', async () => { + const { ROOT_CONTEXT, trace } = await import('@opentelemetry/api') + const span = () => trace.getTracer('post-test').startSpan('post-test') + startCopilotOtelRoot.mockImplementation(() => ({ + span: span(), + context: ROOT_CONTEXT, + requestId: 'req-1', + finish: vi.fn(), + setUserMessagePreview, + setInputMessages, + setOutputMessages: vi.fn(), + setRequestShape: vi.fn(), + })) + return { + startCopilotOtelRoot, + withCopilotSpan: ( + _name: string, + _attrs: Record | undefined, + fn: (child: ReturnType) => unknown, + _context?: unknown + ) => fn(span()), + } +}) + const resolvePermissionGroupConfig = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig const getSession = authMockFns.mockGetSession @@ -1068,6 +1103,38 @@ describe('handleUnifiedChatPost copilot.use capability gate', () => { expect(createSSEStream).toHaveBeenCalledTimes(1) }) + /** + * Prompt content is exported only once the turn is going to run. GenAI + * message capture is gated on whether capture is enabled at all, not on + * whether this caller may send, so capturing at span start exported the + * message of every turn the gate then refused. + */ + it('exports no part of the prompt when the send is refused', async () => { + resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCopilot: true, + }) + + const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true })) + + expect(response.status).toBe(403) + expect(setInputMessages).not.toHaveBeenCalled() + expect(setUserMessagePreview).not.toHaveBeenCalled() + expect(startCopilotOtelRoot).toHaveBeenCalledWith( + expect.not.objectContaining({ userMessagePreview: expect.anything() }) + ) + }) + + it('captures the prompt once the send is allowed to run', async () => { + resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true })) + + expect(response.status).toBe(200) + expect(setUserMessagePreview).toHaveBeenCalledWith('Hello') + expect(setInputMessages).toHaveBeenCalledWith({ userMessage: 'Hello' }) + }) + /** A branch that lands in no workspace at all is governed by no group. */ it('does not consult a permission group when the branch resolves no workspace', async () => { resolveWorkflowIdForUser.mockResolvedValue({ diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index fbcb1309a90..112efea4011 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -1073,7 +1073,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { executionId, runId, transport: CopilotTransport.Stream, - userMessagePreview: body.message, }) if (otelRoot.requestId) { requestId = otelRoot.requestId @@ -1088,10 +1087,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { if (authenticatedUserEmail) { otelRoot.span.setAttribute(TraceAttr.UserEmail, authenticatedUserEmail) } - // `setInputMessages` is internally gated on - // OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT; safe to call. - otelRoot.setInputMessages({ userMessage: body.message }) - // Wrap the rest of the handler so nested spans attach to the // root via AsyncLocalStorage (otherwise they orphan into new traces). const activeOtelRoot = otelRoot @@ -1160,6 +1155,17 @@ export async function handleUnifiedChatPost(req: NextRequest) { return capabilityRefusalResponse(chatCapability) } + /* Prompt content is captured only once the turn is going to run. Both + calls are internally gated on + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, but the gate is on + whether capture is enabled at all, not on whether this caller may send + — so stamping them at span start exported the message of every turn the + capability check above then refused. Every refusal ahead of this point + (a rejected branch, a withheld `copilot.use`) now records the shape of + the request and none of its content. */ + activeOtelRoot.setUserMessagePreview(body.message) + activeOtelRoot.setInputMessages({ userMessage: body.message }) + let currentChat: ChatLoadResult['chat'] = null let conversationHistory: unknown[] = [] let chatIsNew = false diff --git a/apps/sim/lib/copilot/request/otel.ts b/apps/sim/lib/copilot/request/otel.ts index 291ea303d88..fb7828f7bd2 100644 --- a/apps/sim/lib/copilot/request/otel.ts +++ b/apps/sim/lib/copilot/request/otel.ts @@ -360,7 +360,6 @@ interface CopilotOtelScope { runId?: string streamId?: string transport: 'headless' | 'stream' - userMessagePreview?: string } // Dashboard-column width; long enough for triage disambiguation. @@ -368,11 +367,6 @@ const USER_MESSAGE_PREVIEW_MAX_CHARS = 500 function buildAgentSpanAttributes( scope: CopilotOtelScope & { requestId: string } ): Record { - // Gated behind the same env var as full GenAI message capture — a - // 500-char preview is still user prompt content. - const preview = isGenAIMessageCaptureEnabled() - ? truncateUserMessagePreview(scope.userMessagePreview) - : undefined return { [TraceAttr.GenAiAgentName]: 'mothership', [TraceAttr.GenAiAgentId]: @@ -388,7 +382,6 @@ function buildAgentSpanAttributes( ...(scope.executionId ? { [TraceAttr.CopilotExecutionId]: scope.executionId } : {}), ...(scope.runId ? { [TraceAttr.RunId]: scope.runId } : {}), ...(scope.streamId ? { [TraceAttr.StreamId]: scope.streamId } : {}), - ...(preview ? { [TraceAttr.CopilotUserMessagePreview]: preview } : {}), } } @@ -432,6 +425,15 @@ interface CopilotOtelRoot { error?: unknown, cancelReason?: CopilotRequestCancelReasonValue ) => void + /** + * Stamp the triage preview of the user's prompt. + * + * Gated behind the same env var as full GenAI message capture — a 500-char + * preview is still user prompt content — and separate from span creation so + * that a turn refused before it starts (a capability the caller's permission + * group withholds, a rejected branch) exports no part of the prompt. + */ + setUserMessagePreview: (raw: string | undefined) => void setInputMessages: (input: CopilotAgentInputMessages) => void setOutputMessages: (output: CopilotAgentOutputMessages) => void setRequestShape: (shape: CopilotOtelRequestShape) => void @@ -503,6 +505,11 @@ export function startCopilotOtelRoot( context: rootContext, requestId, finish, + setUserMessagePreview: (raw) => { + if (!isGenAIMessageCaptureEnabled()) return + const preview = truncateUserMessagePreview(raw) + if (preview) span.setAttribute(TraceAttr.CopilotUserMessagePreview, preview) + }, setInputMessages: (input) => setAgentInputMessages(span, input), setOutputMessages: (output) => setAgentOutputMessages(span, output), setRequestShape: (shape) => applyRequestShape(span, shape), From f4768b397648b5512ea9bd3b6037a6a9e9f9a0bb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 19:19:57 -0700 Subject: [PATCH 114/179] fix(table): stop a queued cell whose dispatch was cancelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancelling a dispatch row stops the next window, not the one already queued — the dispatcher blocks on a whole window at a time. Account deletion cancels the departing account's dispatches and then deletes the user row, so those in-flight cells kept invoking tools and writing results under a subject that no longer existed. The cell now reads its owning dispatch before executing and terminalizes itself as cancelled; there is no per-cell alternative, since table_row_executions carries no dispatch column. --- .../background/dispatch-cancel-guard.test.ts | 139 ++++++++++++++++++ .../background/workflow-column-execution.ts | 34 +++++ 2 files changed, 173 insertions(+) create mode 100644 apps/sim/background/dispatch-cancel-guard.test.ts diff --git a/apps/sim/background/dispatch-cancel-guard.test.ts b/apps/sim/background/dispatch-cancel-guard.test.ts new file mode 100644 index 00000000000..ecf287abb5c --- /dev/null +++ b/apps/sim/background/dispatch-cancel-guard.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + */ +import { resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + readDispatch: vi.fn(), + getTableById: vi.fn(), + getRowById: vi.fn(), + executeWorkflow: vi.fn(), + loadDeployedWorkflowState: vi.fn(), + writeWorkflowGroupState: vi.fn(), + markWorkflowGroupPickedUp: vi.fn(), + createWorkflowCellProgressWriter: vi.fn(), + pickNextEligibleGroupForRow: vi.fn(), + stashCellContextForResume: vi.fn(), + classifyWorkflowCellTerminalResult: vi.fn(), +})) + +vi.mock('@/lib/table/dispatcher', () => ({ + readDispatch: mocks.readDispatch, + completeDispatchIfActive: vi.fn(), +})) +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/rows/service', () => ({ + getRowById: mocks.getRowById, + updateRow: vi.fn(), +})) +vi.mock('@/lib/workflows/executor/execute-workflow', () => ({ + executeWorkflow: mocks.executeWorkflow, +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadDeployedWorkflowState: mocks.loadDeployedWorkflowState, +})) +vi.mock('@/lib/table/cell-write', () => ({ + buildCancelledExecution: (prev: { executionId: string | null; workflowId: string }) => ({ + status: 'cancelled', + executionId: prev.executionId, + jobId: null, + workflowId: prev.workflowId, + error: 'Cancelled', + }), + createWorkflowCellProgressWriter: mocks.createWorkflowCellProgressWriter, + writeWorkflowGroupState: mocks.writeWorkflowGroupState, + markWorkflowGroupPickedUp: mocks.markWorkflowGroupPickedUp, +})) +vi.mock('@/lib/table/workflow-cell-result', () => ({ + classifyWorkflowCellTerminalResult: mocks.classifyWorkflowCellTerminalResult, +})) +vi.mock('@/lib/table/workflow-columns', () => ({ + pickNextEligibleGroupForRow: mocks.pickNextEligibleGroupForRow, + stashCellContextForResume: mocks.stashCellContextForResume, +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) + +import { runRowCascadeLoop } from '@/background/workflow-column-execution' + +const TABLE = { + id: 'table-1', + name: 'Table', + workspaceId: 'workspace-1', + schema: { + columns: [], + workflowGroups: [{ id: 'group-1', workflowId: 'workflow-1', outputs: [] }], + }, +} + +const PAYLOAD = { + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + dispatchId: 'tdsp_1', + executionTimeoutMs: 10_000, + billingAttribution: { + actorUserId: 'user-1', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'user-1', + billingEntity: { type: 'user' as const, id: 'user-1' }, + billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + payerSubscription: null, + }, +} as Parameters[0] + +describe('the cell guard on its owning dispatch', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getTableById.mockResolvedValue(TABLE) + mocks.getRowById.mockResolvedValue({ id: 'row-1', data: {}, executions: {} }) + mocks.pickNextEligibleGroupForRow.mockReturnValue(null) + mocks.writeWorkflowGroupState.mockResolvedValue('wrote') + mocks.markWorkflowGroupPickedUp.mockResolvedValue('picked-up') + mocks.loadDeployedWorkflowState.mockResolvedValue(null) + }) + + /** + * The dispatcher blocks on a whole window, so cancelling its row — which is + * all account deletion does before the user row goes away — leaves the cells + * that window already queued free to invoke tools and write results. + */ + it('refuses to execute a cell whose dispatch was cancelled', async () => { + mocks.readDispatch.mockResolvedValue({ id: 'tdsp_1', status: 'cancelled' }) + + await runRowCascadeLoop(PAYLOAD) + + expect(mocks.readDispatch).toHaveBeenCalledWith('tdsp_1') + expect(mocks.executeWorkflow).not.toHaveBeenCalled() + expect(mocks.markWorkflowGroupPickedUp).not.toHaveBeenCalled() + expect(mocks.writeWorkflowGroupState).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + executionState: expect.objectContaining({ status: 'cancelled' }), + }) + ) + }) + + /** + * `complete` is the ordinary state a dispatch reaches while its final window + * is still finishing — stopping on it would kill the run's last cells. + */ + it('lets a cell of a still-live dispatch past the guard', async () => { + mocks.readDispatch.mockResolvedValue({ id: 'tdsp_1', status: 'complete' }) + + await runRowCascadeLoop(PAYLOAD) + + // It got as far as loading the workflow, which the db mock does not have. + const statuses = mocks.writeWorkflowGroupState.mock.calls.map( + ([, write]) => (write as { executionState: { status: string } }).executionState.status + ) + expect(statuses).toContain('error') + expect(statuses).not.toContain('cancelled') + }) +}) diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index cf6816d2386..6b8e75dd161 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -469,6 +469,40 @@ async function runWorkflowAndWriteTerminal( secretProvenance, }) + /** + * Dispatch-level cancellation guard. + * + * The dispatcher blocks on a whole window at a time, so cancelling its + * `table_run_dispatches` row stops the NEXT window and nothing that is + * already queued: those cells still invoke tools and write their results. + * That gap is what account deletion falls into — it cancels the departing + * account's dispatches and then deletes the user row, while the cells the + * last window queued keep running under a subject that no longer exists. + * + * The row cannot be reached the other way: `table_row_executions` carries + * no dispatch column, so there is nothing to cancel per cell. Reading the + * owning dispatch here is the dispatch-linked stop, and it costs one + * indexed primary-key read against a whole workflow run. + * + * `cancelled`, or a row that is gone — nothing deletes a dispatch but the + * table cascade, so a missing one means the table it belonged to is gone. + * `complete` deliberately does not stop the cell: it is the ordinary + * terminal state a dispatch reaches while its last window is finishing. + */ + if (dispatchId) { + const { readDispatch } = await import('@/lib/table/dispatcher') + const owningDispatch = await readDispatch(dispatchId) + if (!owningDispatch || owningDispatch.status === 'cancelled') { + logger.info( + `Skipping cell — owning dispatch is cancelled (table=${tableId} row=${rowId} group=${groupId} dispatch=${dispatchId})` + ) + await writeState( + buildCancelledExecution({ executionId, workflowId, blockErrors: undefined }) + ) + return 'cancelled' + } + } + /** Pre-execution cancellation guard: a cell cancelled while it sat in the * queue (e.g. trigger.dev concurrency backlog) must not run once it * dequeues. Reads the already-loaded row's exec — no extra query. */ From 8d29616da726500aa8ca1f6359955b0f8818b66e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 19:21:20 -0700 Subject: [PATCH 115/179] fix(selectors): gate raw-context and api-key selectors on their integration --- .../application/execute-selector.test.ts | 58 ++++++++++++++++ .../selectors/application/execute-selector.ts | 9 ++- .../server/integration-access.test.ts | 69 +++++++++++++++++++ .../selectors/server/integration-access.ts | 56 +++++++++++---- .../selectors/server/providers/cloudwatch.ts | 9 +++ .../selectors/server/providers/harmonic.ts | 7 ++ .../lib/selectors/server/providers/imap.ts | 8 +++ .../server/providers/managed-agent.ts | 12 ++++ .../selectors/server/providers/netsuite.ts | 10 +++ .../selectors/server/providers/snowflake.ts | 15 ++++ apps/sim/lib/selectors/server/types.ts | 17 +++++ 11 files changed, 252 insertions(+), 18 deletions(-) create mode 100644 apps/sim/lib/selectors/server/integration-access.test.ts diff --git a/apps/sim/lib/selectors/application/execute-selector.test.ts b/apps/sim/lib/selectors/application/execute-selector.test.ts index 9448f7359bc..992e8732c2e 100644 --- a/apps/sim/lib/selectors/application/execute-selector.test.ts +++ b/apps/sim/lib/selectors/application/execute-selector.test.ts @@ -275,6 +275,64 @@ describe('executeSelector', () => { expect(mocks.executeAttachment).not.toHaveBeenCalled() }) + /** + * The hole this closes: a selector authenticated from raw context fields + * (CloudWatch's AWS keys, IMAP's host and password) carries no credential + * policy, so the gate used to resolve it to an empty service list and return + * without checking — reaching the third party with the caller's keys under an + * allowlist that never named it. + */ + it('refuses a raw-context selector whose declared integration is excluded', async () => { + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + integrationBlockTypes: ['cloudwatch'], + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['slack_v2'], + }) + + await expect(execute()).rejects.toBeInstanceOf(IntegrationNotAllowedError) + expect(mocks.executeAttachment).not.toHaveBeenCalled() + }) + + it('executes a raw-context selector whose declared integration is permitted', async () => { + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + integrationBlockTypes: ['cloudwatch'], + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['cloudwatch'], + }) + + await expect(execute()).resolves.toMatchObject({ kind: 'list' }) + expect(mocks.executeAttachment).toHaveBeenCalledTimes(1) + }) + + /** + * An API-key integration owns no OAuth catalog entry, so its service id maps + * to no block type. The declaration is what gives the allowlist something to + * judge, and it must win over the catalog. + */ + it('refuses an api-key selector whose declared integration is excluded', async () => { + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + credential: { kind: 'stored', field: 'oauthCredential', serviceIds: ['snowflake'] }, + integrationBlockTypes: ['snowflake'], + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['slack_v2'], + }) + + await expect(execute()).rejects.toBeInstanceOf(IntegrationNotAllowedError) + expect(mocks.executeAttachment).not.toHaveBeenCalled() + }) + /** * A selector with no integration identity is not an integration: an internal * selector declares no credential policy at all, so an allowlist that names diff --git a/apps/sim/lib/selectors/application/execute-selector.ts b/apps/sim/lib/selectors/application/execute-selector.ts index 75661e08726..75a109feb0a 100644 --- a/apps/sim/lib/selectors/application/execute-selector.ts +++ b/apps/sim/lib/selectors/application/execute-selector.ts @@ -20,7 +20,7 @@ import { } from '@/lib/selectors/server/errors' import { assertSelectorIntegrationAllowed, - selectorResourceServiceIds, + selectorIntegrationBlockTypes, } from '@/lib/selectors/server/integration-access' import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' import { resolveSelectorReferences } from '@/lib/selectors/server/references' @@ -171,12 +171,15 @@ async function executeAuthorizedSelector(args: { * * Judged against the selector's own resource — the API it calls — not the * set of credentials it accepts, and not the bound credential's provider. - * Placed before the provider call so a denied integration is never reached. + * A selector the OAuth catalog cannot identify (raw-context credentials, an + * API-key integration) declares its block types instead of resolving to + * none and passing untested. Placed before the provider call so a denied + * integration is never reached. */ await assertSelectorIntegrationAllowed({ principal: args.principal, workspaceId: args.context.workspaceId, - serviceIds: attachment.credential ? selectorResourceServiceIds(attachment.credential) : [], + blockTypes: selectorIntegrationBlockTypes(attachment), }) const credentialAccess = credential?.access diff --git a/apps/sim/lib/selectors/server/integration-access.test.ts b/apps/sim/lib/selectors/server/integration-access.test.ts new file mode 100644 index 00000000000..82aa9da94d2 --- /dev/null +++ b/apps/sim/lib/selectors/server/integration-access.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { selectorManifest } from '@/lib/selectors/manifest' +import { selectorIntegrationBlockTypes } from '@/lib/selectors/server/integration-access' +import { serverSelectorRegistry } from '@/lib/selectors/server/registry' + +describe('selectorIntegrationBlockTypes', () => { + /** + * The gate passes a selector with no integration identity, so an identity it + * cannot derive is a silent hole: `POST /api/selectors/execute` reaches the + * third party with the caller's credentials and the group's + * `allowedIntegrations` never gets a say. Every selector the manifest calls + * `provider-server` must therefore resolve to at least one block type, either + * through the OAuth credential catalog or by declaring one. + */ + it('gives every provider selector an integration identity', () => { + const ungated = Object.entries(serverSelectorRegistry) + .filter(([key]) => selectorManifest[key as keyof typeof selectorManifest]) + .filter( + ([key, attachment]) => + selectorManifest[key as keyof typeof selectorManifest].classification === + 'provider-server' && selectorIntegrationBlockTypes(attachment).length === 0 + ) + .map(([key]) => key) + + expect(ungated).toEqual([]) + }) + + /** + * The other half of the same rule: an internal selector reads Sim's own + * workspace data, so it is not an integration and nothing gates it. + */ + it('gives an internal selector no integration identity', () => { + const internal = Object.entries(serverSelectorRegistry).filter( + ([key]) => + selectorManifest[key as keyof typeof selectorManifest]?.classification === 'internal-server' + ) + + expect(internal.length).toBeGreaterThan(0) + for (const [key, attachment] of internal) { + expect([key, selectorIntegrationBlockTypes(attachment)]).toEqual([key, []]) + } + }) + + /** A declaration wins over the catalog, which is what an API-key selector needs. */ + it('prefers a declared block type over the credential catalog', () => { + expect( + selectorIntegrationBlockTypes({ + credential: { kind: 'stored', field: 'oauthCredential', serviceIds: ['gmail'] }, + integrationBlockTypes: ['snowflake'], + }) + ).toEqual(['snowflake']) + }) + + it('derives the block type from the credential resource when none is declared', () => { + expect( + selectorIntegrationBlockTypes({ + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['google-drive', 'google-sheets'], + resourceServiceId: 'google-drive', + }, + }) + ).toContain('google_drive') + }) +}) diff --git a/apps/sim/lib/selectors/server/integration-access.ts b/apps/sim/lib/selectors/server/integration-access.ts index 35a72f78262..03ce61b35f3 100644 --- a/apps/sim/lib/selectors/server/integration-access.ts +++ b/apps/sim/lib/selectors/server/integration-access.ts @@ -6,7 +6,10 @@ import { isBlockTypeAccessControlExempt, resolveAccessControlBlockType, } from '@/lib/permission-groups/block-access' -import type { SelectorCredentialPolicy } from '@/lib/selectors/server/types' +import type { + SelectorCredentialPolicy, + ServerSelectorAttachment, +} from '@/lib/selectors/server/types' import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' const logger = createLogger('SelectorIntegrationAccess') @@ -32,6 +35,34 @@ export function selectorResourceServiceIds(policy: SelectorCredentialPolicy): re return policy.resourceServiceId ? [policy.resourceServiceId] : policy.serviceIds } +/** + * The block types an allowlist decision about this selector is made against. + * + * Two independent sources, because the OAuth credential catalog cannot identify + * every selector that reaches a third-party API. A selector authenticated from + * raw context fields (CloudWatch's AWS keys, IMAP's host and password) carries + * no credential policy, and an API-key integration (Snowflake, NetSuite, + * Harmonic) owns no OAuth catalog entry, so its service id maps to no block + * type — both used to yield an empty list and pass the gate untested. They + * declare `integrationBlockTypes` instead, and it wins over the catalog when + * both are present. + * + * An empty result means "no integration identity", which is a pass. That is + * reserved for the internal selectors — workspace files, knowledge bases, + * tables — which read only Sim's own data; + * `selectorIntegrationCoverage` in the manifest test keeps every + * `provider-server` selector out of it. + */ +export function selectorIntegrationBlockTypes( + attachment: Pick +): readonly string[] { + if (attachment.integrationBlockTypes?.length) return attachment.integrationBlockTypes + if (!attachment.credential) return [] + return selectorResourceServiceIds(attachment.credential).flatMap((serviceId) => + getIntegrationTypesForOAuthServiceId(serviceId) + ) +} + /** * Refuses a selector execution whose integration the caller's permission group * does not permit. @@ -54,13 +85,12 @@ export function selectorResourceServiceIds(policy: SelectorCredentialPolicy): re * bound to `slack_v2` match. * * A `null` allowlist, a caller no group governs, and a selector with no - * integration identity all pass through. The last covers two real shapes: an - * internal selector (workspace files, knowledge bases) declares no credential - * policy at all, and an API-key integration — Snowflake, NetSuite, Harmonic — - * owns no OAuth entry in the deployment integration catalog and therefore maps - * to no block type. Treating an unmapped service as allowed is deliberate and - * is what the credential catalog already does; see - * `isOAuthServiceAllowedByIntegrationTypes`. + * integration identity all pass through. The last is now reserved for the + * internal selectors — workspace files, knowledge bases, tables — which read + * only Sim's own data and are not an integration at all. Every selector that + * reaches a third party has an identity, either through the OAuth credential + * catalog or through the `integrationBlockTypes` a raw-context or API-key + * selector declares; see {@link selectorIntegrationBlockTypes}. * * One service can still map to several block types — the `google-drive` entry * authenticates both `google_drive` and `google_slides_v2` — and any of them @@ -71,18 +101,14 @@ export function selectorResourceServiceIds(policy: SelectorCredentialPolicy): re export async function assertSelectorIntegrationAllowed(input: { principal: Principal workspaceId: string - serviceIds: readonly string[] + blockTypes: readonly string[] }): Promise { - if (input.serviceIds.length === 0) return + const blockTypes = input.blockTypes + if (blockTypes.length === 0) return const allowlist = await allowedIntegrationTypes(input.principal, input.workspaceId) if (allowlist === null) return - const blockTypes = input.serviceIds.flatMap((serviceId) => - getIntegrationTypesForOAuthServiceId(serviceId) - ) - if (blockTypes.length === 0) return - const allowed = blockTypes.some( (blockType) => isBlockTypeAccessControlExempt(blockType) || diff --git a/apps/sim/lib/selectors/server/providers/cloudwatch.ts b/apps/sim/lib/selectors/server/providers/cloudwatch.ts index 734498c1674..ad82c168ba3 100644 --- a/apps/sim/lib/selectors/server/providers/cloudwatch.ts +++ b/apps/sim/lib/selectors/server/providers/cloudwatch.ts @@ -53,8 +53,16 @@ async function executeCloudWatchListing( } } +/** + * The integration this selector reaches. Declared rather than derived: the selector authenticates from raw AWS keys in the request context and + * carries no stored connection, so the OAuth credential catalog can identify + * nothing to gate it on. + */ +const integrationBlockTypes = ['cloudwatch'] as const + export const cloudWatchSelectorAttachments = { 'cloudwatch.logGroups': { + integrationBlockTypes, destination: 'fixed', async execute(args) { const listingCredentials = credentials(args.context) @@ -94,6 +102,7 @@ export const cloudWatchSelectorAttachments = { }, }, 'cloudwatch.logStreams': { + integrationBlockTypes, destination: 'fixed', async execute(args) { const listingCredentials = credentials(args.context) diff --git a/apps/sim/lib/selectors/server/providers/harmonic.ts b/apps/sim/lib/selectors/server/providers/harmonic.ts index 5621237c3fc..52f4f04eeed 100644 --- a/apps/sim/lib/selectors/server/providers/harmonic.ts +++ b/apps/sim/lib/selectors/server/providers/harmonic.ts @@ -175,9 +175,16 @@ async function executeSavedSearches(args: ExecuteServerSelectorArgs) { ) } +/** + * The integration this selector reaches. Declared rather than derived: Harmonic is an API-key integration with no entry in the deployment OAuth + * catalog, so its service id maps to no block type. + */ +const integrationBlockTypes = ['harmonic'] as const + export const harmonicSelectorAttachments = { 'harmonic.savedSearches': { credential: { kind: 'stored', field: 'oauthCredential', serviceIds: ['harmonic'] }, + integrationBlockTypes, destination: 'fixed', execute: executeSavedSearches, }, diff --git a/apps/sim/lib/selectors/server/providers/imap.ts b/apps/sim/lib/selectors/server/providers/imap.ts index c6557a7c09e..984172f9c5a 100644 --- a/apps/sim/lib/selectors/server/providers/imap.ts +++ b/apps/sim/lib/selectors/server/providers/imap.ts @@ -19,8 +19,16 @@ function throwPublicImapError(error: unknown): never { throw new SelectorConnectionUnavailableError() } +/** + * The integration this selector reaches. Declared rather than derived: the selector opens an IMAP connection from raw host and password fields in + * the request context and carries no stored connection, so the OAuth + * credential catalog can identify nothing to gate it on. + */ +const integrationBlockTypes = ['imap'] as const + export const imapSelectorAttachments = { 'imap.mailboxes': definePreparedSelectorAttachment({ + integrationBlockTypes, destination: { kind: 'user-controlled', async prepare(args) { diff --git a/apps/sim/lib/selectors/server/providers/managed-agent.ts b/apps/sim/lib/selectors/server/providers/managed-agent.ts index 4598b6be86f..6400bc0bc75 100644 --- a/apps/sim/lib/selectors/server/providers/managed-agent.ts +++ b/apps/sim/lib/selectors/server/providers/managed-agent.ts @@ -97,27 +97,39 @@ const credential = { serviceIds: ['claude-platform'], } as const +/** + * The integration this selector reaches. Declared rather than derived: The managed-agent platform is an + * API-key integration with no entry in the deployment OAuth catalog, so its + * service id maps to no block type and the allowlist would have nothing to + * judge it on. + */ +const integrationBlockTypes = ['managed_agent'] as const + export const managedAgentSelectorAttachments = { 'managedAgent.agents': { credential, + integrationBlockTypes, destination: 'fixed', auditCredentialUse: true, execute: (args) => executeResource(args, 'agents'), }, 'managedAgent.environments': { credential, + integrationBlockTypes, destination: 'fixed', auditCredentialUse: true, execute: (args) => executeResource(args, 'environments'), }, 'managedAgent.vaults': { credential, + integrationBlockTypes, destination: 'fixed', auditCredentialUse: true, execute: (args) => executeResource(args, 'vaults'), }, 'managedAgent.memoryStores': { credential, + integrationBlockTypes, destination: 'fixed', auditCredentialUse: true, execute: (args) => executeResource(args, 'memory-stores'), diff --git a/apps/sim/lib/selectors/server/providers/netsuite.ts b/apps/sim/lib/selectors/server/providers/netsuite.ts index 968d8d15595..1991c6d7396 100644 --- a/apps/sim/lib/selectors/server/providers/netsuite.ts +++ b/apps/sim/lib/selectors/server/providers/netsuite.ts @@ -238,14 +238,24 @@ const credential = { serviceIds: ['netsuite'], } as const +/** + * The integration this selector reaches. Declared rather than derived: NetSuite is an + * API-key integration with no entry in the deployment OAuth catalog, so its + * service id maps to no block type and the allowlist would have nothing to + * judge it on. + */ +const integrationBlockTypes = ['netsuite'] as const + export const netsuiteSelectorAttachments = { 'netsuite.recordTypes': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareNetSuiteDestination }, execute: executeNetSuite, }), 'netsuite.asyncTasks': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareNetSuiteDestination }, execute: executeNetSuite, }), diff --git a/apps/sim/lib/selectors/server/providers/snowflake.ts b/apps/sim/lib/selectors/server/providers/snowflake.ts index cb19767e9c2..fc51f3015a6 100644 --- a/apps/sim/lib/selectors/server/providers/snowflake.ts +++ b/apps/sim/lib/selectors/server/providers/snowflake.ts @@ -164,39 +164,54 @@ const credential = { serviceIds: ['snowflake'], } as const +/** + * The integration this selector reaches. Declared rather than derived: Snowflake is an + * API-key integration with no entry in the deployment OAuth catalog, so its + * service id maps to no block type and the allowlist would have nothing to + * judge it on. + */ +const integrationBlockTypes = ['snowflake'] as const + export const snowflakeSelectorAttachments = { 'snowflake.databases': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), 'snowflake.schemas': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), 'snowflake.tables': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), 'snowflake.warehouses': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), 'snowflake.roles': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), 'snowflake.fileFormats': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), 'snowflake.procedures': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), diff --git a/apps/sim/lib/selectors/server/types.ts b/apps/sim/lib/selectors/server/types.ts index 0373704d29a..d5b2bdcfec1 100644 --- a/apps/sim/lib/selectors/server/types.ts +++ b/apps/sim/lib/selectors/server/types.ts @@ -98,6 +98,21 @@ export interface PreparedSelectorDestination { export interface ServerSelectorAttachment { credential?: SelectorCredentialPolicy + /** + * The block type(s) whose integration this selector's API belongs to, for a + * selector the OAuth credential catalog cannot identify. + * + * The integration gate normally derives the block type from the credential + * policy's service ids. Two shapes defeat that: a selector authenticated from + * raw context fields rather than a stored connection (CloudWatch's AWS keys, + * IMAP's host and password) declares no policy at all, and an API-key + * integration (Snowflake, NetSuite, Harmonic) owns no OAuth catalog entry, so + * its service id maps to nothing. Both still reach a third-party API with the + * caller's credentials, so both must name their integration here. Internal + * selectors — the ones reading only Sim's own workspace data — name none, and + * that is what leaves them ungated. + */ + integrationBlockTypes?: readonly string[] destination: 'fixed' | PreparedSelectorDestination auditCredentialUse?: boolean execute( @@ -141,6 +156,7 @@ export function detailSelectorResult(item: SafeSelectorOption | null): SelectorE export function definePreparedSelectorAttachment(input: { credential?: SelectorCredentialPolicy + integrationBlockTypes?: readonly string[] destination: { kind: Exclude prepare(args: ExecuteServerSelectorArgs): Promise @@ -153,6 +169,7 @@ export function definePreparedSelectorAttachment(input: { }): ServerSelectorAttachment { return { ...(input.credential ? { credential: input.credential } : {}), + ...(input.integrationBlockTypes ? { integrationBlockTypes: input.integrationBlockTypes } : {}), destination: { kind: input.destination.kind, prepare: input.destination.prepare, From b028ce6a58a4c6ca41c2e960aaa34f7193784a2a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 19:28:11 -0700 Subject: [PATCH 116/179] fix(table): run a drained pre-stamp under the subject that stamped it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dispatcher pre-stamp outlives the worker that wrote it: a cell task that finds the row's cascade lock held bails, and the lock owner drains the marker under its own payload. With two dispatches overlapping on one row that ran another dispatch's request against the wrong person's tool denylist — or, when the owner was an actorless auto-fire, against none. The marker now carries its governed subject and the drain reads it back; 0316 also adds the partial index account deletion's cancel filter needs and repeats 0315's backfill to close its rolling-deploy window. --- .../background/drain-governed-subject.test.ts | 152 + .../background/workflow-column-execution.ts | 23 + apps/sim/lib/table/dispatcher.ts | 9 + .../table/prestamp-governed-subject.test.ts | 85 + apps/sim/lib/table/rows/executions.test.ts | 42 + apps/sim/lib/table/rows/executions.ts | 30 + apps/sim/lib/table/types.ts | 8 + .../account-deletion-dispatch-cancel.test.ts | 35 +- ...table_row_execution_capability_subject.sql | 44 + .../db/migrations/meta/0316_snapshot.json | 20839 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 28 + packages/testing/src/mocks/schema.mock.ts | 2 + 13 files changed, 21303 insertions(+), 1 deletion(-) create mode 100644 apps/sim/background/drain-governed-subject.test.ts create mode 100644 apps/sim/lib/table/prestamp-governed-subject.test.ts create mode 100644 packages/db/migrations/0316_table_row_execution_capability_subject.sql create mode 100644 packages/db/migrations/meta/0316_snapshot.json diff --git a/apps/sim/background/drain-governed-subject.test.ts b/apps/sim/background/drain-governed-subject.test.ts new file mode 100644 index 00000000000..9ddf4b8ce15 --- /dev/null +++ b/apps/sim/background/drain-governed-subject.test.ts @@ -0,0 +1,152 @@ +/** + * @vitest-environment node + */ +import { resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getTableById: vi.fn(), + getRowById: vi.fn(), + pickNextEligibleGroupForRow: vi.fn(), + writeWorkflowGroupState: vi.fn(), + markWorkflowGroupPickedUp: vi.fn(), + runEnrichment: vi.fn(), + getEnrichment: vi.fn(), + readStampedCapabilitySubject: vi.fn(), + checkAttributedUsageLimits: vi.fn(), + loadTableRowSecretProvenance: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/rows/service', () => ({ getRowById: mocks.getRowById, updateRow: vi.fn() })) +vi.mock('@/lib/table/rows/executions', () => ({ + readStampedCapabilitySubject: mocks.readStampedCapabilitySubject, +})) +vi.mock('@/lib/table/workflow-columns', () => ({ + pickNextEligibleGroupForRow: mocks.pickNextEligibleGroupForRow, + stashCellContextForResume: vi.fn(), + buildWorkflowGroupExecutionCorrelation: vi.fn(), +})) +vi.mock('@/lib/table/cell-write', () => ({ + buildCancelledExecution: vi.fn(), + createWorkflowCellProgressWriter: vi.fn(), + writeWorkflowGroupState: mocks.writeWorkflowGroupState, + markWorkflowGroupPickedUp: mocks.markWorkflowGroupPickedUp, +})) +vi.mock('@/lib/table/workflow-cell-result', () => ({ + classifyWorkflowCellTerminalResult: vi.fn(), +})) +vi.mock('@/enrichments/registry', () => ({ getEnrichment: mocks.getEnrichment })) +vi.mock('@/enrichments/run', () => ({ + runEnrichment: mocks.runEnrichment, + skippedEnrichmentDetail: () => ({}), +})) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + assertBillingAttributionSnapshot: (snapshot: unknown) => snapshot, + checkAttributedUsageLimits: mocks.checkAttributedUsageLimits, + toBillingContext: () => ({}), +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: () => ({ complete: true, columns: {} }), + createTableRowSecretProvenanceFromRegistry: () => ({ complete: true, columns: {} }), + loadTableRowSecretProvenance: mocks.loadTableRowSecretProvenance, +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) +vi.mock('@/lib/table/dispatcher', () => ({ + readDispatch: vi.fn(async () => ({ id: 'tdsp_carrier', status: 'dispatching' })), + completeDispatchIfActive: vi.fn(), +})) + +import { runRowCascadeLoop } from '@/background/workflow-column-execution' + +function enrichmentGroup(id: string) { + return { + id, + type: 'enrichment', + enrichmentId: 'enrich-1', + workflowId: '', + outputs: [{ blockId: '', path: 'out', columnName: 'out' }], + inputMappings: [{ inputName: 'domain', columnName: 'domain' }], + } +} + +const TABLE = { + id: 'table-1', + name: 'Table', + workspaceId: 'workspace-1', + schema: { + columns: [{ id: 'domain', name: 'domain', type: 'string' }], + workflowGroups: [enrichmentGroup('group-1'), enrichmentGroup('group-2')], + }, +} + +/** The carrier belongs to an actorless auto-fire: no subject, so no tool gate. */ +const CARRIER = { + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workflowId: '', + enrichmentId: 'enrich-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + dispatchId: 'tdsp_carrier', + executionTimeoutMs: 10_000, + capabilityGovernedUserId: null, + billingAttribution: { + actorUserId: 'user-1', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'user-1', + billingEntity: { type: 'user' as const, id: 'user-1' }, + billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + payerSubscription: null, + }, +} as Parameters[0] + +describe('draining another dispatch’s pre-stamped marker', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getTableById.mockResolvedValue(TABLE) + mocks.getEnrichment.mockReturnValue({ + id: 'enrich-1', + name: 'Enrich', + inputs: [{ id: 'domain', required: true }], + outputs: [{ id: 'out' }], + }) + mocks.checkAttributedUsageLimits.mockResolvedValue({ isExceeded: false }) + mocks.markWorkflowGroupPickedUp.mockResolvedValue('picked-up') + mocks.writeWorkflowGroupState.mockResolvedValue('wrote') + mocks.loadTableRowSecretProvenance.mockResolvedValue({ + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + byRowId: {}, + }) + mocks.runEnrichment.mockResolvedValue({ result: { out: 'x' }, cost: 0, detail: {} }) + mocks.readStampedCapabilitySubject.mockResolvedValue('requesting-member') + // group-1 completes, then group-2 is picked up carrying an unclaimed marker. + mocks.getRowById.mockResolvedValue({ + id: 'row-1', + data: { domain: 'example.com' }, + executions: { 'group-2': { status: 'pending', executionId: null, workflowId: '' } }, + }) + mocks.pickNextEligibleGroupForRow + .mockReturnValueOnce(enrichmentGroup('group-2')) + .mockReturnValue(null) + }) + + /** + * The lock owner drains markers it did not stamp. Running them under its own + * subject applies the wrong person's tool denylist — and when the owner is an + * actorless auto-fire, no denylist at all. + */ + it('runs the drained cell under the subject stamped with it', async () => { + await runRowCascadeLoop(CARRIER) + + expect(mocks.readStampedCapabilitySubject).toHaveBeenCalledWith('row-1', 'group-2') + const subjects = mocks.runEnrichment.mock.calls.map( + ([, , ctx]) => (ctx as { userId: string | null }).userId + ) + expect(subjects).toEqual([null, 'requesting-member']) + }) +}) diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index 6b8e75dd161..17ecfd0624e 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -301,6 +301,7 @@ export async function executeWorkflowGroupCellJob( const { getTableById } = await import('@/lib/table/service') const { getRowById } = await import('@/lib/table/rows/service') const { pickNextEligibleGroupForRow } = await import('@/lib/table/workflow-columns') + const { readStampedCapabilitySubject } = await import('@/lib/table/rows/executions') let currentPayload = payload while (true) { @@ -344,6 +345,13 @@ export async function executeWorkflowGroupCellJob( // Re-derive so a workflow group after an enrichment group doesn't keep a stale enrichmentId. enrichmentId: next.enrichmentId, executionId: generateId(), + /** + * The marker was stamped by whichever dispatch requested THIS cell, + * which is not necessarily the one that queued this carrier. Its gate + * belongs to the person who asked for it, so take the subject off the + * stamp rather than carrying our own into someone else's request. + */ + capabilityGovernedUserId: await readStampedCapabilitySubject(rowId, next.id), } } } finally { @@ -362,12 +370,14 @@ export async function runRowCascadeLoop( const { getTableById } = await import('@/lib/table/service') const { getRowById } = await import('@/lib/table/rows/service') const { pickNextEligibleGroupForRow } = await import('@/lib/table/workflow-columns') + const { readStampedCapabilitySubject } = await import('@/lib/table/rows/executions') let currentGroupId = payload.groupId let currentWorkflowId = payload.workflowId // Fresh executionId per iteration: SQL guard rejects writes whose id ≠ // row.executions[gid].executionId, so we need a new claim per group. let currentExecutionId = payload.executionId + let currentCapabilityGovernedUserId = payload.capabilityGovernedUserId ?? null while (true) { if (signal?.aborted) { @@ -377,6 +387,7 @@ export async function runRowCascadeLoop( groupId: currentGroupId, workflowId: currentWorkflowId, executionId: currentExecutionId, + capabilityGovernedUserId: currentCapabilityGovernedUserId, }, signal ) @@ -400,6 +411,7 @@ export async function runRowCascadeLoop( groupId: currentGroupId, workflowId: currentWorkflowId, executionId: currentExecutionId, + capabilityGovernedUserId: currentCapabilityGovernedUserId, }, signal, freshTable, @@ -416,6 +428,17 @@ export async function runRowCascadeLoop( if (!freshRow) break const next = pickNextEligibleGroupForRow(freshTable, freshRow, currentGroupId) if (!next) break + const nextExec = freshRow.executions?.[next.id] + /** + * A dep-fill cascade stays under the subject that started it. A group + * carrying an unclaimed pre-stamp is a different thing — an explicit + * request from another dispatch that this cascade is draining — so it runs + * under the subject stamped with that request. + */ + currentCapabilityGovernedUserId = + nextExec?.status === 'pending' && nextExec.executionId == null + ? await readStampedCapabilitySubject(rowId, next.id) + : currentCapabilityGovernedUserId currentGroupId = next.id currentWorkflowId = next.workflowId currentExecutionId = generateId() diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index 9b37a8888eb..503647d9998 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -815,6 +815,15 @@ async function stampQueuedForBatch( jobId: null, workflowId: runOpts.workflowId, error: null, + /** + * The marker outlives this dispatch's own worker: a cell task that + * finds the row's cascade lock held bails, and whoever owns the lock + * drains this marker instead. Persisting the subject is what makes + * that drain run under the person who requested THIS cell rather + * than under the owner's — a different dispatch, and often an + * actorless auto-fire with no gate at all. + */ + capabilityGovernedUserId: runOpts.capabilityGovernedUserId ?? null, }, } ) diff --git a/apps/sim/lib/table/prestamp-governed-subject.test.ts b/apps/sim/lib/table/prestamp-governed-subject.test.ts new file mode 100644 index 00000000000..9ac555ed84b --- /dev/null +++ b/apps/sim/lib/table/prestamp-governed-subject.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getTableById: vi.fn(), + writeWorkflowGroupState: vi.fn(), + batchEnqueueAndWait: vi.fn(), +})) + +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/cell-write', () => ({ + writeWorkflowGroupState: mocks.writeWorkflowGroupState, +})) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + assertBillingAttributionSnapshot: (snapshot: unknown) => snapshot, + resolveBillingAttribution: async () => ({ actorUserId: 'billing-owner' }), + resolveSystemBillingAttribution: async () => ({ actorUserId: null }), +})) +vi.mock('@/lib/core/async-jobs/config', () => ({ + getJobQueue: async () => ({ batchEnqueueAndWait: mocks.batchEnqueueAndWait }), +})) + +import { dispatcherStep } from '@/lib/table/dispatcher' + +const GROUP = { id: 'group-1', workflowId: 'workflow-1', outputs: [] } + +const DISPATCH = { + id: 'tdsp_1', + tableId: 'table-1', + workspaceId: 'workspace-1', + requestId: 'req-1', + mode: 'incomplete', + scope: { groupIds: ['group-1'] }, + status: 'dispatching', + cursor: -1, + limit: null, + processedCount: 0, + isManualRun: true, + triggeredByUserId: 'billing-owner', + capabilityGovernedUserId: 'requesting-member', + requestedAt: new Date('2026-08-21T15:00:00.000Z'), + completedAt: null, + cancelledAt: null, +} + +describe('the dispatcher pre-stamp', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getTableById.mockResolvedValue({ + id: 'table-1', + workspaceId: 'workspace-1', + schema: { columns: [], workflowGroups: [GROUP] }, + }) + mocks.writeWorkflowGroupState.mockResolvedValue('wrote') + dbChainMockFns.limit + .mockResolvedValueOnce([DISPATCH]) + .mockResolvedValueOnce([{ id: 'row-1', tableId: 'table-1', position: 0, data: {} }]) + .mockResolvedValueOnce([DISPATCH]) + }) + + /** + * The marker outlives its own worker: a cell task that finds the row's + * cascade lock held bails, and the lock owner drains the marker. Without the + * subject on the stamp, that drain runs the request under the owner's + * subject — a different dispatch, often an ungated auto-fire. + */ + it('stamps the dispatch’s governed subject onto every cell it queues', async () => { + await dispatcherStep('tdsp_1') + + expect(mocks.writeWorkflowGroupState).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + executionState: expect.objectContaining({ + status: 'pending', + capabilityGovernedUserId: 'requesting-member', + }), + }) + ) + }) +}) diff --git a/apps/sim/lib/table/rows/executions.test.ts b/apps/sim/lib/table/rows/executions.test.ts index dff9b36ae57..01ad6b1c56d 100644 --- a/apps/sim/lib/table/rows/executions.test.ts +++ b/apps/sim/lib/table/rows/executions.test.ts @@ -33,6 +33,48 @@ describe('writeExecutionsPatch guards', () => { resetDbChainMock() }) + /** + * The dispatcher's `pending` marker is drained by whichever worker owns the + * row's cascade lock, which may belong to another dispatch entirely. Storing + * the requesting subject with the marker is what lets that drain run under + * the person who asked rather than under the owner's own subject. + */ + it('persists the pre-stamp’s governed subject on both the insert and the upsert', async () => { + await writeExecutionsPatch( + dbChainMock.db as unknown as Parameters[0], + 'table-1', + 'row-1', + { + 'group-1': { + ...EXECUTION_STATE, + status: 'pending', + executionId: null, + capabilityGovernedUserId: 'requesting-member', + }, + } + ) + + const values = dbChainMockFns.values.mock.calls[0]?.[0] as Record + expect(values.capabilityGovernedUserId).toBe('requesting-member') + const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as { + set: Record + } + expect(conflict.set.capabilityGovernedUserId).toBe('requesting-member') + }) + + /** A write that names no subject clears it — only an unclaimed marker is read. */ + it('writes null for a state that carries no subject', async () => { + await writeExecutionsPatch( + dbChainMock.db as unknown as Parameters[0], + 'table-1', + 'row-1', + { 'group-1': EXECUTION_STATE } + ) + + const values = dbChainMockFns.values.mock.calls[0]?.[0] as Record + expect(values.capabilityGovernedUserId).toBeNull() + }) + it('rejects a worker write when the atomic stale-or-cancel predicate returns no row', async () => { dbChainMockFns.returning.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/table/rows/executions.ts b/apps/sim/lib/table/rows/executions.ts index e1cae632391..6b16a23edda 100644 --- a/apps/sim/lib/table/rows/executions.ts +++ b/apps/sim/lib/table/rows/executions.ts @@ -5,6 +5,7 @@ * directly from `@/lib/table/rows/executions`. */ +import { db } from '@sim/db' import { tableRowExecutions, userTableRows } from '@sim/db/schema' import { and, eq, inArray, type SQL, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' @@ -309,6 +310,12 @@ export async function writeExecutionsPatch( runningBlockIds: value.runningBlockIds ?? [], blockErrors: value.blockErrors ?? {}, cancelledAt: value.cancelledAt ? new Date(value.cancelledAt) : null, + /** + * Written verbatim rather than made sticky like `enrichmentDetails`: only + * an unclaimed pre-stamp is ever read for it, and a re-stamp by a + * different dispatch must not inherit the previous run's subject. + */ + capabilityGovernedUserId: value.capabilityGovernedUserId ?? null, enrichmentDetails: value.enrichmentDetails ?? null, updatedAt: new Date(), } as const @@ -346,6 +353,7 @@ export async function writeExecutionsPatch( runningBlockIds: insertValues.runningBlockIds, blockErrors: insertValues.blockErrors, cancelledAt: insertValues.cancelledAt, + capabilityGovernedUserId: insertValues.capabilityGovernedUserId, // Sticky: preserve a prior cascade breakdown when this write omits // it (e.g. the running pickup stamp) so only an explicit detail // overwrites it. Re-runs delete the row first, so this never serves @@ -374,6 +382,7 @@ export async function writeExecutionsPatch( runningBlockIds: insertValues.runningBlockIds, blockErrors: insertValues.blockErrors, cancelledAt: insertValues.cancelledAt, + capabilityGovernedUserId: insertValues.capabilityGovernedUserId, // Sticky: preserve a prior cascade breakdown when this write omits it // (e.g. the running pickup stamp) so only an explicit detail overwrites // it. Re-runs delete the row first, so this never serves stale detail. @@ -386,6 +395,27 @@ export async function writeExecutionsPatch( return 'wrote' } +/** + * The governed subject persisted with a cell's dispatcher pre-stamp. + * + * Read on the drain path only — a worker taking over a `pending` marker it did + * not stamp — so the column stays off the hot grid read (`loadExecutionsByRow`) + * and never reaches a client. Returns `null` for a marker written before the + * column existed and for a genuinely actorless request; both mean the same + * thing to the gate. + */ +export async function readStampedCapabilitySubject( + rowId: string, + groupId: string +): Promise { + const [stamped] = await db + .select({ capabilityGovernedUserId: tableRowExecutions.capabilityGovernedUserId }) + .from(tableRowExecutions) + .where(and(eq(tableRowExecutions.rowId, rowId), eq(tableRowExecutions.groupId, groupId))) + .limit(1) + return stamped?.capabilityGovernedUserId ?? null +} + /** * Strips the given workflow group ids from every row's executions on a table — * used by the column / group delete paths so stale running/queued exec records diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 51ca2821a55..a9fdb9848e4 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -248,6 +248,14 @@ export interface RowExecutionMetadata { * re-runs whose `cancelledAt > dispatch.requestedAt` — a user cancel * mid-dispatch must not be overridden by `isManualRun`. */ cancelledAt?: string + /** + * Person whose permission group gates this cell's tools, written with the + * dispatcher's `pending` pre-stamp so the worker that eventually drains the + * marker runs it under the subject that requested it rather than its own. + * Persisted on `tableRowExecutions` but NOT hydrated by `loadExecutionsByRow` + * — it is read on demand, only while the marker is still unclaimed. + */ + capabilityGovernedUserId?: string | null /** * Enrichment cascade breakdown for `enrichment`-type groups, written on the * terminal cell write. Persisted on `tableRowExecutions` but NOT hydrated by diff --git a/apps/sim/lib/users/account-deletion-dispatch-cancel.test.ts b/apps/sim/lib/users/account-deletion-dispatch-cancel.test.ts index cc9b03f00c6..ecd9fb429e5 100644 --- a/apps/sim/lib/users/account-deletion-dispatch-cancel.test.ts +++ b/apps/sim/lib/users/account-deletion-dispatch-cancel.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { dbChainMockFns, hasMockCondition, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockIsSoleOwnerOfPaidOrganization, mockGetPersonalSubscription, mockIsUsingCloudStorage } = @@ -57,6 +57,39 @@ describe('deleteUserAccount and the governed-subject foreign key', () => { expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.user) }) + /** + * The subject, not the attribution: `triggered_by_user_id` names the workspace + * billed account when the run's credential named no human, so cancelling on it + * would stop a workspace-key run this account never governed and leave the + * account's own actorless-looking rows alive. + */ + it('cancels on the governed subject and only the still-active statuses', async () => { + await deleteUserAccount('user-1') + + const filter = dbChainMockFns.where.mock.calls + .map(([condition]) => condition) + .find((condition) => + hasMockCondition( + condition, + (node) => node.left === schemaMock.tableRunDispatches.capabilityGovernedUserId + ) + ) + expect(filter).toBeDefined() + expect(hasMockCondition(filter, (node) => node.type === 'eq' && node.right === 'user-1')).toBe( + true + ) + expect( + hasMockCondition( + filter, + (node) => + node.type === 'inArray' && + node.column === schemaMock.tableRunDispatches.status && + Array.isArray(node.values) && + node.values.join(',') === 'pending,dispatching' + ) + ).toBe(true) + }) + /** The cancel must precede the delete, or the FK has already nulled the subject. */ it('orders the cancel ahead of the user delete', async () => { await deleteUserAccount('user-1') diff --git a/packages/db/migrations/0316_table_row_execution_capability_subject.sql b/packages/db/migrations/0316_table_row_execution_capability_subject.sql new file mode 100644 index 00000000000..f063921ede6 --- /dev/null +++ b/packages/db/migrations/0316_table_row_execution_capability_subject.sql @@ -0,0 +1,44 @@ +-- Adds the permission-group subject a queued cell is gated against to the cell sidecar, repeats +-- 0315's dispatch backfill, and adds the index the account-deletion cancel needs. +-- +-- The cell column exists because a dispatcher pre-stamp outlives the worker that wrote it: a cell +-- task that finds the row's cascade lock held bails, and whoever owns the lock drains the marker. +-- Without the subject on the marker that drain runs someone else's request under its own subject. +-- Additive and nullable; NULL means "no acting person, no per-tool gate", which is exactly what a +-- marker written before this column existed was already doing. +ALTER TABLE "table_row_executions" ADD COLUMN "capability_governed_user_id" text;--> statement-breakpoint +ALTER TABLE "table_row_executions" ADD CONSTRAINT "table_row_executions_capability_governed_user_id_user_id_fk" FOREIGN KEY ("capability_governed_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action NOT VALID;--> statement-breakpoint +ALTER TABLE "table_row_executions" VALIDATE CONSTRAINT "table_row_executions_capability_governed_user_id_user_id_fk";--> statement-breakpoint +-- Repeat of 0315's backfill, deliberately. +-- +-- 0315 ran at ITS deploy, while instances of the previous release were still serving. Those +-- instances insert dispatches without the column, so a run they started in that window carries a +-- NULL subject and reads as actorless — ungated — even when a person triggered it. Nothing in a +-- read-side compatibility rule can repair that: treating "NULL subject, non-null triggered_by" as +-- governed re-applies the workspace billed account to workspace-key runs, which is the exact +-- bystander substitution 0315 exists to remove, and "only for rows older than the new writers" is +-- not a predicate this schema can express. +-- +-- Repeating the backfill one migration later closes the 0315 window at the next deploy boundary, +-- because by then every writer of those rows was the old release. It does not close its own: rows +-- inserted by an old instance during THIS deploy stay NULL until they go terminal. That residue is +-- bounded by one deploy's drain rather than by a dispatch's lifetime, and it fails toward the +-- behavior those rows had before the column existed. +-- migration-safe: bounded backfill over non-terminal dispatches only (status pending/dispatching — a few rows at any instant, indexed by `table_run_dispatches_active_idx`), idempotent under the IS NULL guard, and it preserves rather than changes the gate those rows already had. A replay after the new writers are live could only re-gate a still-queued actorless row, which fails closed. +UPDATE "table_run_dispatches" +SET "capability_governed_user_id" = "triggered_by_user_id" +WHERE "status" IN ('pending', 'dispatching') + AND "capability_governed_user_id" IS NULL + AND "triggered_by_user_id" IS NOT NULL;--> statement-breakpoint +-- Concurrent index operations cannot run inside the migration runner's transaction. +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +-- Account deletion cancels every still-active dispatch the departing account governs, and that is +-- the only query keyed on the subject. The other two indexes on this table lead with `table_id` / +-- `status`, so without this one the deletion scans every active dispatch while holding its +-- transaction open. Partial on the two live statuses: a terminal row is never a cancellation +-- target, and dispatch history is what grows. +-- migration-safe: replay removes an invalid build created by this migration; concurrent operations preserve row writes. +DROP INDEX CONCURRENTLY IF EXISTS "table_run_dispatches_governed_active_idx";--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "table_run_dispatches_governed_active_idx" ON "table_run_dispatches" USING btree ("capability_governed_user_id","status") WHERE "table_run_dispatches"."status" IN ('pending', 'dispatching');--> statement-breakpoint +SET lock_timeout = '5s'; diff --git a/packages/db/migrations/meta/0316_snapshot.json b/packages/db/migrations/meta/0316_snapshot.json new file mode 100644 index 00000000000..dbc24cab9e7 --- /dev/null +++ b/packages/db/migrations/meta/0316_snapshot.json @@ -0,0 +1,20839 @@ +{ + "id": "1fafbd78-3e0f-4cc8-9a51-053a3b11af9e", + "prevId": "1c5e9470-9b1b-4290-9952-7adfaac8ae89", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_status_idx": { + "name": "credential_group_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_name_unique": { + "name": "credential_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "managed_oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 84d8c86a129..286f0463fbf 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2206,6 +2206,13 @@ "when": 1788222849643, "tag": "0315_table_dispatch_capability_governed_user", "breakpoints": true + }, + { + "idx": 316, + "version": "7", + "when": 1788229583797, + "tag": "0316_table_row_execution_capability_subject", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 25ad4329c4e..c7aa581629e 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -5003,6 +5003,25 @@ export const tableRowExecutions = pgTable( runningBlockIds: text('running_block_ids').array().notNull().default(sql`'{}'::text[]`), blockErrors: jsonb('block_errors').notNull().default({}), cancelledAt: timestamp('cancelled_at'), + /** + * Person whose permission group gates this cell's tools, persisted with the + * dispatcher's `pending` pre-stamp. + * + * The stamp and the worker that runs it are not the same run: a cell task + * that finds the row's cascade lock held bails, and the lock owner drains + * the marker itself. That owner belongs to whatever dispatch queued IT, so + * without the subject on the marker the drained cell would run under the + * wrong person's group — or under none, when the owner is an actorless + * auto-fire. Read only while the marker is unclaimed (`pending` with a null + * `execution_id`); later writes on the same cell carry no subject and null + * it, which is why nothing reads it after pickup. + * + * `ON DELETE SET NULL`, matching `table_run_dispatches`: a deleted person's + * runs are stopped by that table's cancel, not held open by this reference. + */ + capabilityGovernedUserId: text('capability_governed_user_id').references(() => user.id, { + onDelete: 'set null', + }), /** * Enrichment cascade breakdown (provider outcomes, cost, timing) for * `enrichment`-type groups. Null for workflow groups and pre-feature runs. @@ -5104,6 +5123,15 @@ export const tableRunDispatches = pgTable( (table) => ({ activeIdx: index('table_run_dispatches_active_idx').on(table.tableId, table.status), watchdogIdx: index('table_run_dispatches_watchdog_idx').on(table.status, table.requestedAt), + /** Account deletion cancels every still-active dispatch the departing + * account governs, and that is the only query keyed on the subject. The + * other two indexes lead with `table_id` / `status`, so without this one + * the deletion scans every active dispatch in the deployment while holding + * its transaction open. Partial on the two live statuses: a terminal row is + * never a cancellation target, and dispatch history is what grows. */ + governedActiveIdx: index('table_run_dispatches_governed_active_idx') + .on(table.capabilityGovernedUserId, table.status) + .where(sql`${table.status} IN ('pending', 'dispatching')`), }) ) diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 025fa9951b3..3833a8c9392 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1444,6 +1444,7 @@ export const schemaMock = { runningBlockIds: 'tableRowExecutions.runningBlockIds', blockErrors: 'tableRowExecutions.blockErrors', cancelledAt: 'tableRowExecutions.cancelledAt', + capabilityGovernedUserId: 'tableRowExecutions.capabilityGovernedUserId', updatedAt: 'tableRowExecutions.updatedAt', }, tableRunDispatches: { @@ -1459,6 +1460,7 @@ export const schemaMock = { processedCount: 'tableRunDispatches.processedCount', isManualRun: 'tableRunDispatches.isManualRun', triggeredByUserId: 'tableRunDispatches.triggeredByUserId', + capabilityGovernedUserId: 'tableRunDispatches.capabilityGovernedUserId', requestedAt: 'tableRunDispatches.requestedAt', heartbeatAt: 'tableRunDispatches.heartbeatAt', completedAt: 'tableRunDispatches.completedAt', From 78744ebf60a98344bc2562a189a98bbc649097a8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 19:32:31 -0700 Subject: [PATCH 117/179] test(table): pin the governed subject at the import route, the deletion filter, and the mock The shared schemaMock lacked `capability_governed_user_id`, so the account-deletion cancellation ran its filter against `undefined` and the test could not tell the governed subject from the billing attribution it replaced. Adds both columns to the mock and asserts the filter names the subject and only the two live statuses. --- .../api/table/[tableId]/import/route.test.ts | 20 +++++++++++++++++++ .../background/drain-governed-subject.test.ts | 2 +- .../table/prestamp-governed-subject.test.ts | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index 45de3f210ee..8ab139d3206 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -38,6 +38,9 @@ vi.mock('@/app/api/table/utils', async () => { const { TableLockedError } = await import('@/lib/table/mutation-locks') return { checkAccess: mockCheckAccess, + /** Mirrors the real helper: only a `user` principal names a governed subject. */ + capabilityGovernedUserId: (principal: { kind: string; userId?: string }) => + principal.kind === 'user' ? (principal.userId ?? null) : null, accessError: (result: { status: number }) => { const message = result.status === 404 ? 'Table not found' : 'Access denied' return NextResponse.json({ error: message }, { status: result.status }) @@ -281,6 +284,23 @@ describe('POST /api/table/[tableId]/import', () => { expect(mockImportReplaceRows).not.toHaveBeenCalled() }) + /** + * The appended rows auto-fire the table's workflow columns, and those cells + * gate their tools on the governed subject. Leaving it null ran the importing + * member's cells with no per-tool gate at all. + */ + it('dispatches the auto-fired cells under the person it just gated', async () => { + await callPost(createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' })) + + expect(mockDispatchAfterBatchInsert).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + 'user-1', + 'user-1' + ) + }) + it('accepts chunked multipart imports without a content-length header', async () => { const form = createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) const req = new NextRequest('http://localhost:3000/api/table/tbl_1/import', { diff --git a/apps/sim/background/drain-governed-subject.test.ts b/apps/sim/background/drain-governed-subject.test.ts index 9ddf4b8ce15..6fba14ba188 100644 --- a/apps/sim/background/drain-governed-subject.test.ts +++ b/apps/sim/background/drain-governed-subject.test.ts @@ -148,5 +148,5 @@ describe('draining another dispatch’s pre-stamped marker', () => { ([, , ctx]) => (ctx as { userId: string | null }).userId ) expect(subjects).toEqual([null, 'requesting-member']) - }) + }, 20_000) }) diff --git a/apps/sim/lib/table/prestamp-governed-subject.test.ts b/apps/sim/lib/table/prestamp-governed-subject.test.ts index 9ac555ed84b..73d7af6859c 100644 --- a/apps/sim/lib/table/prestamp-governed-subject.test.ts +++ b/apps/sim/lib/table/prestamp-governed-subject.test.ts @@ -81,5 +81,5 @@ describe('the dispatcher pre-stamp', () => { }), }) ) - }) + }, 20_000) }) From b78516d95cd3ba16292ace769103236d87b1c772 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 19:55:07 -0700 Subject: [PATCH 118/179] Merge remote-tracking branch 'origin/staging' into feat/permission-groups-coverage The block-successors map regenerates with one new entry from staging's table sunset; the check that failed in CI compares against the merge ref, which is exactly the drift it exists to catch. --- apps/sim/lib/permission-groups/block-successors.generated.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/lib/permission-groups/block-successors.generated.ts b/apps/sim/lib/permission-groups/block-successors.generated.ts index 8f7cb1fb395..f7a2a702ad9 100644 --- a/apps/sim/lib/permission-groups/block-successors.generated.ts +++ b/apps/sim/lib/permission-groups/block-successors.generated.ts @@ -43,6 +43,7 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { slack: 'slack_v2', starter: 'start_trigger', stt: 'stt_v2', + table: 'table_v2', textract: 'textract_v2', video_generator: 'video_generator_v3', video_generator_v2: 'video_generator_v3', From f40821e32d5155d323a7aa8718d8a36867adfe7b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 20:08:21 -0700 Subject: [PATCH 119/179] fix(db): make 0316's pre-COMMIT section replay-safe The file ends in post-COMMIT CONCURRENTLY steps, so a failed concurrent build replays it from the top. The column add, the FK, and the VALIDATE now all survive a second run. --- ..._table_row_execution_capability_subject.sql | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/db/migrations/0316_table_row_execution_capability_subject.sql b/packages/db/migrations/0316_table_row_execution_capability_subject.sql index f063921ede6..8646031fd30 100644 --- a/packages/db/migrations/0316_table_row_execution_capability_subject.sql +++ b/packages/db/migrations/0316_table_row_execution_capability_subject.sql @@ -6,8 +6,22 @@ -- Without the subject on the marker that drain runs someone else's request under its own subject. -- Additive and nullable; NULL means "no acting person, no per-tool gate", which is exactly what a -- marker written before this column existed was already doing. -ALTER TABLE "table_row_executions" ADD COLUMN "capability_governed_user_id" text;--> statement-breakpoint -ALTER TABLE "table_row_executions" ADD CONSTRAINT "table_row_executions_capability_governed_user_id_user_id_fk" FOREIGN KEY ("capability_governed_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action NOT VALID;--> statement-breakpoint +-- Replay-safe: this file ends in post-COMMIT CONCURRENTLY steps, and a failed concurrent build +-- replays the whole file from the top (see packages/db/scripts/migrate.ts). Every statement before +-- the COMMIT therefore has to survive being run twice. +ALTER TABLE "table_row_executions" ADD COLUMN IF NOT EXISTS "capability_governed_user_id" text;--> statement-breakpoint +-- Postgres has no ADD CONSTRAINT IF NOT EXISTS, so the replay guard is an explicit pg_constraint +-- lookup. Scoped to conrelid so an identically named constraint on another table cannot mask it. +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'table_row_executions_capability_governed_user_id_user_id_fk' + AND "conrelid" = '"table_row_executions"'::regclass + ) THEN + ALTER TABLE "table_row_executions" ADD CONSTRAINT "table_row_executions_capability_governed_user_id_user_id_fk" FOREIGN KEY ("capability_governed_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action NOT VALID; + END IF; +END $$;--> statement-breakpoint +-- VALIDATE on an already-validated constraint is a no-op, so this needs no guard of its own. ALTER TABLE "table_row_executions" VALIDATE CONSTRAINT "table_row_executions_capability_governed_user_id_user_id_fk";--> statement-breakpoint -- Repeat of 0315's backfill, deliberately. -- From a1ded74bb5838ee06a86acf9b8c416ee918d3bfa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 20:09:49 -0700 Subject: [PATCH 120/179] fix(table): require the governed subject on the add-output payload An omitted subject and a deliberately actorless one read identically at the call site, and the omitted form ran the backfill's downstream cells with no gate. Matches the add/update group payloads. --- apps/sim/lib/table/application/groups.test.ts | 3 +++ apps/sim/lib/table/workflow-groups/service.test.ts | 1 + apps/sim/lib/table/workflow-groups/service.ts | 8 ++++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/table/application/groups.test.ts b/apps/sim/lib/table/application/groups.test.ts index 5c8aa4d46cc..db29e6c125f 100644 --- a/apps/sim/lib/table/application/groups.test.ts +++ b/apps/sim/lib/table/application/groups.test.ts @@ -1087,6 +1087,9 @@ describe('workflow and enrichment Table application commands', () => { expect(mocks.addOutput).toHaveBeenCalledWith( expect.objectContaining({ + // A copilot delegation stays governed, so the backfill's downstream + // cells run under the delegating person rather than ungated. + capabilityGovernedUserId: 'user-1', resolvedOutput: expect.objectContaining({ workflowId: 'workflow-1', columnType: 'number', diff --git a/apps/sim/lib/table/workflow-groups/service.test.ts b/apps/sim/lib/table/workflow-groups/service.test.ts index 179736c2b55..d36ec47276d 100644 --- a/apps/sim/lib/table/workflow-groups/service.test.ts +++ b/apps/sim/lib/table/workflow-groups/service.test.ts @@ -161,6 +161,7 @@ describe('workflow group TTL availability', () => { groupId: 'group-1', blockId: 'block-1', path: 'expiresAt', + capabilityGovernedUserId: null, resolvedOutput: { workflowId: 'workflow-1', columnType: 'ttl', order: [] }, }, 'request-1' diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index 2fd6f96d8b7..028bd60f21b 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -642,8 +642,12 @@ export async function addWorkflowGroupOutput( * row writes. Not the gate: see `capabilityGovernedUserId`. */ actorUserId?: string | null /** Person whose permission group gates any cell the backfill's writes - * cascade into; null when the change has no acting person. */ - capabilityGovernedUserId?: string | null + * cascade into; `null` when the change has no acting person. Required with + * an explicit `null` for the same reason the add/update group payloads + * require it: an omitted subject and a deliberately actorless one read + * identically at the call site, and the omitted form silently runs the + * backfill's downstream cells with no gate at all. */ + capabilityGovernedUserId: string | null resolvedOutput: { workflowId: string columnType: ColumnDefinition['type'] From e3052e605480c166db7da1f280f33d738b5df758 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 20:13:13 -0700 Subject: [PATCH 121/179] fix(table): derive the CSV import governed subject from the auth type Both import routes accept an internal JWT, whose user id is the run's actor. Reading a governed subject off it gated and dispatched under a bystander's permission group; an executor call now names nobody. --- .../api/table/[tableId]/import/route.test.ts | 27 ++++++++++ .../app/api/table/[tableId]/import/route.ts | 13 +++-- .../app/api/table/import-csv/route.test.ts | 42 +++++++++++++++ apps/sim/app/api/table/import-csv/route.ts | 30 ++++++++--- apps/sim/app/api/table/utils.test.ts | 51 +++++++++++++++++++ apps/sim/app/api/table/utils.ts | 25 +++++++++ 6 files changed, 176 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index 8ab139d3206..f8012afe61e 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -41,6 +41,9 @@ vi.mock('@/app/api/table/utils', async () => { /** Mirrors the real helper: only a `user` principal names a governed subject. */ capabilityGovernedUserId: (principal: { kind: string; userId?: string }) => principal.kind === 'user' ? (principal.userId ?? null) : null, + /** Mirrors the real helper: only a session (or personal key) names one. */ + capabilityGovernedAuthUserId: (auth: { authType?: string; userId?: string }) => + auth.authType === 'session' ? (auth.userId ?? null) : null, accessError: (result: { status: number }) => { const message = result.status === 404 ? 'Table not found' : 'Access denied' return NextResponse.json({ error: message }, { status: result.status }) @@ -301,6 +304,30 @@ describe('POST /api/table/[tableId]/import', () => { ) }) + /** + * `checkSessionOrInternalAuth` also accepts an internal JWT, whose user id is + * the run's actor — potentially the workspace billing owner. Dispatching the + * auto-fired cells under it would run them with that bystander's permission + * group; an executor call must dispatch under nobody. + */ + it('dispatches an internal-JWT import under nobody, not the run actor', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'billing-owner', + authType: 'internal_jwt', + }) + + await callPost(createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' })) + + expect(mockDispatchAfterBatchInsert).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + 'billing-owner', + null + ) + }) + it('accepts chunked multipart imports without a content-length header', async () => { const form = createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) const req = new NextRequest('http://localhost:3000/api/table/tbl_1/import', { diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 13b3ab17d15..8c137582b16 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -21,7 +21,7 @@ import { performTableCsvImport } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { accessError, - capabilityGovernedUserId, + capabilityGovernedAuthUserId, checkAccess, csvProxyBodyCapResponse, multipartErrorResponse, @@ -161,11 +161,16 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro requestId, /** * An append starts the table's workflow columns on every row it lands, so - * those cells are governed by the same person `checkAccess` just gated — - * not left ungoverned, which would let an import run tools this member's + * those cells are governed by the person behind the request — not left + * ungoverned, which would let an import run tools this member's * permission group withholds. + * + * Derived from the auth type rather than from `principal`: this route + * also accepts an internal JWT, whose user id is the run's actor, and + * `TableAccessPrincipal` reports one as an ordinary person. An executor + * call must dispatch under nobody. */ - capabilityGovernedUserId: capabilityGovernedUserId(principal), + capabilityGovernedUserId: capabilityGovernedAuthUserId(authResult), }) if (!outcome.success) { diff --git a/apps/sim/app/api/table/import-csv/route.test.ts b/apps/sim/app/api/table/import-csv/route.test.ts index ca30b15c8bb..7c1cdc78ec4 100644 --- a/apps/sim/app/api/table/import-csv/route.test.ts +++ b/apps/sim/app/api/table/import-csv/route.test.ts @@ -41,6 +41,9 @@ vi.mock('@/app/api/table/utils', async () => { const { asOrchestrationError, messageForOrchestrationError, statusForOrchestrationError } = await import('@/lib/core/orchestration/types') return { + /** Mirrors the real helper: only a session (or personal key) names one. */ + capabilityGovernedAuthUserId: (auth: { authType?: string; userId?: string }) => + auth.authType === 'session' ? (auth.userId ?? null) : null, csvProxyBodyCapResponse: () => null, multipartErrorResponse: (error: { code: string; message: string }) => NextResponse.json( @@ -290,6 +293,45 @@ describe('POST /api/table/import-csv', () => { expect(mockCreateTable).not.toHaveBeenCalled() }) + /** + * `checkSessionOrInternalAuth` also accepts an internal JWT, whose user id is + * the run's actor rather than someone asking for a table. Gating on it would + * refuse an executor call for a bystander's group, and dispatching under it + * would run the table's cells with that bystander's capabilities. + */ + it('leaves an internal-JWT import ungoverned rather than gating on the run actor', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'billing-owner', + authType: 'internal_jwt', + }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableCreation: true, + }) + + const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) + + expect(response.status).toBe(200) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + expect(mockBatchInsertRows).toHaveBeenCalledWith( + expect.objectContaining({ capabilityGovernedUserId: null }), + expect.anything(), + expect.any(String) + ) + }) + + it('dispatches a session import under the person it gated', async () => { + const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) + + expect(response.status).toBe(200) + expect(mockBatchInsertRows).toHaveBeenCalledWith( + expect.objectContaining({ capabilityGovernedUserId: 'user-1' }), + expect.anything(), + expect.any(String) + ) + }) + it('lets the import through when the group withholds something else', async () => { permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index e757c8b325c..439a38e067f 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -16,6 +16,7 @@ import { performCreateTableFromCsv } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { + capabilityGovernedAuthUserId, csvProxyBodyCapResponse, multipartErrorResponse, orchestrationOutcomeErrorResponse, @@ -37,6 +38,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } const userId = authResult.userId + /** + * The person whose permission group governs this import, or `null` for the + * internal-JWT caller this route also accepts — an executor's embedded + * subject is the run's actor, not a person asking for a table. One + * derivation for both the gate below and the dispatch subject, so the id + * this route refuses on is the id its writes run under. + */ + const governedUserId = capabilityGovernedAuthUserId(authResult) const oversize = csvProxyBodyCapResponse(request) if (oversize) return oversize @@ -78,9 +87,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { * and predates the operation boundary. An import always ends in a new table, * so it is creation, not ordinary use. `tables.create` subsumes `tables.use`: * its rule is denied by `disableTableCreation` OR `hideTablesTab`, so gating - * on it still refuses a group that hides Tables entirely. + * on it still refuses a group that hides Tables entirely. Keyed to the + * governed subject, which names nobody for an executor call — the same rule + * `createTableImportUseCase` applies on the async import path. */ - if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'tables.create')) { + if ( + governedUserId && + (await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'tables.create')) + ) { return capabilityRefusalResponse('tables.create') } @@ -132,13 +146,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { timezone, requestId, /** - * The session or internal-JWT person this route already gated - * `tables.create` against. A table created here has no workflow columns - * yet, so nothing auto-fires today; naming the subject anyway keeps the - * rule "the id the surface gated is the id the write dispatches under" - * with no producer-specific exception to re-argue. + * The person this route already gated `tables.create` against. A table + * created here has no workflow columns yet, so nothing auto-fires today; + * naming the subject anyway keeps the rule "the id the surface gated is + * the id the write dispatches under" with no producer-specific exception + * to re-argue. */ - capabilityGovernedUserId: userId, + capabilityGovernedUserId: governedUserId, }) if (!outcome.success) { diff --git a/apps/sim/app/api/table/utils.test.ts b/apps/sim/app/api/table/utils.test.ts index 02713a30442..b834a5084e5 100644 --- a/apps/sim/app/api/table/utils.test.ts +++ b/apps/sim/app/api/table/utils.test.ts @@ -7,6 +7,7 @@ import { TableRowLimitError } from '@/lib/table/billing' import { TableRowNotFoundError } from '@/lib/table/rows/errors' import type { ColumnDefinition } from '@/lib/table/types' import { + capabilityGovernedAuthUserId, orchestrationErrorResponse, orchestrationOutcomeErrorResponse, rootErrorMessage, @@ -180,3 +181,53 @@ describe('orchestrationOutcomeErrorResponse', () => { }) }) }) + +describe('capabilityGovernedAuthUserId', () => { + it('governs a session by its user', () => { + expect( + capabilityGovernedAuthUserId({ success: true, userId: 'user-1', authType: 'session' }) + ).toBe('user-1') + }) + + it('governs a personal API key by its owner', () => { + expect( + capabilityGovernedAuthUserId({ + success: true, + userId: 'user-1', + authType: 'api_key', + apiKeyType: 'personal', + }) + ).toBe('user-1') + }) + + /** + * The executor embeds the run's actor in the internal JWT — the workspace + * billing owner, or the member who merely triggered the run. Reading a + * governed subject off it applies that bystander's permission group to an + * executor call, which is the substitution the subject exists to remove. + */ + it('names nobody for an internal JWT even though it carries a user id', () => { + expect( + capabilityGovernedAuthUserId({ + success: true, + userId: 'billing-owner', + authType: 'internal_jwt', + }) + ).toBeNull() + }) + + it('names nobody for a workspace API key, whose user id is the key creator', () => { + expect( + capabilityGovernedAuthUserId({ + success: true, + userId: 'key-creator', + authType: 'api_key', + apiKeyType: 'workspace', + }) + ).toBeNull() + }) + + it('names nobody when the credential carries no user at all', () => { + expect(capabilityGovernedAuthUserId({ success: true, authType: 'internal_jwt' })).toBeNull() + }) +}) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 5d2a604ddda..af68be9bdd5 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { permissionSatisfies } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' +import { type AuthResult, AuthType } from '@/lib/auth/hybrid' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { asOrchestrationError, @@ -279,6 +280,30 @@ export function capabilityGovernedUserId(principal: TableAccessPrincipal): strin return principal.kind === 'user' ? principal.userId : null } +/** + * The id whose permission group governs a request authenticated by + * `checkSessionOrInternalAuth`, or `null` when none does. + * + * `auth.userId` is populated for both credentials that helper accepts, and for + * an internal JWT it is the subject the executor embedded — the run's actor, + * which may be the workspace billing owner or the member who merely triggered + * the run. Keying on the presence of a user id therefore hands an executor call + * that bystander's capabilities, which is the substitution the governed subject + * exists to remove; the executor is exempt from capabilities by the same rule + * `capabilityGovernedPrincipalUserId` applies to a delegated executor + * principal. `authType` is the authoritative signal, and `apiKeyType` covers + * the personal-key case for a caller that later shares this helper. + * + * Distinct from {@link capabilityGovernedUserId}, which answers the same + * question for a {@link TableAccessPrincipal} — a union that has no way to + * spell "internal JWT" and reports one as a person. + */ +export function capabilityGovernedAuthUserId(auth: AuthResult): string | null { + if (!auth.userId) return null + if (auth.authType === AuthType.SESSION) return auth.userId + return auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal' ? auth.userId : null +} + /** * Access check returning `{ ok, table }` or `{ ok: false, status }`. * From 94482e006c4851887a313bfc06d1f8883dbb73dc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 20:24:43 -0700 Subject: [PATCH 122/179] test(background): warm the cascade loop's dynamic imports outside per-test budgets Under a loaded parallel run the loop's dynamic imports can take whole seconds; one test timed out mid-loop and its continuation spilled calls into the next, which is what made these two files flaky in 2 of 4 full runs. A beforeAll pays the import cost once, outside any test's budget. --- .../background/drain-governed-subject.test.ts | 16 +++++++++++++++- .../enrichment-capability-subject.test.ts | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/sim/background/drain-governed-subject.test.ts b/apps/sim/background/drain-governed-subject.test.ts index 6fba14ba188..bde8a75e7c8 100644 --- a/apps/sim/background/drain-governed-subject.test.ts +++ b/apps/sim/background/drain-governed-subject.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ getTableById: vi.fn(), @@ -105,6 +105,20 @@ const CARRIER = { } as Parameters[0] describe('draining another dispatch’s pre-stamped marker', () => { + /** + * The loop under test resolves its collaborators with dynamic imports, which + * under a loaded parallel run can take whole seconds. Paying that cost inside + * a test's own budget is what made this file flaky: one test timed out + * mid-loop and its continuation spilled calls into the next. Warm the graph + * once, outside any per-test budget. + */ + beforeAll(async () => { + await import('@/lib/table/service') + await import('@/lib/table/rows/service') + await import('@/lib/table/workflow-columns') + await import('@/lib/table/rows/executions') + }, 60_000) + beforeEach(() => { vi.clearAllMocks() resetDbChainMock() diff --git a/apps/sim/background/enrichment-capability-subject.test.ts b/apps/sim/background/enrichment-capability-subject.test.ts index 330c61cfb12..5ac2f5f86f1 100644 --- a/apps/sim/background/enrichment-capability-subject.test.ts +++ b/apps/sim/background/enrichment-capability-subject.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ getTableById: vi.fn(), @@ -106,6 +106,20 @@ function gatedUserId(): unknown { } describe('enrichment cell capability subject', () => { + /** + * The loop under test resolves its collaborators with dynamic imports, which + * under a loaded parallel run can take whole seconds. Paying that cost inside + * a test's own budget is what made this file flaky: one test timed out + * mid-loop and its continuation spilled calls into the next. Warm the graph + * once, outside any per-test budget. + */ + beforeAll(async () => { + await import('@/lib/table/service') + await import('@/lib/table/rows/service') + await import('@/lib/table/workflow-columns') + await import('@/lib/table/rows/executions') + }, 60_000) + beforeEach(() => { vi.clearAllMocks() resetDbChainMock() From c66c3d6b535e1f9c08c3a284fa12dfb3393e4aa0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 20:34:38 -0700 Subject: [PATCH 123/179] test(background): pay the cascade loop's whole lazy-import graph in beforeAll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first loop invocation lazily imports the executor graph; under a loaded parallel run that cost blew the first test's budget, and a timed-out test's continuation spilled calls into its successors — the flake in 2 of 4 full runs. Warming every dynamic import (executor included) and stubbing the pacing RateLimiter moves the cost outside test budgets; each test now runs in milliseconds. --- .../background/drain-governed-subject.test.ts | 28 ++++++++++++++++--- .../enrichment-capability-subject.test.ts | 28 ++++++++++++++++--- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/apps/sim/background/drain-governed-subject.test.ts b/apps/sim/background/drain-governed-subject.test.ts index bde8a75e7c8..cb3d28654ca 100644 --- a/apps/sim/background/drain-governed-subject.test.ts +++ b/apps/sim/background/drain-governed-subject.test.ts @@ -52,6 +52,17 @@ vi.mock('@/lib/table/rows/secret-provenance', () => ({ loadTableRowSecretProvenance: mocks.loadTableRowSecretProvenance, })) vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) + +/** + * Unmocked, the pacing loop constructs a real RateLimiter against the global + * db mock and sleeps real jittered backoff between attempts — nondeterministic + * seconds per test, and a timeout under a loaded parallel run. + */ +vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitWithSubscription = vi.fn().mockResolvedValue({ allowed: true }) + }, +})) vi.mock('@/lib/table/dispatcher', () => ({ readDispatch: vi.fn(async () => ({ id: 'tdsp_carrier', status: 'dispatching' })), completeDispatchIfActive: vi.fn(), @@ -113,10 +124,19 @@ describe('draining another dispatch’s pre-stamped marker', () => { * once, outside any per-test budget. */ beforeAll(async () => { - await import('@/lib/table/service') - await import('@/lib/table/rows/service') - await import('@/lib/table/workflow-columns') - await import('@/lib/table/rows/executions') + await Promise.all([ + import('@/enrichments/registry'), + import('@/enrichments/run'), + import('@/lib/billing/core/usage-log'), + import('@/lib/table/cell-write'), + import('@/lib/table/dispatcher'), + import('@/lib/table/rows/executions'), + import('@/lib/table/rows/service'), + import('@/lib/table/service'), + import('@/lib/table/workflow-columns'), + import('@/lib/workflows/executor/execute-workflow'), + import('@/lib/workflows/persistence/utils'), + ]) }, 60_000) beforeEach(() => { diff --git a/apps/sim/background/enrichment-capability-subject.test.ts b/apps/sim/background/enrichment-capability-subject.test.ts index 5ac2f5f86f1..1f5361549a4 100644 --- a/apps/sim/background/enrichment-capability-subject.test.ts +++ b/apps/sim/background/enrichment-capability-subject.test.ts @@ -58,6 +58,17 @@ vi.mock('@/executor/utils/resolved-secret-trace-registry', () => ({ })) vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) +/** + * Unmocked, the pacing loop constructs a real RateLimiter against the global + * db mock and sleeps real jittered backoff between attempts — nondeterministic + * seconds per test, and a timeout under a loaded parallel run. + */ +vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitWithSubscription = vi.fn().mockResolvedValue({ allowed: true }) + }, +})) + import { runRowCascadeLoop } from '@/background/workflow-column-execution' const GROUP = { @@ -114,10 +125,19 @@ describe('enrichment cell capability subject', () => { * once, outside any per-test budget. */ beforeAll(async () => { - await import('@/lib/table/service') - await import('@/lib/table/rows/service') - await import('@/lib/table/workflow-columns') - await import('@/lib/table/rows/executions') + await Promise.all([ + import('@/enrichments/registry'), + import('@/enrichments/run'), + import('@/lib/billing/core/usage-log'), + import('@/lib/table/cell-write'), + import('@/lib/table/dispatcher'), + import('@/lib/table/rows/executions'), + import('@/lib/table/rows/service'), + import('@/lib/table/service'), + import('@/lib/table/workflow-columns'), + import('@/lib/workflows/executor/execute-workflow'), + import('@/lib/workflows/persistence/utils'), + ]) }, 60_000) beforeEach(() => { From 4e6011995a432cb3135cb4dec6c19eee4c8702e0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 22:31:42 -0700 Subject: [PATCH 124/179] refactor(selectors): one home for the selector integration-identity rules selectorResourceServiceIds had no caller outside its own module, and its TSDoc restated the resource-vs-credential rule that SelectorCredentialPolicy already owns; the integrationBlockTypes rationale was spelled out a third time on selectorIntegrationBlockTypes and a fourth on the assertion. Point at the declarations instead. The selectors.execute permission-group-exempt annotation still described the credential-narrowing design that was removed: the gate judges the selector's own resource, deliberately not the bound credential's provider. --- .../lib/selectors/application/operations.ts | 2 +- .../selectors/server/integration-access.ts | 46 ++++++------------- 2 files changed, 15 insertions(+), 33 deletions(-) diff --git a/apps/sim/lib/selectors/application/operations.ts b/apps/sim/lib/selectors/application/operations.ts index a6f055010fe..2e91b477da8 100644 --- a/apps/sim/lib/selectors/application/operations.ts +++ b/apps/sim/lib/selectors/application/operations.ts @@ -1,7 +1,7 @@ import { defineWorkspaceOperation } from '@/lib/core/application' export const selectorOperations = { - // permission-group-exempt: no static capability names selector browsing — credential access is authorized per credential, and per-integration denial is the parameterized allowedIntegrations key, which the funnel cannot apply because it never sees which integration a selector reaches. That decision is now enforced from the use case by assertSelectorIntegrationAllowed, against the resolved credential's provider, ahead of the provider call. + // permission-group-exempt: no static capability names selector browsing — credential access is authorized per credential, and per-integration denial is the parameterized allowedIntegrations key, which the funnel cannot apply because it never sees which integration a selector reaches. That decision is enforced from the use case by assertSelectorIntegrationAllowed, against the selector's own resource, ahead of the provider call. execute: defineWorkspaceOperation({ id: 'selectors.execute', minimumRole: 'read', diff --git a/apps/sim/lib/selectors/server/integration-access.ts b/apps/sim/lib/selectors/server/integration-access.ts index 03ce61b35f3..20fcd1e272d 100644 --- a/apps/sim/lib/selectors/server/integration-access.ts +++ b/apps/sim/lib/selectors/server/integration-access.ts @@ -15,23 +15,12 @@ import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission const logger = createLogger('SelectorIntegrationAccess') /** - * The OAuth services a selector execution actually reaches. - * - * `serviceIds` answers a different question — which *credentials* the selector - * accepts. The two diverge whenever one provider API is reachable with several - * of its sibling connections: `google.drive` accepts a Drive, Docs, Sheets or - * Forms credential because all four carry Drive scope, and `sharepoint.sites` - * accepts a SharePoint or an Excel one. Gating on the accepted set asked - * whether *any* of them was permitted, so a group that allowed - * `google_sheets_v2` and excluded `google_drive` could still read Drive through - * `google.drive`. - * - * The declaration therefore names its own resource, and that is what the - * allowlist judges. The credential's provider id is deliberately not consulted: - * it describes the key, not the API the selector calls, and narrowing by it - * gated Drive reads on whether a Sheets connection was permitted. + * The OAuth service a selector execution actually reaches — its own resource + * rather than the set of credentials it accepts. See + * {@link SelectorCredentialPolicy} for why those two differ and why the bound + * credential's provider id is never consulted. */ -export function selectorResourceServiceIds(policy: SelectorCredentialPolicy): readonly string[] { +function selectorResourceServiceIds(policy: SelectorCredentialPolicy): readonly string[] { return policy.resourceServiceId ? [policy.resourceServiceId] : policy.serviceIds } @@ -39,19 +28,16 @@ export function selectorResourceServiceIds(policy: SelectorCredentialPolicy): re * The block types an allowlist decision about this selector is made against. * * Two independent sources, because the OAuth credential catalog cannot identify - * every selector that reaches a third-party API. A selector authenticated from - * raw context fields (CloudWatch's AWS keys, IMAP's host and password) carries - * no credential policy, and an API-key integration (Snowflake, NetSuite, - * Harmonic) owns no OAuth catalog entry, so its service id maps to no block - * type — both used to yield an empty list and pass the gate untested. They - * declare `integrationBlockTypes` instead, and it wins over the catalog when - * both are present. + * every selector that reaches a third-party API; the declared + * `integrationBlockTypes` cover the shapes it misses and win over the catalog + * when both are present. See + * {@link ServerSelectorAttachment.integrationBlockTypes} for which shapes those + * are. * * An empty result means "no integration identity", which is a pass. That is * reserved for the internal selectors — workspace files, knowledge bases, - * tables — which read only Sim's own data; - * `selectorIntegrationCoverage` in the manifest test keeps every - * `provider-server` selector out of it. + * tables — which read only Sim's own data; `selectorIntegrationCoverage` in the + * manifest test keeps every `provider-server` selector out of it. */ export function selectorIntegrationBlockTypes( attachment: Pick @@ -85,12 +71,8 @@ export function selectorIntegrationBlockTypes( * bound to `slack_v2` match. * * A `null` allowlist, a caller no group governs, and a selector with no - * integration identity all pass through. The last is now reserved for the - * internal selectors — workspace files, knowledge bases, tables — which read - * only Sim's own data and are not an integration at all. Every selector that - * reaches a third party has an identity, either through the OAuth credential - * catalog or through the `integrationBlockTypes` a raw-context or API-key - * selector declares; see {@link selectorIntegrationBlockTypes}. + * integration identity all pass through; see {@link selectorIntegrationBlockTypes} + * for why the last of those is reserved for the internal selectors. * * One service can still map to several block types — the `google-drive` entry * authenticates both `google_drive` and `google_slides_v2` — and any of them From 2644515b51a9c69a59ccb891eb08dfdfd90540f3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 22:33:04 -0700 Subject: [PATCH 125/179] refactor(permission-groups): drop block-access's re-export of the moved allowlist helpers integration-allowlist.ts was split out of block-access.ts, and the re-export kept behind for the move left consumers importing the same three pure functions from two different modules. block-access.ts imports @/blocks/registry, so the re-export also made a registry-free helper look like it came from the registry-bearing module. Every consumer now imports from the source, matching what use-permission-config.ts already did. Also: capability-error.ts still claimed one detail code covered every capability. Four of the thirty-five carry a remedy-specific code, which is why the constructor takes one. --- .../access-control/utils/integration-allowlist-rows.ts | 2 +- apps/sim/ee/access-control/utils/permission-check.ts | 10 +++++----- apps/sim/lib/catalog/application/catalog-context.ts | 6 ++---- apps/sim/lib/copilot/integration-tool-projection.ts | 6 +++--- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 10 +++++----- apps/sim/lib/permission-groups/block-access.ts | 8 +------- apps/sim/lib/permission-groups/capability-error.ts | 8 +++++--- apps/sim/lib/selectors/server/integration-access.ts | 6 ++---- apps/sim/lib/workflows/editing/validation.ts | 6 ++---- .../lib/workflows/persistence/block-access-guard.ts | 6 +++--- 10 files changed, 29 insertions(+), 39 deletions(-) diff --git a/apps/sim/ee/access-control/utils/integration-allowlist-rows.ts b/apps/sim/ee/access-control/utils/integration-allowlist-rows.ts index 818289aa20c..38b14ea10cd 100644 --- a/apps/sim/ee/access-control/utils/integration-allowlist-rows.ts +++ b/apps/sim/ee/access-control/utils/integration-allowlist-rows.ts @@ -1,4 +1,4 @@ -import { toAccessControlAllowlist } from '@/lib/permission-groups/block-access' +import { toAccessControlAllowlist } from '@/lib/permission-groups/integration-allowlist' /** * The stored `allowedIntegrations` re-expressed as editor rows: successor- diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 633df84aa48..ca8033171ec 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -5,11 +5,7 @@ import { isInvitationsDisabled, isPublicApiDisabled, } from '@/lib/core/config/env-flags' -import { - isBlockTypeAccessControlExempt, - resolveAccessControlBlockType, - toAccessControlAllowlist, -} from '@/lib/permission-groups/block-access' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { CAPABILITY_RULES, refuseCapability, @@ -17,6 +13,10 @@ import { } from '@/lib/permission-groups/capabilities' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' +import { + resolveAccessControlBlockType, + toAccessControlAllowlist, +} from '@/lib/permission-groups/integration-allowlist' import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { getUserPermissionConfig, diff --git a/apps/sim/lib/catalog/application/catalog-context.ts b/apps/sim/lib/catalog/application/catalog-context.ts index 195558c5aa8..f06375a630e 100644 --- a/apps/sim/lib/catalog/application/catalog-context.ts +++ b/apps/sim/lib/catalog/application/catalog-context.ts @@ -3,10 +3,8 @@ import { type BlockVisibilityState, getBlockVisibility } from '@/lib/core/config import { OrchestrationError } from '@/lib/core/orchestration/types' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { allowedIntegrationTypes, principalUserId } from '@/lib/integrations/principal-scope.server' -import { - isBlockTypeAccessControlExempt, - resolveAccessControlBlockType, -} from '@/lib/permission-groups/block-access' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { type ActiveWorkspaceApplicationContext, diff --git a/apps/sim/lib/copilot/integration-tool-projection.ts b/apps/sim/lib/copilot/integration-tool-projection.ts index 00a99a4703f..de4efa9b5f5 100644 --- a/apps/sim/lib/copilot/integration-tool-projection.ts +++ b/apps/sim/lib/copilot/integration-tool-projection.ts @@ -6,12 +6,12 @@ import { import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { + intersectIntegrationAllowlists, resolveAccessControlBlockType, toAccessControlAllowlist, -} from '@/lib/permission-groups/block-access' -import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +} from '@/lib/permission-groups/integration-allowlist' import { collectDeniedOperationIds, createToolAccessGate, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 519d203be73..5e2d841568d 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -141,13 +141,13 @@ import { listKnowledgeBases, } from '@/lib/knowledge/application/knowledge-bases' import { validateMermaidSource } from '@/lib/mermaid/validate' -import { - isBlockTypeAccessControlExempt, - toAccessControlAllowlist, -} from '@/lib/permission-groups/block-access' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { + intersectIntegrationAllowlists, + toAccessControlAllowlist, +} from '@/lib/permission-groups/integration-allowlist' import type { IsToolAllowed } from '@/lib/permission-groups/operation-access' import { listOrganizationWorkspaceRefs, diff --git a/apps/sim/lib/permission-groups/block-access.ts b/apps/sim/lib/permission-groups/block-access.ts index 5e7f1e01d03..e18a2068ae1 100644 --- a/apps/sim/lib/permission-groups/block-access.ts +++ b/apps/sim/lib/permission-groups/block-access.ts @@ -1,12 +1,6 @@ -import { - intersectAccessControlAllowlists, - resolveAccessControlBlockType, - toAccessControlAllowlist, -} from '@/lib/permission-groups/integration-allowlist' +import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' import { getBlock } from '@/blocks/registry' -export { intersectAccessControlAllowlists, resolveAccessControlBlockType, toAccessControlAllowlist } - /** * The universal workflow entry point. Every retired entry point resolves to it, * and it is never an allowlist row, so both it and anything that resolves to it diff --git a/apps/sim/lib/permission-groups/capability-error.ts b/apps/sim/lib/permission-groups/capability-error.ts index 9c54c30295b..a470a7592b6 100644 --- a/apps/sim/lib/permission-groups/capability-error.ts +++ b/apps/sim/lib/permission-groups/capability-error.ts @@ -5,9 +5,11 @@ import type { PermissionGroupCapability } from '@/lib/permission-groups/capabili * The caller's permission group withholds a capability the request needs. * * Carries the capability so a log line or an audit entry can name it; the - * message names it for the caller. One detail code covers every capability - * because the remedy is the same for all of them — the closed code set is closed - * over remedies, not over causes. + * message names it for the caller. The detail code comes from the capability's + * own rule rather than being fixed here — the closed code set is closed over + * remedies, so the handful of capabilities with a remedy of their own (a chat + * auth mode, public sharing, personal API keys) carry a code of their own and + * the rest share the generic one. * * Lives here rather than beside the authorization funnel so the assertion * helpers can throw it without importing the funnel, which imports them. diff --git a/apps/sim/lib/selectors/server/integration-access.ts b/apps/sim/lib/selectors/server/integration-access.ts index 20fcd1e272d..ea2a167d7d1 100644 --- a/apps/sim/lib/selectors/server/integration-access.ts +++ b/apps/sim/lib/selectors/server/integration-access.ts @@ -2,10 +2,8 @@ import type { Principal } from '@sim/auth/principal' import { getIntegrationTypesForOAuthServiceId } from '@sim/deployment-config/integration-availability' import { createLogger } from '@sim/logger' import { allowedIntegrationTypes } from '@/lib/integrations/principal-scope.server' -import { - isBlockTypeAccessControlExempt, - resolveAccessControlBlockType, -} from '@/lib/permission-groups/block-access' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' import type { SelectorCredentialPolicy, ServerSelectorAttachment, diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index 4294f15041e..5a676c57e0c 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -3,11 +3,9 @@ import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import { isHosted as isHostedDeployment } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' -import { - isBlockTypeAccessControlExempt, - resolveAccessControlBlockType, -} from '@/lib/permission-groups/block-access' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' +import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { validateSelectorIds } from '@/lib/workflows/editing/selector-validator' import { getSkillById } from '@/lib/workflows/skills/operations' diff --git a/apps/sim/lib/workflows/persistence/block-access-guard.ts b/apps/sim/lib/workflows/persistence/block-access-guard.ts index 4f4541caf1e..1da04b15972 100644 --- a/apps/sim/lib/workflows/persistence/block-access-guard.ts +++ b/apps/sim/lib/workflows/persistence/block-access-guard.ts @@ -1,9 +1,9 @@ +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { - isBlockTypeAccessControlExempt, resolveAccessControlBlockType, toAccessControlAllowlist, -} from '@/lib/permission-groups/block-access' -import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +} from '@/lib/permission-groups/integration-allowlist' import { BlockType } from '@/executor/constants' /** From c717bb2e4279f53c804ce129e9eb5c3440f11b14 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 22:34:41 -0700 Subject: [PATCH 126/179] docs(permission-groups): one home for the capability-governed subject rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capabilityGovernedPrincipalUserId already declares itself THE statement of which principals a permission group governs, but five rounds each restated the workspace-key-creator reasoning in full at their own helper. Each site now keeps only what is specific to its input type — TableAccessPrincipal's type-system argument, capabilityGovernedAuthUserId's internal-JWT case, v1's keyType signal and its audit-script coupling — and links the rule. No decision changes: workspace key and executor stay null, session, personal key and Copilot delegation stay governed. --- apps/sim/app/api/table/utils.ts | 33 +++++++------------ apps/sim/app/api/v1/middleware.ts | 14 ++++---- .../[id]/executions/[executionId]/route.ts | 16 +++++---- 3 files changed, 30 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index af68be9bdd5..1de6cba864a 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -244,11 +244,9 @@ interface ApiErrorResponse { * itself. It has no user, so there is no group to resolve. * `keyCreatorUserId` is the id `authenticateApiKeyFromHeader` reports: the * person who *minted* the key, a bystander who may not even be the caller. It - * carries the workspace role check that predates this union and nothing else - * — applying their permission group here would break a live shared credential - * for reasons that have nothing to do with whoever is calling it. Same - * reasoning as the `workspace_api_key` branch of - * `authorizeWorkspaceOperation` and of `resolveCapabilityRefusal`. + * carries the workspace role check that predates this union and nothing else. + * Same rule, and the same reasoning, as `capabilityGovernedPrincipalUserId` in + * `@/lib/core/application`. * * Required, and with no permissive default, for the same reason `capability` is * required on `defineWorkspaceOperation`: an absent declaration cannot be told @@ -284,19 +282,13 @@ export function capabilityGovernedUserId(principal: TableAccessPrincipal): strin * The id whose permission group governs a request authenticated by * `checkSessionOrInternalAuth`, or `null` when none does. * - * `auth.userId` is populated for both credentials that helper accepts, and for - * an internal JWT it is the subject the executor embedded — the run's actor, - * which may be the workspace billing owner or the member who merely triggered - * the run. Keying on the presence of a user id therefore hands an executor call - * that bystander's capabilities, which is the substitution the governed subject - * exists to remove; the executor is exempt from capabilities by the same rule - * `capabilityGovernedPrincipalUserId` applies to a delegated executor - * principal. `authType` is the authoritative signal, and `apiKeyType` covers - * the personal-key case for a caller that later shares this helper. - * * Distinct from {@link capabilityGovernedUserId}, which answers the same - * question for a {@link TableAccessPrincipal} — a union that has no way to - * spell "internal JWT" and reports one as a person. + * question for a {@link TableAccessPrincipal} — a union that has no way to spell + * "internal JWT" and reports one as a person. An internal JWT's `auth.userId` is + * the subject the executor embedded, so keying on its presence would hand the + * run's actor's capabilities to a caller the executor exemption deliberately + * passes ungated. `authType` is the authoritative signal, and `apiKeyType` + * covers the personal-key case for a caller that later shares this helper. */ export function capabilityGovernedAuthUserId(auth: AuthResult): string | null { if (!auth.userId) return null @@ -319,10 +311,9 @@ export function capabilityGovernedAuthUserId(auth: AuthResult): string | null { * exists, and refusing on capability first would tell a non-member which * modules the organization withholds. * - * The gate applies to a `user` principal only. `/api/v1/tables/**` shares this - * helper and authenticates with an API key, and a workspace key reports its - * creator as its user id; gating on that id would apply a bystander's group to - * every caller of a shared credential. See {@link TableAccessPrincipal}. + * The gate applies to a `user` principal only; see {@link TableAccessPrincipal} + * for why `/api/v1/tables/**`, which shares this helper under an API key, must + * reach the table ungated on a workspace key. * * Nothing here exempts the executor. A workflow run reaches tables through * `tableOperations`, where the delegated-principal branch already withholds diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 10999de3610..f95ceccf467 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -98,13 +98,15 @@ export function requireRateLimitUserId(rateLimit: RateLimitResult): string { * The user whose permission group governs this request, or `null` when none * does. * - * `rateLimit.userId` is present for BOTH key kinds, and for a workspace key it - * is the key's *creator* — a bystander who may not be the caller. Any gate keyed - * on the presence of a user id therefore applies that bystander's group to every - * caller of a shared credential. `keyType` is the authoritative signal, and this - * is the one place v1 reads it for that purpose, so - * {@link resolveCapabilityRefusal}, {@link tableAccessPrincipal} and the log + * The v1 reading of the rule `capabilityGovernedPrincipalUserId` states in + * `@/lib/core/application`: `rateLimit.userId` is present for BOTH key kinds, + * and for a workspace key it is the key's creator. `keyType` is the + * authoritative signal, and this is the one place v1 reads it for that purpose, + * so {@link resolveCapabilityRefusal}, {@link tableAccessPrincipal} and the log * field projection cannot drift. + * + * `scripts/check-capability-subject.ts` is written in terms of this name and + * asserts every v1 capability subject came from it; rename them together. */ export function capabilityGovernedUserId(rateLimit: RateLimitResult): string | null { return rateLimit.keyType === 'personal' ? (rateLimit.userId ?? null) : null diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts index 4e0f74a4d9c..e31868e37e0 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts @@ -16,18 +16,22 @@ const logger = createLogger('WorkflowExecutionStatusAPI') /** * The user whose permission group governs this read, or `null` when none does. * - * `auth.userId` is populated for every credential this route accepts, and for a - * workspace API key it is the key's *creator* — a bystander who may not be the - * caller — while an internal JWT is the executor, which carries a role but no - * capabilities. Keying on the presence of a user id would apply a group to both. - * `authType` and `apiKeyType` are the authoritative signals, the same pair - * `capabilityGovernedUserId` reads on the v1 surface. + * The rule and the reasoning behind it belong to `capabilityGovernedPrincipalUserId` + * in `@/lib/core/application`; this reads the same decision off an `AuthResult`, + * which is what `validateWorkflowAccess` reports. `authType` and `apiKeyType` are + * the authoritative signals — `auth.userId` is populated for every credential + * this route accepts, including the workspace key and the executor's internal JWT. + * + * Spelled out here rather than shared with the identical + * `capabilityGovernedAuthUserId` in `@/app/api/table/utils`, which would pull the + * whole table graph into this route for six lines. */ function capabilityGovernedUserId(auth: AuthResult | undefined): string | null { if (!auth?.userId) return null if (auth.authType === AuthType.SESSION) return auth.userId return auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal' ? auth.userId : null } + export const GET = withRouteHandler( async ( request: NextRequest, From 46e619f255eb9137bde5a9da3c2a72c82e398cfa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 22:35:19 -0700 Subject: [PATCH 127/179] refactor(scripts): tidy the permission-group audits check-permission-group-enforcement: the header listed assertions A-E while the body has an F (every exported registry member was read) documented only inline; lineAt was defined twice inside the file; the annotation count in parseEnforcedAnnotations' measurement note had drifted from 50-odd to 70-odd. check-capability-subject: the per-file sink table was named sinks_. generate-block-successors: the header restated flattenSuccessors' walk and stopping rules verbatim. 322 operations / 35 capabilities and 5 governed v1 subjects unchanged. --- scripts/check-capability-subject.ts | 6 ++--- scripts/check-permission-group-enforcement.ts | 23 ++++++++++++------- scripts/generate-block-successors.ts | 7 ++---- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/scripts/check-capability-subject.ts b/scripts/check-capability-subject.ts index 2de20f115d3..e1833528622 100644 --- a/scripts/check-capability-subject.ts +++ b/scripts/check-capability-subject.ts @@ -195,10 +195,10 @@ export function auditSource(file: string, source: string): { findings: Finding[] * the import line — so the audit stayed green with a governed-call count that * never moved. Aliases are folded into the sink table for this file. */ - const sinks_ = { ...CAPABILITY_SINKS } + const fileSinks = { ...CAPABILITY_SINKS } for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)/g)) { const subjectIndex = CAPABILITY_SINKS[match[1]] - if (subjectIndex !== undefined) sinks_[match[2]] = subjectIndex + if (subjectIndex !== undefined) fileSinks[match[2]] = subjectIndex } /** @@ -222,7 +222,7 @@ export function auditSource(file: string, source: string): { findings: Finding[] } } - for (const [sink, subjectIndex] of Object.entries(sinks_)) { + for (const [sink, subjectIndex] of Object.entries(fileSinks)) { for (const match of source.matchAll(new RegExp(`\\b${sink}\\s*\\(`, 'g'))) { const openIndex = match.index + match[0].length - 1 /** A declaration or an import of the sink, not a call into it. */ diff --git a/scripts/check-permission-group-enforcement.ts b/scripts/check-permission-group-enforcement.ts index 114079eaf0a..2675e1e75a3 100644 --- a/scripts/check-permission-group-enforcement.ts +++ b/scripts/check-permission-group-enforcement.ts @@ -19,6 +19,10 @@ * D every key claiming `enforcement: 'capability'` is read by some rule * E no key claiming a weaker mechanism is read by one, so a key cannot gain * enforcement while still documented as cosmetic or execution-scoped + * F every member of an exported `*Operations` registry was read by A's + * parsers. Everything above can only speak about what they found, and an + * operation minted in a form they do not follow yields nothing — which + * reads exactly like a clean file * * A capability the funnel cannot apply — one needing a request value, like an * auth mode — is declared at its call site instead: @@ -87,6 +91,11 @@ interface Finding { message: string } +/** The 1-based line `index` falls on. */ +function lineAt(source: string, index: number): number { + return source.slice(0, index).split('\n').length +} + const CLOSING: Record = { '(': ')', '{': '}', '[': ']' } /** Text of the balanced `(...)`, `{...}` or `[...]` group that starts at `openIndex`. */ @@ -189,7 +198,6 @@ interface ParsedOperations { export function parseOperationCapabilities(source: string): ParsedOperations { const declarations: OperationDeclaration[] = [] const unreadable: number[] = [] - const lineAt = (index: number) => source.slice(0, index).split('\n').length /** * Domains that wrap a builder in a same-file factory declare the capability @@ -237,12 +245,12 @@ export function parseOperationCapabilities(source: string): ParsedOperations { accepted.push([openIndex, openIndex + call.length]) const id = /id\s*:\s*'([^']+)'/.exec(call)?.[1] if (!id) { - unreadable.push(lineAt(match.index)) + unreadable.push(lineAt(source, match.index)) continue } declarations.push({ id, - line: lineAt(match.index), + line: lineAt(source, match.index), capability: /capability\s*:\s*'([a-z0-9_.]+)'/.exec(call)?.[1], }) } @@ -256,7 +264,7 @@ export function parseOperationCapabilities(source: string): ParsedOperations { if (insideFactory(match.index)) continue declarations.push({ id: match[1], - line: lineAt(match.index), + line: lineAt(source, match.index), capability: capability === 'positional' ? match[2] : capability, }) } @@ -359,7 +367,6 @@ function topLevelMembers(body: string): Array<{ key: string; start: number; end: */ export function parseOperationRegistryMembers(source: string): OperationRegistryMember[] { const members: OperationRegistryMember[] = [] - const lineAt = (index: number) => source.slice(0, index).split('\n').length OPERATION_REGISTRY.lastIndex = 0 for ( let match = OPERATION_REGISTRY.exec(source); @@ -372,8 +379,8 @@ export function parseOperationRegistryMembers(source: string): OperationRegistry members.push({ registry: match[1], member: member.key, - startLine: lineAt(openIndex + member.start), - endLine: lineAt(openIndex + member.end), + startLine: lineAt(source, openIndex + member.start), + endLine: lineAt(source, openIndex + member.end), }) } } @@ -389,7 +396,7 @@ export function parseOperationRegistryMembers(source: string): OperationRegistry * green. That is a real gap, and it is left open because every cheap shape that * would close it is wrong more often than it is right. * - * The obvious shapes were measured against the 50-odd annotations in the tree: + * The obvious shapes were measured against the 70-odd annotations in the tree: * * - "the capability id appears in code in the same file" misses 7, among them * `logs/application/list-public-logs.ts` and `get-public-log.ts`, which diff --git a/scripts/generate-block-successors.ts b/scripts/generate-block-successors.ts index 2eb36b3ff66..5a60ddaa047 100644 --- a/scripts/generate-block-successors.ts +++ b/scripts/generate-block-successors.ts @@ -12,11 +12,8 @@ * `slack` against a group naming `slack_v2` intersected to nothing — refusing an * integration both policies allow. * - * Entries are flattened to the terminal successor, reproducing the transitive - * walk `resolveAccessControlBlockType` used to perform against the registry, - * including its two stopping rules: a cycle stops at the last id visited, and a - * `replacedBy` naming an unregistered block leaves the id as its own answer. - * Only ids whose answer differs from themselves are emitted. + * Entries are flattened to the terminal successor; see {@link flattenSuccessors} + * for the walk and its stopping rules. * * Usage: * bun run scripts/generate-block-successors.ts From dc54559d5e3648c8111bc6d8423b5c6e02fac2e6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 22:37:21 -0700 Subject: [PATCH 128/179] docs(skills): re-audit permission-group skills against current code Fixes drift (operation count 315 -> 322, workspace-authorization.ts:171 -> :204, stale capabilityRefusalResponse exemplars, a warning about a deleted assertOrganizationCapability) and adds the two mechanisms an author or auditor now gets wrong without: the governed-subject helper family with the required `string | null` producer rule, and canonical-before-intersection integration allowlist semantics over the generated successor map. --- .../skills/add-permission-group-item/SKILL.md | 34 ++++++++++++++----- .../validate-permission-group-item/SKILL.md | 17 ++++++---- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/.agents/skills/add-permission-group-item/SKILL.md b/.agents/skills/add-permission-group-item/SKILL.md index ef6f0671199..c218ddf7a38 100644 --- a/.agents/skills/add-permission-group-item/SKILL.md +++ b/.agents/skills/add-permission-group-item/SKILL.md @@ -14,7 +14,8 @@ You are adding one governed item an organization admin can withhold from a cohor - `lib/permission-groups/fields.ts` — registry, three field builders, `permissionGroupConfigSchema`, `tolerantArray`, `parsePermissionGroupConfig`. There is **no `types.ts`** (folded in here); the DB constraint maps live in `constraints.ts` - `lib/permission-groups/capabilities.ts` — `CAPABILITY_IDS`, `CAPABILITY_RULES`, `capabilityRefusal`, `refuseCapability`, the static/parameterized split -- `lib/permission-groups/capability-assertions.ts` — the sanctioned assertion API; re-exports `capabilityRefusal` +- `lib/permission-groups/capability-assertions.ts` — the sanctioned assertion API; re-exports `capabilityRefusal`. `capability-error.ts` holds the thrown error, `capability-response.ts` the raw-route 403 +- `lib/permission-groups/integration-allowlist.ts` — the canonicalizing allowlist algebra, over the generated `block-successors.generated.ts` - `lib/permission-groups/resolve.server.ts` — `resolveWorkspaceGroup`, `resolveVerifiedUserAccessControlContext`, `getUserPermissionConfig`, `getUserPermissionConfigForOrganization`, `mergeEnvAllowlist`. `ee/access-control/utils/permission-check.ts` re-exports it and keeps the executor gates - `lib/permission-groups/config-scope.server.ts` (`resolvePermissionGroupConfig`, the per-request memo every assertion resolves through) and `request-scope.server.ts` (`withPermissionGroupScope`, deliberately import-free because `withRouteHandler` imports it) - `lib/core/application/workspace-operation.ts` and `workspace-authorization.ts` — the required `capability` field, and the funnel @@ -37,14 +38,14 @@ Allowlist when the safe posture is "only what the admin named" and the member se | Value | Meaning | |---|---| | `'capability'` | An operation declares a capability whose rule reads the key; the funnel refuses before the use case runs. Default answer for anything reachable through an application operation | -| `'executor'` | Read per block/tool/model at run time by `assertPermissionsAllowed` in `ee/access-control/utils/permission-check.ts`. Governs what a *run* may do, which no operation gate can express (one API call executes fifty blocks). Only `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, `deniedTools` live here. The matching primitives these four keys are compared with live in `lib/permission-groups/` — `block-access.ts` (exemptions, superseded-version resolution), `operation-access.ts` (`createToolAccessGate`), `model-access.ts` (`createModelAccessGate`), `integration-allowlist.ts` — shared so the run-time gate and the editor/Copilot projections cannot drift | +| `'executor'` | Read per block/tool/model at run time by `assertPermissionsAllowed` in `ee/access-control/utils/permission-check.ts`. Governs what a *run* may do, which no operation gate can express (one API call executes fifty blocks). Only `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, `deniedTools` live here. The matching primitives these four keys are compared with live in `lib/permission-groups/` — `block-access.ts` (exemptions, superseded-version resolution), `operation-access.ts` (`createToolAccessGate`), `model-access.ts` (`createModelAccessGate`), `integration-allowlist.ts` — shared so the run-time gate and the editor/Copilot projections cannot drift. `allowedIntegrations` alone is also asserted outside a run, by `assertSelectorIntegrationAllowed` (`lib/selectors/server/integration-access.ts`) ahead of the provider call in `selectors.execute`, against the selector's own `resourceServiceId` / `integrationBlockTypes` rather than the credentials it accepts — reaching a provider API is a use of the integration, so a key here can still need a non-run enforcement site | | `'ui-only'` | Hides a surface without withholding it. **Almost never right** — nothing ships as `ui-only`. Justify in the `enforcement` comment why a determined caller reaching the data is acceptable, and expect review to question it | **Is it per-operation at all?** `personal_api_key.use` is the one capability that is not: it withholds a *principal kind* across every operation, checked in the funnel's `personal_api_key` branch (`workspace-authorization.ts`) and again in `app/api/v1/middleware.ts`, so no operation declares it and its absence from every `capability:` field is correct rather than a hole. **Is the decision knowable from the config alone?** A rule needing a request value (an auth mode, a connector id) is *parameterized* and cannot be declared on an operation — see Step 3. -**Is it a gate or a projection?** A key that withholds *fields from a response* rather than the response is a projection. `hideTraceSpans` and `hideCostInfo` work this way: the logs routes declare `capability: 'none'` and strip fields, because refusing the read would withhold the status and error message too. Projections have one owner — `lib/logs/log-projection.ts` (`resolveLogFieldProjection`, `projectExecutionData`, `projectCostTotal`), carrying the `permission-group-enforced:` annotations. Add yours there; two copies of a redaction rule is how one of them stops redacting. Corollary: refuse the query that *selects on* a withheld field — otherwise the projection is a filter oracle. +**Is it a gate or a projection?** A key that withholds *fields from a response* rather than the response is a projection. `hideTraceSpans` and `hideCostInfo` work this way: the logs routes declare `capability: 'none'` and strip fields, because refusing the read would withhold the status and error message too. Projections have one owner — `lib/logs/log-projection.ts` (`resolveLogFieldProjection`, `projectExecutionData`, `projectCostTotal`), carrying the `permission-group-enforced:` annotations. Add yours there; two copies of a redaction rule is how one of them stops redacting. Corollary: refuse the query that *selects on* a withheld field — otherwise the projection is a filter oracle; `logQuerySelectsCost` / `assertLogCostQueryAllowed` in that same module are the shape. ## Step 1: Append the field entry — never insert @@ -128,7 +129,7 @@ export const shareWidget = defineWorkspaceOperation({ **The factory trap.** An operation minted by a factory that does not call `defineWorkspaceOperation` — a hand-frozen object — bypasses the required type *and*, once bypassed, the audit; twenty-one operations across six domains were invisible that way, and the file still printed a tick because some other operation in it was counted. The audit now matches the whole `defineOperation` family, resolves a same-file `function` factory (capability fixed in the body or taken as a positional second argument — `lib/table/application/operations.ts` shows both, with **no default** on the positional form so nothing inherits `tables.use` unreviewed), and cross-checks the members of every exported `*Operations` registry against what it parsed. Keep new operations inside an exported `*Operations` registry, mint them through a `define*Operation` builder taking an object literal with a string `id`, and use a `function` factory rather than an arrow const. -**Static, but no operation to hang it on** — a raw route or an organization-level action. There is no `assertOrganizationCapability`; it was deleted. +**Static, but no operation to hang it on** — a raw route or an organization-level action. | Helper | Use when | |---|---| @@ -166,7 +167,18 @@ Always route through `CAPABILITY_RULES` and raise with `refuseCapability` — a ### Surfaces that do not go through the funnel -- **`/api/v1`** authorizes in `app/api/v1/middleware.ts`. Every route threads a `V1RouteCapability` (`StaticPermissionGroupCapability | 'none'`, required and spelled out) whose value must match what its v2 or internal counterpart declares — v1 gets no mapping of its own. The subject **must** come from `capabilityGovernedUserId(rateLimit)`, which returns `null` for a workspace key: `rateLimit.userId` is populated for *both* key kinds and is the key's **creator** for a workspace key, a bystander. `check-capability-subject.ts` exists because that has shipped and been fixed twice. +**Whose group applies — never `userId` off whatever identity is nearest.** Each helper below returns `null` for a caller no group governs (workspace key, internal JWT, executor delegation), and `null` is a *pass*, not a denial. + +| You hold | Helper | +|---|---| +| `Principal` | `capabilityGovernedPrincipalUserId` (`lib/core/application`) — mirrors the funnel exactly, executor exemption included | +| v1 `RateLimitResult` | `capabilityGovernedUserId(rateLimit)` (`app/api/v1/middleware.ts`) — branches on `keyType`, never on the presence of `userId` | +| `TableAccessPrincipal` | `capabilityGovernedUserId(principal)` (`app/api/table/utils.ts`) | +| `AuthResult` from `checkSessionOrInternalAuth` | `capabilityGovernedAuthUserId` (same file) — an internal JWT's `userId` is the run's actor, a bystander | + +When the subject is **persisted and read back later** — the table dispatch pipeline stamps it on `table_run_dispatches` / `table_row_executions` so auto-fired cells run under the person the write was gated for — declare it `capabilityGovernedUserId: string | null`, required with an explicit `null` and never optional. An optional field with a fallback is how every producer that had not been taught the distinction silently inherited `triggeredByUserId`, an *attribution* naming the billed account; making omission a compile error is the whole enforcement. A persisted subject also has a lifecycle: `lib/users/account-deletion.ts` cancels the dispatches stamped with a deleted user. + +- **`/api/v1`** authorizes in `app/api/v1/middleware.ts`. Every route threads a `V1RouteCapability` (`StaticPermissionGroupCapability | 'none'`, required and spelled out) whose value must match what its v2 or internal counterpart declares — v1 gets no mapping of its own. `check-capability-subject.ts` audits v1's subjects only, because the bug has shipped and been fixed twice there. - **Raw internal table routes** (`/api/table/**`) share one gate in `checkAccess` (`app/api/table/utils.ts`), whose signature takes a `TableAccessPrincipal` union — `{ kind: 'user'; userId }` or `{ kind: 'workspace_api_key'; keyCreatorUserId }` — so a bare id no longer type-checks and only the kind that says so skips the gate. `tableAccessPrincipal(rateLimit)` builds it for v1. - **The route-wrapper graph.** `withRouteHandler` imports `request-scope.server.ts` and nothing heavier. Import a resolver at the *call site*, never from the wrapper or `lib/core/application` — see Step 6. @@ -174,7 +186,7 @@ Always route through `CAPABILITY_RULES` and raise with `refuseCapability` — a Add the key to **both** the `input` and `expected` objects of the `'a fully populated config'` fixture in `lib/permission-groups/fields.test.ts`, set to a non-default value. That fixture is the pinned coercion corpus: a row that changes in a later diff is a semantic decision someone defends rather than a silent regression. The file's other assertions derive from `DEFAULT_PERMISSION_GROUP_CONFIG` (wire order, idempotence, read-schema acceptance, the 2000-iteration seeded fuzz, write/default/read key-set agreement, boolean-to-`PLATFORM_FEATURES` coverage) and pick your key up for free, as does `features.test.ts`. -**Give the funnel test a real `workspaceOrganizationId`.** `requireCapability` short-circuits on `context.workspaceOrganizationId === null` (`lib/core/application/workspace-authorization.ts:171`), so a fixture whose workspace context leaves it null passes with the gate present *and* with it removed — a vacuous test that reads as load-bearing. +**Give the funnel test a real `workspaceOrganizationId`.** `requireCapability` short-circuits on `context.workspaceOrganizationId === null` (`lib/core/application/workspace-authorization.ts:204`), so a fixture whose workspace context leaves it null passes with the gate present *and* with it removed — a vacuous test that reads as load-bearing. Add a case to `capabilities.test.ts` for any rule with logic beyond reading one key. For an allowlist assert all three states — `null` permits every member, a populated list only the named ones, `[]` permits **none** — as `capabilities.test.ts` already does for `knowledge.connectors`. @@ -206,12 +218,12 @@ Also `bun run check:api-validation` if you touched a contract or the group route Read the success lines, not the exit codes — the counts should have grown by your operation and capability: ``` -✓ permission-group enforcement: 315 operations declare a capability, 35 capabilities all enforced +✓ permission-group enforcement: 322 operations declare a capability, 35 capabilities all enforced ✅ Application graph clean: 5 roots reach none of 11 forbidden module trees check:capability-subject — 32 v1 files, 5 capability subjects resolved through capabilityGovernedUserId. ``` -The enforcement audit is all-or-nothing — one success line or findings, no migration mode that exits 0 with work outstanding. Because it reads source text with regexes it carries self-checks: it refuses success when `CAPABILITY_IDS`, `CAPABILITY_RULES` or `PERMISSION_GROUP_FIELDS` parse to nothing or when rule and capability counts disagree; it reports per call any operation whose `id` it cannot read; **a file that mints an operation and parses to ZERO declarations is a finding**; and every member of an exported `*Operations` registry that it read no operation from is a finding. If one fires, teach the parsers the new form — do not work around it. +The enforcement audit is all-or-nothing — one success line or findings, no migration mode that exits 0 with work outstanding. Because it reads source text it also refuses success when its own parsers come up empty or disagree with each other; if a self-check fires, teach the parsers the new form rather than working around it. The audits prove *reachability*: your capability is named somewhere, your key is read by some rule. They cannot tell whether the rule's logic is right or whether every operation reaching the behavior declares it. Green is not proof the gate fires. @@ -225,6 +237,8 @@ The audits prove *reachability*: your capability is named somewhere, your key is **An empty allowlist denies everything; `null` allows everything.** They must never collapse — not in the parser, the UI setter, or a `deniedBy`. `allowlistDenies` encodes it as `allowed !== null && !allowed.includes(member)`; a `?? []` anywhere on this path inverts the unrestricted case. +**Canonicalize both halves of an integration allowlist *before* intersecting, never after.** `allowedIntegrations` and the deployment's `ALLOWED_INTEGRATIONS` are written independently, so one can name `slack` and the other `slack_v2`; fold only case and they intersect to nothing, hiding an integration both policies allow. Compose `intersectAccessControlAllowlists` / `toAccessControlAllowlist` / `resolveAccessControlBlockType` from `integration-allowlist.ts` — never a hand-rolled `Set` intersection — and successor-resolve the type you test against the result the same way. They resolve through `block-successors.generated.ts`, a projection of the block registry because `check:application-graph` forbids the funnel from importing `blocks/`; `check:block-successors` fails the build when it drifts. Read that map only through `Object.hasOwn`: the ids arriving are admin-supplied jsonb, and a group naming `constructor` otherwise gets back an inherited function and 500s every enforcement path that reads it. + **Not everyone goes through the funnel.** | Principal | Rule | @@ -251,7 +265,9 @@ What a run *does* is still governed by `assertPermissionsAllowed`. An item that - [ ] Declared on every operation it governs, or asserted from the use case with a `// permission-group-enforced:` annotation raising through `refuseCapability` / `capabilityRefusal` - [ ] New operations minted through a `define*Operation` builder and exported from an `*Operations` registry - [ ] Any `capability: 'none'` carries a `// permission-group-exempt:` reason -- [ ] v1 routes thread the capability through `middleware.ts` and take their subject from `capabilityGovernedUserId`; table routes pass a `TableAccessPrincipal` +- [ ] Every gate's subject comes from the `capabilityGoverned*` helper for the identity it holds, and a persisted subject is a required `string | null` +- [ ] v1 routes thread the capability through `middleware.ts`; table routes pass a `TableAccessPrincipal` +- [ ] An integration-shaped allowlist canonicalizes through `integration-allowlist.ts` on both sides of every comparison - [ ] Added to the `'a fully populated config'` fixture in `fields.test.ts`, input and expected - [ ] Allowlist three-state (`null` / populated / `[]`) covered in `capabilities.test.ts` - [ ] No new runtime import from a guarded root into a forbidden tree diff --git a/.agents/skills/validate-permission-group-item/SKILL.md b/.agents/skills/validate-permission-group-item/SKILL.md index a75133baeb3..febb6c278ec 100644 --- a/.agents/skills/validate-permission-group-item/SKILL.md +++ b/.agents/skills/validate-permission-group-item/SKILL.md @@ -36,6 +36,7 @@ Only the registry and the resolvers are excluded, so `capabilities.ts` stays in - **`z.array(...).catch(...)` anywhere on this key's path** — whole-value tolerant, so one bad member discards every good one, and on an allowlist the `null` fallback means unrestricted. That is fail-**open**. `tolerantArray` filters element-wise. Rank a regression here with the enforcement findings. - **`?? []` applied to an allowlist** — collapses "allows everything" into "allows nothing". +- **A hand-rolled comparison against an integration allowlist.** `allowedIntegrations` and the deployment's `ALLOWED_INTEGRATIONS` are written independently, so one names `slack` where the other names `slack_v2`; anything folding only case intersects them to nothing and hides an integration both allow. Both halves must canonicalize through `integration-allowlist.ts` (`intersectAccessControlAllowlists` / `toAccessControlAllowlist` / `resolveAccessControlBlockType`) *before* intersecting, with the checked type resolved the same way. That module reads `block-successors.generated.ts` through `Object.hasOwn` — a bare bracket lookup answers an admin-supplied `constructor` with an inherited function and 500s every path reading that group; `check:block-successors` catches the map going stale. - **Any config read not from `parsePermissionGroupConfig` or a `resolvePermissionGroupConfig` caller.** Two structural guards must still be present: @@ -76,20 +77,22 @@ The second grep misses a gate whose annotation sits in a TSDoc block above the e Classify into exactly one of: 1. **Declared on operations.** The funnel enforces in `requireCurrentHumanAccess` → `requireCapability`. Verify the set is *complete*: enumerate every route and tool reaching the same behavior. One declaring `capability: 'none'` is the hole. -2. **Asserted at a call site** with a `// permission-group-enforced: ` annotation. Verify it goes through `capability-assertions.ts` (`assertWorkspaceCapability`, `isWorkspaceCapabilityWithheld`, `isOrganizationCapabilityWithheld`, `capabilityDeniedBy`), through `isCapabilityWithheldForUser` (`lib/permission-groups/user-scope.server.ts` — workspace group first, else the organization's default, for a user-level act that may or may not name a workspace; outside `capability-assertions.ts` on purpose because it reads org membership through the billing graph, a guarded root of `check:application-graph`; `app/api/cli/auth/approve/route.ts` is the shape), or a direct `CAPABILITY_RULES[''].deniedBy(...)` rather than reading `config.disableX` inline, **and** that it *raises* through `refuseCapability` / renders `capabilityRefusal(cap)` rather than building its own `ForbiddenOperationError` with a hand-written message — the easy half to miss, because the decision looks right. Use-case shape: `validatePublicFileSharing`, `validateChatDeployAuth` (`ee/access-control/utils/permission-check.ts`), `assertConnectorTypeAllowed` (`lib/knowledge/application/connectors.ts`). Raw-route shape: `app/api/logs/export/route.ts` and the inbox and api-keys routes; a raw route should render through `capabilityRefusalResponse` (`lib/permission-groups/capability-response.ts`), which reads `details.code` off the rule — a hand-rolled `NextResponse.json({ error: capabilityRefusal(cap) }, { status: 403 })` drops it, reporting the four specifically-coded capabilities (`deploy.chat.auth_mode`, `file_share.publish`, `file_share.auth_mode`, `personal_api_key.use`) as the generic block. v1 is deliberately not converged on it (`resolveCapabilityRefusal` in `app/api/v1/middleware.ts`). -3. **Executor-gated** by `assertPermissionsAllowed`, per block / tool / model, matching through the shared primitives in `lib/permission-groups/` — `block-access.ts`, `operation-access.ts`, `model-access.ts`, `integration-allowlist.ts` — which the editor and Copilot projections read too, so a second copy of a match rule is a finding. Verify the branch throws a real error and that the id it compares against is the vocabulary the admin UI writes — `deniedTools` holds block `tools.access` ids verbatim, version suffix included. +2. **Asserted at a call site** with a `// permission-group-enforced: ` annotation. Verify it goes through `capability-assertions.ts` (`assertWorkspaceCapability`, `isWorkspaceCapabilityWithheld`, `isOrganizationCapabilityWithheld`, `capabilityDeniedBy`), through `isCapabilityWithheldForUser` (`lib/permission-groups/user-scope.server.ts` — workspace group first, else the organization's default, for a user-level act that may or may not name a workspace; outside `capability-assertions.ts` on purpose because it reads org membership through the billing graph, a guarded root of `check:application-graph`; `app/api/cli/auth/approve/route.ts` is the shape), or a direct `CAPABILITY_RULES[''].deniedBy(...)` rather than reading `config.disableX` inline, **and** that it *raises* through `refuseCapability` / renders `capabilityRefusal(cap)` rather than building its own `ForbiddenOperationError` with a hand-written message — the easy half to miss, because the decision looks right. Use-case shape: `validatePublicFileSharing`, `validateChatDeployAuth` (`ee/access-control/utils/permission-check.ts`), `assertConnectorTypeAllowed` (`lib/knowledge/application/connectors.ts`). Raw-route shape: `app/api/logs/stats/route.ts`, `app/api/table/[tableId]/export/route.ts`. A raw route should render through `capabilityRefusalResponse` (`lib/permission-groups/capability-response.ts`), which reads `details.code` off the rule — a hand-rolled `NextResponse.json({ error: capabilityRefusal(cap) }, { status: 403 })` drops it, reporting the four specifically-coded capabilities (`deploy.chat.auth_mode`, `file_share.publish`, `file_share.auth_mode`, `personal_api_key.use`) as the generic block. Convergence is partial — the inbox, api-keys, oauth-credentials, cli-approve and `logs/export` routes still hand-roll it, harmlessly today because all of their capabilities carry the generic code, so report one only if its capability gains a specific code. v1 is deliberately not converged on it (`resolveCapabilityRefusal` in `app/api/v1/middleware.ts`). +3. **Executor-gated** by `assertPermissionsAllowed`, per block / tool / model, matching through the shared primitives in `lib/permission-groups/` — `block-access.ts`, `operation-access.ts`, `model-access.ts`, `integration-allowlist.ts` — which the editor and Copilot projections read too, so a second copy of a match rule is a finding. Verify the branch throws a real error and that the id it compares against is the vocabulary the admin UI writes — `deniedTools` holds block `tools.access` ids verbatim, version suffix included. `allowedIntegrations` is *also* enforced off the run, by `assertSelectorIntegrationAllowed` (`lib/selectors/server/integration-access.ts`), so an executor key's coverage is not complete until every non-run path that reaches the third party is checked too. 4. **A field projection, not a gate.** `logs.trace_spans` and `logs.cost` withhold fields, so the logs routes correctly declare `capability: 'none'`. Single owner: `lib/logs/log-projection.ts` (`resolveLogFieldProjection`, `projectExecutionData`, `projectCostTotal`), which carries both annotations. A **second** implementation of the same redaction is the finding — as is a query that lets a caller filter or sort on a withheld field, which turns the projection into an oracle. -Ahead of all five: `personal_api_key.use` fits none of them. It withholds a *principal kind* across every operation — the funnel's `personal_api_key` branch (`lib/core/application/workspace-authorization.ts`) and `app/api/v1/middleware.ts` — so no operation declares it and `disablePersonalApiKeys` being absent from every `capability:` field is correct, not a hole. - 5. **Nothing.** Report as a defect: "an organization that sets this believes it applied a restriction that does not exist". -Then **make the refusal happen**: write a failing case, or remove the gate (the `capability:` field, the `deniedBy` body, the assertion call) and confirm an existing test goes red. A test that still passes with the gate removed proves nothing. Restore afterward. **Check the fixture's `workspaceOrganizationId` first**: `requireCapability` short-circuits when it is `null` (`lib/core/application/workspace-authorization.ts:171`), so a context that leaves it unset passes either way and the existing test proves nothing even before you touch it. +Ahead of all five: `personal_api_key.use` fits none of them. It withholds a *principal kind* across every operation — the funnel's `personal_api_key` branch (`lib/core/application/workspace-authorization.ts`) and `app/api/v1/middleware.ts` — so no operation declares it and `disablePersonalApiKeys` being absent from every `capability:` field is correct, not a hole. + +Then **make the refusal happen**: write a failing case, or remove the gate (the `capability:` field, the `deniedBy` body, the assertion call) and confirm an existing test goes red. A test that still passes with the gate removed proves nothing. Restore afterward. **Check the fixture's `workspaceOrganizationId` first**: `requireCapability` short-circuits when it is `null` (`lib/core/application/workspace-authorization.ts:204`), so a context that leaves it unset passes either way and the existing test proves nothing even before you touch it. For an allowlist the three states must be tested separately — `null` permits every member, a populated list only the named ones, `[]` permits **none**. `capabilities.test.ts` pins all three for `knowledge.connectors`; less than that elsewhere is a gap. ### Who the gate runs against -- **`/api/v1`** authorizes in `app/api/v1/middleware.ts`, not through `authorizeWorkspaceOperation`. The subject must come from `capabilityGovernedUserId(rateLimit)`, which returns `null` for a workspace key; `rateLimit.userId` is populated for **both** key kinds and is the key's *creator* for a workspace key, so a gate keyed on the presence of a user id applies a bystander's group to every caller of a shared credential. Reading `rateLimit.userId` (or `auth.userId`) into a capability sink is the finding — `check-capability-subject.ts` exists because it has shipped twice. Each route also threads a required, spelled-out `V1RouteCapability`. +**Read the subject, not the nearest user id.** Every capability sink must take its subject from the `capabilityGoverned*` helper for the identity the surface holds — `capabilityGovernedPrincipalUserId` for a `Principal` (`lib/core/application`), `capabilityGovernedUserId` for a v1 `RateLimitResult` or a `TableAccessPrincipal`, `capabilityGovernedAuthUserId` for a `checkSessionOrInternalAuth` result. Each returns `null` where no group governs, and `null` is a pass. Reading `rateLimit.userId`, `auth.userId`, `subjectUserId` or `triggeredByUserId` into a sink is the finding: for a workspace key the first is the key's *creator*, for an internal JWT the second is the run's actor, and the last is a billing *attribution*. `check-capability-subject.ts` audits **v1 only**, so every other surface is on you. Where the subject is persisted and read back later (`capabilityGovernedUserId` on `table_run_dispatches` / `table_row_executions`), it must be declared required as `string | null` — an optional field with a fallback is exactly how producers re-inherited `triggeredByUserId`, so a proposal to make it optional is a finding. + +- **`/api/v1`** authorizes in `app/api/v1/middleware.ts`, not through `authorizeWorkspaceOperation`; `capabilityGovernedUserId(rateLimit)` branches on `keyType`, never on the presence of a user id. Each route also threads a required, spelled-out `V1RouteCapability`. - **Raw internal table routes** gate `tables.use` in `checkAccess` (`app/api/table/utils.ts`) via a `TableAccessPrincipal` union — `{ kind: 'user'; userId }` or `{ kind: 'workspace_api_key'; keyCreatorUserId }` — so a bare id no longer type-checks. `tableAccessPrincipal(rateLimit)` is the one place v1 builds it. - **The definition-time `undefined` guard** on `defineWorkspaceOperation` is not redundant even though `capability` is required on the `ApplicationOperation` **base type** (`lib/core/application/operation.ts:31`, not merely on the builder — which is what stops a bare-literal factory from compiling): `apps/sim/tsconfig.json` excludes `*.test.ts` / `*.test.tsx` and the enforcement audit walks past test files, so a fixture is the one construction site no static check reads. Without it a capability-less operation defines cleanly and then throws `Cannot read properties of undefined` inside `capabilityDeniedBy` **only for tenants that actually have a permission group**, passing CI and every personal workspace. A proposal to drop it is a finding. @@ -112,7 +115,7 @@ cd apps/sim && bun run type-check && bunx vitest run lib/permission-groups All three are inside `check:audits`, which derives its list from the `check:*` scripts in `package.json` — a new audit is opted *out* deliberately. Read the output, not the exit codes. Reference success lines (counts grow): ``` -✓ permission-group enforcement: 315 operations declare a capability, 35 capabilities all enforced +✓ permission-group enforcement: 322 operations declare a capability, 35 capabilities all enforced ✅ Application graph clean: 5 roots reach none of 11 forbidden module trees check:capability-subject — 32 v1 files, 5 capability subjects resolved through capabilityGovernedUserId. ``` From 640daf0d8412247c8af20516bbf154b5c859e9a7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 22:37:42 -0700 Subject: [PATCH 129/179] docs(table): one home for the capability-governed payload rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same six-line paragraph — the gate subject is not the attribution field beside it, and it is required with an explicit null — was restated on fourteen payload fields across types.ts, dispatcher.ts, workflow-columns.ts, orchestration/import.ts, application/groups.ts and workflow-groups/service.ts, including two byte-identical copies in one file. Three of them claimed to be the canonical statement. InsertRowData.capabilityGovernedUserId now carries the whole rule, absorbing insertDispatch's sharper why-required argument, and links onward to capabilityGovernedPrincipalUserId for which principals a group governs. The rest keep their site-specific sentence and point at it, matching the {@link InsertRowData.secretProvenance} convention already in the file. --- apps/sim/lib/table/application/groups.ts | 6 +- apps/sim/lib/table/dispatcher.ts | 9 +-- apps/sim/lib/table/orchestration/import.ts | 18 +++--- apps/sim/lib/table/types.ts | 60 ++++++++----------- apps/sim/lib/table/workflow-columns.ts | 12 ++-- apps/sim/lib/table/workflow-groups/service.ts | 7 +-- 6 files changed, 43 insertions(+), 69 deletions(-) diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index b35eaf6e25b..bbbb1750f8e 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -184,10 +184,8 @@ function dispatchGroupAutoRun(params: { groupId: string actorUserId: string /** - * The gate's subject, which is not the meter's. `actorUserId` is an - * attribution and names the workspace billed account when the credential - * names no human, so passing it as the gate would run that bystander's tool - * denylist against an actorless run. Null means no acting person. + * The gate's subject, which is not the meter's `actorUserId`; `null` means no + * acting person. See {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. */ capabilityGovernedUserId: string | null label: string diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index 503647d9998..f38a0f68bab 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -256,13 +256,8 @@ export async function insertDispatch(input: { * The person whose permission group gates this run's cells, or `null` when * the run has no acting person (workspace key, schedule, auto-fire). * - * Required with an explicit `null` rather than optional: the only way to get - * this wrong is to not think about it, and an optional field with a fallback - * let every producer that had not been taught the distinction silently - * inherit `triggeredByUserId` — an *attribution* that names the workspace - * billed account when the credential names no human. Making omission a - * compile error is what stops the next producer from re-introducing that - * bystander substitution. + * Never defaulted from `triggeredByUserId`, and required with an explicit + * `null`; see {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. */ capabilityGovernedUserId: string | null }): Promise { diff --git a/apps/sim/lib/table/orchestration/import.ts b/apps/sim/lib/table/orchestration/import.ts index 9b42a52eca3..5b2dc97549b 100644 --- a/apps/sim/lib/table/orchestration/import.ts +++ b/apps/sim/lib/table/orchestration/import.ts @@ -236,11 +236,10 @@ export interface PerformTableCsvImportParams { requestId?: string /** * The person whose permission group gates any cell this import auto-fires, - * or `null` when no person is behind it. Required with an explicit `null` - * rather than optional, matching `insertDispatch`: an import lands rows, and - * landing rows starts workflow and enrichment cells on the table's workflow - * columns. Threaded from the surface that holds the principal rather than - * re-derived here — the route has already gated the same subject. + * or `null` when no person is behind it. An import lands rows, and landing + * rows starts the table's workflow and enrichment cells. Threaded from the + * surface that holds the principal — the route has already gated the same + * subject. Required; see {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. */ capabilityGovernedUserId: string | null } @@ -439,11 +438,10 @@ export interface PerformCreateTableFromCsvParams { userId: string /** * The person whose permission group gates any cell this import auto-fires, - * or `null` when no person is behind it. Required with an explicit `null` - * rather than optional, matching `insertDispatch`: an import lands rows, and - * landing rows starts workflow and enrichment cells on the table's workflow - * columns. Threaded from the surface that holds the principal rather than - * re-derived here — the route has already gated the same subject. + * or `null` when no person is behind it. An import lands rows, and landing + * rows starts the table's workflow and enrichment cells. Threaded from the + * surface that holds the principal — the route has already gated the same + * subject. Required; see {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. */ capabilityGovernedUserId: string | null diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index f57ae499d2f..98747c75ed6 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -716,12 +716,25 @@ export interface InsertRowData { * unstamped write. */ secretProvenance: TableRowSecretProvenanceWrite | undefined - /** The person whose permission group gates any enrichment this write - * auto-fires; `null` when the write has no acting person (workspace API key, - * schedule, internal state patch). Required with an explicit `null` — - * deliberately not the attribution field beside it, which names the - * workspace billed account when the credential names no human, and would run - * that bystander's tool denylist against an actorless run. */ + /** + * The person whose permission group gates any enrichment this write + * auto-fires; `null` when the write has no acting person (workspace API key, + * schedule, internal state patch). + * + * THE statement of the rule for every table payload that carries this field. + * It is deliberately not the attribution field beside it, which names the + * workspace billed account when the credential names no human and would run + * that bystander's tool denylist against an actorless run. Which principals + * a group governs at all is `capabilityGovernedPrincipalUserId` in + * `@/lib/core/application`; every surface resolves the subject there and + * threads it down rather than re-deriving it. + * + * Required with an explicit `null` rather than optional: the only way to get + * this wrong is to not think about it, and an optional field with a fallback + * let every producer that had not been taught the distinction silently + * inherit the attribution. Making omission a compile error is what stops the + * next producer from re-introducing that bystander substitution. + */ capabilityGovernedUserId: string | null } @@ -738,11 +751,7 @@ export interface BatchInsertData { /** Encrypted provenance for the values in `rows`, positionally aligned. Required; see {@link InsertRowData.secretProvenance}. */ secretProvenance: Array | undefined /** The person whose permission group gates any enrichment this write - * auto-fires; `null` when the write has no acting person (workspace API key, - * schedule, internal state patch). Required with an explicit `null` — - * deliberately not the attribution field beside it, which names the - * workspace billed account when the credential names no human, and would run - * that bystander's tool denylist against an actorless run. */ + * auto-fires. Required; see {@link InsertRowData.capabilityGovernedUserId}. */ capabilityGovernedUserId: string | null } @@ -756,11 +765,7 @@ export interface UpsertRowData { /** Encrypted provenance for the values in `data`. Required; see {@link InsertRowData.secretProvenance}. */ secretProvenance: TableRowSecretProvenanceWrite | undefined /** The person whose permission group gates any enrichment this write - * auto-fires; `null` when the write has no acting person (workspace API key, - * schedule, internal state patch). Required with an explicit `null` — - * deliberately not the attribution field beside it, which names the - * workspace billed account when the credential names no human, and would run - * that bystander's tool denylist against an actorless run. */ + * auto-fires. Required; see {@link InsertRowData.capabilityGovernedUserId}. */ capabilityGovernedUserId: string | null } @@ -811,11 +816,7 @@ export interface UpdateRowData { /** Encrypted provenance for the values in this partial patch. Required; see {@link InsertRowData.secretProvenance}. */ secretProvenance: TableRowSecretProvenanceWrite | undefined /** The person whose permission group gates any enrichment this write - * auto-fires; `null` when the write has no acting person (workspace API key, - * schedule, internal state patch). Required with an explicit `null` — - * deliberately not the attribution field beside it, which names the - * workspace billed account when the credential names no human, and would run - * that bystander's tool denylist against an actorless run. */ + * auto-fires. Required; see {@link InsertRowData.capabilityGovernedUserId}. */ capabilityGovernedUserId: string | null } @@ -828,11 +829,7 @@ export interface BulkUpdateData { /** Encrypted provenance for the values in this partial patch. Required; see {@link InsertRowData.secretProvenance}. */ secretProvenance: TableRowSecretProvenanceWrite | undefined /** The person whose permission group gates any enrichment this write - * auto-fires; `null` when the write has no acting person (workspace API key, - * schedule, internal state patch). Required with an explicit `null` — - * deliberately not the attribution field beside it, which names the - * workspace billed account when the credential names no human, and would run - * that bystander's tool denylist against an actorless run. */ + * auto-fires. Required; see {@link InsertRowData.capabilityGovernedUserId}. */ capabilityGovernedUserId: string | null } @@ -849,11 +846,7 @@ export interface BatchUpdateByIdData { /** Encrypted provenance for the values in all partial patches; omitted by legacy callers. */ secretProvenanceByRowId?: Record /** The person whose permission group gates any enrichment this write - * auto-fires; `null` when the write has no acting person (workspace API key, - * schedule, internal state patch). Required with an explicit `null` — - * deliberately not the attribution field beside it, which names the - * workspace billed account when the credential names no human, and would run - * that bystander's tool denylist against an actorless run. */ + * auto-fires. Required; see {@link InsertRowData.capabilityGovernedUserId}. */ capabilityGovernedUserId: string | null } @@ -1040,10 +1033,7 @@ export interface UpdateWorkflowGroupData { /** The member updating the group — billed for any triggered re-run. */ actorUserId?: string | null /** The person whose permission group gates the auto-run pass this write can - * start; `null` when the write has no acting person (workspace key, system). - * Required with an explicit `null` — deliberately not `actorUserId`, which - * is an attribution and names the workspace billed account when the - * credential names no human. */ + * start. Required; see {@link InsertRowData.capabilityGovernedUserId}. */ capabilityGovernedUserId: string | null } diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 48338a46fd6..96c252bd2c3 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -450,9 +450,8 @@ export interface WorkflowGroupCellPayload { * billed account. */ triggeredByUserId?: string /** Person whose permission group gates this cell's tools. Null/absent means - * no acting person, so no per-tool gate applies. Not `triggeredByUserId`: - * that is an attribution and names the workspace billed account when the - * credential names no human, which would run a bystander's denylist. */ + * no acting person, so no per-tool gate applies. Not `triggeredByUserId`; + * see {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. */ capabilityGovernedUserId?: string | null } @@ -873,11 +872,8 @@ export async function runWorkflowColumn(opts: { * account at billing time. */ triggeredByUserId?: string | null /** Person whose permission group gates the run's cells; `null` when the run - * has no acting person (workspace key, schedule, auto-fire). Required with - * an explicit `null` — never defaulted from `triggeredByUserId`, which is an - * attribution and names the workspace billed account when the credential - * names no human. Producers that sit below the principal take it from the - * surface that holds one rather than re-deriving it here. */ + * has no acting person (workspace key, schedule, auto-fire). Required, and + * never defaulted from `triggeredByUserId`; see {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. */ capabilityGovernedUserId: string | null }): Promise<{ dispatchId: string | null; shouldSignalRowsChanged: boolean }> { const { diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index 028bd60f21b..0796cb81b91 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -642,11 +642,8 @@ export async function addWorkflowGroupOutput( * row writes. Not the gate: see `capabilityGovernedUserId`. */ actorUserId?: string | null /** Person whose permission group gates any cell the backfill's writes - * cascade into; `null` when the change has no acting person. Required with - * an explicit `null` for the same reason the add/update group payloads - * require it: an omitted subject and a deliberately actorless one read - * identically at the call site, and the omitted form silently runs the - * backfill's downstream cells with no gate at all. */ + * cascade into; `null` when the change has no acting person. Required; see + * {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. */ capabilityGovernedUserId: string | null resolvedOutput: { workflowId: string From febcf385be485ee8c79ddc9d4f3105da42d71d9a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 22:41:44 -0700 Subject: [PATCH 130/179] test(harness): share the v1 personal/workspace key credential factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three v1 suites that steer a permission group through the public API — the module gate, the tables gate, and the logs projection — each carried byte-identical `personalKey()` and `workspaceKey()` builders. The workspace one encodes the trap all three exist to pin (the key reports its CREATOR's user id), so it belongs beside the other v1 admission mocks rather than in three copies that can drift apart. `governedBy()` stays per-file: moving it would mean injecting DEFAULT_PERMISSION_GROUP_CONFIG from apps/sim, which packages/testing may not import, and the call site would grow past the six lines it saves. --- apps/sim/app/api/v1/capability-gate.test.ts | 36 ++------------ apps/sim/app/api/v1/logs/projection.test.ts | 28 ++--------- .../app/api/v1/tables/capability-gate.test.ts | 36 ++------------ packages/testing/src/mocks/index.ts | 4 +- packages/testing/src/mocks/v1-route.mock.ts | 47 +++++++++++++++++++ 5 files changed, 65 insertions(+), 86 deletions(-) diff --git a/apps/sim/app/api/v1/capability-gate.test.ts b/apps/sim/app/api/v1/capability-gate.test.ts index 92eee049439..287239fe7e3 100644 --- a/apps/sim/app/api/v1/capability-gate.test.ts +++ b/apps/sim/app/api/v1/capability-gate.test.ts @@ -21,9 +21,11 @@ import { permissionGroupScopeMock, permissionGroupScopeMockFns, resetPermissionGroupScopeMock, + v1PersonalKeyCredential, v1RateLimitContextModuleMock, v1RateLimiterModuleMock, v1SubscriptionModuleMock, + v1WorkspaceKeyCredential, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -117,34 +119,6 @@ const USER_ID = 'user-1' const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' const WORKFLOW_ID = 'wf-1' -function personalKey() { - return { - authenticated: true, - userId: USER_ID, - keyType: 'personal' as const, - principal: { kind: 'personal_api_key' as const, userId: USER_ID, keyId: 'key-1' }, - } -} - -/** - * A workspace key still reports a `userId` — the key's creator — so a gate keyed - * on the presence of a user rather than on `keyType` would silently apply a - * bystander's group to every caller of a shared credential. - */ -function workspaceKey() { - return { - authenticated: true, - userId: 'key-creator', - workspaceId: WORKSPACE_ID, - keyType: 'workspace' as const, - principal: { - kind: 'workspace_api_key' as const, - workspaceId: WORKSPACE_ID, - keyId: 'key-2', - }, - } -} - function governedBy(overrides: Partial) { permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, @@ -175,7 +149,7 @@ const REFUSAL = /is not available under your organization's permission group/ beforeEach(() => { vi.clearAllMocks() resetPermissionGroupScopeMock() - mockAuthenticateV1Request.mockResolvedValue(personalKey()) + mockAuthenticateV1Request.mockResolvedValue(v1PersonalKeyCredential(USER_ID)) mockGetUserEntityPermissions.mockResolvedValue('admin') mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true }) mockListTables.mockResolvedValue([]) @@ -239,7 +213,7 @@ describe('v1 permission-group capability gate', () => { describe('exceptions that must keep working', () => { it('a workspace API key passes through ungated — it has no user, so no group', async () => { - mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) governedBy({ hideTablesTab: true }) const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) @@ -289,7 +263,7 @@ describe('v1 permission-group capability gate', () => { }) it('passes a workspace key through the same group that would deny its creator', async () => { - mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) governedBy({ disablePersonalApiKeys: true }) const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) diff --git a/apps/sim/app/api/v1/logs/projection.test.ts b/apps/sim/app/api/v1/logs/projection.test.ts index fae245f7143..99b9328c487 100644 --- a/apps/sim/app/api/v1/logs/projection.test.ts +++ b/apps/sim/app/api/v1/logs/projection.test.ts @@ -16,9 +16,11 @@ import { permissionGroupScopeMock, permissionGroupScopeMockFns, resetPermissionGroupScopeMock, + v1PersonalKeyCredential, v1RateLimitContextModuleMock, v1RateLimiterModuleMock, v1SubscriptionModuleMock, + v1WorkspaceKeyCredential, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -118,26 +120,6 @@ const LOG_ROW = { workflowUpdatedAt: new Date('2026-01-01T00:00:00.000Z'), } -function personalKey() { - return { - authenticated: true, - userId: USER_ID, - keyType: 'personal' as const, - principal: { kind: 'personal_api_key' as const, userId: USER_ID, keyId: 'key-1' }, - } -} - -/** A workspace key authorizes as the workspace: its creator's group is nobody's. */ -function workspaceKey() { - return { - authenticated: true, - userId: 'key-creator', - workspaceId: WORKSPACE_ID, - keyType: 'workspace' as const, - principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-2' }, - } -} - function governedBy(overrides: Partial) { permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, @@ -175,7 +157,7 @@ function readDetail() { beforeEach(() => { vi.clearAllMocks() resetPermissionGroupScopeMock() - mockAuthenticateV1Request.mockResolvedValue(personalKey()) + mockAuthenticateV1Request.mockResolvedValue(v1PersonalKeyCredential(USER_ID)) mockGetUserEntityPermissions.mockResolvedValue('admin') mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true }) mockListPublicWorkflowLogs.mockResolvedValue({ data: [LOG_ROW], nextCursor: null }) @@ -224,7 +206,7 @@ describe('GET /api/v1/logs?details=full', () => { }) it('withholds nothing from a workspace API key, whose creator has no say', async () => { - mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) governedBy({ hideTraceSpans: true, hideCostInfo: true }) const body = await (await listFull()).json() @@ -275,7 +257,7 @@ describe('GET /api/v1/logs cost-selective queries', () => { /** A workspace key has no user and therefore no group to refuse on behalf of. */ it('answers the same filter for a workspace API key', async () => { - mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) governedBy({ hideCostInfo: true }) const response = await listFiltered('minCost=0.5') diff --git a/apps/sim/app/api/v1/tables/capability-gate.test.ts b/apps/sim/app/api/v1/tables/capability-gate.test.ts index 30608d4b114..1936de7a5c5 100644 --- a/apps/sim/app/api/v1/tables/capability-gate.test.ts +++ b/apps/sim/app/api/v1/tables/capability-gate.test.ts @@ -18,9 +18,11 @@ import { permissionGroupScopeMock, permissionGroupScopeMockFns, resetPermissionGroupScopeMock, + v1PersonalKeyCredential, v1RateLimitContextModuleMock, v1RateLimiterModuleMock, v1SubscriptionModuleMock, + v1WorkspaceKeyCredential, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -78,34 +80,6 @@ const TABLE = { updatedAt: new Date('2026-01-01T00:00:00.000Z'), } -function personalKey() { - return { - authenticated: true, - userId: MEMBER_ID, - keyType: 'personal' as const, - principal: { kind: 'personal_api_key' as const, userId: MEMBER_ID, keyId: 'key-1' }, - } -} - -/** - * A workspace key still reports a `userId` — the key's CREATOR. A gate keyed on - * the presence of a user id rather than on the principal kind would apply that - * bystander's group to every caller of the shared credential. - */ -function workspaceKey() { - return { - authenticated: true, - userId: 'key-creator', - workspaceId: WORKSPACE_ID, - keyType: 'workspace' as const, - principal: { - kind: 'workspace_api_key' as const, - workspaceId: WORKSPACE_ID, - keyId: 'key-2', - }, - } -} - function governedBy(overrides: Partial) { permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, @@ -128,7 +102,7 @@ const REFUSAL = /is not available under your organization's permission group/ beforeEach(() => { vi.clearAllMocks() resetPermissionGroupScopeMock() - mockAuthenticateV1Request.mockResolvedValue(personalKey()) + mockAuthenticateV1Request.mockResolvedValue(v1PersonalKeyCredential(MEMBER_ID)) mockGetUserEntityPermissions.mockResolvedValue('admin') mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true }) mockGetTableById.mockResolvedValue(TABLE) @@ -136,7 +110,7 @@ beforeEach(() => { describe('tables.use gate on /api/v1/tables/[tableId]', () => { it('lets a workspace API key through even when its CREATOR is denied Tables', async () => { - mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) governedBy({ hideTablesTab: true }) const response = await readTable() @@ -147,7 +121,7 @@ describe('tables.use gate on /api/v1/tables/[tableId]', () => { }) it('never resolves a group for a workspace API key at all', async () => { - mockAuthenticateV1Request.mockResolvedValue(workspaceKey()) + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) governedBy({ hideTablesTab: true }) await readTable() diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 5edba2736a3..e8b26d408fc 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -177,11 +177,13 @@ export { } from './terminal-console.mock' // URL mocks export { LOCALHOST_HOSTNAMES_MOCK, resetUrlsMock, urlsMock, urlsMockFns } from './urls.mock' -// v1 public API ambient request-admission mocks +// v1 public API ambient request-admission mocks and credential factories export { + v1PersonalKeyCredential, v1RateLimitContextModuleMock, v1RateLimiterModuleMock, v1SubscriptionModuleMock, + v1WorkspaceKeyCredential, } from './v1-route.mock' export { MockV2ApiKeyUnauthenticatedError, diff --git a/packages/testing/src/mocks/v1-route.mock.ts b/packages/testing/src/mocks/v1-route.mock.ts index 3714d6b2932..344068543b1 100644 --- a/packages/testing/src/mocks/v1-route.mock.ts +++ b/packages/testing/src/mocks/v1-route.mock.ts @@ -40,3 +40,50 @@ export const v1RateLimitContextModuleMock = { recordRateLimitSnapshot: vi.fn(), getRateLimitHeaders: () => null, } + +/** + * The credential `authenticateV1Request` resolves for a personal API key. + * + * The gate and projection suites steer this deliberately — it is the caller a + * permission group governs — so it is a factory rather than a module mock. + * + * @example + * ```ts + * mockAuthenticateV1Request.mockResolvedValue(v1PersonalKeyCredential(USER_ID)) + * ``` + */ +export function v1PersonalKeyCredential(userId: string, keyId = 'key-1') { + return { + authenticated: true, + userId, + keyType: 'personal' as const, + principal: { kind: 'personal_api_key' as const, userId, keyId }, + } +} + +/** + * The credential `authenticateV1Request` resolves for a workspace API key. + * + * It still reports a `userId` — the key's CREATOR — which is the trap every + * caller of this factory exists to pin: a gate keyed on the presence of a user + * id rather than on the principal kind applies that bystander's permission + * group to every caller of a shared credential. + * + * @example + * ```ts + * mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) + * ``` + */ +export function v1WorkspaceKeyCredential( + workspaceId: string, + creatorUserId = 'key-creator', + keyId = 'key-2' +) { + return { + authenticated: true, + userId: creatorUserId, + workspaceId, + keyType: 'workspace' as const, + principal: { kind: 'workspace_api_key' as const, workspaceId, keyId }, + } +} From 3080b7074803e128b4094ec5299f9fcab496ac9b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 22:41:44 -0700 Subject: [PATCH 131/179] refactor(permission-groups): use the assertions module's own refusal re-export capability-assertions.ts re-exports capabilityRefusal so a route that gates inline reaches the sentence and the assertion through one module. The one route that gates inline against a workspace capability was importing the two halves from two modules instead. --- apps/sim/app/api/auth/oauth/credentials/route.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index 782730b836b..0625008f233 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -16,8 +16,10 @@ import { getServiceAccountProviderForProviderId, providerIdsForService, } from '@/lib/oauth/utils' -import { capabilityRefusal } from '@/lib/permission-groups/capabilities' -import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' From e028b041a725294fb4454e710ab9816651ce1328 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 22:42:43 -0700 Subject: [PATCH 132/179] test(workflows): keep one home for readWorkflowRun selector resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rounds pinned the same gate at the same altitude under different names. `workflow-runs.test.ts` had 'refuses a selector that is not headed by a block id' and 'allows a block id that produced no output on this run'; `read-workflow-run.test.ts` has the same two directions plus the two this pair never covered — a run whose output projection is not recorded yet, which is the regression that motivated the check. Keep the four-direction suite, and carry the only assertion the deleted pair made that it did not: the semantic `code: 'validation'`, folded onto the message assertion so both survive. --- .../application/read-workflow-run.test.ts | 9 ++-- .../application/workflow-runs.test.ts | 46 ------------------- 2 files changed, 6 insertions(+), 49 deletions(-) diff --git a/apps/sim/lib/workflows/application/read-workflow-run.test.ts b/apps/sim/lib/workflows/application/read-workflow-run.test.ts index 750be295b92..1fa45862fb8 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.test.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.test.ts @@ -116,9 +116,12 @@ describe('readWorkflowRun selector resolution', () => { status: 'completed', blockOutputs: { [BLOCK_ID]: { content: 'hi' } }, }) - await expect(readWorkflowRun.execute({ principal, input: input(['Agent 1']) })).rejects.toThrow( - /did not resolve to any block on this run: Agent 1/ - ) + await expect( + readWorkflowRun.execute({ principal, input: input(['Agent 1']) }) + ).rejects.toMatchObject({ + code: 'validation', + message: expect.stringContaining('did not resolve to any block on this run: Agent 1'), + }) }) /** diff --git a/apps/sim/lib/workflows/application/workflow-runs.test.ts b/apps/sim/lib/workflows/application/workflow-runs.test.ts index d095c5a11fb..929a1a7f9cf 100644 --- a/apps/sim/lib/workflows/application/workflow-runs.test.ts +++ b/apps/sim/lib/workflows/application/workflow-runs.test.ts @@ -136,52 +136,6 @@ describe('workflow run application use cases', () => { }) }) - it('refuses a selector that is not headed by a block id instead of answering an empty selection', async () => { - mocks.getStatus.mockResolvedValueOnce({ - executionId: 'run-1', - workflowId: 'workflow-1', - status: 'completed', - blockOutputs: {}, - }) - - await expect( - readWorkflowRun.execute({ - principal: principals[2], - input: { - workflowId: 'workflow-1', - runId: 'run-1', - includeOutput: true, - selectedOutputs: ['doubler.doubled'], - }, - }) - ).rejects.toMatchObject({ code: 'validation' }) - }) - - /** - * A well-formed id that produced nothing is a legitimate empty answer — the - * block may simply not have run on this path. - */ - it('allows a block id that produced no output on this run', async () => { - mocks.getStatus.mockResolvedValueOnce({ - executionId: 'run-1', - workflowId: 'workflow-1', - status: 'completed', - blockOutputs: {}, - }) - - const result = await readWorkflowRun.execute({ - principal: principals[2], - input: { - workflowId: 'workflow-1', - runId: 'run-1', - includeOutput: true, - selectedOutputs: ['4f1c2b3a-0000-4000-8000-000000000001.value'], - }, - }) - - expect(result.blockOutputs).toEqual({}) - }) - /** * File descriptors follow `output`'s gating: a caller that did not ask for * output must not receive a file list it did not request. From 409d3e46cb3e378894c532b7ec8ccde26ffc9269 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 22:43:17 -0700 Subject: [PATCH 133/179] test(core): one describe for the defineWorkspaceOperation capability guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two files carried a `defineWorkspaceOperation capability policy` describe under the same name — half the guard's directions in the builder's own suite, half in `workspace-authorization.test.ts`, whose subject is `authorizeWorkspaceOperation` rather than the builder. Move the parameterized and opt-out cases onto the builder suite so all four directions read together. --- .../workspace-authorization.test.ts | 27 ------------------- .../application/workspace-operation.test.ts | 25 +++++++++++++++++ 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index 77031b9e663..b19260d860b 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -485,33 +485,6 @@ describe('authorizeWorkspaceOperation permission-group capability', () => { }) }) -describe('defineWorkspaceOperation capability policy', () => { - it('refuses a parameterized capability, which the funnel could never apply', () => { - expect(() => - defineWorkspaceOperation({ - id: 'test.parameterized', - minimumRole: 'read', - workspaceApiKey: 'deny', - principalKinds: ['session'], - // @ts-expect-error a parameterized capability is not assignable to the field - capability: 'deploy.chat.auth_mode', - }) - ).toThrow(/parameterized capability/) - }) - - it('accepts an explicit opt-out', () => { - expect(() => - defineWorkspaceOperation({ - id: 'test.ungoverned', - minimumRole: 'read', - workspaceApiKey: 'deny', - principalKinds: ['session'], - capability: 'none', - }) - ).not.toThrow() - }) -}) - const personalKeyOperation = defineWorkspaceOperation({ id: 'test.personal-key-read', minimumRole: 'read', diff --git a/apps/sim/lib/core/application/workspace-operation.test.ts b/apps/sim/lib/core/application/workspace-operation.test.ts index 9bd6ce5e996..f347a16e4e9 100644 --- a/apps/sim/lib/core/application/workspace-operation.test.ts +++ b/apps/sim/lib/core/application/workspace-operation.test.ts @@ -131,4 +131,29 @@ describe('defineWorkspaceOperation capability policy', () => { } as never) ).toThrow('Operation test.unknown_capability names unknown capability') }) + + it('refuses a parameterized capability, which the funnel could never apply', () => { + expect(() => + defineWorkspaceOperation({ + id: 'test.parameterized', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + // @ts-expect-error a parameterized capability is not assignable to the field + capability: 'deploy.chat.auth_mode', + }) + ).toThrow(/parameterized capability/) + }) + + it('accepts an explicit opt-out', () => { + expect(() => + defineWorkspaceOperation({ + id: 'test.ungoverned', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + capability: 'none', + }) + ).not.toThrow() + }) }) From 5466f9fedce5d1c1a4757b728e13b59fd28fee7a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 22:44:56 -0700 Subject: [PATCH 134/179] test(table): warm the pre-stamp suite's lazy cell-task import Its one test measured 2952ms in isolation and carried a 20s budget to absorb it. The cost is not the pre-stamp: `buildEnqueueItems` resolves the cell task with a dynamic import of `@/background/workflow-column-execution`, which no mock in this file intercepts, and paying that inside a test's own budget is the shape that already made two background suites flaky. Warm it in `beforeAll` like those two, and drop the inflated budget: the test now runs in 3ms and fits the default. --- .../table/prestamp-governed-subject.test.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/table/prestamp-governed-subject.test.ts b/apps/sim/lib/table/prestamp-governed-subject.test.ts index 73d7af6859c..70bdf7e0bdb 100644 --- a/apps/sim/lib/table/prestamp-governed-subject.test.ts +++ b/apps/sim/lib/table/prestamp-governed-subject.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ getTableById: vi.fn(), @@ -48,6 +48,22 @@ const DISPATCH = { } describe('the dispatcher pre-stamp', () => { + /** + * `buildEnqueueItems` resolves the cell task with a dynamic import of + * `@/background/workflow-column-execution` — the largest graph this step + * touches, and one none of this file's mocks intercept. Under a loaded + * parallel run that first resolution costs whole seconds, which is why the + * only test here needed a 20s budget to hold. Warm it once, outside any + * per-test budget, so the test measures the pre-stamp rather than a module + * load. + */ + beforeAll(async () => { + await Promise.all([ + import('@/background/workflow-column-execution'), + import('@/lib/table/workflow-columns'), + ]) + }, 60_000) + beforeEach(() => { vi.clearAllMocks() resetDbChainMock() @@ -81,5 +97,5 @@ describe('the dispatcher pre-stamp', () => { }), }) ) - }, 20_000) + }) }) From 5e612ca1b4ec4dbe54be79247f84b11d558aebe7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 22:52:45 -0700 Subject: [PATCH 135/179] refactor(auth): give the AuthResult capability derivation one home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capabilityGovernedAuthUserId moves to lib/auth/hybrid, where AuthResult and AuthType live — both routes that shared it from table utils and the workflows executions route that had spelled out a verbatim twin (importing the table graph for six lines was the only reason it existed) now read one function. The global hybrid mock carries the real derivation rather than a vi.fn(): it is a pure branch on fields tests already control, and a stub returning undefined would silently un-gate every consumer. --- .../api/table/[tableId]/import/route.test.ts | 3 --- .../app/api/table/[tableId]/import/route.ts | 3 +-- .../app/api/table/import-csv/route.test.ts | 3 --- apps/sim/app/api/table/import-csv/route.ts | 3 +-- apps/sim/app/api/table/utils.test.ts | 2 +- apps/sim/app/api/table/utils.ts | 19 --------------- .../[id]/executions/[executionId]/route.ts | 23 ++----------------- apps/sim/lib/auth/hybrid.ts | 17 ++++++++++++++ .../testing/src/mocks/hybrid-auth.mock.ts | 16 +++++++++++++ 9 files changed, 38 insertions(+), 51 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index f8012afe61e..6a15da4fe23 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -41,9 +41,6 @@ vi.mock('@/app/api/table/utils', async () => { /** Mirrors the real helper: only a `user` principal names a governed subject. */ capabilityGovernedUserId: (principal: { kind: string; userId?: string }) => principal.kind === 'user' ? (principal.userId ?? null) : null, - /** Mirrors the real helper: only a session (or personal key) names one. */ - capabilityGovernedAuthUserId: (auth: { authType?: string; userId?: string }) => - auth.authType === 'session' ? (auth.userId ?? null) : null, accessError: (result: { status: number }) => { const message = result.status === 404 ? 'Table not found' : 'Access denied' return NextResponse.json({ error: message }, { status: result.status }) diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 8c137582b16..04e4915a632 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -11,7 +11,7 @@ import { } from '@/lib/api/contracts/tables' import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' @@ -21,7 +21,6 @@ import { performTableCsvImport } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { accessError, - capabilityGovernedAuthUserId, checkAccess, csvProxyBodyCapResponse, multipartErrorResponse, diff --git a/apps/sim/app/api/table/import-csv/route.test.ts b/apps/sim/app/api/table/import-csv/route.test.ts index 7c1cdc78ec4..136eb4b0f3e 100644 --- a/apps/sim/app/api/table/import-csv/route.test.ts +++ b/apps/sim/app/api/table/import-csv/route.test.ts @@ -41,9 +41,6 @@ vi.mock('@/app/api/table/utils', async () => { const { asOrchestrationError, messageForOrchestrationError, statusForOrchestrationError } = await import('@/lib/core/orchestration/types') return { - /** Mirrors the real helper: only a session (or personal key) names one. */ - capabilityGovernedAuthUserId: (auth: { authType?: string; userId?: string }) => - auth.authType === 'session' ? (auth.userId ?? null) : null, csvProxyBodyCapResponse: () => null, multipartErrorResponse: (error: { code: string; message: string }) => NextResponse.json( diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index 439a38e067f..f3b03e66baa 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables' import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -16,7 +16,6 @@ import { performCreateTableFromCsv } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { - capabilityGovernedAuthUserId, csvProxyBodyCapResponse, multipartErrorResponse, orchestrationOutcomeErrorResponse, diff --git a/apps/sim/app/api/table/utils.test.ts b/apps/sim/app/api/table/utils.test.ts index b834a5084e5..fb6e06b6e33 100644 --- a/apps/sim/app/api/table/utils.test.ts +++ b/apps/sim/app/api/table/utils.test.ts @@ -2,12 +2,12 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { capabilityGovernedAuthUserId } from '@/lib/auth/hybrid' import { OrchestrationError } from '@/lib/core/orchestration/types' import { TableRowLimitError } from '@/lib/table/billing' import { TableRowNotFoundError } from '@/lib/table/rows/errors' import type { ColumnDefinition } from '@/lib/table/types' import { - capabilityGovernedAuthUserId, orchestrationErrorResponse, orchestrationOutcomeErrorResponse, rootErrorMessage, diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 1de6cba864a..1a777864438 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { permissionSatisfies } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' -import { type AuthResult, AuthType } from '@/lib/auth/hybrid' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { asOrchestrationError, @@ -278,24 +277,6 @@ export function capabilityGovernedUserId(principal: TableAccessPrincipal): strin return principal.kind === 'user' ? principal.userId : null } -/** - * The id whose permission group governs a request authenticated by - * `checkSessionOrInternalAuth`, or `null` when none does. - * - * Distinct from {@link capabilityGovernedUserId}, which answers the same - * question for a {@link TableAccessPrincipal} — a union that has no way to spell - * "internal JWT" and reports one as a person. An internal JWT's `auth.userId` is - * the subject the executor embedded, so keying on its presence would hand the - * run's actor's capabilities to a caller the executor exemption deliberately - * passes ungated. `authType` is the authoritative signal, and `apiKeyType` - * covers the personal-key case for a caller that later shares this helper. - */ -export function capabilityGovernedAuthUserId(auth: AuthResult): string | null { - if (!auth.userId) return null - if (auth.authType === AuthType.SESSION) return auth.userId - return auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal' ? auth.userId : null -} - /** * Access check returning `{ ok, table }` or `{ ok: false, status }`. * diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts index e31868e37e0..d91f113e2e3 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' -import { type AuthResult, AuthType } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, @@ -13,25 +13,6 @@ import { validateWorkflowAccess } from '@/app/api/workflows/middleware' const logger = createLogger('WorkflowExecutionStatusAPI') -/** - * The user whose permission group governs this read, or `null` when none does. - * - * The rule and the reasoning behind it belong to `capabilityGovernedPrincipalUserId` - * in `@/lib/core/application`; this reads the same decision off an `AuthResult`, - * which is what `validateWorkflowAccess` reports. `authType` and `apiKeyType` are - * the authoritative signals — `auth.userId` is populated for every credential - * this route accepts, including the workspace key and the executor's internal JWT. - * - * Spelled out here rather than shared with the identical - * `capabilityGovernedAuthUserId` in `@/app/api/table/utils`, which would pull the - * whole table graph into this route for six lines. - */ -function capabilityGovernedUserId(auth: AuthResult | undefined): string | null { - if (!auth?.userId) return null - if (auth.authType === AuthType.SESSION) return auth.userId - return auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal' ? auth.userId : null -} - export const GET = withRouteHandler( async ( request: NextRequest, @@ -55,7 +36,7 @@ export const GET = withRouteHandler( includeOutput, selectedOutputs, workspaceId: access.workflow.workspaceId, - viewerUserId: capabilityGovernedUserId(access.auth), + viewerUserId: capabilityGovernedAuthUserId(access.auth), }) } catch (error) { if (error instanceof FunctionalOutputsUnavailableError) { diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts index f5531234007..819301f9e55 100644 --- a/apps/sim/lib/auth/hybrid.ts +++ b/apps/sim/lib/auth/hybrid.ts @@ -42,6 +42,23 @@ export interface AuthResult { error?: string } +/** + * The id whose permission group governs a request authenticated by + * `checkSessionOrInternalAuth`, or `null` when none does. + * + * An internal JWT's `auth.userId` is the subject the executor embedded, so + * keying on its presence would hand the run's actor's capabilities to a caller + * the executor exemption deliberately passes ungated. `authType` is the + * authoritative signal, and `apiKeyType` covers the personal-key case. The + * principal rules live on `capabilityGovernedPrincipalUserId` in + * `@/lib/core/application`; this reads the same decision off an `AuthResult`. + */ +export function capabilityGovernedAuthUserId(auth: AuthResult | undefined): string | null { + if (!auth?.userId) return null + if (auth.authType === AuthType.SESSION) return auth.userId + return auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal' ? auth.userId : null +} + /** * Resolves userId from a verified internal JWT token. * Only trusts the userId embedded in the JWT payload — never from user-controlled sources. diff --git a/packages/testing/src/mocks/hybrid-auth.mock.ts b/packages/testing/src/mocks/hybrid-auth.mock.ts index ab5c6c0439f..e9739496172 100644 --- a/packages/testing/src/mocks/hybrid-auth.mock.ts +++ b/packages/testing/src/mocks/hybrid-auth.mock.ts @@ -52,6 +52,22 @@ export const hybridAuthMockFns = { */ export const hybridAuthMock = { AuthType: AuthTypeMock, + /** + * The real derivation, not a vi.fn(): it is a pure branch on fields the test + * already controls through the auth result, and a stub returning undefined + * would silently un-gate every consumer. Mirrors `capabilityGovernedAuthUserId` + * in `@/lib/auth/hybrid` — only a session or a personal API key names a + * governed subject. + */ + capabilityGovernedAuthUserId: (auth?: { + userId?: string + authType?: string + apiKeyType?: string + }): string | null => { + if (!auth?.userId) return null + if (auth.authType === 'session') return auth.userId + return auth.authType === 'api_key' && auth.apiKeyType === 'personal' ? auth.userId : null + }, checkHybridAuth: hybridAuthMockFns.mockCheckHybridAuth, checkSessionOrInternalAuth: hybridAuthMockFns.mockCheckSessionOrInternalAuth, checkInternalAuth: hybridAuthMockFns.mockCheckInternalAuth, From 1c2d19c7eca65a8d197f905380623a75444b037a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 22:53:56 -0700 Subject: [PATCH 136/179] chore(permission-groups): apply the surviving comment-pass findings The dedicated simplification pass covered most of the 25 findings; what survived was narrative history in two middleware TSDoc blocks (the live rule stays, the story goes), one drifted count, and one projection-rationale restatement now a link. --- .../sim/app/api/table/capability-gate.test.ts | 2 +- apps/sim/app/api/v1/logs/route.ts | 7 +------ apps/sim/app/api/v1/middleware.ts | 19 +++++++------------ 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/api/table/capability-gate.test.ts b/apps/sim/app/api/table/capability-gate.test.ts index 271f434c974..5dd9e2007c3 100644 --- a/apps/sim/app/api/table/capability-gate.test.ts +++ b/apps/sim/app/api/table/capability-gate.test.ts @@ -9,7 +9,7 @@ * * The write path is column creation on purpose: a TTL column is the only column * configuration that causes rows to be deleted on a schedule, so a member of a - * group denied Tables driving this route is the worst of the sixteen. + * group denied Tables driving this route is the worst of them. */ import { hybridAuthMockFns, diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index 866d1d89144..a8da4c32ce3 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -60,12 +60,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (accessError) return accessError - /** - * `logs.trace_spans` and `logs.cost` are projections, not gates, which is - * why this route declares `'none'` above and still has to withhold fields - * here. Same helper the internal/v2 detail path uses — a hidden tab - * withholds nothing from a caller reading the public API directly. - */ + /** `logs.trace_spans` and `logs.cost` are projections, not gates — see {@link resolveLogFieldProjection}. */ const projection = await resolveLogFieldProjection( capabilityGovernedUserId(rateLimit), params.workspaceId diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index f95ceccf467..ec26a57e682 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -313,12 +313,10 @@ export type V1RouteCapability = StaticPermissionGroupCapability | 'none' * to a non-member would confirm the workspace exists and disclose which modules * the organization withholds; the role failure conceals both. * - * Takes no caller-supplied user id on purpose. It used to take the same `userId` - * the role check uses, guard on {@link capabilityGovernedUserId} and then assert - * against that *other* variable — the two agreed, but nothing made them agree, - * and a caller passing the key creator's id past a guard that said "personal" is - * the exact shape this bug has taken twice. The subject is now the guard's own - * return value, so there is only one id and no way to assert against another. + * Takes no caller-supplied user id on purpose: the subject is the guard's own + * return value from {@link capabilityGovernedUserId}, so there is only one id — + * a caller cannot assert the withhold against a different one (the key + * creator's) than the one the guard said was personal. */ export async function resolveCapabilityRefusal( rateLimit: RateLimitResult, @@ -539,12 +537,9 @@ export async function resolveWorkspaceRequestActor( * * The resolver answers `null` for a real, reachable request: an authenticated * workspace key whose workspace has since been archived or deleted has no - * billed account to stand in as its system actor. Every call site used to - * `throw` on that, which the route's catch-all turned into a generic 500 — an - * unreachable workspace reported as a server fault. It is the same condition - * the routes already report as a 400 `Invalid workspace ID` when the addressed - * table belongs to another workspace, so it is reported the same way, from one - * place, rather than five copies of a throw. + * billed account to stand in as its system actor. That is the same condition + * the routes already report as a 400 `Invalid workspace ID` for a workspace + * mismatch, so it is reported the same way, from one place. */ export async function requireWorkspaceRequestActor( rateLimit: RateLimitResult, From 84db55757c5bfe3ea9c72220d6df9d2bb46b85d2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:22:25 -0700 Subject: [PATCH 137/179] fix(db): give 0315/0316 real transaction boundaries under the batching runner --- ...able_dispatch_capability_governed_user.sql | 34 +++++++++++- ...table_row_execution_capability_subject.sql | 54 +++++++++---------- 2 files changed, 56 insertions(+), 32 deletions(-) diff --git a/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql b/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql index 2e0462d2453..70107ff48e4 100644 --- a/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql +++ b/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql @@ -10,12 +10,42 @@ -- dispatch can outlive the deploy by hours. The backfill below therefore gives every non-terminal -- pre-migration row exactly the subject it was already gated on — `triggered_by_user_id` — while -- rows written by the new code carry the corrected acting-person/attribution distinction. -ALTER TABLE "table_run_dispatches" ADD COLUMN "capability_governed_user_id" text;--> statement-breakpoint +-- +-- Transaction shape: the runner batches every pending file into ONE transaction (drizzle's +-- `session.transaction()`, see packages/db/scripts/migrate.ts), so `NOT VALID` + `VALIDATE` in a +-- single file is inert — the validation scan holds exactly the locks the two-step exists to shed, +-- and holds them until the last pending file commits. The `COMMIT;` breakpoint below ends that +-- batch transaction so the FK is added and validated in their own autocommit statements. From that +-- breakpoint on, this file and every later pending file run in autocommit and a failed run replays +-- unjournaled files from the top, so every statement here is written to survive a second run. +ALTER TABLE "table_run_dispatches" ADD COLUMN IF NOT EXISTS "capability_governed_user_id" text;--> statement-breakpoint -- migration-safe: bounded backfill over non-terminal dispatches only (status pending/dispatching — a few rows at any instant, indexed by `table_run_dispatches_active_idx`), idempotent under the IS NULL guard, and it preserves rather than changes the gate those rows already had. A replay after the new writers are live could only re-gate a still-queued actorless row, which fails closed. UPDATE "table_run_dispatches" SET "capability_governed_user_id" = "triggered_by_user_id" WHERE "status" IN ('pending', 'dispatching') AND "capability_governed_user_id" IS NULL AND "triggered_by_user_id" IS NOT NULL;--> statement-breakpoint -ALTER TABLE "table_run_dispatches" ADD CONSTRAINT "table_run_dispatches_capability_governed_user_id_user_id_fk" FOREIGN KEY ("capability_governed_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action NOT VALID;--> statement-breakpoint +-- Ends the runner's batch transaction. Everything above committed together: the column add takes +-- ACCESS EXCLUSIVE on `table_run_dispatches`, and the backfill that follows it is index-driven over +-- the handful of live dispatches, so the exclusive lock is held for milliseconds rather than +-- through the next file's validation scan. +COMMIT;--> statement-breakpoint +-- Postgres has no ADD CONSTRAINT IF NOT EXISTS, so the replay guard is an explicit pg_constraint +-- lookup. Scoped to conrelid so an identically named constraint on another table cannot mask it. +-- NOT VALID keeps this to a metadata change: ACCESS EXCLUSIVE on `table_run_dispatches` and SHARE +-- ROW EXCLUSIVE on `user` (which blocks concurrent writes to `user` — signups included) for this +-- one statement, instead of for the whole batch. +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'table_run_dispatches_capability_governed_user_id_user_id_fk' + AND "conrelid" = '"table_run_dispatches"'::regclass + ) THEN + ALTER TABLE "table_run_dispatches" ADD CONSTRAINT "table_run_dispatches_capability_governed_user_id_user_id_fk" FOREIGN KEY ("capability_governed_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action NOT VALID; + END IF; +END $$;--> statement-breakpoint +-- Own transaction, which is the entire point of the breakpoint above: VALIDATE takes only SHARE +-- UPDATE EXCLUSIVE on `table_run_dispatches` and ROW SHARE on `user`, so the scan runs alongside +-- ordinary reads and writes. VALIDATE on an already-validated constraint is a no-op, so a replay +-- needs no guard of its own. ALTER TABLE "table_run_dispatches" VALIDATE CONSTRAINT "table_run_dispatches_capability_governed_user_id_user_id_fk"; diff --git a/packages/db/migrations/0316_table_row_execution_capability_subject.sql b/packages/db/migrations/0316_table_row_execution_capability_subject.sql index 8646031fd30..a92ce451c81 100644 --- a/packages/db/migrations/0316_table_row_execution_capability_subject.sql +++ b/packages/db/migrations/0316_table_row_execution_capability_subject.sql @@ -1,15 +1,29 @@ --- Adds the permission-group subject a queued cell is gated against to the cell sidecar, repeats --- 0315's dispatch backfill, and adds the index the account-deletion cancel needs. +-- Adds the permission-group subject a queued cell is gated against to the cell sidecar, and adds +-- the index the account-deletion cancel needs. -- -- The cell column exists because a dispatcher pre-stamp outlives the worker that wrote it: a cell -- task that finds the row's cascade lock held bails, and whoever owns the lock drains the marker. -- Without the subject on the marker that drain runs someone else's request under its own subject. -- Additive and nullable; NULL means "no acting person, no per-tool gate", which is exactly what a --- marker written before this column existed was already doing. --- Replay-safe: this file ends in post-COMMIT CONCURRENTLY steps, and a failed concurrent build --- replays the whole file from the top (see packages/db/scripts/migrate.ts). Every statement before --- the COMMIT therefore has to survive being run twice. +-- marker written before this column existed was already doing. There is no backfill: every +-- pre-existing row is NULL by definition and NULL is the correct, pre-migration-equivalent value. +-- +-- Transaction shape: the runner batches every pending file into ONE transaction and only an +-- embedded `COMMIT;` ends it (packages/db/scripts/migrate.ts). `table_row_executions` is the large +-- table in this pair, so its `VALIDATE CONSTRAINT` scan must not run inside that batch — there it +-- would hold 0315's ACCESS EXCLUSIVE on `table_run_dispatches` and the FK's SHARE ROW EXCLUSIVE on +-- `user` (blocking every signup) for the length of the scan. The `COMMIT;` below puts the scan in +-- its own autocommit statement, where it takes only SHARE UPDATE EXCLUSIVE and runs alongside +-- ordinary traffic. +-- +-- Replay-safe: this file runs entirely in autocommit (0315 already committed), so a failure at any +-- statement leaves it unjournaled and replays the whole file from the top. Every statement below +-- has to survive being run twice. ALTER TABLE "table_row_executions" ADD COLUMN IF NOT EXISTS "capability_governed_user_id" text;--> statement-breakpoint +-- Ends the runner's batch transaction if one is still open — it is not when 0315 applies in the +-- same run, and a redundant COMMIT is a Postgres WARNING, not an error. Keeping it unconditional +-- is what makes this file correct on its own, for a database that already has 0315. +COMMIT;--> statement-breakpoint -- Postgres has no ADD CONSTRAINT IF NOT EXISTS, so the replay guard is an explicit pg_constraint -- lookup. Scoped to conrelid so an identically named constraint on another table cannot mask it. DO $$ BEGIN @@ -21,31 +35,11 @@ DO $$ BEGIN ALTER TABLE "table_row_executions" ADD CONSTRAINT "table_row_executions_capability_governed_user_id_user_id_fk" FOREIGN KEY ("capability_governed_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action NOT VALID; END IF; END $$;--> statement-breakpoint --- VALIDATE on an already-validated constraint is a no-op, so this needs no guard of its own. +-- Own transaction. The scan is the price of a constraint the schema snapshot describes as an +-- ordinary FK: leaving it NOT VALID forever would make a migrated database differ from a freshly +-- created one, which every later drift check and snapshot diff would then have to special-case. +-- VALIDATE on an already-validated constraint is a no-op, so this needs no replay guard. ALTER TABLE "table_row_executions" VALIDATE CONSTRAINT "table_row_executions_capability_governed_user_id_user_id_fk";--> statement-breakpoint --- Repeat of 0315's backfill, deliberately. --- --- 0315 ran at ITS deploy, while instances of the previous release were still serving. Those --- instances insert dispatches without the column, so a run they started in that window carries a --- NULL subject and reads as actorless — ungated — even when a person triggered it. Nothing in a --- read-side compatibility rule can repair that: treating "NULL subject, non-null triggered_by" as --- governed re-applies the workspace billed account to workspace-key runs, which is the exact --- bystander substitution 0315 exists to remove, and "only for rows older than the new writers" is --- not a predicate this schema can express. --- --- Repeating the backfill one migration later closes the 0315 window at the next deploy boundary, --- because by then every writer of those rows was the old release. It does not close its own: rows --- inserted by an old instance during THIS deploy stay NULL until they go terminal. That residue is --- bounded by one deploy's drain rather than by a dispatch's lifetime, and it fails toward the --- behavior those rows had before the column existed. --- migration-safe: bounded backfill over non-terminal dispatches only (status pending/dispatching — a few rows at any instant, indexed by `table_run_dispatches_active_idx`), idempotent under the IS NULL guard, and it preserves rather than changes the gate those rows already had. A replay after the new writers are live could only re-gate a still-queued actorless row, which fails closed. -UPDATE "table_run_dispatches" -SET "capability_governed_user_id" = "triggered_by_user_id" -WHERE "status" IN ('pending', 'dispatching') - AND "capability_governed_user_id" IS NULL - AND "triggered_by_user_id" IS NOT NULL;--> statement-breakpoint --- Concurrent index operations cannot run inside the migration runner's transaction. -COMMIT;--> statement-breakpoint SET lock_timeout = 0;--> statement-breakpoint -- Account deletion cancels every still-active dispatch the departing account governs, and that is -- the only query keyed on the subject. The other two indexes on this table lead with `table_id` / From 200094b1518eadb1af0e42deacda730f4cb07490 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:24:42 -0700 Subject: [PATCH 138/179] audit(capability-subject): refuse a fallback welded to the governed subject --- scripts/check-capability-subject.test.ts | 75 +++++++++++++ scripts/check-capability-subject.ts | 127 ++++++++++++++++++++++- 2 files changed, 197 insertions(+), 5 deletions(-) diff --git a/scripts/check-capability-subject.test.ts b/scripts/check-capability-subject.test.ts index 761a91e5e39..56604fa2078 100644 --- a/scripts/check-capability-subject.test.ts +++ b/scripts/check-capability-subject.test.ts @@ -127,3 +127,78 @@ describe('assertion A — the name the audit is written in terms of', () => { ).toEqual([]) }) }) + +describe('assertion C — a fallback welded to the governed subject', () => { + /** + * The verified evasion. `capabilityGovernedUserId(rateLimit) ?? …` is what a + * reviewer writes when the governed subject's `null` reads as a gap rather + * than an answer: it satisfies the prefix match, it reintroduces the + * key-creator substitution verbatim, and before this assertion it INCREMENTED + * the liveness counter — the audit reported itself more alive for the evasion. + */ + it('reports a nullish fallback on an inline governed call, and does not count it', () => { + const { findings, sinks } = auditSource( + ROUTE, + 'const withheld = await isWorkspaceCapabilityWithheld(\n' + + ' capabilityGovernedUserId(rateLimit) ?? requireRateLimitUserId(rateLimit),\n' + + " workspaceId,\n 'tables.use'\n)\n" + ) + + expect(sinks).toBe(0) + expect(findings).toHaveLength(1) + expect(findings[0].message).toContain('falls back when') + }) + + it('reports a logical-or fallback the same way', () => { + const { findings, sinks } = auditSource( + ROUTE, + "const withheld = await isWorkspaceCapabilityWithheld(capabilityGovernedUserId(rateLimit) || rateLimit.userId, workspaceId, 'tables.use')\n" + ) + + expect(sinks).toBe(0) + expect(findings).toHaveLength(1) + }) + + /** + * The bound-local form of the same evasion. The local is refused rather than + * registered: registering it would make every sink taking it read as governed. + */ + it('reports a fallback on the binding, and refuses the local it binds', () => { + const { findings, sinks } = auditSource( + ROUTE, + 'const subject = capabilityGovernedUserId(rateLimit) ?? rateLimit.userId\n' + + "await assertWorkspaceCapability(subject, workspaceId, 'tables.use')\n" + ) + + expect(sinks).toBe(0) + expect(findings).toHaveLength(2) + expect(findings[0].message).toContain('falls back when') + expect(findings[1].message).toContain('did not come from') + }) + + it('leaves an un-welded governed call alone, inline and through a local', () => { + const { findings, sinks } = auditSource( + ROUTE, + 'const subject = capabilityGovernedUserId(rateLimit)\n' + + "await assertWorkspaceCapability(subject, workspaceId, 'tables.use')\n" + + "await isWorkspaceCapabilityWithheld(capabilityGovernedUserId(rateLimit), workspaceId, 'tables.use')\n" + ) + + expect(findings).toEqual([]) + expect(sinks).toBe(2) + }) + + /** + * A `??` inside a nested argument list is not a fallback applied to the + * subject, so the depth tracking has to survive one. + */ + it('does not mistake a nested ?? inside the governed call for a fallback', () => { + const { findings, sinks } = auditSource( + ROUTE, + "await isWorkspaceCapabilityWithheld(capabilityGovernedUserId(rateLimit ?? auth), workspaceId, 'tables.use')\n" + ) + + expect(findings).toEqual([]) + expect(sinks).toBe(1) + }) +}) diff --git a/scripts/check-capability-subject.ts b/scripts/check-capability-subject.ts index e1833528622..52ce08ebf0c 100644 --- a/scripts/check-capability-subject.ts +++ b/scripts/check-capability-subject.ts @@ -30,17 +30,48 @@ * under assertion C. * C every call to a capability sink passes a subject that came from * `capabilityGovernedUserId` — the call expression inline, or a local bound - * to it. An id read off `rateLimit`/`auth` is the failure this exists for. - * Import aliases (`sink as local`) are folded in, and a local of the - * audit's own name is refused outright: either one turns a source-text - * match into a no-op that still passes. + * to it — and passes it WITHOUT a fallback. An id read off + * `rateLimit`/`auth` is the failure this exists for. Import aliases + * (`sink as local`) are folded in, and a local of the audit's own name is + * refused outright: either one turns a source-text match into a no-op that + * still passes. * D at least one governed sink call was actually found. The assertions are * source-text matches, so a refactor into a form the parser cannot follow * would otherwise be indistinguishable from a clean tree. * + * The fallback half of C is the one evasion that survived the first version of + * this audit. `capabilityGovernedUserId(rateLimit) ?? rateLimit.userId` is what + * a reviewer writes when the governed subject's `null` reads as a gap to be + * filled — it satisfies the prefix match, it reintroduces the key-creator + * substitution verbatim, and it used to increment the liveness counter, so the + * audit reported itself MORE alive for the evasion. Both the inline form and the + * bound-local form (`const id = capabilityGovernedUserId(x) ?? x.userId`) are + * findings. + * * Scope is v1 on purpose. It is the one surface that authorizes in a middleware * of its own rather than through `authorizeWorkspaceOperation`, which decides * from a `Principal` that has no user to substitute in the first place. + * + * ## What this audit does not cover + * + * - Assertion B bans a fixed list of modules, and `@/lib/logs/log-projection` is + * deliberately absent from it: three v1 logs routes import it by design, + * because a log FIELD PROJECTION is not a gate the middleware can apply — the + * route reads the whole log and blanks fields. `resolveLogFieldProjection` is + * on the sink list instead, which is the stronger check of the two: B asks + * only whether a module was imported, C asks what subject its sink was given. + * A capability decider added under `lib/permission-groups/` belongs on B; one + * whose call site is legitimately a route belongs on C. + * - The sink list is enumerated, not derived. A new helper that takes a user id + * and resolves a capability is invisible until it is added to + * `CAPABILITY_SINKS` — assertion D catches only the case where EVERY governed + * call disappears, not the case where one new ungoverned sink appears beside + * them. + * - `subject` is matched as source text. A subject laundered through a helper + * (`subjectFor(rateLimit)`) reads as ungoverned and is reported, which is the + * safe direction; a subject laundered through an object property + * (`ctx.governed`, assigned from the governed call elsewhere) is also reported. + * Neither is followed across files — this audit has no call graph. */ import { readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' @@ -153,6 +184,43 @@ function callArguments(source: string, openIndex: number): string | null { return null } +/** + * Whether an expression contains a top-level `??` or `||`. + * + * `capabilityGovernedUserId(rateLimit) ?? rateLimit.userId` is the natural fix + * for the null the governed subject returns on a workspace key, and it is the + * exact substitution this audit exists to stop: the prefix match sees the + * governed call, the fallback supplies the key creator, and the sink is asked + * about the creator on every workspace-key request. Nested groups and string + * bodies are skipped so a `??` inside an argument list is not mistaken for one + * applied to the subject. + */ +export function hasTopLevelFallback(expression: string): boolean { + let depth = 0 + let quote: string | null = null + for (let index = 0; index < expression.length; index++) { + const char = expression[index] + if (quote) { + if (char === quote && expression[index - 1] !== '\\') quote = null + continue + } + if (char === "'" || char === '"' || char === '`') { + quote = char + continue + } + if (char === '(' || char === '[' || char === '{') depth++ + else if (char === ')' || char === ']' || char === '}') depth-- + else if ( + depth === 0 && + ((char === '?' && expression[index + 1] === '?') || + (char === '|' && expression[index + 1] === '|')) + ) { + return true + } + } + return false +} + function lineOf(source: string, index: number): number { return source.slice(0, index).split('\n').length } @@ -177,7 +245,15 @@ export function auditSource(file: string, source: string): { findings: Finding[] } } - /** Locals bound to the governed id, so a call may pass the variable rather than the call. */ + /** + * Locals bound to the governed id, so a call may pass the variable rather than + * the call. + * + * A binding that falls back — `const id = capabilityGovernedUserId(x) ?? x.userId` + * — is refused rather than registered: the local then holds the key creator on + * exactly the requests the governed subject was written to exclude, and every + * sink taking it would be counted as governed. + */ const governedLocals = new Set() for (const match of source.matchAll( new RegExp( @@ -185,6 +261,29 @@ export function auditSource(file: string, source: string): { findings: Finding[] 'g' ) )) { + const openIndex = match.index + match[0].length - 1 + const argumentText = callArguments(source, openIndex) + const closeIndex = argumentText === null ? -1 : openIndex + 1 + argumentText.length + const statementEnd = source.slice(closeIndex + 1).search(/[;\n]/) + const trailing = + closeIndex === -1 + ? '' + : source.slice( + closeIndex + 1, + statementEnd === -1 ? undefined : closeIndex + 1 + statementEnd + ) + if (/^\s*(\?\?|\|\|)/.test(trailing)) { + findings.push({ + file, + line: lineOf(source, match.index), + message: + `\`${match[1]}\` falls back when \`${GOVERNED}\` names nobody. That null is the ` + + 'answer, not a gap: a workspace API key authorizes as the workspace and its reported ' + + "user id is the key's creator, so the fallback hands a bystander's permission group to " + + 'every caller of a shared credential — the substitution this audit exists to stop.', + }) + continue + } governedLocals.add(match[1]) } @@ -239,6 +338,24 @@ export function auditSource(file: string, source: string): { findings: Finding[] subject.startsWith(`await ${GOVERNED}(`) || governedLocals.has(subject) if (governed) { + /** + * A governed call with a fallback welded to it is worse than an ungoverned + * one: it reads as the fix, it passes the prefix match, and it used to + * INCREMENT the liveness counter below — so the evasion made the audit + * look more alive, not less. + */ + if (hasTopLevelFallback(subject)) { + findings.push({ + file, + line: lineOf(source, match.index), + message: + `${sink}(...) is asked about \`${subject}\`, which falls back when ` + + `\`${GOVERNED}\` names nobody. The null is the answer: a workspace API key has no ` + + "governed person, and the fallback substitutes the key's creator. Refuse, project, " + + 'or pass the null through — do not replace it.', + }) + continue + } sinks++ continue } From a3f99ec7878b5a1ca5ba582b5adfd86292bbc247 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:25:36 -0700 Subject: [PATCH 139/179] fix(logs): withhold a run's file list on the same terms as its output --- .../lib/workflows/application/operations.ts | 16 +++- .../application/read-workflow-run.test.ts | 88 +++++++++++++++++-- .../application/read-workflow-run.ts | 19 +++- .../application/workflow-runs.test.ts | 7 +- .../workflows/executor/execution-status.ts | 33 +++++-- 5 files changed, 142 insertions(+), 21 deletions(-) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index eac00638ea5..428fe8f57f4 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -454,8 +454,22 @@ export const workflowOperations = { * the run resource does not; it keeps `readRun`'s policy because the resource * being authorized is still the run — a run file is reachable only through * the run that recorded it, never as a standalone workspace file. + * + * `logs.trace_spans` is deliberately not a gate here, and the distinction is + * the one that capability draws everywhere else: it withholds *fields* inside + * a run, not the right to read one. `readRun` therefore withholds the file + * *listing* from a viewer whose group hides execution data — the descriptors + * are that data, and `includeFileBase64` is its bytes — while this operation, + * which resolves one already-named file id, stays governed by workspace role. + * A run file id exists nowhere but the execution data the same projection + * withholds, so hiding the listing removes the way to name a file rather than + * the right to fetch a named one. + * + * If that ever needs to become a refusal rather than a projection, it belongs + * in `capability` on this operation, where the funnel applies it — not in a + * check at one of the surfaces that reach it. */ - // permission-group-exempt: a run's own output bytes belong to the run its reader may already open; files.bulk_download withholds the workspace file store, not run artifacts + // permission-group-exempt: a run's own output bytes belong to the run its reader may already open; files.bulk_download withholds the workspace file store, and logs.trace_spans withholds the run's listed fields — including readRun's file list — not the right to fetch one named file downloadRunFile: defineWorkspaceOperation({ id: 'workflows.download_run_file', minimumRole: 'read', diff --git a/apps/sim/lib/workflows/application/read-workflow-run.test.ts b/apps/sim/lib/workflows/application/read-workflow-run.test.ts index 1fa45862fb8..3f9f4a6ec12 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.test.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.test.ts @@ -32,7 +32,7 @@ vi.mock('@/lib/workflows/application/context', () => ({ resolveActiveWorkflowRunApplicationContext: mocks.resolveContext, })) vi.mock('@/lib/workflows/executor/execution-status', () => ({ - getWorkflowExecutionStatus: mocks.getStatus, + getProjectedWorkflowExecutionStatus: mocks.getStatus, })) vi.mock('@/lib/workflows/executor/execution-run-files', () => ({ getWorkflowRunFiles: mocks.getRunFiles, @@ -53,6 +53,8 @@ const context = { const principal = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' } +const NO_PROJECTION = { hideTraceSpans: false, hideCostInfo: false } + const BLOCK_ID = '2f9c2d4e-1a3b-4c5d-8e7f-0a1b2c3d4e5f' function input(selectedOutputs: string[]) { @@ -72,7 +74,10 @@ describe('readWorkflowRun projection subject', () => { mocks.resolveContext.mockResolvedValue(context) mocks.resolvePermission.mockResolvedValue('read') mocks.getRunFiles.mockResolvedValue(null) - mocks.getStatus.mockResolvedValue({ status: 'completed', blockOutputs: {} }) + mocks.getStatus.mockResolvedValue({ + status: { status: 'completed', blockOutputs: {} }, + projection: NO_PROJECTION, + }) }) it('names the acting user as the projection subject', async () => { @@ -108,13 +113,16 @@ describe('readWorkflowRun selector resolution', () => { mocks.resolveContext.mockResolvedValue(context) mocks.resolvePermission.mockResolvedValue('read') mocks.getRunFiles.mockResolvedValue(null) - mocks.getStatus.mockResolvedValue({ status: 'completed', blockOutputs: {} }) + mocks.getStatus.mockResolvedValue({ + status: { status: 'completed', blockOutputs: {} }, + projection: NO_PROJECTION, + }) }) it('rejects a block-name selector against a recorded output projection', async () => { mocks.getStatus.mockResolvedValue({ - status: 'completed', - blockOutputs: { [BLOCK_ID]: { content: 'hi' } }, + status: { status: 'completed', blockOutputs: { [BLOCK_ID]: { content: 'hi' } } }, + projection: NO_PROJECTION, }) await expect( readWorkflowRun.execute({ principal, input: input(['Agent 1']) }) @@ -130,14 +138,20 @@ describe('readWorkflowRun selector resolution', () => { * nothing selected — the silent empty answer the check exists to remove. */ it('rejects a block-name selector on a run with no recorded output projection', async () => { - mocks.getStatus.mockResolvedValue({ status: 'queued', blockOutputs: null }) + mocks.getStatus.mockResolvedValue({ + status: { status: 'queued', blockOutputs: null }, + projection: NO_PROJECTION, + }) await expect(readWorkflowRun.execute({ principal, input: input(['Agent 1']) })).rejects.toThrow( /did not resolve to any block on this run: Agent 1/ ) }) it('accepts a well-formed block id on a run with no recorded output projection', async () => { - mocks.getStatus.mockResolvedValue({ status: 'queued', blockOutputs: null }) + mocks.getStatus.mockResolvedValue({ + status: { status: 'queued', blockOutputs: null }, + projection: NO_PROJECTION, + }) await expect( readWorkflowRun.execute({ principal, input: input([`${BLOCK_ID}.content`]) }) ).resolves.toMatchObject({ status: 'queued', blockOutputs: null }) @@ -149,3 +163,63 @@ describe('readWorkflowRun selector resolution', () => { ).resolves.toMatchObject({ status: 'completed', blockOutputs: {} }) }) }) + +/** + * A run's output files are its execution data: the descriptors name what the + * run produced and `includeFileBase64` returns the bytes. When the viewer's + * group withholds execution data under `logs.trace_spans`, the file list has to + * go with `finalOutput` and `blockOutputs` — otherwise the withheld output + * comes back one field over. + */ +describe('readWorkflowRun file projection', () => { + const WITHHELD = { hideTraceSpans: true, hideCostInfo: false } + + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getRunFiles.mockResolvedValue({ + terminal: true, + workspaceId: 'workspace-1', + filesById: new Map([['file-1', { key: 'k', name: 'out.csv' }]]), + }) + mocks.describeRunFiles.mockResolvedValue([{ id: 'file-1', name: 'out.csv' }]) + }) + + it('lists the run files when nothing is withheld', async () => { + mocks.getStatus.mockResolvedValue({ + status: { status: 'completed', blockOutputs: {}, finalOutput: { ok: true } }, + projection: NO_PROJECTION, + }) + + await expect(readWorkflowRun.execute({ principal, input: input([]) })).resolves.toMatchObject({ + files: [{ id: 'file-1' }], + }) + }) + + it('withholds the file list when the group withholds execution data', async () => { + mocks.getStatus.mockResolvedValue({ + status: { status: 'completed', blockOutputs: null, finalOutput: null }, + projection: WITHHELD, + }) + + await expect(readWorkflowRun.execute({ principal, input: input([]) })).resolves.toMatchObject({ + files: null, + }) + }) + + it('does not read the run files at all when the group withholds execution data', async () => { + mocks.getStatus.mockResolvedValue({ + status: { status: 'completed', blockOutputs: null, finalOutput: null }, + projection: WITHHELD, + }) + + await readWorkflowRun.execute({ + principal, + input: { ...input([]), includeFileBase64: true }, + }) + + expect(mocks.getRunFiles).not.toHaveBeenCalled() + expect(mocks.describeRunFiles).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-run.ts b/apps/sim/lib/workflows/application/read-workflow-run.ts index a19111a852f..b553783e95a 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.ts @@ -13,7 +13,7 @@ import { getWorkflowRunFiles, type WorkflowRunFileDescriptor, } from '@/lib/workflows/executor/execution-run-files' -import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' +import { getProjectedWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' /** * Selectors this resource can never answer, so the caller hears about them. @@ -68,7 +68,7 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ * `undefined` and reads the run whole. Substituting the key's creator would * apply a bystander's group to every caller of a shared credential. */ - const status = await getWorkflowExecutionStatus({ + const projected = await getProjectedWorkflowExecutionStatus({ workflowId: context.workflowId, executionId: context.runId, includeOutput: input.includeOutput, @@ -77,7 +77,8 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ workspaceOrganizationId: context.workspaceOrganizationId, viewerUserId: resolvePrincipalSubjectUserId(principal), }) - if (!status) throw new OrchestrationError('not_found', 'Run not found') + if (!projected) throw new OrchestrationError('not_found', 'Run not found') + const { status, projection } = projected /** * A run whose `blockOutputs` the viewer's group withholds joins the same @@ -99,14 +100,24 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ * a list it did not request. Derived from the run's own recording, which * is also where the download endpoint re-derives each storage key. * + * They follow the viewer's projection for the same reason. A run's output + * files *are* its execution data — the descriptors name them, and + * `includeFileBase64` hands back their bytes — so a group that withholds + * `finalOutput` and `blockOutputs` under `logs.trace_spans` and then let + * the file list through would return the withheld output one field over. + * The list is `null`, exactly as for a caller that asked for no output, + * and the read is skipped rather than performed and discarded. + * * This re-reads the run rather than reusing what the status read already * loaded, and must: the status read materializes execution data *for * display*, a projection that strips `key` and `context` — exactly the * fields a file descriptor needs — and it also answers from the job queue * for runs that have no log row yet. + * + * permission-group-enforced: logs.trace_spans */ let files: WorkflowRunFileDescriptor[] | null = null - if (input.includeOutput) { + if (input.includeOutput && !projection.hideTraceSpans) { const runFiles = await getWorkflowRunFiles({ workflowId: context.workflowId, runId: context.runId, diff --git a/apps/sim/lib/workflows/application/workflow-runs.test.ts b/apps/sim/lib/workflows/application/workflow-runs.test.ts index 929a1a7f9cf..13364b75e46 100644 --- a/apps/sim/lib/workflows/application/workflow-runs.test.ts +++ b/apps/sim/lib/workflows/application/workflow-runs.test.ts @@ -34,7 +34,7 @@ vi.mock('@/lib/workflows/executor/execution-queries', () => ({ })) vi.mock('@/lib/workflows/executor/execution-status', () => ({ - getWorkflowExecutionStatus: mocks.getStatus, + getProjectedWorkflowExecutionStatus: mocks.getStatus, })) vi.mock('@/lib/workflows/executor/execution-run-files', () => ({ @@ -81,9 +81,8 @@ describe('workflow run application use cases', () => { mocks.resolveRunContext.mockResolvedValue(runContext) mocks.list.mockResolvedValue({ data: [], nextCursor: null }) mocks.getStatus.mockResolvedValue({ - executionId: 'run-1', - workflowId: 'workflow-1', - status: 'completed', + status: { executionId: 'run-1', workflowId: 'workflow-1', status: 'completed' }, + projection: { hideTraceSpans: false, hideCostInfo: false }, }) mocks.getRunFiles.mockResolvedValue({ terminal: true, diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index 0a5540d1b7f..873dc5182ed 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -175,16 +175,29 @@ function projectExecutionStatus( } } +/** A projected status resource together with the projection that produced it. */ +export interface ProjectedWorkflowExecutionStatus { + status: WorkflowExecutionStatusResponse + projection: LogFieldProjection +} + /** - * Reads the execution status resource, projected for the viewer. + * Reads the execution status resource, projected for the viewer, and reports + * the projection alongside it. * - * Projection lives in this shared read rather than in each of its two route + * Projection lives in this shared read rather than in each of its route * adapters so the rule has one copy and the next consumer inherits it, and it * runs after the caller's authorization, on whichever branch answered. + * + * The projection is returned rather than kept private because a caller that + * appends more of the run's execution data to this resource has to withhold it + * on the same terms — a run's output *files* are the clearest case. Deriving + * that answer from the applied projection is what keeps the two from drifting; + * resolving the viewer's group a second time would be a second copy of the rule. */ -export async function getWorkflowExecutionStatus( +export async function getProjectedWorkflowExecutionStatus( input: GetWorkflowExecutionStatusInput -): Promise { +): Promise { const status = await readWorkflowExecutionStatus(input) if (!status) return null const projection = await resolveLogFieldProjection( @@ -192,7 +205,17 @@ export async function getWorkflowExecutionStatus( input.workspaceId, input.workspaceOrganizationId ) - return projectExecutionStatus(status, projection) + return { status: projectExecutionStatus(status, projection), projection } +} + +/** + * The projected status resource alone, for a caller that renders nothing beyond + * it. + */ +export async function getWorkflowExecutionStatus( + input: GetWorkflowExecutionStatusInput +): Promise { + return (await getProjectedWorkflowExecutionStatus(input))?.status ?? null } async function readWorkflowExecutionStatus( From 0b32b6c437671484e316da06dc101328980228ad Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:26:08 -0700 Subject: [PATCH 140/179] fix(permission-groups): fail closed when the entitlement read fails --- apps/sim/lib/billing/core/subscription.ts | 48 +++++++++++++++++-- .../lib/permission-groups/resolve.server.ts | 13 ++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 1140e654dbe..228c212ae59 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -446,7 +446,29 @@ export function isSubscriptionBackedEntitlement(): boolean { return isBillingEnabled && !(isAccessControlEnabled && !isHosted) } -async function resolveOrganizationEnterprisePlan(organizationId: string): Promise { +/** + * What a billing-read failure resolves to for the Enterprise gate. + * + * `'return-false'` (the default) fails closed for a *feature* gate: the feature + * is hidden, and the worst outcome is a button that is briefly missing. + * + * `'throw'` is for callers where "no Enterprise plan" is not a smaller answer + * but a different regime. Access Control resolves to `config: null` when the + * organization is not entitled, and `null` means *every* capability allowed and + * every allowlist off — so a swallowed subscription-read failure would silently + * disable the whole permission-group regime for the request instead of + * surfacing an error. Those callers must pass `'throw'`. + * + * A primitive rather than an options object on purpose: `cache()` keys on the + * argument list, and a fresh object literal per call would miss the memo every + * time. + */ +export type EnterprisePlanErrorPolicy = 'return-false' | 'throw' + +async function resolveOrganizationEnterprisePlan( + organizationId: string, + onError: EnterprisePlanErrorPolicy = 'return-false' +): Promise { try { if (!isBillingEnabled) { return true @@ -460,11 +482,23 @@ async function resolveOrganizationEnterprisePlan(organizationId: string): Promis return false } - const orgSub = await getOrganizationSubscriptionUsable(organizationId) + /** + * The subscription read soft-fails to `null` by default, which would arrive + * here as an ordinary "no usable subscription" and return a successful + * `false` — the catch below never sees it. A caller that asked to throw + * needs that failure propagated too. + */ + const orgSub = await getOrganizationSubscriptionUsable( + organizationId, + onError === 'throw' ? { onError: 'throw' } : {} + ) return !!orgSub && checkEnterprisePlan(orgSub) } catch (error) { logger.error('Error checking organization enterprise plan status', { error, organizationId }) + if (onError === 'throw') { + throw error + } return false } } @@ -537,7 +571,15 @@ export async function resolveOrganizationPlan( * Used for Access Control (Permission Groups) feature gating * * Request-memoized: a settings render gates several sections on the same - * organization's plan, and it cannot change mid-render. + * organization's plan, and it cannot change mid-render. `cache()` keys on the + * whole argument list, so the default and `'throw'` policies memoize + * separately — a request that mixes both pays for two reads, and a rejection is + * replayed to every later caller that asked for the same policy, which is the + * fail-closed behavior those callers want. + * + * Pass `'throw'` from any caller for which a swallowed read failure would read + * as a *permissive* answer rather than a restrictive one — see + * {@link EnterprisePlanErrorPolicy}. */ export const isOrganizationOnEnterprisePlan = cache(resolveOrganizationEnterprisePlan) diff --git a/apps/sim/lib/permission-groups/resolve.server.ts b/apps/sim/lib/permission-groups/resolve.server.ts index b254e7d3e2a..ec5beb74d0e 100644 --- a/apps/sim/lib/permission-groups/resolve.server.ts +++ b/apps/sim/lib/permission-groups/resolve.server.ts @@ -202,7 +202,15 @@ async function resolveUserAccessControlContextForOrganization( ): Promise { if (!organizationId) return inactiveUserAccessControlContext(null) - const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId) + /** + * `'throw'` because an unentitled organization resolves to `config: null`, + * and `null` is not a smaller permission set — it is *no* permission group at + * all: every capability allowed, every allowlist off. Under the lenient + * default a single subscription-read failure would be indistinguishable from + * a genuine plan lapse and would turn the whole regime off for the request. + * Throwing surfaces the outage as an error instead. + */ + const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId, 'throw') if (!isEnterprise) { return inactiveUserAccessControlContext(organizationId) } @@ -277,7 +285,8 @@ export async function getUserPermissionConfigForOrganization( return mergeEnvAllowlist(null) } - const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId) + /** `'throw'` for the same reason as in {@link resolveUserAccessControlContextForOrganization}. */ + const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId, 'throw') if (!isEnterprise) { return mergeEnvAllowlist(null) } From a50a5e89563012f7f3f97036ab24b22e0907a11f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:26:08 -0700 Subject: [PATCH 141/179] test(permission-groups): cover the fail-closed entitlement policy --- .../utils/permission-check.test.ts | 4 +- .../sim/lib/billing/core/subscription.test.ts | 58 ++++++++++ .../permission-groups/resolve.server.test.ts | 104 ++++++++++++++++++ 3 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/permission-groups/resolve.server.test.ts 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 e38273f660b..02baa8ee855 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -222,7 +222,7 @@ describe('access control context resolution', () => { await expect(getUserPermissionConfig('user-123', 'workspace-1')).resolves.toMatchObject({ disableMcpTools: true, }) - expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-1') + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-1', 'throw') }) it('returns the explicit governing group and its effective config', async () => { @@ -306,7 +306,7 @@ describe('access control context resolution', () => { ) expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled() - expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-verified') + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-verified', 'throw') expect(context).toMatchObject({ organizationId: 'org-verified', entitled: true, diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts index 31060f810ef..7edb6d3ccf2 100644 --- a/apps/sim/lib/billing/core/subscription.test.ts +++ b/apps/sim/lib/billing/core/subscription.test.ts @@ -69,6 +69,7 @@ import { hasPaidSubscription, hasWorkspaceLiveSyncAccess, hasWorkspaceSandboxAccess, + isOrganizationOnEnterprisePlan, isWorkspaceOnEnterprisePlan, resolveOrganizationPlan, syncSubscriptionPlan, @@ -474,3 +475,60 @@ describe('resolveOrganizationPlan', () => { ) }) }) + +describe('isOrganizationOnEnterprisePlan', () => { + const ORGANIZATION_ID = 'org-1' + + beforeEach(() => { + vi.clearAllMocks() + /** An earlier describe leaves billing disabled, which short-circuits this gate to true. */ + setEnvFlags({ isBillingEnabled: true, isHosted: true }) + mockIsOrganizationBillingBlocked.mockResolvedValue(false) + mockCheckEnterprisePlan.mockReturnValue(true) + }) + + it('accepts an organization holding a usable enterprise plan', async () => { + dbChainMockFns.limit.mockResolvedValue([{ plan: 'enterprise', status: 'active' }]) + + await expect(isOrganizationOnEnterprisePlan(ORGANIZATION_ID)).resolves.toBe(true) + }) + + /** + * The lenient default is what every feature gate reads — a hidden button is + * the worst outcome there — so a read failure must keep resolving `false` + * rather than starting to reject through those callers. + */ + it('keeps failing closed to false for the default policy', async () => { + dbChainMockFns.limit.mockRejectedValue(new Error('billing database unavailable')) + + await expect(isOrganizationOnEnterprisePlan(ORGANIZATION_ID)).resolves.toBe(false) + await expect(isOrganizationOnEnterprisePlan(ORGANIZATION_ID, 'return-false')).resolves.toBe( + false + ) + }) + + /** + * The subscription read soft-fails to `null` on its own, so without the + * policy threaded through it an outage arrives as an ordinary "no usable + * subscription" and returns a successful `false`. For Access Control that + * `false` means `config: null` — every capability allowed — so it has to + * propagate. + */ + it('propagates a failed subscription read when the caller asked to throw', async () => { + dbChainMockFns.limit.mockRejectedValue(new Error('billing database unavailable')) + + await expect(isOrganizationOnEnterprisePlan(ORGANIZATION_ID, 'throw')).rejects.toThrow( + 'billing database unavailable' + ) + }) + + it('propagates a failed block-state read the same way', async () => { + mockIsOrganizationBillingBlocked.mockRejectedValue(new Error('userStats unavailable')) + dbChainMockFns.limit.mockResolvedValue([{ plan: 'enterprise', status: 'active' }]) + + await expect(isOrganizationOnEnterprisePlan(ORGANIZATION_ID)).resolves.toBe(false) + await expect(isOrganizationOnEnterprisePlan(ORGANIZATION_ID, 'throw')).rejects.toThrow( + 'userStats unavailable' + ) + }) +}) diff --git a/apps/sim/lib/permission-groups/resolve.server.test.ts b/apps/sim/lib/permission-groups/resolve.server.test.ts new file mode 100644 index 00000000000..12e4176cb14 --- /dev/null +++ b/apps/sim/lib/permission-groups/resolve.server.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsOrganizationOnEnterprisePlan, mockGetWorkspaceWithOwner } = vi.hoisted(() => ({ + mockIsOrganizationOnEnterprisePlan: vi.fn(), + mockGetWorkspaceWithOwner: vi.fn(), +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: mockGetWorkspaceWithOwner, +})) + +import { + getUserPermissionConfig, + getUserPermissionConfigForOrganization, + resolveVerifiedUserAccessControlContext, +} from '@/lib/permission-groups/resolve.server' + +const ORGANIZATION_ID = 'org-1' +const USER_ID = 'user-1' +const WORKSPACE_ID = 'workspace-1' + +/** + * Stands in for the entitlement resolver's two regimes: it answers `false` for + * the lenient default — which is exactly what a swallowed billing outage looks + * like — and rejects only for a caller that asked to throw. A resolution path + * that drops the `'throw'` argument therefore reads the outage as "not + * entitled" and these tests go red. + */ +function entitlementReadFails(): void { + mockIsOrganizationOnEnterprisePlan.mockImplementation( + async (_organizationId: string, onError?: string) => { + if (onError === 'throw') throw new Error('billing database unavailable') + return false + } + ) +} + +describe('permission-group resolution under a failed entitlement read', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isHosted: true, isAccessControlEnabled: true }) + mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: ORGANIZATION_ID }) + }) + + afterAll(resetEnvFlagsMock) + + /** + * `config: null` is not a stricter answer — it means every capability allowed + * and every allowlist off. Resolving it from a billing-read failure would + * turn the whole regime off for the request, so the failure has to surface. + */ + it('rejects rather than resolving an unrestricted context for a verified workspace', async () => { + entitlementReadFails() + + await expect( + resolveVerifiedUserAccessControlContext(USER_ID, WORKSPACE_ID, ORGANIZATION_ID) + ).rejects.toThrow('billing database unavailable') + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith(ORGANIZATION_ID, 'throw') + }) + + it('rejects rather than resolving a null config from the workspace-lookup path', async () => { + entitlementReadFails() + + await expect(getUserPermissionConfig(USER_ID, WORKSPACE_ID)).rejects.toThrow( + 'billing database unavailable' + ) + }) + + it('rejects rather than resolving a null config for the organization-addressed path', async () => { + entitlementReadFails() + + await expect(getUserPermissionConfigForOrganization(ORGANIZATION_ID)).rejects.toThrow( + 'billing database unavailable' + ) + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith(ORGANIZATION_ID, 'throw') + }) + + /** + * The fail-closed policy must not turn a genuine plan lapse into an error: + * an organization that simply is not on the plan still resolves to an + * inactive context. + */ + it('still resolves an inactive context when the organization is genuinely unentitled', async () => { + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false) + + await expect( + resolveVerifiedUserAccessControlContext(USER_ID, WORKSPACE_ID, ORGANIZATION_ID) + ).resolves.toEqual({ + organizationId: ORGANIZATION_ID, + entitled: false, + permissionGroup: null, + config: null, + }) + await expect(getUserPermissionConfigForOrganization(ORGANIZATION_ID)).resolves.toBeNull() + }) +}) From e78534030d5b6b54fdf566fd931ee0e1fe89e893 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:26:59 -0700 Subject: [PATCH 142/179] docs(logs): state why logs.export has no admin exemption, and pin it --- apps/sim/app/api/logs/export/route.test.ts | 17 +++++++++++++++++ apps/sim/app/api/logs/export/route.ts | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/apps/sim/app/api/logs/export/route.test.ts b/apps/sim/app/api/logs/export/route.test.ts index 9834e4aca7e..0955840a5c3 100644 --- a/apps/sim/app/api/logs/export/route.test.ts +++ b/apps/sim/app/api/logs/export/route.test.ts @@ -278,6 +278,23 @@ describe('GET /api/logs/export', () => { expect(dbChainMockFns.where).not.toHaveBeenCalled() }) + /** + * The decision, pinned: `logs.export` has no admin exemption. The refusal is + * reached without reading a role at all — no organization membership, no + * workspace permission row — so an exemption cannot be added without this + * failing. + */ + it("refuses the download without consulting the caller's role", async () => { + mockGetUserPermissionConfig.mockResolvedValue({ disableLogExport: true }) + queueTableRows(workflowExecutionLogs, [logRow(0)]) + + const response = await GET(makeRequest()) + + expect(response.status).toBe(403) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + it('exports normally when no group withholds log export', async () => { queueTableRows(workflowExecutionLogs, [logRow(0)]) diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index eea59855c4f..f79149e437b 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -110,6 +110,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { * Checked here rather than in an application use case because this route * queries directly and predates that boundary; migrating it is worth doing, * and is not a reason to leave the export ungoverned meanwhile. + * + * No admin exemption, unlike `organization.member_directory`. That one is + * organization-scoped: it reads the org *default* group, which governs the + * admins too, and the page it withholds is the roster an admin needs open to + * change the setting — an exemption there breaks a bootstrap loop. This + * capability has neither half. It resolves the caller's own workspace group, + * so an admin who wants the export edits the group that withheld it, on a + * settings page this refusal does not touch. Exempting admins would instead + * make `disableLogExport` unable to say what it says — that nobody in this + * group downloads the whole workspace's execution payloads — for exactly the + * accounts whose export is widest. */ const permissionConfig = await resolvePermissionGroupConfig( userId, From ebe281d1142acb10051762af1b9bb9e69848cf20 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:27:29 -0700 Subject: [PATCH 143/179] audit(application-graph): report a deferred import into a forbidden tree --- scripts/check-application-graph.test.ts | 68 +++++++++++++++- scripts/check-application-graph.ts | 103 ++++++++++++++++++++++-- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/scripts/check-application-graph.test.ts b/scripts/check-application-graph.test.ts index 0e6d3f209b1..a477dd07bc6 100644 --- a/scripts/check-application-graph.test.ts +++ b/scripts/check-application-graph.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + deferredSpecifiers, FORBIDDEN_PREFIXES, findViolations, GUARDED_ROOTS, @@ -26,7 +27,7 @@ describe('runtimeSpecifiers', () => { ).toEqual(['@/lib/uploads/core/setup.server', '@/lib/a']) }) - it('ignores a dynamic import, which is a call rather than a load', () => { + it('leaves a dynamic import out of the module-evaluation set', () => { expect(runtimeSpecifiers("const a = await import('@/lib/a')\n")).toEqual([]) }) @@ -91,3 +92,68 @@ describe('the guarded roots', () => { ]) }) }) + +describe('deferredSpecifiers', () => { + it('collects a dynamic import, awaited or not', () => { + expect( + deferredSpecifiers( + "const a = await import('@/lib/a')\nvoid import('@/lib/b').then(noop)\n" + + "const { c } = await import(\n '@/lib/c'\n)\n" + ) + ).toEqual(['@/lib/a', '@/lib/b', '@/lib/c']) + }) + + it('ignores a `typeof import(…)` type query, which the compiler erases', () => { + expect(deferredSpecifiers("type A = typeof import('@/lib/a')\n")).toEqual([]) + }) + + it('leaves static forms to runtimeSpecifiers', () => { + expect(deferredSpecifiers("import { a } from '@/lib/a'\nimport '@/lib/b'\n")).toEqual([]) + }) +}) + +describe('a deferred edge into a forbidden tree', () => { + /** + * The evasion: a root that goes red on a static import is one keystroke from + * green if `await import(…)` produces no edge. On the funnel's hot path the + * deferral moves nothing — the registry loads on the first gated request + * instead of on the first import — so the edge is reported. + */ + it('is reported when a root defers the load of a forbidden module', () => { + /** + * Walked from a module that defers the block registry — `const + * { getBlockRegistry } = await import('@/blocks/registry')` — and nothing + * else about it matters here. Before the deferred pass this root was green + * on `blocks/`, which is the whole evasion in one line. + */ + const violations = findViolations({ + root: 'lib/copilot/chat/process-contents.ts', + forbidden: { 'blocks/': FORBIDDEN_PREFIXES['blocks/'] }, + }) + + expect(violations).toHaveLength(1) + expect(violations[0].forbidden).toBe('blocks/registry.ts') + expect(violations[0].reason).toContain('deferred') + expect(violations[0].path).toEqual([ + 'lib/copilot/chat/process-contents.ts', + 'blocks/registry.ts', + ]) + }) + + /** + * The other half of the rule, and the reason it is not "walk dynamic imports + * like static ones": `lib/billing/core/subscription.ts` sits in the funnel's + * static graph and lazily loads `@/components/emails` on a plan-upgrade + * webhook — a template that statically imports the workflow graph. Walking + * past the deferred hop reports a module nothing loads until that webhook + * fires, which is a false alarm about what an authorization decision costs. + */ + it('is not walked through, so a deferred module’s own graph stays out', () => { + expect( + findViolations({ + root: 'lib/billing/core/subscription.ts', + forbidden: { 'lib/workflows/': FORBIDDEN_PREFIXES['lib/workflows/'] }, + }) + ).toEqual([]) + }) +}) diff --git a/scripts/check-application-graph.ts b/scripts/check-application-graph.ts index 11767991f8f..0281bde0a2c 100644 --- a/scripts/check-application-graph.ts +++ b/scripts/check-application-graph.ts @@ -23,9 +23,50 @@ * into every route test, and the only symptom was an unrelated OTP-route test * failing on its own partial `zod` mock. * - * Walks runtime `import`/`export … from` specifiers only. `import type` is - * erased by the compiler and costs nothing at runtime, so a type-only edge into - * a forbidden module is allowed and deliberately not reported. + * Walks `import`/`export … from` specifiers. `import type` is erased by the + * compiler and costs nothing at runtime, so a type-only edge into a forbidden + * module is allowed and deliberately not reported. A dynamic `import(…)` is + * reported when its own target is forbidden but is not walked through — see + * {@link DYNAMIC_IMPORT_PATTERN} for both halves of that rule. + * + * ## Exactly what is guarded, and what is not + * + * FIVE entry points, listed in {@link GUARDED_ROOTS}: `lib/core/application`, + * three permission-group modules (`capabilities`, `capability-assertions`, + * `config-scope.server`) and the route wrapper. It is NOT "all of + * `lib/permission-groups/`", and the difference is not a rounding error: + * + * - `lib/permission-groups/model-access.ts` imports `providers/utils.ts` + * directly, on purpose — deciding which models a group allows is the one + * permission-group question that genuinely needs the provider registry. It is + * unguardable by construction, and the graph test uses it as its proof that + * the walker can still fail. + * - `lib/permission-groups/user-scope.server.ts` — the user-global resolver — + * reaches the workflow graph today, through + * `lib/billing/organizations/membership.ts` -> `lib/billing/core/usage.ts` -> + * `components/emails`. Guarding it is therefore not free: it would go red on + * arrival. It is left unguarded rather than added with an exception, because + * an exception list is how a root stops meaning anything. What holds it + * instead is `check-capability-subject.ts`, which bans a v1 route from + * importing it at all. + * + * The rule for adding a root is the one the two cases above illustrate: guard an + * entry point whose graph EVERY authorizing surface pays for, and only while the + * guard passes without exceptions. A module reached by one gate on one path is + * not that, however capability-shaped it looks. + * + * ## What this audit cannot see + * + * - A specifier that is not a literal — `import(someVariable)`, or a require + * built from a template string. There is no call graph here, only source + * text. + * - Weight that is not a forbidden prefix. A root can reach an arbitrarily + * expensive module and stay green if that module is not under one of the + * listed trees; the list is a record of what has actually gone wrong, not a + * budget. + * - Whether a deferred edge is hot or cold. A dynamic import on a per-request + * path and one on a once-a-month webhook read identically, which is why the + * deferred check stops at the edge's own target rather than walking past it. */ import { existsSync, readFileSync, statSync } from 'node:fs' import { dirname, relative, resolve } from 'node:path' @@ -110,11 +151,36 @@ const IMPORT_PATTERN = * Matches a side-effect import — `import '…'`, with no clause and so no * `from`. It is the heaviest edge of all: the module is loaded purely to run, * and nothing in the importing file names it, so it is also the easiest one to - * miss by eye. Dynamic `import(…)` is not matched: the quote must follow the - * keyword directly, and a call opens a parenthesis first. + * miss by eye. It does not match a dynamic `import(…)`: the quote must follow + * the keyword directly, and a call opens a parenthesis first. */ const SIDE_EFFECT_IMPORT_PATTERN = /(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g +/** + * Matches a dynamic `import('…')`, which is CHECKED but not TRAVERSED. + * + * Checked, because "make it lazy" is the first thing anyone reaches for when + * this audit goes red, and on the hot path it moves nothing — a helper the + * funnel calls on every gated request still drags the provider registry in, one + * request later instead of one import earlier. The pattern is a real one in this + * repo (see `scripts/generate-block-successors.ts`, which defers the block + * registry so its unit test need not resolve it), so it is a form the walker has + * to know about rather than one it can assume absent. + * + * Not traversed, because a deferred module's own graph is deferred with it. + * `lib/billing/core/subscription.ts` — squarely inside the funnel's static graph + * — lazily loads `@/components/emails` on a plan-upgrade webhook, and that + * template statically imports the workflow graph. Walking through the deferred + * hop reports `lib/workflows/schedules/disable-reasons.ts` as an edge every + * authorization decision pays for, which it is not: nothing loads it until that + * webhook fires. The edge worth reporting is the deferred one itself, when its + * TARGET is forbidden. + * + * `typeof import('…')` is excluded: that is a type query the compiler erases, + * the same reason `import type` is dropped above. + */ +const DYNAMIC_IMPORT_PATTERN = /(? (first.index ?? 0) - (second.index ?? 0)) .map((match) => match[1]) } +/** The specifiers `source` loads on demand through a dynamic `import(…)` call. */ +export function deferredSpecifiers(source: string): string[] { + return [...source.matchAll(DYNAMIC_IMPORT_PATTERN)].map((match) => match[1]) +} + export interface GraphViolation { root: string forbidden: string @@ -165,6 +236,26 @@ export function findViolations({ root, forbidden }: GuardedRoot): GraphViolation continue } + /** + * A deferred edge is reported when its target is forbidden and then dropped: + * the module it names is not loaded until the call runs, so its own graph is + * not part of what the funnel costs. See {@link DYNAMIC_IMPORT_PATTERN}. + */ + for (const specifier of deferredSpecifiers(source)) { + const next = resolveSpecifier(specifier, file) + if (next === null) continue + const rel = relative(APP_ROOT, next) + const prefix = Object.keys(forbidden).find((candidate) => rel.startsWith(candidate)) + if (prefix === undefined || reported.has(prefix)) continue + reported.add(prefix) + violations.push({ + root, + forbidden: rel, + reason: `${forbidden[prefix]} (reached by a deferred \`import()\`, which defers the load but not the dependency)`, + path: [...path, next].map((entry) => relative(APP_ROOT, entry)), + }) + } + for (const specifier of runtimeSpecifiers(source)) { const next = resolveSpecifier(specifier, file) if (next === null || seen.has(next)) continue From f750e15a908fad943153e215083904f1d545a67e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:28:34 -0700 Subject: [PATCH 144/179] fix(billing): gate the account-scoped read on personal_api_key.use --- .../authorized-billing-read-use-case.ts | 24 +++++++++++++++ .../application/billing-use-cases.test.ts | 30 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts index 1edd942ccb3..5a6105d6cf1 100644 --- a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts +++ b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts @@ -16,6 +16,7 @@ import { WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server' import { type ActiveWorkspaceApplicationContext, loadActiveWorkspaceApplicationContext, @@ -64,6 +65,29 @@ async function resolveBillingReadScope( throw new WorkspaceApiKeyScopeAuthorizationError() } } else if (!requestedWorkspaceId) { + /** + * permission-group-enforced: personal_api_key.use — the account-scoped read + * names no workspace, so nothing above resolved a group for it and the + * workspace branch's check below never runs. + * + * `personal_api_key.use` refuses a *principal kind* rather than a capability + * of the resource, so it applies to every operation a personal key can + * reach — including the one that happens to carry no workspace. Left out, + * the narrower scope would be the guarded one: the same key an organization + * withholds from `GET /billing?workspaceId=…` would still read the account's + * plan, balance and usage by omitting the parameter. + * + * Resolved from the organization's default group, the fallback + * {@link isCapabilityWithheldForUser} defines for a user-global action — + * the same one the personal-API-key and CLI mint paths use. A no-op when the + * caller is in no organization or no group governs them. + */ + if ( + principal.kind === 'personal_api_key' && + (await isCapabilityWithheldForUser(principal.userId, 'personal_api_key.use')) + ) { + throw new PersonalApiKeysDisabledError() + } return { kind: 'account', userId: principal.userId } } diff --git a/apps/sim/lib/billing/application/billing-use-cases.test.ts b/apps/sim/lib/billing/application/billing-use-cases.test.ts index 18f7cf4dc89..0665c709b26 100644 --- a/apps/sim/lib/billing/application/billing-use-cases.test.ts +++ b/apps/sim/lib/billing/application/billing-use-cases.test.ts @@ -33,6 +33,11 @@ const mocks = vi.hoisted(() => ({ recordAudit: vi.fn(), canUserManageWorkspaceBilling: vi.fn(), canUserManageBillingEntity: vi.fn(), + isCapabilityWithheldForUser: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/user-scope.server', () => ({ + isCapabilityWithheldForUser: mocks.isCapabilityWithheldForUser, })) vi.mock('@/lib/billing/core/workspace-billing-authority', () => ({ @@ -113,6 +118,7 @@ describe('billing application use cases', () => { mocks.resolvePermission.mockResolvedValue('read') mocks.canUserManageWorkspaceBilling.mockResolvedValue(false) mocks.canUserManageBillingEntity.mockResolvedValue(false) + mocks.isCapabilityWithheldForUser.mockResolvedValue(false) mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 1, limit: 10, isExceeded: false }) mocks.checkAttributedBlocks.mockResolvedValue({ blocked: false }) mocks.toUsageLimitSubscription.mockReturnValue(null) @@ -224,6 +230,30 @@ describe('billing application use cases', () => { ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) }) + /** + * The account-scoped read names no workspace, so the workspace branch's + * `personal_api_key.use` check never runs on it. Without a gate of its own, + * the same key an organization withholds from the workspace-scoped read still + * reads the account's plan, balance and usage by dropping the parameter. + */ + it('refuses an account-scoped personal key through the organization default group', async () => { + mocks.isCapabilityWithheldForUser.mockResolvedValue(true) + + await expect( + getBillingStatus.execute({ principal: personalPrincipal, input: {} }) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + + expect(mocks.isCapabilityWithheldForUser).toHaveBeenCalledWith('user-1', 'personal_api_key.use') + }) + + it('never applies the account-scoped personal-key gate to a workspace key', async () => { + mocks.isCapabilityWithheldForUser.mockResolvedValue(true) + + await expect( + getBillingStatus.execute({ principal: workspacePrincipal, input: {} }) + ).resolves.toBeDefined() + }) + it('never reads the payer storage pool it may not disclose', async () => { await getBillingStatus.execute({ principal: workspacePrincipal, input: {} }) await getBillingStatus.execute({ From 3f183baa0e1d897294dc20a6d62c961b2225601f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:29:21 -0700 Subject: [PATCH 145/179] audit(permission-groups): refuse a factory that admits a Partial operation override --- ...check-permission-group-enforcement.test.ts | 60 ++++++++++++++++ scripts/check-permission-group-enforcement.ts | 68 ++++++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/scripts/check-permission-group-enforcement.test.ts b/scripts/check-permission-group-enforcement.test.ts index 18e44a3bd45..f1da09f02fa 100644 --- a/scripts/check-permission-group-enforcement.test.ts +++ b/scripts/check-permission-group-enforcement.test.ts @@ -267,3 +267,63 @@ describe('registry completeness', () => { ).toEqual([]) }) }) + +describe('a factory that admits a Partial override of the operation', () => { + /** + * Nothing in the tree does this today, which is why it is probed here rather + * than caught in the wild: the capability the parsers read is the literal in + * the factory body, and a `Partial` spread over the result + * can replace it with `'none'` after the audit has already approved it. The + * factory is reported as unparseable rather than resolved — the override's + * value lives at the call site, and following it is the call graph this audit + * does not have. + */ + it('reports an `overrides?: Partial` parameter', () => { + const { overridable } = parseOperationCapabilities( + 'function defineTableOperation(id: string, overrides?: Partial) {\n' + + " return defineWorkspaceOperation({ id, capability: 'tables.use', ...overrides })\n" + + '}\n' + ) + + expect(overridable).toEqual([1]) + }) + + it('reports the override however the parameter is spelled', () => { + const { overridable } = parseOperationCapabilities( + 'function defineKnowledgeOperation(\n' + + ' id: string,\n' + + ' patch: Partial = {}\n' + + ') {\n' + + " return defineWorkspaceOperation({ id, capability: 'knowledge.use', ...patch })\n" + + '}\n' + ) + + expect(overridable).toEqual([1]) + }) + + /** + * `Partial` over something that is not an operation is ordinary code — a + * factory taking a partial audit payload has nothing to say about capability. + */ + it('leaves a Partial of an unrelated type alone', () => { + const { overridable } = parseOperationCapabilities( + 'function defineTableOperation(id: string, audit?: Partial) {\n' + + " return defineWorkspaceOperation({ id, capability: 'tables.use', audit })\n" + + '}\n' + ) + + expect(overridable).toEqual([]) + }) + + it('leaves a factory with named parameters alone', () => { + const { declarations, overridable } = parseOperationCapabilities( + 'function defineTableOperation(id: string, capability: string) {\n' + + ' return defineWorkspaceOperation({ id, capability })\n' + + '}\n' + + "defineTableOperation('table.read', 'tables.use')\n" + ) + + expect(overridable).toEqual([]) + expect(declarations).toEqual([{ id: 'table.read', line: 4, capability: 'tables.use' }]) + }) +}) diff --git a/scripts/check-permission-group-enforcement.ts b/scripts/check-permission-group-enforcement.ts index 2675e1e75a3..6f16b5c825f 100644 --- a/scripts/check-permission-group-enforcement.ts +++ b/scripts/check-permission-group-enforcement.ts @@ -39,6 +39,34 @@ * the type: an operation written in a form the parsers cannot follow yields no * capability, and without the check it would be skipped in silence — counted as * reviewed while nothing had actually read what it declares. + * + * ## What "enforced" means here, and where it stops + * + * Two different strengths of evidence sit behind assertion C, and the printed + * total does not distinguish them: + * + * - A capability NAMED BY AN OPERATION is enforced by construction. The funnel + * applies it; the declaration and the gate are the same fact. + * - A capability reachable only through a `permission-group-enforced:` comment + * is enforced by ASSERTION OF THE AUTHOR. This audit matches the comment + * text; it does not verify that anything below it gates. Today that is 18 of + * 35 capabilities — among them `logs.cost`, `inbox.use`, `personal_api_key.use` + * and `copilot.tool_auto_approval` — so it is the majority of the registry, + * not a rounding error. {@link parseEnforcedAnnotations} records which cheap + * lookahead shapes were measured against the tree and why each is wrong more + * often than right; closing this properly wants a call graph. What still + * holds is narrow but real: deleting a gate AND its comment is reported by C + * immediately, and assertion E stops an annotation from inventing enforcement + * for a key whose field says `ui-only` or `executor`. The uncovered case is + * exactly one — a gate deleted while its comment is left behind. + * + * {@link SCAN_ROOTS} is the other boundary. `background/`, `connectors/`, + * `tools/`, `enrichments/`, `triggers/` and every `.tsx` are unscanned, and as + * of this writing each contains ZERO operation declarations and ZERO capability + * gate calls — checked, not assumed. The boundary is documented rather than + * widened because a scan root that finds nothing costs walk time and teaches a + * reader that operations might live there. If one ever does, two things change + * together: `SCAN_ROOTS`, and the `.ts`-only filter in `walk`. */ import { readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' @@ -76,6 +104,22 @@ const MAX_ANNOTATION_LOOKBACK = 3 * builder visible by default instead of on purpose. */ const MINTING_NAME = /^define[A-Za-z0-9_$]*Operation$/ +/** + * A factory parameter that admits a `Partial<…>` override of the operation it + * mints — `overrides?: Partial` and its relatives. + * + * The capability this audit reads is the one written in the factory body or at + * the call site. A parameter spread over the result afterwards can replace it, + * including with `'none'`, and the audit would keep reporting whatever the + * literal said — a green tick over an operation whose capability is decided by + * whoever calls it. No factory in the tree does this today; the point of + * refusing it here is that the first one to try is reported rather than + * discovered later. + * + * `Partial` and not `Omit`/`Pick`: those narrow a type, they do not make a + * declared field optional to overwrite. + */ +const OVERRIDE_PARAMETER = /\bPartial\s*<[^>]*Operation\b/ const MINTING_CALL_SOURCE = String.raw`\b(define[A-Za-z0-9_$]*Operation)\s*\(` const mintingCallPattern = () => new RegExp(MINTING_CALL_SOURCE, 'g') /** Whether a module mints an operation at all, so files that do not are skipped cheaply. */ @@ -177,6 +221,11 @@ interface OperationDeclaration { interface ParsedOperations { declarations: OperationDeclaration[] + /** + * Lines of same-file factories whose parameters admit a `Partial<…>` override + * of the operation itself. See {@link OVERRIDE_PARAMETER}. + */ + overridable: number[] /** * Lines of operation-minting calls this parser could not read an id from, * and which no recognized factory accounts for. @@ -198,6 +247,7 @@ interface ParsedOperations { export function parseOperationCapabilities(source: string): ParsedOperations { const declarations: OperationDeclaration[] = [] const unreadable: number[] = [] + const overridable: number[] = [] /** * Domains that wrap a builder in a same-file factory declare the capability @@ -216,6 +266,11 @@ export function parseOperationCapabilities(source: string): ParsedOperations { const body = balancedGroup(source, bodyIndex) if (!MINTS_AN_OPERATION.test(body) && !MINTING_NAME.test(match[1])) continue factoryRanges.push([bodyIndex, bodyIndex + body.length]) + const parameterIndex = source.indexOf('(', match.index + match[0].length - 1) + if (parameterIndex !== -1 && parameterIndex < bodyIndex) { + const parameters = balancedGroup(source, parameterIndex) + if (OVERRIDE_PARAMETER.test(parameters)) overridable.push(lineAt(source, match.index)) + } const fixed = /capability\s*:\s*'([a-z0-9_.]+)'/.exec(body)?.[1] if (fixed) factoryCapabilities.set(match[1], fixed) else if (/capability\s*[,:}]/.test(body)) factoryCapabilities.set(match[1], 'positional') @@ -270,7 +325,7 @@ export function parseOperationCapabilities(source: string): ParsedOperations { } } - return { declarations, unreadable } + return { declarations, unreadable, overridable } } export interface OperationRegistryMember { @@ -496,7 +551,16 @@ function main(): void { if (!MINTS_AN_OPERATION.test(source) && !DECLARES_A_REGISTRY.test(source)) continue - const { declarations, unreadable } = parseOperationCapabilities(source) + const { declarations, unreadable, overridable } = parseOperationCapabilities(source) + + for (const line of overridable) { + findings.push({ + file: relativePath, + line, + message: + "mints operations through a factory that takes a `Partial<…Operation>` override. The capability this audit reads is the one in the factory body or at the call site, and a partial spread over the result can replace it — including with 'none' — so what is declared here stops being what ships. Take the fields the factory varies as named parameters rather than an open override", + }) + } for (const line of unreadable) { findings.push({ From eaf29793a697a13962b8cb891582822444d59a95 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:30:48 -0700 Subject: [PATCH 146/179] docs(table): state the gate-vs-dispatch subject model once --- .../api/table/[tableId]/columns/run/route.ts | 18 ++++--- apps/sim/app/api/table/utils.ts | 52 ++++++++++++++----- 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts index 5375e160fb4..3c184e0a65b 100644 --- a/apps/sim/app/api/table/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -2,8 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { runColumnContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { TableQueryValidationError } from '@/lib/table/errors' @@ -65,13 +64,16 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro requestId, triggeredByUserId: auth.userId, /** - * The gate's subject, not the meter's. An internal JWT resolves to the - * executor principal, which carries a role but no capabilities, so it - * governs nothing — only a session caller does. + * Whose group governs the cells this dispatch STARTS, not who is billed + * and not who was gated above — the second of the two questions on + * `capabilityGovernedUserId` in `@/app/api/table/utils`. + * + * Derived from the auth type rather than from the gated principal: + * `checkSessionOrInternalAuth` admits exactly a session and an internal + * JWT here, and the JWT carries the run's actor, whom + * `checkAccess` above may gate on but no dispatch may run as. */ - capabilityGovernedUserId: auth.principal - ? capabilityGovernedPrincipalUserId(auth.principal) - : null, + capabilityGovernedUserId: capabilityGovernedAuthUserId(auth), }) // Starting a run clears the target group's cells to pending (`bulkClearWorkflowGroupCells`) — a DB diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 1a777864438..daf4ed3d838 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -236,9 +236,11 @@ interface ApiErrorResponse { * A discriminated union rather than a user id, because the two kinds are * indistinguishable as strings and the gate must treat them differently: * - * - `user` — a session, an internal JWT acting as the person, or a personal API - * key. There is a real person behind the request, so their permission group - * governs it and `tables.use` applies. + * - `user` — a session, a personal API key, or an internal JWT carrying the + * run's actor. The id is answerable for the request, so the workspace role + * check runs against it and this surface's `tables.use` gate applies. See + * {@link capabilityGovernedUserId} for why the JWT case belongs here and + * nonetheless must not be reused to attribute dispatched work. * - `workspace_api_key` — a shared credential that authorizes as the workspace * itself. It has no user, so there is no group to resolve. * `keyCreatorUserId` is the id `authenticateApiKeyFromHeader` reports: the @@ -264,14 +266,34 @@ function roleSubjectUserId(principal: TableAccessPrincipal): string { } /** - * The id whose permission group governs the request, or `null` when no group + * The id whose permission group governs THIS REQUEST, or `null` when no group * does. Only a `user` principal has one — see {@link TableAccessPrincipal}. * - * Exported because the gate is not the only thing that needs the subject: a - * write that lands rows auto-fires the table's workflow and enrichment cells, - * and those cells must run under the same person this check just gated, not - * under whatever id the surface had nearest. One statement of the rule, so a - * route cannot gate one subject and dispatch another. + * ## Two questions, two subjects + * + * A table route asks the permission group two things, and they take different + * answers for the same caller. Conflating them is how a run either stops working + * or gains grants it was never given: + * + * 1. MAY THIS REQUEST PROCEED — the role check and the `tables.use` gate in + * {@link checkAccess}. Answered with the id the credential presents, this + * function. An internal JWT presents the run's actor, and applying that + * person's group here is deliberate: the answer can only withhold the table + * from a run whose actor lost Tables, never open one. Failing closed on a + * bystander's group is a conservative read of an id we already trust for the + * role. + * 2. UNDER WHOSE GROUP DOES WORK THIS REQUEST STARTS RUN — the workflow and + * enrichment cells a landed row auto-fires. Answered by + * `capabilityGovernedAuthUserId` in `@/lib/auth/hybrid`, off the auth TYPE, + * which names NOBODY for an internal JWT. Here the actor's group would run + * the other way: it would grant a bystander's tools to an executor call, and + * the executor's own withholding in `tableOperations` is what governs that + * path instead. + * + * So: gate with this, dispatch with `capabilityGovernedAuthUserId`. Exported + * because both the gate and the callers that hand a subject to a batch write + * need question 1 answered the same way — a route must not gate one subject and + * check another. */ export function capabilityGovernedUserId(principal: TableAccessPrincipal): string | null { return principal.kind === 'user' ? principal.userId : null @@ -296,10 +318,14 @@ export function capabilityGovernedUserId(principal: TableAccessPrincipal): strin * for why `/api/v1/tables/**`, which shares this helper under an API key, must * reach the table ungated on a workspace key. * - * Nothing here exempts the executor. A workflow run reaches tables through - * `tableOperations`, where the delegated-principal branch already withholds - * capabilities from an executor subject; these HTTP routes are UI surfaces, and - * the internal-JWT branch of `checkSessionOrInternalAuth` acts as the person. + * Nothing here exempts the executor, and that is question 1 of the two in + * {@link capabilityGovernedUserId}: an internal JWT presents the run's actor, so + * this gate runs against the actor's group and can only refuse more. A workflow + * run that reaches tables through `tableOperations` instead is governed by that + * funnel's delegated-principal branch, which withholds capabilities from an + * executor subject outright. Neither answer is the one question 2 takes — + * a route dispatching cells off this request derives its subject from the auth + * type, not from the principal gated here. */ export async function checkAccess( tableId: string, From 8e0400fbb2b0d81b2d6de69a0080e63c9fca3a31 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:31:38 -0700 Subject: [PATCH 147/179] feat(access-control): mark organization-scoped keys and render them inert off the default group --- .../components/group-detail.tsx | 90 +++++++++++++------ .../lib/permission-groups/features.test.ts | 78 ++++++++++++++++ apps/sim/lib/permission-groups/features.ts | 28 ++++++ apps/sim/lib/permission-groups/fields.ts | 59 ++++++++++++ 4 files changed, 226 insertions(+), 29 deletions(-) diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index c803d242f96..4f66aa493f6 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -31,7 +31,12 @@ import { useQueryState } from 'nuqs' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { ShareAuthType } from '@/lib/api/contracts/public-shares' import { isAccessControlAllowlistRow } from '@/lib/permission-groups/block-access' -import { PLATFORM_CATEGORY_ORDER, PLATFORM_FEATURES } from '@/lib/permission-groups/features' +import { + isFeatureInertForGroup, + ORGANIZATION_SCOPED_FEATURE_NOTE, + PLATFORM_CATEGORY_ORDER, + PLATFORM_FEATURES, +} from '@/lib/permission-groups/features' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { @@ -1439,7 +1444,20 @@ export function GroupDetail({ const filteredProvidersAllAllowed = filteredProviders.every((id) => isProviderAllowed(id)) const coreBlocksAllAllowed = filteredCoreBlocks.every((b) => isIntegrationAllowed(b.type)) const toolBlocksAllAllowed = filteredToolBlocks.every((b) => isIntegrationAllowed(b.type)) - const platformAllAllowed = filteredPlatformFeatures.every((f) => !editingConfig[f.configKey]) + /** + * Rows this group cannot decide: an organization-scoped key is read from the + * organization's *default* group, so setting it on any other group changes + * nothing. They render inert, and every bulk action skips them — "Select All" + * writing a value the server would never read is the same false promise as + * the checkbox itself. + */ + const editablePlatformFeatures = useMemo( + () => + filteredPlatformFeatures.filter((f) => !isFeatureInertForGroup(f, viewingGroup.isDefault)), + [filteredPlatformFeatures, viewingGroup.isDefault] + ) + + const platformAllAllowed = editablePlatformFeatures.every((f) => !editingConfig[f.configKey]) return ( <> @@ -1777,11 +1795,11 @@ export function GroupDetail({ setEditingConfig((prev) => ({ ...prev, ...Object.fromEntries( - filteredPlatformFeatures.map((f) => [f.configKey, platformAllAllowed]) + editablePlatformFeatures.map((f) => [f.configKey, platformAllAllowed]) ), })) } - disabled={filteredPlatformFeatures.length === 0} + disabled={editablePlatformFeatures.length === 0} > {platformAllAllowed ? 'Deselect All' : 'Select All'} @@ -1794,32 +1812,46 @@ export function GroupDetail({ {platformCategorySections.map(({ category, features }) => (

- {features.map((feature) => ( -
-
- - - {feature.hint} - + {features.map((feature) => { + const inert = isFeatureInertForGroup(feature, viewingGroup.isDefault) + return ( +
+
+ + + {inert + ? `${feature.hint} ${ORGANIZATION_SCOPED_FEATURE_NOTE}` + : feature.hint} + +
+ {featureExtras[feature.id]}
- {featureExtras[feature.id]} -
- ))} + ) + })}
))} diff --git a/apps/sim/lib/permission-groups/features.test.ts b/apps/sim/lib/permission-groups/features.test.ts index 906ff6c963b..45211533c16 100644 --- a/apps/sim/lib/permission-groups/features.test.ts +++ b/apps/sim/lib/permission-groups/features.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { getActivePermissionGroupRestrictions, + isFeatureInertForGroup, PLATFORM_FEATURES, } from '@/lib/permission-groups/features' import { @@ -95,3 +96,80 @@ describe('getActivePermissionGroupRestrictions', () => { } ) }) + +/** + * The editor renders every boolean key on every group, so a key whose + * capability is read from the organization's *default* group is a checkbox that + * does nothing on any other group. `scope` is what lets the editor say so, and + * this pins the membership of each class: a new key that reads the default + * group has to be added here deliberately, rather than shipping as a silently + * inert checkbox. + * + * `workspace-or-organization` is the honest third answer. `api_keys.manage`, + * `cli.use`, `integrations.manage`, `invitations.send` and + * `personal_api_key.use` each have a workspace-scoped path that reads the group + * being edited *and* an account-level path that falls back to the default + * group, so they are neither inert nor purely local — marking them + * `organization` would tell an admin their workspace restriction does not apply + * when it does. + */ +describe('platform feature scope', () => { + function keysWithScope(scope: string): string[] { + return PLATFORM_FEATURES.filter((feature) => feature.scope === scope) + .map((feature) => feature.configKey) + .sort() + } + + it('reads exactly two keys from the organization default group alone', () => { + expect(keysWithScope('organization')).toEqual([ + 'disableWorkspaceCreation', + 'hideOrgMemberDirectory', + ]) + }) + + it('reads exactly five keys from both a workspace group and the default group', () => { + expect(keysWithScope('workspace-or-organization')).toEqual([ + 'disableCliAccess', + 'disableInvitations', + 'disablePersonalApiKeys', + 'hideApiKeysTab', + 'hideIntegrationsTab', + ]) + }) + + it('gives every feature a scope', () => { + for (const feature of PLATFORM_FEATURES) { + expect( + ['workspace', 'organization', 'workspace-or-organization'], + `${feature.configKey} declares no known scope` + ).toContain(feature.scope) + } + }) +}) + +describe('isFeatureInertForGroup', () => { + function feature(configKey: string) { + const found = PLATFORM_FEATURES.find((f) => f.configKey === configKey) + if (!found) throw new Error(`No platform feature for ${configKey}`) + return found + } + + it('makes an organization-scoped key inert on a non-default group', () => { + expect(isFeatureInertForGroup(feature('hideOrgMemberDirectory'), false)).toBe(true) + expect(isFeatureInertForGroup(feature('disableWorkspaceCreation'), false)).toBe(true) + }) + + it('leaves an organization-scoped key editable on the default group', () => { + expect(isFeatureInertForGroup(feature('hideOrgMemberDirectory'), true)).toBe(false) + }) + + /** + * The dual-scope keys have a workspace path that reads the group being + * edited, so making them inert would withhold a restriction that does apply. + */ + it('never makes a workspace or dual-scope key inert', () => { + expect(isFeatureInertForGroup(feature('hideApiKeysTab'), false)).toBe(false) + expect(isFeatureInertForGroup(feature('disableCliAccess'), false)).toBe(false) + expect(isFeatureInertForGroup(feature('hideTraceSpans'), false)).toBe(false) + }) +}) diff --git a/apps/sim/lib/permission-groups/features.ts b/apps/sim/lib/permission-groups/features.ts index 5dea5b68895..642921fa3ca 100644 --- a/apps/sim/lib/permission-groups/features.ts +++ b/apps/sim/lib/permission-groups/features.ts @@ -1,5 +1,6 @@ import { PERMISSION_GROUP_FIELDS, + type PermissionGroupCapabilityScope, type PermissionGroupConfig, type PermissionGroupConfigKey, } from '@/lib/permission-groups/fields' @@ -14,6 +15,33 @@ export interface PermissionGroupPlatformFeature { category: string configKey: BooleanPermissionGroupConfigKey hint: string + /** See {@link PermissionGroupCapabilityScope}. */ + scope: PermissionGroupCapabilityScope +} + +/** + * The note a group editor shows beside an organization-scoped row. + * + * One sentence, in one place, so the editor and any other surface that has to + * explain the row say the same thing. + */ +export const ORGANIZATION_SCOPED_FEATURE_NOTE = + "Read from the organization's default group, so it applies organization-wide no matter which group sets it." + +/** + * Whether a group editor can decide this key at all. + * + * An `organization`-scoped key is read from the organization's default group, so + * on any other group the checkbox writes a value nothing will ever read. The + * editor renders those rows inert and every bulk action skips them, both from + * this one predicate — a second copy is how the row and the "Select All" it sits + * under would come to disagree. + */ +export function isFeatureInertForGroup( + feature: PermissionGroupPlatformFeature, + groupIsDefault: boolean +): boolean { + return feature.scope === 'organization' && !groupIsDefault } export interface ActivePermissionGroupRestriction { diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index 31f8132953a..77478cdf6b7 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -33,11 +33,38 @@ const shareAuthType = z.enum(FILE_SHARE_AUTH_TYPES) */ type PermissionGroupEnforcement = 'capability' | 'executor' | 'ui-only' +/** + * Which group a key's capability is actually read from when it is enforced. + * + * - `workspace`: resolved from the group governing the caller in the workspace + * the request names, so the group being edited is the group that applies. + * - `organization`: resolved from the organization's *default* group, because + * the act names no workspace (creating one, reading the member directory). + * Setting it on any other group changes nothing at all. + * - `workspace-or-organization`: both, on different paths — a workspace-scoped + * act reads this group, and the same capability's account-level path + * (minting a key, an organization-wide invitation) falls back to the default + * group. + * + * Declared because the editor renders every key on every group, and two of them + * are read from one group no matter which is open. That was disclosed only in a + * hint an admin has to hover, so the checkbox looked like it did something on + * the group in front of them; `scope` is what lets the editor say so in the row + * itself. Required rather than optional so the next organization-scoped key + * ships marked instead of inheriting the majority answer by omission. + */ +export type PermissionGroupCapabilityScope = + | 'workspace' + | 'organization' + | 'workspace-or-organization' + /** The admin-editor descriptor for a boolean key, rendered from the registry. */ interface PlatformFeatureMeta { readonly id: string readonly label: string readonly category: string + /** See {@link PermissionGroupCapabilityScope}. */ + readonly scope: PermissionGroupCapabilityScope /** * What the key withholds, in one or two short sentences. Read twice — as the * editor's hint and as the prose `getActivePermissionGroupRestrictions` @@ -211,90 +238,105 @@ export const PERMISSION_GROUP_FIELDS = { 'Integration tools listed in effectiveConfig.deniedTools are blocked.' ), hideTraceSpans: booleanRestriction('capability', { + scope: 'workspace', id: 'hide-trace-spans', label: 'Trace Spans', category: 'Logs', hint: 'Withhold per-block trace spans from logs and from the API.', }), hideKnowledgeBaseTab: booleanRestriction('capability', { + scope: 'workspace', id: 'hide-knowledge-base', label: 'Knowledge Base', category: 'Knowledge Base', hint: 'Revoke the Knowledge Base module. Members cannot open, search, or query any knowledge base.', }), hideTablesTab: booleanRestriction('capability', { + scope: 'workspace', id: 'hide-tables', label: 'Tables', category: 'Tables', hint: 'Revoke the Tables module. Members cannot read or write any table.', }), hideCopilot: booleanRestriction('capability', { + scope: 'workspace', id: 'hide-copilot', label: 'Chat', category: 'Modules', hint: 'Revoke Chat. Members cannot ask Sim to build or edit anything.', }), hideIntegrationsTab: booleanRestriction('capability', { + scope: 'workspace-or-organization', id: 'hide-integrations', label: 'Integrations', category: 'Credentials & Access', hint: 'Revoke integration connections. Members cannot view, add, or remove an OAuth connection.', }), hideSecretsTab: booleanRestriction('capability', { + scope: 'workspace', id: 'hide-secrets', label: 'Secrets', category: 'Credentials & Access', hint: 'Revoke secrets. Members cannot read, add, or change a workspace environment variable.', }), hideApiKeysTab: booleanRestriction('capability', { + scope: 'workspace-or-organization', id: 'hide-api-keys', label: 'API Keys', category: 'Credentials & Access', hint: 'Revoke workspace API keys. Members cannot list, create, or revoke one.', }), hideInboxTab: booleanRestriction('capability', { + scope: 'workspace', id: 'hide-inbox', label: 'Sim Mailer', category: 'Modules', hint: 'Revoke the Sim Mailer inbox. Members cannot read or send mail.', }), hideFilesTab: booleanRestriction('capability', { + scope: 'workspace', id: 'hide-files', label: 'Files', category: 'Files', hint: 'Revoke the Files module. Members cannot list, upload, or download workspace files.', }), disableMcpTools: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-mcp', label: 'MCP Tools', category: 'Tools', hint: 'Block agents from calling MCP tools.', }), disableCustomTools: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-custom-tools', label: 'Custom Tools', category: 'Tools', hint: 'Block agents from calling user-defined custom tools.', }), disableSkills: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-skills', label: 'Skills', category: 'Tools', hint: 'Block agents from loading skills.', }), disableInvitations: booleanRestriction('capability', { + scope: 'workspace-or-organization', id: 'disable-invitations', label: 'Invitations', category: 'Collaboration', hint: 'Prevent inviting anyone to a workspace or to the organization.', }), disablePublicApi: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-public-api', label: 'Public API', category: 'Deployment', hint: 'Revoke public API access. Calls to a deployed workflow are refused.', }), disablePublicFileSharing: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-public-file-sharing', label: 'Public Sharing', category: 'Files', @@ -306,18 +348,21 @@ export const PERMISSION_GROUP_FIELDS = { empty: 'No public file-share authentication modes are allowed.', }), hideDeployApi: booleanRestriction('capability', { + scope: 'workspace', id: 'hide-deploy-api', label: 'API Deployment', category: 'Deployment', hint: 'Prevent deploying a workflow as an API endpoint.', }), hideDeployMcp: booleanRestriction('capability', { + scope: 'workspace', id: 'hide-deploy-mcp', label: 'MCP Server', category: 'Deployment', hint: 'Prevent exposing a workflow as an MCP server.', }), hideDeployChatbot: booleanRestriction('capability', { + scope: 'workspace', id: 'hide-deploy-chatbot', label: 'Chat Deployment', category: 'Deployment', @@ -334,30 +379,35 @@ export const PERMISSION_GROUP_FIELDS = { * change in every open group editor. */ disablePersonalApiKeys: booleanRestriction('capability', { + scope: 'workspace-or-organization', id: 'disable-personal-api-keys', label: 'Personal API Keys', category: 'Credentials & Access', hint: 'Prevent members from using a personal API key against this workspace.', }), disableLogExport: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-log-export', label: 'Log Export', category: 'Logs', hint: 'Prevent downloading execution logs as a CSV.', }), hideCostInfo: booleanRestriction('capability', { + scope: 'workspace', id: 'hide-cost-info', label: 'Execution Cost', category: 'Logs', hint: 'Withhold execution cost. Logs and exports omit cost and token spend.', }), disableKnowledgeBaseCreation: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-knowledge-base-creation', label: 'Knowledge Base Creation', category: 'Knowledge Base', hint: 'Prevent creating knowledge bases, leaving existing ones queryable.', }), disableKnowledgeBaseFileUpload: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-knowledge-base-upload', label: 'Knowledge Base Uploads', category: 'Knowledge Base', @@ -368,54 +418,63 @@ export const PERMISSION_GROUP_FIELDS = { empty: 'No knowledge base connectors are allowed.', }), disableTableCreation: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-table-creation', label: 'Table Creation', category: 'Tables', hint: 'Prevent creating tables, leaving existing ones usable.', }), disableTableExport: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-table-export', label: 'Table Export', category: 'Tables', hint: 'Prevent downloading a whole table as CSV or JSON.', }), disableBulkFileDownload: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-bulk-file-download', label: 'Bulk Download', category: 'Files', hint: 'Prevent downloading folders as an archive.', }), disablePersonalCredentials: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-personal-credentials', label: 'Personal Credentials', category: 'Credentials & Access', hint: 'Prevent connecting personal credentials, leaving only workspace-shared ones.', }), disableWorkspaceCreation: booleanRestriction('capability', { + scope: 'organization', id: 'disable-workspace-creation', label: 'Workspace Creation', category: 'Collaboration', hint: "Prevent creating new workspaces, which no existing group would govern. Read from the organization's default group, because creating a workspace names none.", }), hideOrgMemberDirectory: booleanRestriction('capability', { + scope: 'organization', id: 'hide-org-member-directory', label: 'Member Directory', category: 'Collaboration', hint: "Withhold the member directory. Members cannot see the names or email addresses of other members. Read from the organization's default group, because the directory belongs to the organization and names no workspace.", }), disableCliAccess: booleanRestriction('capability', { + scope: 'workspace-or-organization', id: 'disable-cli-access', label: 'CLI Access', category: 'Credentials & Access', hint: "Prevent approving a CLI login, which mints a key for the public API. A login naming one of this group's workspaces is refused; an account-level login names none, so it is read from the organization's default group.", }), disableWebhookTriggers: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-webhook-triggers', label: 'Webhook Triggers', category: 'Deployment', hint: 'Prevent making a workflow reachable from an inbound webhook.', }), disableToolAutoApproval: booleanRestriction('capability', { + scope: 'workspace', id: 'disable-tool-auto-approval', label: 'Tool Auto-Approval', category: 'Tools', From 559af5a1371303c2c9d61d0c70456f5b144a0ddf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:32:23 -0700 Subject: [PATCH 148/179] fix(table): carry the governed subject across a cell pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workflow group that pauses mid-run resumes through the resume worker, whose payload carries the resumer and the payer but no gate: the row marker it was stamped with is long claimed, and no dispatch is in scope. The post-resume cascade therefore ran with a null subject, so a tool the requester's permission group denies executed once a wait block had been crossed. The pause snapshot is `paused_executions.metadata`, a jsonb document, so the subject rides it with no schema change; a stash written before the field existed reads back as the ungated run it was. `CellResumeContext` and `WorkflowGroupCellPayload` both take it as required, matching every sibling in `@/lib/table/types` — an omitted key and a deliberate `null` mean the same thing to the gate, so the compiler is what makes a call site say which one it means. --- apps/sim/background/resume-execution.ts | 18 +- .../resume-governed-subject.test.ts | 186 ++++++++++++++++++ .../background/workflow-column-execution.ts | 8 +- apps/sim/lib/table/dispatcher.ts | 4 +- .../resume-context-governed-subject.test.ts | 78 ++++++++ apps/sim/lib/table/workflow-columns.ts | 47 ++++- 6 files changed, 323 insertions(+), 18 deletions(-) create mode 100644 apps/sim/background/resume-governed-subject.test.ts create mode 100644 apps/sim/lib/table/resume-context-governed-subject.test.ts diff --git a/apps/sim/background/resume-execution.ts b/apps/sim/background/resume-execution.ts index d25cb8bb786..4fa33ffd4b5 100644 --- a/apps/sim/background/resume-execution.ts +++ b/apps/sim/background/resume-execution.ts @@ -17,6 +17,7 @@ import { withCascadeLock } from '@/lib/table/cascade-lock' import { isExecCancelled } from '@/lib/table/deps' import type { RowExecutionMetadata } from '@/lib/table/types' import { classifyWorkflowCellTerminalResult } from '@/lib/table/workflow-cell-result' +import type { CellResumeContext } from '@/lib/table/workflow-columns' import { createResumeAttemptTimeoutController, PauseResumeManager, @@ -390,12 +391,10 @@ async function runResumeAndCellTerminal( } async function continueCascadeAfterResume( - cellContext: { - tableId: string - rowId: string - workspaceId: string - groupId: string - }, + cellContext: Pick< + CellResumeContext, + 'tableId' | 'rowId' | 'workspaceId' | 'groupId' | 'capabilityGovernedUserId' + >, billingAttribution: BillingAttributionSnapshot, signal?: AbortSignal ): Promise { @@ -420,6 +419,13 @@ async function continueCascadeAfterResume( workflowId: next.workflowId, executionId: generateId(), billingAttribution, + /** + * The person who asked for the run that paused still gates the groups it + * cascades into. Reconstructing this from the resume payload is not + * possible — `payload.userId` is the resumer/attribution, not the gate — + * so it rides the pause snapshot instead. + */ + capabilityGovernedUserId: cellContext.capabilityGovernedUserId, }, signal ) diff --git a/apps/sim/background/resume-governed-subject.test.ts b/apps/sim/background/resume-governed-subject.test.ts new file mode 100644 index 00000000000..1817f61cfd4 --- /dev/null +++ b/apps/sim/background/resume-governed-subject.test.ts @@ -0,0 +1,186 @@ +/** + * @vitest-environment node + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + task: vi.fn((config) => config), + getPausedExecutionById: vi.fn(), + startResumeExecution: vi.fn(), + snapshotFromJson: vi.fn(), + createResumeAttemptTimeoutController: vi.fn(), + findCellContextByExecutionId: vi.fn(), + pickNextEligibleGroupForRow: vi.fn(), + withCascadeLock: vi.fn(), + getTableById: vi.fn(), + getRowById: vi.fn(), + writeWorkflowGroupState: vi.fn(), + createWorkflowCellProgressWriter: vi.fn(), + runRowCascadeLoop: vi.fn(), +})) + +vi.mock('@trigger.dev/sdk', () => ({ task: mocks.task, timeout: { None: 'none' } })) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + assertBillingAttributionSnapshot: (value: unknown) => value, + billingAttributionsEqual: () => true, +})) +vi.mock('@/lib/table/cascade-lock', () => ({ withCascadeLock: mocks.withCascadeLock })) +vi.mock('@/lib/table/deps', () => ({ isExecCancelled: () => false })) +vi.mock('@/lib/table/workflow-columns', () => ({ + findCellContextByExecutionId: mocks.findCellContextByExecutionId, + pickNextEligibleGroupForRow: mocks.pickNextEligibleGroupForRow, +})) +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/rows/service', () => ({ getRowById: mocks.getRowById })) +vi.mock('@/lib/table/cell-write', () => ({ + buildCancelledExecution: vi.fn(), + createWorkflowCellProgressWriter: mocks.createWorkflowCellProgressWriter, + writeWorkflowGroupState: mocks.writeWorkflowGroupState, +})) +vi.mock('@/lib/table/workflow-cell-result', () => ({ + classifyWorkflowCellTerminalResult: () => ({ status: 'completed', error: null }), +})) +vi.mock('@/background/workflow-column-execution', () => ({ + runRowCascadeLoop: mocks.runRowCascadeLoop, +})) +vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({ + createResumeAttemptTimeoutController: mocks.createResumeAttemptTimeoutController, + PauseResumeManager: { + getPausedExecutionById: mocks.getPausedExecutionById, + startResumeExecution: mocks.startResumeExecution, + }, +})) +vi.mock('@/executor/execution/snapshot', () => ({ + ExecutionSnapshot: { fromJSON: mocks.snapshotFromJson }, +})) + +import { executeResumeJob, type ResumeExecutionPayload } from '@/background/resume-execution' + +const PAYLOAD: ResumeExecutionPayload = { + resumeEntryId: 'resume-entry-1', + resumeExecutionId: 'resume-execution-1', + pausedExecutionId: 'paused-execution-1', + contextId: 'context-1', + resumeInput: {}, + /** The resumer / attribution — deliberately NOT the gate's subject. */ + userId: 'workspace-billing-owner', + workflowId: 'workflow-1', + parentExecutionId: 'parent-execution-1', +} + +const GROUP = { + id: 'group-1', + type: 'workflow', + workflowId: 'workflow-1', + outputs: [], + inputMappings: [], +} +const NEXT_GROUP = { ...GROUP, id: 'group-2' } +const TABLE = { + id: 'table-1', + name: 'Table', + workspaceId: 'workspace-1', + schema: { columns: [], workflowGroups: [GROUP, NEXT_GROUP] }, +} + +describe('resuming a paused table cell', () => { + /** The resume worker resolves its collaborators with dynamic imports. */ + beforeAll(async () => { + await Promise.all([ + import('@/lib/table/cell-write'), + import('@/lib/table/rows/service'), + import('@/lib/table/service'), + import('@/lib/table/workflow-columns'), + import('@/background/workflow-column-execution'), + ]) + }, 60_000) + + beforeEach(() => { + vi.clearAllMocks() + mocks.getPausedExecutionById.mockResolvedValue({ executionSnapshot: { snapshot: {} } }) + mocks.createResumeAttemptTimeoutController.mockReturnValue({ + signal: new AbortController().signal, + cleanup: vi.fn(), + abort: vi.fn(), + isTimedOut: () => false, + timeoutMs: 5_000, + }) + mocks.snapshotFromJson.mockReturnValue({ + metadata: { + billingAttribution: { + actorUserId: 'workspace-billing-owner', + workspaceId: 'workspace-1', + }, + }, + }) + mocks.findCellContextByExecutionId.mockResolvedValue({ + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + capabilityGovernedUserId: 'requesting-member', + }) + mocks.getTableById.mockResolvedValue(TABLE) + mocks.getRowById.mockResolvedValue({ id: 'row-1', data: {}, executions: {} }) + mocks.pickNextEligibleGroupForRow.mockReturnValue(NEXT_GROUP) + mocks.writeWorkflowGroupState.mockResolvedValue('wrote') + mocks.createWorkflowCellProgressWriter.mockReturnValue({ + onBlockComplete: vi.fn(), + finish: vi.fn(), + getEventOutputs: () => ({}), + getPendingDataPatch: () => ({}), + getBlockErrors: () => ({}), + getPendingSecretProvenance: () => undefined, + }) + mocks.startResumeExecution.mockResolvedValue({ + success: true, + status: 'completed', + output: {}, + }) + mocks.withCascadeLock.mockImplementation( + async ( + _tableId: string, + _rowId: string, + _executionId: string, + fn: () => Promise + ) => ({ + status: 'ran', + result: await fn(), + }) + ) + }) + + /** + * The scenario the gate exists for: the cell was stamped with the person + * whose group denies a tool, then paused on a wait block. If the subject does + * not survive the pause, the cascade the resume drives runs ungated and the + * denied tool executes. + */ + it('drives the post-resume cascade under the subject stamped before the pause', async () => { + await executeResumeJob(PAYLOAD) + + expect(mocks.runRowCascadeLoop).toHaveBeenCalledTimes(1) + const [cascadePayload] = mocks.runRowCascadeLoop.mock.calls[0] + expect(cascadePayload.capabilityGovernedUserId).toBe('requesting-member') + expect(cascadePayload.capabilityGovernedUserId).not.toBe(PAYLOAD.userId) + }, 20_000) + + it('resumes ungated when the paused cell had no acting person', async () => { + mocks.findCellContextByExecutionId.mockResolvedValue({ + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + capabilityGovernedUserId: null, + }) + + await executeResumeJob(PAYLOAD) + + const [cascadePayload] = mocks.runRowCascadeLoop.mock.calls[0] + expect(cascadePayload.capabilityGovernedUserId).toBeNull() + }, 20_000) +}) diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index 0452203619f..f59f7b1806e 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -377,7 +377,7 @@ export async function runRowCascadeLoop( // Fresh executionId per iteration: SQL guard rejects writes whose id ≠ // row.executions[gid].executionId, so we need a new claim per group. let currentExecutionId = payload.executionId - let currentCapabilityGovernedUserId = payload.capabilityGovernedUserId ?? null + let currentCapabilityGovernedUserId = payload.capabilityGovernedUserId while (true) { if (signal?.aborted) { @@ -1151,6 +1151,12 @@ async function runWorkflowAndWriteTerminal( groupId, workflowId, workspaceId, + /** + * The gate has to survive the pause. Nothing downstream of a resume + * can re-derive it: the marker this cell was stamped with is long + * claimed, and the resume worker's own payload has no dispatch. + */ + capabilityGovernedUserId: payload.capabilityGovernedUserId, }) return 'paused' } diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index f38a0f68bab..d05e905819d 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -605,11 +605,11 @@ export async function dispatcherStep( isManualRun: dispatch.isManualRun, groupIds: dispatch.scope.groupIds, mode: dispatch.mode, + capabilityGovernedUserId: dispatch.capabilityGovernedUserId, }).map((p) => ({ ...p, dispatchId, triggeredByUserId: dispatch.triggeredByUserId ?? undefined, - capabilityGovernedUserId: dispatch.capabilityGovernedUserId, })) // Cursor advances to the last position in this chunk regardless of @@ -818,7 +818,7 @@ async function stampQueuedForBatch( * than under the owner's — a different dispatch, and often an * actorless auto-fire with no gate at all. */ - capabilityGovernedUserId: runOpts.capabilityGovernedUserId ?? null, + capabilityGovernedUserId: runOpts.capabilityGovernedUserId, }, } ) diff --git a/apps/sim/lib/table/resume-context-governed-subject.test.ts b/apps/sim/lib/table/resume-context-governed-subject.test.ts new file mode 100644 index 00000000000..aa9f977502e --- /dev/null +++ b/apps/sim/lib/table/resume-context-governed-subject.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + findCellContextByExecutionId, + stashCellContextForResume, +} from '@/lib/table/workflow-columns' + +const CONTEXT = { + executionId: 'execution-1', + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + capabilityGovernedUserId: 'requesting-member', +} + +/** + * The pause snapshot is `paused_executions.metadata`, a jsonb document, so the + * subject rides it without a schema change. What this pins is that it is + * actually written and read back — the resume worker has no other source for + * it once the row's marker has been claimed. + */ +describe('the governed subject across a pause', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('writes the subject into the stashed cell context', async () => { + await stashCellContextForResume(CONTEXT) + + const [{ metadata }] = dbChainMockFns.set.mock.calls[0] + /** The jsonb literal the `||` merge appends, as bound to the SQL template. */ + const [, serializedPatch] = (metadata as { values: string[] }).values + expect(JSON.parse(serializedPatch).cellContext).toMatchObject({ + capabilityGovernedUserId: 'requesting-member', + }) + }) + + it('reads the stashed subject back', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { metadata: { cellContext: { ...CONTEXT, executionId: undefined } } }, + ]) + + const context = await findCellContextByExecutionId('execution-1') + + expect(context?.capabilityGovernedUserId).toBe('requesting-member') + }) + + /** A pause stashed before the subject was carried must read as ungated, not + * as `undefined` leaking into the payload the compiler now requires. */ + it('normalizes a legacy stash with no subject to null', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + metadata: { + cellContext: { + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + }, + }, + ]) + + const context = await findCellContextByExecutionId('execution-1') + + expect(context).not.toBeNull() + expect(context?.capabilityGovernedUserId).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 96c252bd2c3..7fd9c6a640f 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -192,6 +192,11 @@ export interface ScheduleOpts { groupIds?: string[] isManualRun?: boolean mode?: DispatchMode + /** Person whose permission group gates every cell this batch emits, or `null` + * for an actorless run. Required so a new call site cannot emit a payload + * with no gate by simply not thinking about one; see + * {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. */ + capabilityGovernedUserId: string | null } /** Pure eligibility filter + payload building. Shared by the auto-fire path @@ -199,15 +204,15 @@ export interface ScheduleOpts { export function buildPendingRuns( table: TableDefinition, rows: TableRow[], - opts?: ScheduleOpts + opts: ScheduleOpts ): WorkflowGroupCellPayload[] { const allGroups = table.schema.workflowGroups ?? [] if (allGroups.length === 0) return [] if (rows.length === 0) return [] - const groupIdFilter = opts?.groupIds + const groupIdFilter = opts.groupIds ? new Set(opts.groupIds) - : opts?.groupId + : opts.groupId ? new Set([opts.groupId]) : null const groups = groupIdFilter ? allGroups.filter((g) => groupIdFilter.has(g.id)) : allGroups @@ -221,8 +226,8 @@ export function buildPendingRuns( for (const row of orderedRows) { for (const group of groups) { const reason = classifyEligibility(group, row, { - isManualRun: opts?.isManualRun, - mode: opts?.mode, + isManualRun: opts.isManualRun, + mode: opts.mode, }) reasonCounts[reason] = (reasonCounts[reason] ?? 0) + 1 if (reason !== 'eligible' && reason !== 'manual-bypass') continue @@ -235,6 +240,7 @@ export function buildPendingRuns( ...(group.enrichmentId ? { enrichmentId: group.enrichmentId } : {}), workspaceId: table.workspaceId, executionId: generateId(), + capabilityGovernedUserId: opts.capabilityGovernedUserId, }) } } @@ -451,8 +457,11 @@ export interface WorkflowGroupCellPayload { triggeredByUserId?: string /** Person whose permission group gates this cell's tools. Null/absent means * no acting person, so no per-tool gate applies. Not `triggeredByUserId`; - * see {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. */ - capabilityGovernedUserId?: string | null + * see {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. + * Required like every sibling in `@/lib/table/types`: an omitted key and a + * deliberate `null` both read as "ungated", so the compiler is what makes a + * caller state which one it means. */ + capabilityGovernedUserId: string | null } export type QueuedWorkflowGroupCellPayload = Omit< @@ -1095,10 +1104,24 @@ export interface CellResumeContext { groupId: string workspaceId: string workflowId: string + /** + * Person whose permission group gates the tools of everything this cell's + * run still has to do. Required, because a pause is the one boundary where + * the subject would otherwise be reconstructed from scratch: the resumed + * cascade is driven by the resume worker, whose payload carries no dispatch + * and no row marker to re-read it from. `null` is the actorless run — no + * per-tool gate — and has to be written, not inferred from an absent key. + * + * Lives in `paused_executions.metadata`, a jsonb document, so carrying it + * needs no schema change: a pause row written before this field existed + * reads back `undefined`, which the resume worker normalizes to `null`. + */ + capabilityGovernedUserId: string | null } interface PausedMetadataPatch { - cellContext?: CellResumeContext + /** Read back from jsonb, so a pause written before a field existed lacks it. */ + cellContext?: Partial & Omit [key: string]: unknown } @@ -1144,7 +1167,13 @@ export async function findCellContextByExecutionId( .where(eq(pausedExecutions.executionId, executionId)) .limit(1) const meta = row?.metadata as PausedMetadataPatch | null - return meta?.cellContext ?? null + const stored = meta?.cellContext + if (!stored) return null + return { + ...stored, + /** A pause stashed before the subject was carried is an ungated resume. */ + capabilityGovernedUserId: stored.capabilityGovernedUserId ?? null, + } } catch (err) { logger.error(`Failed to read cell context for executionId=${executionId}:`, err) return null From 8a2783cd954e2b1bb593688b8da9bbf874b96fe1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:32:27 -0700 Subject: [PATCH 149/179] refactor(chat): take the v2 refusal detail code off the capability rule --- apps/sim/app/api/v2/chat/route.test.ts | 4 +++- apps/sim/app/api/v2/chat/route.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index cd1ea42cd08..af7f28a3930 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -125,6 +125,8 @@ vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScop const mockResolvePermissionGroupConfig = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig +import { chatOperations } from '@/lib/copilot/application/operations' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { POST } from '@/app/api/v2/chat/route' @@ -327,7 +329,7 @@ describe('POST /api/v2/chat', () => { error: { code: 'FORBIDDEN', message: "Chat is not available under your organization's permission group", - details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + details: { code: CAPABILITY_RULES[chatOperations.send.capability].detailCode }, }, }) expect(mockResolveOrCreateChat).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index 35da036b1df..17932605f92 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -45,6 +45,7 @@ import { import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' import { capabilityRefusal, isWorkspaceCapabilityWithheld, @@ -230,7 +231,11 @@ export const POST = withRouteHandler( /** * permission-group-enforced: copilot.use — read off the operation so this - * route and the funnel can never name different capabilities. + * route and the funnel can never name different capabilities, and the + * error's `detailCode` off the rule for the same reason: a capability + * whose rule reports something other than the generic block (the way + * `personal_api_key.use` reports `PERSONAL_API_KEYS_DISABLED`) would + * otherwise be flattened by a constant spelled out here. * * A raw special route: `admitV2Request` authenticates and rate-limits but * never authorizes, so nothing else on this path applies the capability @@ -246,7 +251,7 @@ export const POST = withRouteHandler( (await isWorkspaceCapabilityWithheld(userId, workspaceId, sendCapability)) ) { return v2Error('FORBIDDEN', capabilityRefusal(sendCapability), { - details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + details: { code: CAPABILITY_RULES[sendCapability].detailCode }, }) } From 655768b97e79150bf3371c1ee1b1ab3038a8a060 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:32:39 -0700 Subject: [PATCH 150/179] fix(workflows): enforce the block allowlist on the shared persistence primitive findWithheldBlockType ran at two of the three doors that store a whole graph. PUT /api/v2/workflows/{id}/state reached replaceWorkflowNormalizedState directly and the Copilot materialize import reached saveWorkflowToNormalizedTables directly, so an organization that excluded an integration was refused in the canvas, the internal save and the import and still had it stored through either. The check now lives on saveWorkflowToNormalizedTables, the one primitive every normalized-table write funnels through, and every caller states whose grants judge it through a required governance argument. A null subject is how an actorless write declares itself -- an executor revert, a fork copy, workspace creation, the platform-admin imports -- so a run persisting its own graph is not blocked by the triggering member's group. workflows.state.replace's exemption cited the persist-time guard as its justification while not being covered by it; it now is. --- .../api/superuser/import-workflow/route.ts | 10 +- .../api/v1/admin/workflows/import/route.ts | 21 ++- .../v1/admin/workspaces/[id]/import/route.ts | 21 ++- .../lib/copy/copy-workflows.ts | 16 +- .../ee/workspace-forking/lib/create-fork.ts | 11 +- .../lib/promote/reactivate-in-tx.ts | 15 +- .../tools/handlers/materialize-file.test.ts | 37 +++++ .../tools/handlers/materialize-file.ts | 25 ++- .../application/apply-workflow-operations.ts | 6 + .../lib/workflows/application/operations.ts | 4 +- .../replace-workflow-state.test.ts | 1 + .../application/replace-workflow-state.ts | 13 +- .../update-workflow-content.test.ts | 1 + .../application/update-workflow-content.ts | 12 ++ .../workflows/operations/import-workflow.ts | 14 +- .../workflows/orchestration/deploy.test.ts | 2 + .../sim/lib/workflows/orchestration/deploy.ts | 17 +- .../orchestration/workflow-lifecycle.ts | 14 +- .../persistence/block-access-guard.ts | 55 +++++++ .../persist-block-access-gate.test.ts | 145 ++++++++++++++++++ .../replace-normalized-state.test.ts | 2 + .../persistence/replace-normalized-state.ts | 37 ++++- .../persistence/save-normalized-state.ts | 35 ++--- .../save-workflow-normalized-state.test.ts | 39 +++-- .../lib/workflows/persistence/utils.test.ts | 40 +++-- apps/sim/lib/workflows/persistence/utils.ts | 23 +++ apps/sim/lib/workflows/utils.ts | 9 +- apps/sim/lib/workspaces/create.ts | 11 +- 28 files changed, 558 insertions(+), 78 deletions(-) create mode 100644 apps/sim/lib/workflows/persistence/persist-block-access-gate.test.ts diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts index 8b0e8ec373f..5bc5b4bf5ee 100644 --- a/apps/sim/app/api/superuser/import-workflow/route.ts +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -152,7 +152,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) // Save using existing persistence logic - const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData) + const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData, { + /** + * Actorless. The superuser debug import is a platform-operator tool for + * reproducing a customer's workflow, not a member authoring one, so no + * workspace permission group governs it. + */ + workspaceId: null, + subjectUserId: null, + }) if (!saveResult.success) { // Clean up the workflow record if save failed diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts index 732c4e4d6fe..3f4290a8b0d 100644 --- a/apps/sim/app/api/v1/admin/workflows/import/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -141,10 +141,23 @@ export const POST = withRouteHandler( logger.warn('Admin API: normalized imported workflow with warnings', { warnings }) } - const saveResult = await saveWorkflowToNormalizedTables(workflowId, { - ...workflowData, - ...preparedState, - }) + const saveResult = await saveWorkflowToNormalizedTables( + workflowId, + { + ...workflowData, + ...preparedState, + }, + { + /** + * Actorless. This is the platform-admin surface: the caller is a Sim + * operator restoring data, not a member of the target workspace, so no + * member's permission group governs the write. `check-capability-subject` + * excludes `v1/admin` for the same reason. + */ + workspaceId: null, + subjectUserId: null, + } + ) if (!saveResult.success) { await db.delete(workflow).where(eq(workflow.id, workflowId)) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index 2284b4e4a7e..33f94c17a08 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -377,10 +377,23 @@ async function importSingleWorkflow( logger.warn(`Admin API: normalized "${dedupedName}" with warnings`, { warnings }) } - const saveResult = await saveWorkflowToNormalizedTables(workflowId, { - ...workflowData, - ...preparedState, - }) + const saveResult = await saveWorkflowToNormalizedTables( + workflowId, + { + ...workflowData, + ...preparedState, + }, + { + /** + * Actorless. This is the platform-admin surface: the caller is a Sim + * operator restoring data, not a member of the target workspace, so no + * member's permission group governs the write. `check-capability-subject` + * excludes `v1/admin` for the same reason. + */ + workspaceId: null, + subjectUserId: null, + } + ) if (!saveResult.success) { await db.delete(workflow).where(eq(workflow.id, workflowId)) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index 11696d24ee5..db75eac97c4 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -706,7 +706,21 @@ export async function copyWorkflowStateIntoTarget( parallels: newParallels, variables: remappedVariables, } - const saved = await saveWorkflowToNormalizedTables(targetWorkflowId, remappedState, tx) + const saved = await saveWorkflowToNormalizedTables( + targetWorkflowId, + remappedState, + { + /** + * Actorless. A fork copies rows that already exist in the source + * workspace; the blocks are not chosen by whoever triggered the fork, and + * governing the copy by their group would leave a fork that silently + * dropped part of the source graph. + */ + workspaceId: null, + subjectUserId: null, + }, + tx + ) if (!saved.success) { throw new Error(`Failed to write forked workflow ${targetWorkflowId}: ${saved.error}`) } diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.ts b/apps/sim/ee/workspace-forking/lib/create-fork.ts index 31ccf6e12bf..99b90845748 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.ts @@ -436,7 +436,16 @@ export async function createFork(params: CreateForkParams): Promise ({ extractWorkflowMetadata: vi.fn() }) import type { ExecutionContext } from '@/lib/copilot/request/types' import { executeMaterializeFile } from '@/lib/copilot/tools/handlers/materialize-file' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -278,6 +279,42 @@ describe('executeMaterializeFile - workflow import', () => { 'PRIVATE WORKFLOW DESCRIPTION' ) }) + + /** + * Copilot is a surface adapter, not an exemption. The imported graph comes + * from a file the user uploaded, so it is exactly the caller-supplied + * whole-graph write the integration allowlist judges — and the subject is the + * person chatting. + */ + it('names the chatting user as the subject the permission group governs', async () => { + await executeMaterializeFile({ fileNames: ['workflow.json'], operation: 'import' }, context) + + expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledWith( + expect.any(String), + expect.anything(), + { workspaceId: 'ws-1', subjectUserId: 'user-1' } + ) + }) + + it('surfaces the shared write refusal and rolls the shell workflow row back', async () => { + saveWorkflowToNormalizedTablesMock.mockRejectedValue( + new OrchestrationError( + 'forbidden', + 'Block type "gmail" is not allowed by your organization\'s permission group' + ) + ) + + const result = await executeMaterializeFile( + { fileNames: ['workflow.json'], operation: 'import' }, + context + ) + + expect(result.success).toBe(false) + expect( + (result.output as { failed: { fileName: string; error: string }[] }).failed[0].error + ).toContain('gmail') + expect(dbChainMockFns.delete).toHaveBeenCalled() + }) }) describe('executeMaterializeFile - save storage transition', () => { diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 3ebfb5e2941..aefdec1dabc 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -27,6 +27,7 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' import { canonicalWorkspaceFilePath, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { getServePathPrefix } from '@/lib/uploads' import { ArchiveError, @@ -303,7 +304,29 @@ async function executeImport( variables: {}, }) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData) + let saveResult: Awaited> + try { + /** + * Copilot is a surface adapter, not an exemption. The graph here comes from + * a JSON file the user uploaded, so it names whatever block types the file + * names — exactly the whole-graph write the workspace's integration + * allowlist exists to judge — and the subject is the person chatting, never + * the workflow's billing owner. + * + * The shared write refuses a withheld type by throwing, so the shell row + * inserted above is rolled back here the same way a failed save is; + * otherwise a refusal would leave an empty workflow behind. + */ + saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData, { + workspaceId, + subjectUserId: userId, + }) + } catch (error) { + await db.delete(workflow).where(eq(workflow.id, workflowId)) + const classified = asOrchestrationError(error) + if (classified) return { success: false, error: classified.message } + throw error + } if (!saveResult.success) { await db.delete(workflow).where(eq(workflow.id, workflowId)) return { success: false, error: `Failed to save workflow state: ${saveResult.error}` } diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.ts index 2c8bff0815b..0aff805b423 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.ts @@ -421,6 +421,12 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ workflowId: context.workflowId, workspaceId: context.workspaceId, attributedUserId: subjectUserId, + /** + * The same id line 287 already resolves the permission config against. + * This operation denies workspace API keys, so the attribution and the + * governed subject are the same human and cannot diverge here. + */ + subjectUserId, state: { blocks: graph.blocks, edges: graph.edges }, }) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index eac00638ea5..31a985fa57a 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -104,7 +104,7 @@ export const workflowOperations = { * Personal keys keep the capability, so headless authoring is unaffected for a * credential that names a human. * - * permission-group-exempt: which blocks a member may store is judged against allowedIntegrations at persist time, not by a capability the authorization funnel can apply + * permission-group-exempt: which blocks a member may store is judged against allowedIntegrations inside replaceWorkflowNormalizedState, which this use case passes the principal's human subject to, not by a capability the authorization funnel can apply */ replaceState: defineWorkspaceOperation({ id: 'workflows.state.replace', @@ -130,7 +130,7 @@ export const workflowOperations = { * credential that names a human. Re-open this to workspace keys only once the * three lookups can express a workspace-scoped policy that fails closed. * - * permission-group-exempt: which blocks a member may store is judged against allowedIntegrations at persist time, not by a capability the authorization funnel can apply + * permission-group-exempt: which blocks a member may store is judged against allowedIntegrations inside replaceWorkflowNormalizedState, which this use case passes the principal's human subject to, not by a capability the authorization funnel can apply */ applyOperations: defineWorkspaceOperation({ id: 'workflows.operations.apply', diff --git a/apps/sim/lib/workflows/application/replace-workflow-state.test.ts b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts index 8e3354813f6..fc8a9171360 100644 --- a/apps/sim/lib/workflows/application/replace-workflow-state.test.ts +++ b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts @@ -137,6 +137,7 @@ describe('replaceWorkflowState', () => { }) expect(mocks.replace).toHaveBeenCalledWith({ + subjectUserId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1', attributedUserId: 'user-1', diff --git a/apps/sim/lib/workflows/application/replace-workflow-state.ts b/apps/sim/lib/workflows/application/replace-workflow-state.ts index 627fce476a4..5cc97b8d2da 100644 --- a/apps/sim/lib/workflows/application/replace-workflow-state.ts +++ b/apps/sim/lib/workflows/application/replace-workflow-state.ts @@ -135,10 +135,12 @@ export const replaceWorkflowState = defineAuthorizedWorkflowUseCase({ * the reference pass is skipped for them rather than resolved against the * billing owner. See {@link buildWorkflowLintReport}. */ + const subjectUserId = humanSubjectUserId(principal) + const lint = await buildWorkflowLintReport(graph, { workflowId: context.workflowId, workspaceId: context.workspaceId, - subjectUserId: humanSubjectUserId(principal), + subjectUserId, }) if (input.dryRun) { @@ -180,6 +182,15 @@ export const replaceWorkflowState = defineAuthorizedWorkflowUseCase({ workflowId: context.workflowId, workspaceId: context.workspaceId, attributedUserId: attribution.attributedUserId, + /** + * The same human the lint pass resolved above, and never + * `attribution.attributedUserId`: that answers a workspace API key with + * the billing owner, so reusing it would judge a caller-supplied graph + * against a bystander's grants. This operation admits only principals + * that name a human, so the `null` branch is a fail-safe rather than a + * reachable state. + */ + subjectUserId, state: { blocks: graph.blocks, edges: graph.edges, diff --git a/apps/sim/lib/workflows/application/update-workflow-content.test.ts b/apps/sim/lib/workflows/application/update-workflow-content.test.ts index 86383f35fc8..9b93febdf20 100644 --- a/apps/sim/lib/workflows/application/update-workflow-content.test.ts +++ b/apps/sim/lib/workflows/application/update-workflow-content.test.ts @@ -230,6 +230,7 @@ describe('setWorkflowBlockEnabled', () => { ).resolves.toMatchObject({ changed: true, affectedBlockIds: ['block-1'] }) expect(mocks.replace).toHaveBeenCalledWith({ + subjectUserId: null, workflowId: 'workflow-1', workspaceId: 'workspace-1', attributedUserId: 'user-1', diff --git a/apps/sim/lib/workflows/application/update-workflow-content.ts b/apps/sim/lib/workflows/application/update-workflow-content.ts index 0ae8ef511a1..a50cda3848b 100644 --- a/apps/sim/lib/workflows/application/update-workflow-content.ts +++ b/apps/sim/lib/workflows/application/update-workflow-content.ts @@ -238,6 +238,18 @@ export const setWorkflowBlockEnabled = defineAuthorizedWorkflowUseCase({ workflowId: context.workflowId, workspaceId: context.workspaceId, attributedUserId: attribution.attributedUserId, + /** + * Actorless on purpose. This operation writes back the graph it just read + * under the row lock with one block's `enabled` flipped — the caller + * supplies no blocks, so there is no caller-chosen block type for an + * allowlist to judge. Governing it would only mean refusing a member the + * ability to *disable* a block their group withholds. + * + * `attribution.attributedUserId` is deliberately not reused: it answers a + * workspace API key with the workspace's billing owner, which is right for + * custom-tool ownership and wrong for anything reading a person's grants. + */ + subjectUserId: null, state: async (tx) => { const locked = await loadWorkflowFromNormalizedTables(context.workflowId, tx) if (!locked) { diff --git a/apps/sim/lib/workflows/operations/import-workflow.ts b/apps/sim/lib/workflows/operations/import-workflow.ts index a169b5c4b67..a6c42d03dbf 100644 --- a/apps/sim/lib/workflows/operations/import-workflow.ts +++ b/apps/sim/lib/workflows/operations/import-workflow.ts @@ -336,7 +336,19 @@ async function executeImportWorkflowIntoWorkspace( */ try { await db.transaction(async (tx) => { - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + const saveResult = await saveWorkflowToNormalizedTables( + workflowId, + workflowState, + /** + * The same subject the pre-check above used. The pre-check stays because + * it renders this door's own 403 before the shell workflow row is + * created — a refusal after that point would have to roll the row back — + * and the two agree by construction: both read `capabilityUserId`, and + * an import with no governed user passes `null` to both. + */ + { workspaceId, subjectUserId: capabilityUserId ?? null }, + tx + ) if (!saveResult.success) { throw new Error(saveResult.error || 'Failed to save workflow state') } diff --git a/apps/sim/lib/workflows/orchestration/deploy.test.ts b/apps/sim/lib/workflows/orchestration/deploy.test.ts index 08e4f63fce8..7a43664cdfa 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.test.ts @@ -163,6 +163,8 @@ describe('performRevertToVersion', () => { }, }, }), + /** A revert restores a graph the workspace already deployed, so it writes as nobody. */ + { workspaceId: null, subjectUserId: null }, dbChainMock.db ) expect(dbChainMockFns.set).toHaveBeenCalledWith( diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 9290143972e..097a296a83e 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -966,7 +966,22 @@ export async function performRevertToVersion( restoredState.variables = deployedState.variables || {} } - const result = await saveWorkflowToNormalizedTables(workflowId, restoredState, tx) + const result = await saveWorkflowToNormalizedTables( + workflowId, + restoredState, + { + /** + * Actorless, and deliberately so. This is the executor-adjacent path: + * it writes back a graph the workspace already deployed. A run + * persisting its own state must not be refused because the member who + * triggered it is in a group that withholds a block the deployment + * uses — the deployment was authorized when it was created. + */ + workspaceId: null, + subjectUserId: null, + }, + tx + ) if (!result.success) return result await tx diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 7c5414f63ae..d2098cbd418 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -239,7 +239,19 @@ export async function performCreateWorkflowTransition( variables: {}, }) - await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + await saveWorkflowToNormalizedTables( + workflowId, + workflowState, + { + /** + * Actorless: the starter graph a new workflow is seeded with is the + * platform's, not a member's choice of blocks. + */ + workspaceId: null, + subjectUserId: null, + }, + tx + ) }) break } catch (error) { diff --git a/apps/sim/lib/workflows/persistence/block-access-guard.ts b/apps/sim/lib/workflows/persistence/block-access-guard.ts index 1da04b15972..e7d624f012d 100644 --- a/apps/sim/lib/workflows/persistence/block-access-guard.ts +++ b/apps/sim/lib/workflows/persistence/block-access-guard.ts @@ -1,3 +1,4 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { @@ -67,3 +68,57 @@ export async function findWithheldBlockType(params: { export function withheldBlockTypeMessage(blockType: string): string { return `Block type "${blockType}" is not allowed by your organization's permission group` } + +/** + * Who a normalized-state write is performed *as*, for permission-group purposes. + * + * Both fields are required at every call site, and `null` is spelled out rather + * than omitted, for the reason `capability` is required on + * `defineWorkspaceOperation`: an absent declaration cannot be told apart from an + * unreviewed one. The guard used to sit at individual doors, and the two that + * never grew one — `PUT /api/v2/workflows/{id}/state` and the Copilot + * materialize-import — were exactly the doors nobody remembered to add it to. + * + * `subjectUserId` is `null` only when the write is not a member's authoring + * action: an executor run persisting its own graph, a workspace fork copying + * rows, or workspace creation seeding a starter workflow. A member's group must + * not govern those, because the writer is the platform rather than the member. + */ +export interface WorkflowPersistGovernance { + /** Canonical workspace whose permission groups govern the write, or `null` when it has none. */ + workspaceId: string | null + /** The human this write is performed as, or `null` when it is performed as no human. */ + subjectUserId: string | null +} + +/** + * Refuses a normalized-state write carrying a block type the writer's permission + * group withholds. + * + * Lives on the shared persistence primitive rather than at each door so a new + * caller inherits the check instead of having to remember it. A `null` subject + * or workspace no-ops: there is no group to resolve, and inventing one would + * either fail open against a bystander's grants or block the executor. + * + * Throws {@link OrchestrationError} rather than returning a union, matching the + * rest of the persistence layer: `statusForOrchestrationError` renders + * `forbidden` as the 403 the pre-consolidation doors returned, and + * `messageForOrchestrationError` passes this message through unchanged, so the + * refusal a caller sees is byte-identical to the one it rendered itself. + */ +export async function assertNoWithheldBlockType( + governance: WorkflowPersistGovernance, + blocks: Iterable<{ type?: string }> +): Promise { + const { workspaceId, subjectUserId } = governance + if (!workspaceId || !subjectUserId) return + + const withheldBlockType = await findWithheldBlockType({ + userId: subjectUserId, + workspaceId, + blocks, + }) + if (withheldBlockType) { + throw new OrchestrationError('forbidden', withheldBlockTypeMessage(withheldBlockType)) + } +} diff --git a/apps/sim/lib/workflows/persistence/persist-block-access-gate.test.ts b/apps/sim/lib/workflows/persistence/persist-block-access-gate.test.ts new file mode 100644 index 00000000000..2645bb64bdd --- /dev/null +++ b/apps/sim/lib/workflows/persistence/persist-block-access-gate.test.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment node + * + * The gate lives on the shared write rather than at each door, so this is where + * it is proved: `saveWorkflowToNormalizedTables` is the one primitive every + * normalized-table write funnels through, and the assertions below are about + * the primitive, not about any caller that happens to reach it. + */ +import { + dbChainMock, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetDbChainMock, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + saveRaw: vi.fn(), + lock: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@sim/workflow-persistence/save', () => ({ + saveWorkflowToNormalizedTables: mocks.saveRaw, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +function stateWith(type: string): WorkflowState { + return { + blocks: { + 'block-1': { + id: 'block-1', + type, + name: 'Block', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } as unknown as WorkflowState +} + +const GOVERNED = { workspaceId: 'workspace-1', subjectUserId: 'user-1' } + +describe('saveWorkflowToNormalizedTables permission-group gate', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + resetPermissionGroupScopeMock() + mocks.saveRaw.mockResolvedValue({ success: true }) + }) + + it('refuses a block type the governed subject’s allowlist withholds, before any write', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + allowedIntegrations: ['slack'], + }) + + await expect( + saveWorkflowToNormalizedTables('workflow-1', stateWith('gmail'), GOVERNED) + ).rejects.toMatchObject({ + name: 'OrchestrationError', + code: 'forbidden', + message: expect.stringContaining('gmail'), + }) + expect(mocks.saveRaw).not.toHaveBeenCalled() + }) + + it('writes a block type the allowlist names', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + allowedIntegrations: ['slack'], + }) + + await expect( + saveWorkflowToNormalizedTables('workflow-1', stateWith('slack'), GOVERNED, dbChainMock.db) + ).resolves.toMatchObject({ success: true }) + expect(mocks.saveRaw).toHaveBeenCalled() + }) + + /** + * The executor exemption. A run — or a revert, or a fork copy — persists a + * graph the workspace already holds, and blocking it on the triggering + * member's group would fail a run for a block the deployment was authorized + * with. Every such caller states that by passing a `null` subject. + */ + it('writes for an actorless caller even when the workspace withholds the block type', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + allowedIntegrations: ['slack'], + }) + + await expect( + saveWorkflowToNormalizedTables( + 'workflow-1', + stateWith('gmail'), + { workspaceId: 'workspace-1', subjectUserId: null }, + dbChainMock.db + ) + ).resolves.toMatchObject({ success: true }) + expect(mocks.saveRaw).toHaveBeenCalled() + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('writes when no workspace, and therefore no permission group, scopes the workflow', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + allowedIntegrations: ['slack'], + }) + + await expect( + saveWorkflowToNormalizedTables( + 'workflow-1', + stateWith('gmail'), + { workspaceId: null, subjectUserId: 'user-1' }, + dbChainMock.db + ) + ).resolves.toMatchObject({ success: true }) + expect(mocks.saveRaw).toHaveBeenCalled() + }) + + /** + * The refusal must not be folded into the `{ success: false }` union: every + * caller renders that as a 500, and this one is a 403. + */ + it('throws the refusal rather than returning it, on the external-transaction path too', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + allowedIntegrations: ['slack'], + }) + + const thrown = await saveWorkflowToNormalizedTables( + 'workflow-1', + stateWith('gmail'), + GOVERNED, + dbChainMock.db + ).catch((error: unknown) => error) + + expect(thrown).toBeInstanceOf(OrchestrationError) + expect(mocks.saveRaw).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts index 516e73fb092..17c4fb5acc3 100644 --- a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts @@ -49,6 +49,7 @@ function input(overrides: Record = {}) { workflowId: 'workflow-1', workspaceId: 'workspace-1', attributedUserId: 'user-1', + subjectUserId: 'user-1', state: { blocks: { 'block-1': BLOCK }, edges: [] }, ...overrides, } as Parameters[0] @@ -107,6 +108,7 @@ describe('replaceWorkflowNormalizedState', () => { expect(mocks.save).toHaveBeenCalledWith( 'workflow-1', expect.objectContaining({ blocks: PREPARED.blocks, edges: PREPARED.edges }), + { workspaceId: 'workspace-1', subjectUserId: 'user-1' }, expect.anything() ) expect(mocks.prepare).toHaveBeenCalledBefore(mocks.save) diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts index 9355a0bc45e..131645d8499 100644 --- a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts @@ -5,6 +5,7 @@ import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/erro import { and, eq, inArray, isNull, ne } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' +import { assertNoWithheldBlockType } from '@/lib/workflows/persistence/block-access-guard' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' import { type PreparedWorkflowState, @@ -189,6 +190,18 @@ export interface ReplaceWorkflowNormalizedStateInput { workspaceId: string | null /** Owner recorded on any custom tool this graph defines. */ attributedUserId: string + /** + * The human this replace is performed as, or `null` when it is performed as no + * human. + * + * Deliberately separate from `attributedUserId`, which answers a workspace API + * key with the workspace's billing owner: attribution is a billing question + * and fails open here, where this one decides whether a member's own + * permission group may store a block type. Required, and `null` spelled out, + * so an actorless write is a claim the caller made rather than an argument it + * forgot. + */ + subjectUserId: string | null /** * The graph to write, or a reader that produces it. * @@ -230,9 +243,24 @@ export interface ReplaceWorkflowNormalizedStateResult { export async function replaceWorkflowNormalizedState( input: ReplaceWorkflowNormalizedStateInput ): Promise { - const { workflowId, workspaceId, attributedUserId, state, requestId } = input + const { workflowId, workspaceId, attributedUserId, subjectUserId, state, requestId } = input const logPrefix = requestId ? `[${requestId}] ` : '' + /** + * Hoisted ahead of the transaction even though the shared write checks it + * again: the second call is answered from the request-scoped memo, and + * refusing here means a withheld block type never takes the workflow's row + * lock or reaches drizzle's transaction wrapper — so the thrown + * `OrchestrationError` arrives at callers unwrapped. + * + * A caller that produces its graph from a reader is unaffected: the reader + * composes a graph from what is already stored, and this pass covers the + * blocks it hands back through the inner check. + */ + if (typeof state !== 'function') { + await assertNoWithheldBlockType({ workspaceId, subjectUserId }, Object.values(state.blocks)) + } + let preparedState!: PreparedWorkflowState let warnings: string[] = [] let workflowState!: WorkflowState @@ -277,7 +305,12 @@ export async function replaceWorkflowNormalizedState( let result: Awaited> try { - result = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + result = await saveWorkflowToNormalizedTables( + workflowId, + workflowState, + { workspaceId, subjectUserId }, + tx + ) } catch (error) { if (isGraphIdUniqueViolation(error)) { throw new OrchestrationError( diff --git a/apps/sim/lib/workflows/persistence/save-normalized-state.ts b/apps/sim/lib/workflows/persistence/save-normalized-state.ts index b77f969b231..c5e4239f84b 100644 --- a/apps/sim/lib/workflows/persistence/save-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/save-normalized-state.ts @@ -16,10 +16,6 @@ import { statusForOrchestrationError, } from '@/lib/core/orchestration/types' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' -import { - findWithheldBlockType, - withheldBlockTypeMessage, -} from '@/lib/workflows/persistence/block-access-guard' import { replaceWorkflowNormalizedState, WorkflowStatePersistenceError, @@ -98,28 +94,6 @@ export async function saveWorkflowNormalizedState(params: { throw error } - /** - * A whole-graph replace does not go through the editing operations, so this - * is the only point at which the blocks it carries meet the workspace's - * integration allowlist. Checking before the write keeps a withheld - * integration out of the stored workflow rather than leaving it for the - * executor to refuse mid-run. A workflow with no workspace has no permission - * group to resolve. - */ - if (workflowData.workspaceId) { - const withheldBlockType = await findWithheldBlockType({ - userId, - workspaceId: workflowData.workspaceId, - blocks: Object.values(state.blocks), - }) - if (withheldBlockType) { - logger.warn( - `[${requestId}] User ${userId} attempted to save workflow ${workflowId} with withheld block type ${withheldBlockType}` - ) - return { success: false, status: 403, error: withheldBlockTypeMessage(withheldBlockType) } - } - } - let warnings: string[] try { const saved = await replaceWorkflowNormalizedState({ @@ -127,6 +101,15 @@ export async function saveWorkflowNormalizedState(params: { workflowId, workspaceId: workflowData.workspaceId ?? null, attributedUserId: userId, + /** + * This door authorizes by bare `userId`, so the writer and the governed + * subject are the same person. The integration allowlist that used to be + * checked inline here now lives on the shared write, which refuses a + * withheld block type as a `forbidden` `OrchestrationError` — read below + * by the same `asOrchestrationError` branch that classifies the rest, and + * rendered as the identical 403 and message. + */ + subjectUserId: userId, state: { blocks: state.blocks as Record, edges: state.edges as WorkflowState['edges'], diff --git a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts index 5818e7ef6b0..b18ca24a384 100644 --- a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts @@ -80,32 +80,31 @@ describe('saveWorkflowNormalizedState', () => { * The bypass this closes: a graph replace never went through the editing * operations, so a member whose group withholds an integration could still * store a block using it and have it refused only at run time, if ever. + * + * The check itself now lives on the shared write, so what this door owes is + * naming the right subject and rendering the primitive's `forbidden` refusal + * as the 403 it used to build inline. */ - it('refuses a state carrying a block type the permission group withholds', async () => { - mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + it('names the authorizing user as the subject the permission group governs', async () => { + await saveWorkflowNormalizedState(params()) - const result = await saveWorkflowNormalizedState( - params({ - state: { - blocks: { - 'block-1': { - id: 'block-1', - type: 'gmail', - name: 'Send', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - }, - }) + expect(mocks.replace).toHaveBeenCalledWith( + expect.objectContaining({ subjectUserId: 'user-1', workspaceId: 'workspace-1' }) + ) + }) + + it('refuses a state carrying a block type the permission group withholds', async () => { + mocks.replace.mockRejectedValue( + new OrchestrationError( + 'forbidden', + 'Block type "gmail" is not allowed by your organization\'s permission group' + ) ) + const result = await saveWorkflowNormalizedState(params()) + expect(result).toMatchObject({ success: false, status: 403 }) expect(result.success === false && result.error).toContain('gmail') - expect(mocks.replace).not.toHaveBeenCalled() expect(mocks.notify).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/workflows/persistence/utils.test.ts b/apps/sim/lib/workflows/persistence/utils.test.ts index 0059d781832..b4b50ab88a0 100644 --- a/apps/sim/lib/workflows/persistence/utils.test.ts +++ b/apps/sim/lib/workflows/persistence/utils.test.ts @@ -293,6 +293,13 @@ const mockWorkflowState = createWorkflowState({ }, }) +/** + * The ungoverned write every characterization here performs: these exercise the + * table mechanics, not the permission-group gate, and a `null` subject is how a + * caller declares the write is not a member's authoring action. + */ +const UNGOVERNED = { workspaceId: null, subjectUserId: null } + describe('Database Helpers', () => { beforeEach(() => { vi.clearAllMocks() @@ -510,7 +517,8 @@ describe('Database Helpers', () => { it('should successfully save workflow data to normalized tables', async () => { const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - asAppState(mockWorkflowState) + asAppState(mockWorkflowState), + UNGOVERNED ) expect(result.success).toBe(true) @@ -523,7 +531,8 @@ describe('Database Helpers', () => { const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - asAppState(emptyWorkflowState) + asAppState(emptyWorkflowState), + UNGOVERNED ) expect(result.success).toBe(true) @@ -534,7 +543,8 @@ describe('Database Helpers', () => { const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - asAppState(mockWorkflowState) + asAppState(mockWorkflowState), + UNGOVERNED ) expect(result.success).toBe(false) @@ -549,7 +559,8 @@ describe('Database Helpers', () => { const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - asAppState(mockWorkflowState) + asAppState(mockWorkflowState), + UNGOVERNED ) expect(result.success).toBe(false) @@ -557,7 +568,11 @@ describe('Database Helpers', () => { }) it('should properly format block data for database insertion', async () => { - await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, asAppState(mockWorkflowState)) + await dbHelpers.saveWorkflowToNormalizedTables( + mockWorkflowId, + asAppState(mockWorkflowState), + UNGOVERNED + ) const [capturedBlockInserts = []] = insertedRowsFor(schemaMock.workflowBlocks) const [capturedEdgeInserts = []] = insertedRowsFor(schemaMock.workflowEdges) @@ -631,7 +646,11 @@ describe('Database Helpers', () => { staleWorkflowState.loops = {} staleWorkflowState.parallels = {} - await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, asAppState(staleWorkflowState)) + await dbHelpers.saveWorkflowToNormalizedTables( + mockWorkflowId, + asAppState(staleWorkflowState), + UNGOVERNED + ) const [capturedSubflowInserts = []] = insertedRowsFor(schemaMock.workflowSubflows) @@ -737,7 +756,8 @@ describe('Database Helpers', () => { const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - asAppState(largeWorkflowState) + asAppState(largeWorkflowState), + UNGOVERNED ) expect(result.success).toBe(true) @@ -869,7 +889,8 @@ describe('Database Helpers', () => { const saveResult = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - workflowState + workflowState, + UNGOVERNED ) expect(saveResult.success).toBe(true) @@ -940,7 +961,8 @@ describe('Database Helpers', () => { const saveResult = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - asAppState(testWorkflowState) + asAppState(testWorkflowState), + UNGOVERNED ) expect(saveResult.success).toBe(true) diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 80da0d470d6..eff130b60c1 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -33,6 +33,10 @@ import { migrateSubblockIds, } from '@/lib/workflows/migrations/subblock-migrations' import { backfillWhatsAppInteractiveType } from '@/lib/workflows/migrations/whatsapp-interactive-type' +import { + assertNoWithheldBlockType, + type WorkflowPersistGovernance, +} from '@/lib/workflows/persistence/block-access-guard' import { supersedeInFlightDeploymentOperations } from '@/lib/workflows/persistence/deployment-operations' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' @@ -638,11 +642,30 @@ export function buildWorkflowDeploymentSnapshot( } } +/** + * The one door every normalized-table write goes through, and therefore the one + * place the workspace's integration allowlist can be enforced for all of them. + * + * `governance` is required rather than optional: a whole-graph write hands over + * finished blocks naming whatever types it likes, so every caller has to state + * whose grants judge them. Passing `{ subjectUserId: null }` is how a caller + * declares itself actorless — the executor persisting a run's own graph, a fork + * copying rows, workspace creation seeding a starter workflow — and that is a + * claim a reader can check, where an omitted argument was not. + * + * The check runs before any transaction is opened so a refusal never holds the + * workflow's row lock, and it throws rather than folding into the `{ success }` + * union: the union collapses to a 500 at every caller, and this refusal is a + * 403. + */ export async function saveWorkflowToNormalizedTables( workflowId: string, state: WorkflowState, + governance: WorkflowPersistGovernance, externalTx?: DbOrTx ): Promise<{ success: boolean; error?: string }> { + await assertNoWithheldBlockType(governance, Object.values(state.blocks)) + if (externalTx) { return saveWorkflowToNormalizedTablesRaw(workflowId, state, externalTx) } diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 83e98bbf3cc..6d0c8ea4ba7 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -416,7 +416,14 @@ export async function createWorkflowRecord(params: CreateWorkflowInput) { }) const { workflowState } = buildDefaultWorkflowArtifacts() - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState) + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, { + /** + * Actorless: `buildDefaultWorkflowArtifacts` produces the platform's starter + * graph, so there is no caller-chosen block type for a group to judge. + */ + workspaceId: null, + subjectUserId: null, + }) if (!saveResult.success) { throw new Error(saveResult.error || 'Failed to save workflow state') } diff --git a/apps/sim/lib/workspaces/create.ts b/apps/sim/lib/workspaces/create.ts index 8ca1b60ef71..70d433c0e18 100644 --- a/apps/sim/lib/workspaces/create.ts +++ b/apps/sim/lib/workspaces/create.ts @@ -139,7 +139,16 @@ export async function createWorkspaceInTransaction( variables: {}, }) const { workflowState } = buildDefaultWorkflowArtifacts() - await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + await saveWorkflowToNormalizedTables( + workflowId, + workflowState, + { + /** Actorless: workspace creation seeds a platform-authored starter workflow. */ + workspaceId: null, + subjectUserId: null, + }, + tx + ) } return { From 23cd90f8e0bf991d7cd411b196f99ff5b2e5e140 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:32:54 -0700 Subject: [PATCH 151/179] fix(table): gate the workflow half of a cell on its governed subject, not its payer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ExecutionMetadata.userId` is the run's billing/rate actor and its credential subject, and the permission gate read it as the person too. On a workspace-API-key table run that id is the workspace's billing owner — a bystander — so the tool denylist ran against someone who did not ask and skipped the one belonging to whoever did. The enrichment half of this same worker was already fixed; this closes the workflow half. The two ids are now separate: `capabilityGovernedUserId` on the run metadata is the gate, `userId` stays the meter and the credential subject, untouched. It is tri-state — absent means the surface has exactly one person and the actor remains the subject, so every other trigger is unchanged; a declared `null` is the actorless run and applies no group gate. Resolving it in `assertPermissionsAllowed` puts the decision in one place for every gate a run reaches, and the metadata is spread onto the pause snapshot, so it survives a resume. --- .../background/workflow-column-execution.ts | 10 + .../workflow-group-governed-subject.test.ts | 236 ++++++++++++++++++ .../access-control/utils/permission-check.ts | 26 +- .../utils/permission-gate-subject.test.ts | 103 ++++++++ apps/sim/executor/execution/types.ts | 9 + apps/sim/executor/types.ts | 18 ++ .../workflows/executor/execute-workflow.ts | 7 + 7 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 apps/sim/background/workflow-group-governed-subject.test.ts create mode 100644 apps/sim/ee/access-control/utils/permission-gate-subject.test.ts diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index f59f7b1806e..907765d65a8 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -1102,6 +1102,16 @@ async function runWorkflowAndWriteTerminal( actorUserId, { enabled: true, + /** + * The gate, which is not the actor above. `actorUserId` is an + * attribution: for a workspace-API-key run it names the workspace's + * billing owner, so gating the run's tools on it applies a + * bystander's denylist and skips the requester's. Declared + * explicitly — `null` is the actorless run, which the executor + * reads as "no per-tool gate", exactly as the enrichment half of + * this worker already does. + */ + capabilityGovernedUserId: payload.capabilityGovernedUserId, principal: { kind: 'system', serviceId: 'table', diff --git a/apps/sim/background/workflow-group-governed-subject.test.ts b/apps/sim/background/workflow-group-governed-subject.test.ts new file mode 100644 index 00000000000..43862ec270e --- /dev/null +++ b/apps/sim/background/workflow-group-governed-subject.test.ts @@ -0,0 +1,236 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getTableById: vi.fn(), + getRowById: vi.fn(), + pickNextEligibleGroupForRow: vi.fn(), + stashCellContextForResume: vi.fn(), + writeWorkflowGroupState: vi.fn(), + markWorkflowGroupPickedUp: vi.fn(), + createWorkflowCellProgressWriter: vi.fn(), + loadDeployedWorkflowState: vi.fn(), + executeWorkflow: vi.fn(), + preprocessExecution: vi.fn(), + loadTableRowSecretProvenance: vi.fn(), + findStartBlock: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/rows/service', () => ({ + getRowById: mocks.getRowById, + updateRow: vi.fn(), +})) +vi.mock('@/lib/table/workflow-columns', () => ({ + pickNextEligibleGroupForRow: mocks.pickNextEligibleGroupForRow, + stashCellContextForResume: mocks.stashCellContextForResume, + buildWorkflowGroupExecutionCorrelation: () => ({}), +})) +vi.mock('@/lib/table/cell-write', () => ({ + buildCancelledExecution: (prev: { executionId: string | null; workflowId: string }) => ({ + status: 'cancelled', + executionId: prev.executionId, + jobId: null, + workflowId: prev.workflowId, + error: 'Cancelled', + }), + createWorkflowCellProgressWriter: mocks.createWorkflowCellProgressWriter, + writeWorkflowGroupState: mocks.writeWorkflowGroupState, + markWorkflowGroupPickedUp: mocks.markWorkflowGroupPickedUp, +})) +vi.mock('@/lib/table/workflow-cell-result', () => ({ + classifyWorkflowCellTerminalResult: () => ({ status: 'completed', error: null }), +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) +vi.mock('@/lib/table/dispatcher', () => ({ + readDispatch: async () => ({ id: 'tdsp_1', status: 'dispatching' }), + completeDispatchIfActive: vi.fn(), +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadDeployedWorkflowState: mocks.loadDeployedWorkflowState, +})) +vi.mock('@/lib/workflows/executor/execute-workflow', () => ({ + executeWorkflow: mocks.executeWorkflow, +})) +vi.mock('@/lib/workflows/triggers/triggers', () => ({ + TriggerUtils: { findStartBlock: mocks.findStartBlock }, +})) +vi.mock('@/lib/workflows/blocks/flatten-outputs', () => ({ flattenWorkflowOutputs: () => [] })) +vi.mock('@/lib/workflows/input-format', () => ({ normalizeInputFormatValue: () => [] })) +vi.mock('@/lib/execution/preprocessing', () => ({ + preprocessExecution: mocks.preprocessExecution, +})) +vi.mock('@/lib/table/admission-retry', () => ({ + retryTableAdmission: (fn: () => Promise) => fn(), +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: () => ({ complete: true, columns: {} }), + createTableRowSecretProvenanceFromRegistry: () => ({ complete: true, columns: {} }), + loadTableRowSecretProvenance: mocks.loadTableRowSecretProvenance, +})) +vi.mock('@/executor/utils/resolved-secret-trace-registry', () => ({ + ResolvedSecretTraceRegistry: class { + importCrossingProvenance = vi.fn() + exportCheckpointProvenance = vi.fn(() => undefined) + }, +})) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + assertBillingAttributionSnapshot: (snapshot: unknown) => snapshot, + checkAttributedUsageLimits: async () => ({ isExceeded: false }), + toBillingContext: () => ({}), +})) +/** Real pacing would sleep jittered backoff against the global db mock. */ +vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitWithSubscription = vi.fn().mockResolvedValue({ allowed: true }) + }, +})) + +import { runRowCascadeLoop } from '@/background/workflow-column-execution' + +const GROUP = { + id: 'group-1', + type: 'workflow', + workflowId: 'workflow-1', + outputs: [], + inputMappings: [], +} +const TABLE = { + id: 'table-1', + name: 'Table', + workspaceId: 'workspace-1', + schema: { columns: [], workflowGroups: [GROUP] }, +} + +const BILLING = { + actorUserId: 'workspace-billing-owner', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'workspace-billing-owner', + billingEntity: { type: 'user' as const, id: 'workspace-billing-owner' }, + billingPeriod: { start: '2026-08-01T00:00:00.000Z', end: '2026-09-01T00:00:00.000Z' }, + payerSubscription: null, +} + +/** + * A workspace-API-key run: the attribution names the workspace's billing owner, + * while the person who actually asked is the governed subject. + */ +const PAYLOAD = { + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + dispatchId: 'tdsp_1', + executionTimeoutMs: 10_000, + triggeredByUserId: 'workspace-billing-owner', + capabilityGovernedUserId: 'requesting-member', + billingAttribution: BILLING, +} as Parameters[0] + +describe('the workflow half of a table cell', () => { + /** The cell resolves its collaborators with dynamic imports; warm them once. */ + beforeAll(async () => { + await Promise.all([ + import('@/lib/table/cell-write'), + import('@/lib/table/dispatcher'), + import('@/lib/table/rows/service'), + import('@/lib/table/service'), + import('@/lib/table/workflow-columns'), + import('@/lib/workflows/executor/execute-workflow'), + import('@/lib/workflows/persistence/utils'), + ]) + }, 60_000) + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getTableById.mockResolvedValue(TABLE) + mocks.getRowById.mockResolvedValue({ + id: 'row-1', + data: {}, + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + executions: {}, + }) + mocks.pickNextEligibleGroupForRow.mockReturnValue(null) + mocks.writeWorkflowGroupState.mockResolvedValue('wrote') + mocks.markWorkflowGroupPickedUp.mockResolvedValue('picked-up') + mocks.loadDeployedWorkflowState.mockResolvedValue({ blocks: {}, edges: [] }) + mocks.findStartBlock.mockReturnValue({ blockId: 'start-1', block: { subBlocks: {} } }) + mocks.loadTableRowSecretProvenance.mockResolvedValue({ + scope: { userId: 'workflow-owner', workspaceId: 'workspace-1' }, + byRowId: {}, + }) + mocks.createWorkflowCellProgressWriter.mockReturnValue({ + onBlockStart: vi.fn(), + onBlockComplete: vi.fn(), + finish: vi.fn(), + getEventOutputs: () => ({}), + getPendingDataPatch: () => ({}), + getBlockErrors: () => ({}), + getPendingSecretProvenance: () => undefined, + }) + mocks.preprocessExecution.mockResolvedValue({ + success: true, + actorUserId: 'workspace-billing-owner', + actorSubscription: null, + billingAttribution: BILLING, + }) + mocks.executeWorkflow.mockResolvedValue({ success: true, status: 'completed', output: {} }) + /** The workflow record read. */ + dbChainMockFns.limit.mockResolvedValue([ + { + id: 'workflow-1', + userId: 'workflow-owner', + workspaceId: 'workspace-1', + variables: {}, + }, + ]) + }) + + /** + * The gate and the meter are different people on a workspace-key run. Gating + * on the billing owner applies a bystander's denylist and skips the + * requester's — the exact defect the enrichment half of this worker was fixed + * for. + */ + it('gates on the governed subject while still billing the attributed actor', async () => { + await runRowCascadeLoop(PAYLOAD) + + expect(mocks.executeWorkflow).toHaveBeenCalledTimes(1) + const [workflow, , , actorUserId, options] = mocks.executeWorkflow.mock.calls[0] + expect(options.capabilityGovernedUserId).toBe('requesting-member') + // Untouched: billing actor, credential/env subject, and payer snapshot. + expect(actorUserId).toBe('workspace-billing-owner') + expect(workflow.userId).toBe('workflow-owner') + expect(options.billingAttribution).toBe(BILLING) + }, 20_000) + + it('declares an explicit null for an actorless auto-fire', async () => { + await runRowCascadeLoop({ ...PAYLOAD, capabilityGovernedUserId: null }) + + const [, , , , options] = mocks.executeWorkflow.mock.calls[0] + expect(options.capabilityGovernedUserId).toBeNull() + }, 20_000) + + /** The subject has to survive the pause — nothing downstream can re-derive it. */ + it('stashes the governed subject with the pause context', async () => { + mocks.executeWorkflow.mockResolvedValue({ success: true, status: 'paused', output: {} }) + + await runRowCascadeLoop(PAYLOAD) + + expect(mocks.stashCellContextForResume).toHaveBeenCalledWith( + expect.objectContaining({ + executionId: 'execution-1', + groupId: 'group-1', + capabilityGovernedUserId: 'requesting-member', + }) + ) + }, 20_000) +}) diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index ca8033171ec..bc8eaf7fb90 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -176,6 +176,29 @@ export async function validateChatDeployAuth( } } +/** + * The person a run's group gates are decided about. + * + * A run's actor and its gate subject are not the same id. `userId` is the + * billing/rate actor and the credential subject, and for a trigger with no + * acting person — a table cell dispatched by a workspace API key — it names the + * workspace's billing owner. Gating on that bystander denies tools nobody meant + * to deny and skips the denylist of whoever actually asked, so a trigger that + * knows its acting person declares it on the run's metadata instead. + * + * `undefined` there means "not declared": the surface has always had exactly + * one person, and the actor stays the subject. A declared `null` is the + * actorless run — no group, hence no group gate. + */ +function governedSubjectUserId( + actorUserId: string | undefined, + ctx: ExecutionContext | undefined +): string | undefined { + const declared = ctx?.metadata?.capabilityGovernedUserId + if (declared === undefined) return actorUserId + return declared ?? undefined +} + /** * Cache-aware wrapper around `getUserPermissionConfig`. When an * `ExecutionContext` is provided, the resolved config is memoized on the @@ -479,7 +502,8 @@ interface PermissionAssertion { /** permission-group-enforced: custom_tools.use — gates tool invocation during a run, not an operation */ /** permission-group-enforced: skills.use — gates skill loading during a run, not an operation */ export async function assertPermissionsAllowed(req: PermissionAssertion): Promise { - const { userId, workspaceId, model, blockType, toolId, toolKind, ctx } = req + const { workspaceId, model, blockType, toolId, toolKind, ctx } = req + const userId = governedSubjectUserId(req.userId, ctx) const blockTypeExempt = blockType ? isBlockTypeAccessControlExempt(blockType) : false diff --git a/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts b/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts new file mode 100644 index 00000000000..2856f76200c --- /dev/null +++ b/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts @@ -0,0 +1,103 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, + getUserPermissionConfigForOrganization: vi.fn(), + mergeEnvAllowlist: (config: unknown) => config, + resolveVerifiedUserAccessControlContext: vi.fn(), + resolveWorkspaceGroup: vi.fn(), +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: vi.fn(), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: vi.fn() })) +vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: () => false, + getProviderFromModel: () => 'openai', +})) + +import type { ExecutionContext } from '@/executor/types' +import { assertPermissionsAllowed, ToolNotAllowedError } from './permission-check' + +/** + * A run's own metadata carries its gate subject. Only a trigger whose acting + * person differs from the one it bills declares one; everything else omits the + * field and keeps gating on the caller. + */ +function runDeclaring(capabilityGovernedUserId?: string | null): ExecutionContext { + return { metadata: { capabilityGovernedUserId } } as unknown as ExecutionContext +} + +describe('the subject a run’s permission gate is decided about', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getUserPermissionConfig.mockResolvedValue({ deniedTools: ['exa_search'] }) + }) + + /** + * A table cell dispatched by a workspace API key attributes to the + * workspace's billing owner. Loading that bystander's group runs a denylist + * nobody asked for and skips the one belonging to whoever actually asked. + */ + it('resolves the declared subject’s group, not the billing actor’s', async () => { + await expect( + assertPermissionsAllowed({ + userId: 'workspace-billing-owner', + workspaceId: 'workspace-1', + toolId: 'exa_search', + ctx: runDeclaring('requesting-member'), + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledWith('requesting-member', 'workspace-1') + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalledWith( + 'workspace-billing-owner', + 'workspace-1' + ) + }) + + /** A declared `null` is the actorless run: there is no group to consult. */ + it('consults no group when the run declares no acting person', async () => { + await assertPermissionsAllowed({ + userId: 'workspace-billing-owner', + workspaceId: 'workspace-1', + toolId: 'exa_search', + ctx: runDeclaring(null), + }) + + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + }) + + /** Every surface with exactly one person declares nothing and is unchanged. */ + it('keeps gating on the caller when the run declares nothing', async () => { + await expect( + assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + toolId: 'exa_search', + ctx: runDeclaring(), + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledWith('user-123', 'workspace-1') + }) + + it('keeps gating on the caller when there is no run context at all', async () => { + await expect( + assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + toolId: 'exa_search', + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledWith('user-123', 'workspace-1') + }) +}) diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index ba7e7db6a8a..24bb0376f60 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -25,6 +25,15 @@ export interface ExecutionMetadata { workflowId: string workspaceId: string userId: string + /** + * Person whose permission group gates this run — the gate, separate from + * {@link userId}, which is the billing/rate actor and the credential subject. + * Spread onto the execution context (and so onto the pause snapshot) so a + * trigger with no acting person to charge does not end up gating on the + * bystander it bills. Tri-state; see the field of the same name on the + * context's `ExecutionMetadata` in `@/executor/types`. + */ + capabilityGovernedUserId?: string | null /** Original authenticated caller. Billing and executor user IDs never replace it. */ principal: WorkflowExecutionPrincipal /** Immutable actor/payer decision captured before execution. */ diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 6f8ee60002f..315cee0ae48 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -333,6 +333,24 @@ interface ExecutionMetadata { depth: number } userId?: string + /** + * Person whose permission group gates what this run's tools, models and + * blocks may do — the *gate*, deliberately separate from {@link userId}, + * which is the billing/rate actor and the credential subject. + * + * The two coincide for a session-triggered run and diverge whenever the + * trigger has no acting person to charge: a table cell dispatched by a + * workspace API key attributes to the workspace's billing owner, and gating + * on that bystander is wrong in both directions — it applies a denylist + * nobody meant to apply, and it skips the one belonging to whoever actually + * asked. + * + * Tri-state on purpose. `undefined` means the trigger declares no separate + * gate, so the gate stays on {@link userId} (every surface that has always + * had one acting person). A declared `string` gates on that person; a + * declared `null` is an actorless run and applies no group gate at all. + */ + capabilityGovernedUserId?: string | null principal?: WorkflowExecutionPrincipal executionId?: string triggerType?: string diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index 39cdad0c07a..ce5026dbd11 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -86,6 +86,12 @@ export interface ExecuteWorkflowOptions { * Callers set this only when the surface consumes thinking/tool events. */ agentEvents?: boolean + /** + * Gate subject for this run, separate from the billing actor + * (see {@link ExecutionMetadata.capabilityGovernedUserId}). Omit unless the + * trigger genuinely has a person distinct from the one it bills. + */ + capabilityGovernedUserId?: string | null } export interface WorkflowInfo { @@ -146,6 +152,7 @@ export async function executeWorkflow( workflowId, workspaceId, userId: actorUserId, + capabilityGovernedUserId: streamConfig?.capabilityGovernedUserId, principal, billingAttribution, workflowUserId: workflow.userId, From 03947aef35cd9c5bbcf028d09e7105972c140e51 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:33:39 -0700 Subject: [PATCH 152/179] fix(invitations): gate resend on invitations.send --- .../api/invitations/[id]/resend/route.test.ts | 173 ++++++++++++++++++ .../app/api/invitations/[id]/resend/route.ts | 39 ++++ 2 files changed, 212 insertions(+) create mode 100644 apps/sim/app/api/invitations/[id]/resend/route.test.ts diff --git a/apps/sim/app/api/invitations/[id]/resend/route.test.ts b/apps/sim/app/api/invitations/[id]/resend/route.test.ts new file mode 100644 index 00000000000..eaca10f230a --- /dev/null +++ b/apps/sim/app/api/invitations/[id]/resend/route.test.ts @@ -0,0 +1,173 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + MockInvitationsNotAllowedError, + mockGetInvitationById, + mockIsOrganizationOwnerOrAdmin, + mockHasWorkspaceAdminAccess, + mockGetWorkspaceWithOwner, + mockGetWorkspaceInvitePolicy, + mockValidateInvitationsAllowed, + mockSendInvitationEmail, + mockPrepareInvitationResend, + mockPersistInvitationResend, + mockGetOrganizationSubscription, +} = vi.hoisted(() => ({ + MockInvitationsNotAllowedError: class extends Error { + constructor() { + super('Invitations are not allowed based on your permission group settings') + this.name = 'InvitationsNotAllowedError' + } + }, + mockGetInvitationById: vi.fn(), + mockIsOrganizationOwnerOrAdmin: vi.fn(), + mockHasWorkspaceAdminAccess: vi.fn(), + mockGetWorkspaceWithOwner: vi.fn(), + mockGetWorkspaceInvitePolicy: vi.fn(), + mockValidateInvitationsAllowed: vi.fn(), + mockSendInvitationEmail: vi.fn(), + mockPrepareInvitationResend: vi.fn(), + mockPersistInvitationResend: vi.fn(), + mockGetOrganizationSubscription: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { INVITATION_RESENT: 'invitation.resent', ORG_INVITATION_RESENT: 'org.resent' }, + AuditResourceType: { WORKSPACE: 'workspace', ORGANIZATION: 'organization' }, + recordAudit: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + InvitationsNotAllowedError: MockInvitationsNotAllowedError, + validateInvitationsAllowed: mockValidateInvitationsAllowed, +})) + +vi.mock('@/lib/invitations/core', () => ({ getInvitationById: mockGetInvitationById })) +vi.mock('@/lib/invitations/send', () => ({ + sendInvitationEmail: mockSendInvitationEmail, + prepareInvitationResend: mockPrepareInvitationResend, + persistInvitationResend: mockPersistInvitationResend, +})) +vi.mock('@/lib/billing/core/organization', () => ({ + isOrganizationOwnerOrAdmin: mockIsOrganizationOwnerOrAdmin, +})) +vi.mock('@/lib/billing/core/billing', () => ({ + getOrganizationSubscription: mockGetOrganizationSubscription, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess, + getWorkspaceWithOwner: mockGetWorkspaceWithOwner, +})) +vi.mock('@/lib/workspaces/policy', () => ({ + getWorkspaceInvitePolicy: mockGetWorkspaceInvitePolicy, +})) + +import { POST } from '@/app/api/invitations/[id]/resend/route' + +const mockGetSession = authMockFns.mockGetSession + +function callResend() { + return POST( + createMockRequest( + 'POST', + undefined, + {}, + 'http://localhost:3000/api/invitations/11111111-1111-4111-8111-111111111111/resend' + ), + { params: Promise.resolve({ id: '11111111-1111-4111-8111-111111111111' }) } + ) +} + +const workspaceInvitation = { + id: '11111111-1111-4111-8111-111111111111', + status: 'pending', + kind: 'workspace', + email: 'invitee@example.com', + role: 'member', + token: 'token-1', + organizationId: 'organization-1', + membershipIntent: 'member', + grants: [{ workspaceId: 'workspace-1', permission: 'read' }], +} + +/** + * A resend re-delivers a working link and pushes the expiry forward, so it is a + * send: without the gate an organization that has withheld invitations still + * admits every pending invitee, indefinitely. + */ +describe('POST /api/invitations/[id]/resend', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1', email: 'admin@example.com' } }) + mockGetInvitationById.mockResolvedValue(workspaceInvitation) + mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) + mockHasWorkspaceAdminAccess.mockResolvedValue(true) + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + organizationId: 'organization-1', + }) + mockGetWorkspaceInvitePolicy.mockResolvedValue({ allowed: true }) + mockValidateInvitationsAllowed.mockResolvedValue(undefined) + mockPrepareInvitationResend.mockResolvedValue({ + tokenForEmail: 'token-2', + nextToken: 'token-2', + nextExpiresAt: new Date('2026-09-30T00:00:00.000Z'), + }) + mockSendInvitationEmail.mockResolvedValue({ success: true }) + mockPersistInvitationResend.mockResolvedValue(undefined) + }) + + it('resends when no group withholds invitations', async () => { + const response = await callResend() + + expect(response.status).toBe(200) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + workspaceId: 'workspace-1', + }) + expect(mockSendInvitationEmail).toHaveBeenCalled() + }) + + it('refuses the resend when the group withholds invitations', async () => { + mockValidateInvitationsAllowed.mockRejectedValue(new MockInvitationsNotAllowedError()) + + const response = await callResend() + + expect(response.status).toBe(403) + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(mockPersistInvitationResend).not.toHaveBeenCalled() + }) + + /** + * The refusal names an organization setting, so it must never be reached by + * someone with no admin standing to hear it. + */ + it('checks admin standing before the permission group', async () => { + mockIsOrganizationOwnerOrAdmin.mockResolvedValue(false) + mockHasWorkspaceAdminAccess.mockResolvedValue(false) + + const response = await callResend() + + expect(response.status).toBe(403) + expect(mockValidateInvitationsAllowed).not.toHaveBeenCalled() + }) + + it('resolves the organization default group for an invitation with no grants', async () => { + mockGetInvitationById.mockResolvedValue({ + ...workspaceInvitation, + kind: 'organization', + grants: [], + }) + mockGetOrganizationSubscription.mockResolvedValue({ status: 'active', plan: 'team' }) + + const response = await callResend() + + expect(response.status).toBe(200) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + organizationId: 'organization-1', + }) + }) +}) diff --git a/apps/sim/app/api/invitations/[id]/resend/route.ts b/apps/sim/app/api/invitations/[id]/resend/route.ts index dbd34b75014..5bc7dabd6e3 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.ts @@ -20,6 +20,10 @@ import { } from '@/lib/invitations/send' import { getWorkspaceWithOwner, hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' +import { + InvitationsNotAllowedError, + validateInvitationsAllowed, +} from '@/ee/access-control/utils/permission-check' const logger = createLogger('InvitationResendAPI') @@ -65,6 +69,41 @@ export const POST = withRouteHandler( ) } + /** + * permission-group-enforced: invitations.send — a resend is a send. + * + * It re-delivers a working link and pushes `expiresAt` forward, so an + * organization that has withheld invitations would otherwise still admit + * new people: every pending invitation stays revivable indefinitely by + * anyone who can reach this route, and each resend mints a fresh token. + * The invitee has not joined yet — resend is the step that gets them in — + * which is why this is not the webhook active-config carve-out, where the + * reachability already exists and the edit only adjusts it. + * + * Scoped exactly as creation is: each granted workspace resolves the + * group governing the caller there, and an organization-only invitation + * with no grants falls back to the organization's default group. Run + * after the admin check above, for the reason + * `resolveWorkspaceInvitationContext` records — the refusal names an + * organization setting, so it must not reach someone with no admin reach. + */ + try { + for (const grant of inv.grants) { + await validateInvitationsAllowed(session.user.id, { workspaceId: grant.workspaceId }) + } + if (inv.grants.length === 0 && inv.organizationId) { + await validateInvitationsAllowed(session.user.id, { + organizationId: inv.organizationId, + }) + } + } catch (error) { + if (error instanceof InvitationsNotAllowedError) { + logger.warn('Invitation resend blocked by permission group', { invitationId: id }) + return NextResponse.json({ error: error.message }, { status: 403 }) + } + throw error + } + for (const grant of inv.grants) { const workspaceDetails = await getWorkspaceWithOwner(grant.workspaceId) if (!workspaceDetails) { From 21b832424f08af059a54fc5ea7cf4dcd16b9ddfa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:35:09 -0700 Subject: [PATCH 153/179] fix(api-keys): gate the workspace key rename on api_keys.manage --- .../[id]/api-keys/[keyId]/route.test.ts | 117 ++++++++++++++++++ .../workspaces/[id]/api-keys/[keyId]/route.ts | 32 +++++ 2 files changed, 149 insertions(+) create mode 100644 apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.test.ts diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.test.ts b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.test.ts new file mode 100644 index 00000000000..dc8b2212ab6 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { + authMockFns, + createMockRequest, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + queueTableRows, + resetDbChainMock, + resetPermissionGroupScopeMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +const { mockGetUserEntityPermissions } = vi.hoisted(() => ({ + mockGetUserEntityPermissions: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { API_KEY_UPDATED: 'api_key.updated', API_KEY_REVOKED: 'api_key.revoked' }, + AuditResourceType: { API_KEY: 'api_key' }, + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { capabilityRefusal } from '@/lib/permission-groups/capabilities' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { DELETE, PUT } from '@/app/api/workspaces/[id]/api-keys/[keyId]/route' + +const mockGetSession = authMockFns.mockGetSession + +const context = { params: Promise.resolve({ id: 'workspace-1', keyId: 'key-1' }) } + +function renameRequest() { + return createMockRequest( + 'PUT', + { name: 'Renamed key' }, + {}, + 'http://localhost:3000/api/workspaces/workspace-1/api-keys/key-1' + ) +} + +describe('workspace API key by id', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + resetPermissionGroupScopeMock() + mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + }) + + afterAll(() => { + resetDbChainMock() + }) + + /** + * A rename is "managing API keys" like the list and the mint are, and grants + * no access of its own — but neither does the list, and leaving the rename + * open also answers whether a key id exists to a caller the same group + * refuses the listing. + */ + it('refuses a rename when the group withholds API key management', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideApiKeysTab: true, + }) + + const response = await PUT(renameRequest(), context) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: capabilityRefusal('api_keys.manage'), + }) + }) + + it('renames when no group withholds API key management', async () => { + queueTableRows(schemaMock.apiKey, [{ id: 'key-1', name: 'Old name' }]) + queueTableRows(schemaMock.apiKey, []) + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + name: 'Renamed key', + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-02T00:00:00.000Z'), + }, + ]) + + const response = await PUT(renameRequest(), context) + + expect(response.status).toBe(200) + }) + + /** + * Revocation stays ungated on purpose: withholding key management must never + * withhold the one act that removes a leaked credential. + */ + it('revokes even when the group withholds API key management', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideApiKeysTab: true, + }) + const response = await DELETE(createMockRequest('DELETE'), context) + + expect(response.status).not.toBe(403) + await expect(response.json()).resolves.not.toEqual({ + error: capabilityRefusal('api_keys.manage'), + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts index dab424729cb..1bde6980ea4 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts @@ -10,6 +10,10 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -34,6 +38,28 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + /** + * permission-group-enforced: api_keys.manage — raw handler with inline + * queries, which the authorization funnel never sees. + * + * Gated like the list and the mint on the collection route, not exempted + * like the revocations. A rename grants no access — the body carries a + * `name` and nothing else, so no scope, workspace binding or expiry moves + * — but neither does reading the list, and this is the same "managing API + * keys" the group withheld. The revocation carve-out is narrower than it + * looks: it exists because withholding management must not withhold the + * one act that *removes* a credential. Renaming removes nothing, so it + * inherits nothing from it, and leaving it open would also answer whether + * a given key id exists to a caller the same group refuses the list. + * + * After the admin check, like every other capability assertion here: the + * refusal names an organization setting, and a non-admin should not hear + * it. + */ + if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'api_keys.manage')) { + return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) + } + const parsed = await parseRequest(updateWorkspaceApiKeyContract, request, context) if (!parsed.success) return parsed.response const { name } = parsed.data.body @@ -143,6 +169,12 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + /** + * Deliberately not capability-gated, for the reason the bulk delete on the + * collection route records at length: withholding key *management* must + * never withhold key *revocation*, or a group setting becomes the thing + * standing between an admin and a leaked credential. + */ const deletedRows = await db .delete(apiKey) .where( From 2d2f63b0dd1733568a8c99c15805a800d876a3a7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:35:39 -0700 Subject: [PATCH 154/179] fix(users): stop the deleted account's pre-stamped cell markers too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Account deletion cancelled the departing account's `table_run_dispatches` rows and stopped there. The cells those dispatches had already pre-stamped on `table_row_executions` survived: a marker is drained by whichever worker holds the row's cascade lock, and that worker's dispatch-cancel guard consults its OWN dispatch id, so an unrelated still-active sibling dispatch drained the deleted person's marker. By then the subject reference has been `SET NULL`, which reads exactly like a legitimately actorless request — the drain ran with no per-tool gate at all. Terminalizes those markers with the canonical cancel state, in the same transaction and ahead of the user delete, scoped to the subject and to `pending`/`queued` — the states a marker sits in before a worker claims it. Every drain path already refuses a cancelled cell. --- .../workflow-group-governed-subject.test.ts | 29 +++++ apps/sim/lib/table/rows/executions.ts | 43 +++++++ .../account-deletion-marker-cancel.test.ts | 117 ++++++++++++++++++ apps/sim/lib/users/account-deletion.ts | 11 ++ 4 files changed, 200 insertions(+) create mode 100644 apps/sim/lib/users/account-deletion-marker-cancel.test.ts diff --git a/apps/sim/background/workflow-group-governed-subject.test.ts b/apps/sim/background/workflow-group-governed-subject.test.ts index 43862ec270e..5c53a5ad304 100644 --- a/apps/sim/background/workflow-group-governed-subject.test.ts +++ b/apps/sim/background/workflow-group-governed-subject.test.ts @@ -233,4 +233,33 @@ describe('the workflow half of a table cell', () => { }) ) }, 20_000) + + /** + * Account deletion terminalizes the departing person's still-unstarted + * markers with the canonical cancel. This is the guard that makes that stick: + * the sibling dispatch that would otherwise drain the marker ungated reads + * the cell's own state before running anything. + */ + it('refuses a marker another path terminalized before pickup', async () => { + mocks.getRowById.mockResolvedValue({ + id: 'row-1', + data: {}, + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + executions: { + 'group-1': { + status: 'cancelled', + executionId: null, + jobId: null, + workflowId: 'workflow-1', + error: 'Cancelled', + cancelledAt: '2026-08-28T00:00:00.000Z', + }, + }, + }) + + await runRowCascadeLoop(PAYLOAD) + + expect(mocks.executeWorkflow).not.toHaveBeenCalled() + expect(mocks.markWorkflowGroupPickedUp).not.toHaveBeenCalled() + }, 20_000) }) diff --git a/apps/sim/lib/table/rows/executions.ts b/apps/sim/lib/table/rows/executions.ts index 6b16a23edda..0d441bc1505 100644 --- a/apps/sim/lib/table/rows/executions.ts +++ b/apps/sim/lib/table/rows/executions.ts @@ -416,6 +416,49 @@ export async function readStampedCapabilitySubject( return stamped?.capabilityGovernedUserId ?? null } +/** + * Terminalizes every still-unstarted cell marker stamped with `userId`, in the + * caller's transaction. + * + * Cancelling the departing account's `table_run_dispatches` rows is not enough + * on its own. A pre-stamp on `table_row_executions` is drained by whichever + * worker holds the row's cascade lock, and that worker's dispatch-cancel guard + * consults ITS OWN dispatch — so an unrelated, still-active sibling dispatch + * happily drains the deleted person's marker. The subject reference is + * `ON DELETE SET NULL`, which by then makes the marker indistinguishable from a + * legitimately actorless request: the drain runs it with no per-tool gate at + * all. Going terminal here is the same honest reading the dispatch cancel takes + * — a deleted person's runs stop rather than silently lose their gate. + * + * Scoped to `pending`/`queued` because those are the states a marker sits in + * before a worker claims it; a claimed or terminal row carries no subject to + * match anyway. The written state is the canonical cancel + * (`buildCancelledExecution`), which every drain path's `isExecCancelled` check + * already refuses to run. + */ +export async function cancelPendingMarkersForGovernedSubject( + trx: DbOrTx, + userId: string +): Promise { + const now = new Date() + await trx + .update(tableRowExecutions) + .set({ + status: 'cancelled', + jobId: null, + error: 'Cancelled', + runningBlockIds: [], + cancelledAt: now, + updatedAt: now, + }) + .where( + and( + eq(tableRowExecutions.capabilityGovernedUserId, userId), + inArray(tableRowExecutions.status, ['pending', 'queued']) + ) + ) +} + /** * Strips the given workflow group ids from every row's executions on a table — * used by the column / group delete paths so stale running/queued exec records diff --git a/apps/sim/lib/users/account-deletion-marker-cancel.test.ts b/apps/sim/lib/users/account-deletion-marker-cancel.test.ts new file mode 100644 index 00000000000..11c206c0ae0 --- /dev/null +++ b/apps/sim/lib/users/account-deletion-marker-cancel.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, hasMockCondition, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsSoleOwnerOfPaidOrganization, mockGetPersonalSubscription, mockIsUsingCloudStorage } = + vi.hoisted(() => ({ + mockIsSoleOwnerOfPaidOrganization: vi.fn(), + mockGetPersonalSubscription: vi.fn(), + mockIsUsingCloudStorage: vi.fn(), + })) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + isSoleOwnerOfPaidOrganization: mockIsSoleOwnerOfPaidOrganization, +})) +vi.mock('@/lib/billing/core/plan', () => ({ + getHighestPriorityPersonalSubscription: mockGetPersonalSubscription, +})) +vi.mock('@/lib/uploads', () => ({ + isUsingCloudStorage: mockIsUsingCloudStorage, + StorageService: { deleteFiles: vi.fn(async () => ({ failed: [] })) }, +})) +vi.mock('@/lib/workspaces/utils', () => ({ + reassignBilledAccountForUser: vi.fn(async () => ({ unresolved: [] })), + reassignOwnedWorkspacesForUser: vi.fn(async () => ({ unresolved: [] })), +})) + +import { deleteUserAccount } from '@/lib/users/account-deletion' + +/** The `where` filter of the update that targets `table_row_executions`. */ +function markerCancelFilter() { + return dbChainMockFns.where.mock.calls + .map(([condition]) => condition) + .find((condition) => + hasMockCondition( + condition, + (node) => node.left === schemaMock.tableRowExecutions.capabilityGovernedUserId + ) + ) +} + +describe('deleteUserAccount and the account’s pre-stamped cell markers', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsSoleOwnerOfPaidOrganization.mockResolvedValue({ isSoleOwner: false, name: null }) + mockGetPersonalSubscription.mockResolvedValue(null) + mockIsUsingCloudStorage.mockReturnValue(false) + }) + + /** + * Cancelling only the dispatches leaves the markers those dispatches already + * stamped. A marker is drained by whichever worker holds the row's cascade + * lock, and that worker's guard consults its OWN dispatch — so an unrelated + * active dispatch drains the departing account's marker, whose `SET NULL` + * subject then reads as an actorless run with no per-tool gate. + */ + it('terminalizes the account’s still-unstarted markers', async () => { + await deleteUserAccount('user-1') + + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.tableRowExecutions) + const filter = markerCancelFilter() + expect(filter).toBeDefined() + expect(hasMockCondition(filter, (node) => node.type === 'eq' && node.right === 'user-1')).toBe( + true + ) + }) + + /** The same terminal state a cancel writes, so every `isExecCancelled` drain + * guard already refuses to run it. */ + it('writes the canonical cancelled cell state', async () => { + await deleteUserAccount('user-1') + + const cancelled = dbChainMockFns.set.mock.calls + .map(([patch]) => patch as { status?: string; cancelledAt?: Date; error?: string }) + .filter((patch) => patch.status === 'cancelled') + expect(cancelled.some((patch) => patch.error === 'Cancelled')).toBe(true) + expect( + cancelled.every( + (patch) => patch.cancelledAt === undefined || patch.cancelledAt instanceof Date + ) + ).toBe(true) + }) + + /** Only the states a marker sits in before a worker claims it. */ + it('leaves running and terminal cells alone', async () => { + await deleteUserAccount('user-1') + + expect( + hasMockCondition( + markerCancelFilter(), + (node) => + node.type === 'inArray' && + node.column === schemaMock.tableRowExecutions.status && + Array.isArray(node.values) && + node.values.join(',') === 'pending,queued' + ) + ).toBe(true) + }) + + /** After the FK nulls the subject there is nothing left to match on. */ + it('runs before the user row is deleted', async () => { + await deleteUserAccount('user-1') + + const markerCancelOrder = dbChainMockFns.update.mock.calls.reduce( + (found, call, index) => + call[0] === schemaMock.tableRowExecutions + ? dbChainMockFns.update.mock.invocationCallOrder[index] + : found, + undefined as number | undefined + ) + const deleteOrder = dbChainMockFns.delete.mock.invocationCallOrder.at(-1) + expect(markerCancelOrder).toBeDefined() + expect(markerCancelOrder as number).toBeLessThan(deleteOrder as number) + }) +}) diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts index 6a44092271a..6b06c788db2 100644 --- a/apps/sim/lib/users/account-deletion.ts +++ b/apps/sim/lib/users/account-deletion.ts @@ -23,6 +23,7 @@ import type { import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan' import { isSoleOwnerOfPaidOrganization } from '@/lib/billing/organizations/membership' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { cancelPendingMarkersForGovernedSubject } from '@/lib/table/rows/executions' import type { StorageContext } from '@/lib/uploads' import { isUsingCloudStorage, StorageService } from '@/lib/uploads' import { @@ -629,6 +630,16 @@ export async function deleteUserAccount(userId: string): Promise Date: Mon, 31 Aug 2026 23:36:40 -0700 Subject: [PATCH 155/179] refactor(logs): require the projection flags on the shared log reads --- apps/sim/lib/logs/fetch-log-detail.ts | 8 ++++-- apps/sim/lib/logs/list-logs.ts | 28 +++++++++++++------ .../selectors/server/integration-access.ts | 6 ++-- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts index a12c40ac660..d54b6182b13 100644 --- a/apps/sim/lib/logs/fetch-log-detail.ts +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -54,8 +54,10 @@ interface FetchLogDetailArgs { * itemized ledger and the per-block and per-span costs are the figures the * restriction exists to withhold, and a hidden column withholds nothing from a * caller reading the route directly. + * + * Required for the same reason as {@link FetchLogDetailArgs.hideTraceSpans}. */ - hideCostInfo?: boolean + hideCostInfo: boolean } /** @@ -161,8 +163,8 @@ export async function readLogDetail({ lookupColumn, lookupValue, signal, - hideTraceSpans = false, - hideCostInfo = false, + hideTraceSpans, + hideCostInfo, }: FetchLogDetailArgs): Promise { signal?.throwIfAborted() const workflowMatch: SQL = diff --git a/apps/sim/lib/logs/list-logs.ts b/apps/sim/lib/logs/list-logs.ts index 8c3754608b4..2fd1baa5acb 100644 --- a/apps/sim/lib/logs/list-logs.ts +++ b/apps/sim/lib/logs/list-logs.ts @@ -39,14 +39,24 @@ import { encodeLogSortCursor, } from '@/lib/logs/sort-cursor' +/** What a caller asks for: the contract's query, plus cancellation. */ export type ListLogsParams = z.output & { signal?: AbortSignal - /** - * Whether the viewer's permission group withholds spend. Resolved by the - * application use case, never from the query: the contract does not carry it, - * so a client cannot ask for a row it is not entitled to. - */ - hideCostInfo?: boolean +} + +/** + * What the query actually runs with — the request, plus the viewer's spend + * projection. + * + * `hideCostInfo` is required and lives on this type rather than on + * {@link ListLogsParams} for two reasons that pull the same way. It is resolved + * by the application use case and never read off the query, so a client cannot + * ask for a row it is not entitled to; and being required rather than defaulted + * to `false`, a caller of this read that forgets it fails to compile instead of + * quietly disclosing every run's cost. + */ +export type ReadLogsParams = ListLogsParams & { + hideCostInfo: boolean } type SortBy = 'date' | 'duration' | 'cost' | 'status' @@ -55,9 +65,9 @@ type SortOrder = 'asc' | 'desc' /** * Canonical logs list query after workspace authorization. */ -export async function readLogs(params: ListLogsParams): Promise { +export async function readLogs(params: ReadLogsParams): Promise { params.signal?.throwIfAborted() - const hideCostInfo = params.hideCostInfo === true + const { hideCostInfo } = params const sortBy = params.sortBy as SortBy const sortOrder = params.sortOrder as SortOrder const cursor = params.cursor ? decodeLogSortCursor(params.cursor) : null @@ -68,7 +78,7 @@ export async function readLogs(params: ListLogsParams): Promise = (() => { switch (sortBy) { diff --git a/apps/sim/lib/selectors/server/integration-access.ts b/apps/sim/lib/selectors/server/integration-access.ts index ea2a167d7d1..a1fc5a513cb 100644 --- a/apps/sim/lib/selectors/server/integration-access.ts +++ b/apps/sim/lib/selectors/server/integration-access.ts @@ -34,8 +34,10 @@ function selectorResourceServiceIds(policy: SelectorCredentialPolicy): readonly * * An empty result means "no integration identity", which is a pass. That is * reserved for the internal selectors — workspace files, knowledge bases, - * tables — which read only Sim's own data; `selectorIntegrationCoverage` in the - * manifest test keeps every `provider-server` selector out of it. + * tables — which read only Sim's own data. `integration-access.test.ts` keeps + * every provider selector out of it: "gives every provider selector an + * integration identity" walks the whole manifest and fails on the first + * provider-backed selector this function answers with an empty list. */ export function selectorIntegrationBlockTypes( attachment: Pick From fefb852f681529e53273f94e9f2af835c623c67f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:37:35 -0700 Subject: [PATCH 156/179] fix(mcp): gate every raw MCP management route on its declared capability deploy.mcp was enforced at one of the thirteen routes behind validateMcpAuth -- the workflow-server create handler, which grew an inline check. Its siblings had none, so an organization setting hideDeployMcp got a 403 from the v2 twin and from create, while POST .../tools still published a workflow over MCP and PATCH isPublic:true still stripped authentication from everything that server publishes. withMcpAuth now takes the capability as a required argument and applies it after the role check, with capabilityGovernedAuthUserId as the subject so the executor's internal JWT passes ungated the way every other surface treats it. Each route declares the capability its mcpServerOperations twin already declares. --- .../api/mcp/capability-declarations.test.ts | 76 +++++ apps/sim/app/api/mcp/oauth/start/route.ts | 5 +- .../app/api/mcp/servers/[id]/refresh/route.ts | 229 +++++++------- apps/sim/app/api/mcp/servers/[id]/route.ts | 5 +- apps/sim/app/api/mcp/servers/route.ts | 295 +++++++++--------- .../api/mcp/servers/test-connection/route.ts | 5 +- apps/sim/app/api/mcp/tools/discover/route.ts | 10 +- apps/sim/app/api/mcp/tools/stored/route.ts | 5 +- .../api/mcp/workflow-servers/[id]/route.ts | 95 +++--- .../[id]/tools/[toolId]/route.ts | 89 +++--- .../mcp/workflow-servers/[id]/tools/route.ts | 102 +++--- .../api/mcp/workflow-servers/route.test.ts | 48 +-- .../sim/app/api/mcp/workflow-servers/route.ts | 114 +++---- apps/sim/lib/mcp/middleware.test.ts | 163 ++++++++++ apps/sim/lib/mcp/middleware.ts | 74 ++++- 15 files changed, 807 insertions(+), 508 deletions(-) create mode 100644 apps/sim/app/api/mcp/capability-declarations.test.ts create mode 100644 apps/sim/lib/mcp/middleware.test.ts diff --git a/apps/sim/app/api/mcp/capability-declarations.test.ts b/apps/sim/app/api/mcp/capability-declarations.test.ts new file mode 100644 index 00000000000..083b5b23c33 --- /dev/null +++ b/apps/sim/app/api/mcp/capability-declarations.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + * + * Every raw MCP management route declares the permission-group capability its + * `/api/v2` twin declares in `mcpServerOperations`. + * + * The type system already forces a declaration to exist — `withMcpAuth` takes + * the capability as a required argument — but it cannot say whether the value is + * the right one, and a new sibling reaching for `'none'` because it compiles is + * the exact shape of the bug this closes: twelve routes sat beside a gated + * thirteenth for a release, including the one whose `isPublic` flip strips + * authentication from everything the server publishes. + */ +import { describe, expect, it, vi } from 'vitest' + +const { recorded } = vi.hoisted(() => ({ + recorded: [] as { file: string; level: string; capability: string }[], +})) + +vi.mock('@/lib/mcp/middleware', () => ({ + readMcpJsonBodyWithLimit: (request: Request) => request.json(), + mcpBodyReadErrorResponse: () => null, + withMcpAuth: (level: string, capability: string) => { + /** + * The declaring module, read off the call site. `withMcpAuth` is invoked at + * module scope, so the frame below this one is the route file — which is + * what lets one recording mock speak about thirteen modules at once. + */ + const frame = new Error('capture').stack?.split('\n')[2] ?? '' + const file = frame.match(/app\/api\/mcp\/[^):\s]*route\.ts/)?.[0] ?? frame + recorded.push({ file, level, capability }) + return (handler: unknown) => handler + }, +})) + +import '@/app/api/mcp/workflow-servers/route' +import '@/app/api/mcp/workflow-servers/[id]/route' +import '@/app/api/mcp/workflow-servers/[id]/tools/route' +import '@/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route' +import '@/app/api/mcp/servers/route' +import '@/app/api/mcp/servers/[id]/route' +import '@/app/api/mcp/servers/[id]/refresh/route' +import '@/app/api/mcp/servers/test-connection/route' +import '@/app/api/mcp/tools/discover/route' +import '@/app/api/mcp/tools/stored/route' +import '@/app/api/mcp/oauth/start/route' + +describe('MCP management route capability declarations', () => { + it('gates every handler on a capability, never on nothing', () => { + expect(recorded.length).toBeGreaterThanOrEqual(20) + expect(recorded.filter((entry) => entry.capability === 'none')).toEqual([]) + }) + + /** + * `mcp_servers.workflow_deployments.*` is publishing a workflow *as* an MCP + * server, which is what `hideDeployMcp` names — reads included, so a group + * withholding the surface does not still answer with what is published on it. + */ + it('declares deploy.mcp on every workflow-server route, reads included', () => { + const deployRoutes = recorded.filter((entry) => entry.file.includes('workflow-servers')) + + expect(deployRoutes.length).toBe(10) + expect(deployRoutes.every((entry) => entry.capability === 'deploy.mcp')).toBe(true) + }) + + /** + * `mcp_servers.*` is the workspace's registry of external MCP servers, which + * every one of its operations declares `mcp_tools.use` for. + */ + it('declares mcp_tools.use on every external-server registry route', () => { + const registryRoutes = recorded.filter((entry) => !entry.file.includes('workflow-servers')) + + expect(registryRoutes.length).toBeGreaterThanOrEqual(10) + expect(registryRoutes.every((entry) => entry.capability === 'mcp_tools.use')).toBe(true) + }) +}) diff --git a/apps/sim/app/api/mcp/oauth/start/route.ts b/apps/sim/app/api/mcp/oauth/start/route.ts index 3a936167650..948185368b6 100644 --- a/apps/sim/app/api/mcp/oauth/start/route.ts +++ b/apps/sim/app/api/mcp/oauth/start/route.ts @@ -93,7 +93,10 @@ function truncate(message: string): string { export const dynamic = 'force-dynamic' export const GET = withRouteHandler( - withMcpAuth('write')(async (request: NextRequest, { userId, workspaceId }) => { + withMcpAuth( + 'write', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId }) => { try { const parsed = await parseRequest(startMcpOauthContract, request, {}) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts index 550aa2f77d3..c4131eec801 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -158,137 +158,138 @@ async function syncToolSchemasToWorkflows( } export const POST = withRouteHandler( - withMcpAuth<{ id: string }>('write')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { - try { - const paramsValidation = mcpServerIdParamsSchema.safeParse(await params) - if (!paramsValidation.success) return validationErrorResponse(paramsValidation.error) - const { id: serverId } = paramsValidation.data - logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`) - - const [server] = await db - .select() - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) + withMcpAuth<{ id: string }>( + 'write', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const paramsValidation = mcpServerIdParamsSchema.safeParse(await params) + if (!paramsValidation.success) return validationErrorResponse(paramsValidation.error) + const { id: serverId } = paramsValidation.data + logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`) + + const [server] = await db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) ) - .limit(1) + ) + .limit(1) + + if (!server) { + return createMcpErrorResponse( + new Error('Server not found or access denied'), + 'Server not found', + 404 + ) + } - if (!server) { - return createMcpErrorResponse( - new Error('Server not found or access denied'), - 'Server not found', - 404 - ) - } + let syncResult: SyncResult = { updatedCount: 0, updatedWorkflowIds: [] } + let discoveredTools: McpTool[] = [] + let discoveryError: string | null = null + const discoveryStartedAt = new Date() - let syncResult: SyncResult = { updatedCount: 0, updatedWorkflowIds: [] } - let discoveredTools: McpTool[] = [] - let discoveryError: string | null = null - const discoveryStartedAt = new Date() + try { + discoveredTools = await mcpService.discoverServerTools( + userId, + serverId, + workspaceId, + 'force' + ) + logger.info( + `[${requestId}] Discovered ${discoveredTools.length} tools from server ${serverId}` + ) + } catch (error) { + discoveryError = truncate(categorizeError(error).message, 200, '') + logger.warn(`[${requestId}] Failed to connect to server ${serverId}`, { + error: discoveryError, + }) + } + if (discoveryError === null) { try { - discoveredTools = await mcpService.discoverServerTools( - userId, - serverId, + syncResult = await syncToolSchemasToWorkflows( workspaceId, - 'force' - ) - logger.info( - `[${requestId}] Discovered ${discoveredTools.length} tools from server ${serverId}` + serverId, + discoveredTools, + requestId, + { url: server.url ?? undefined, name: server.name ?? undefined } ) } catch (error) { - discoveryError = truncate(categorizeError(error).message, 200, '') - logger.warn(`[${requestId}] Failed to connect to server ${serverId}`, { - error: discoveryError, + // Discovery already persisted status and cached tools; a workflow-sync + // failure is a secondary propagation and must not fail the refresh with + // a 500. Surface it as zero workflows updated instead. + logger.warn(`[${requestId}] Tool schema sync failed after successful discovery`, { + error: getErrorMessage(error), }) } + } - if (discoveryError === null) { - try { - syncResult = await syncToolSchemasToWorkflows( - workspaceId, - serverId, - discoveredTools, - requestId, - { url: server.url ?? undefined, name: server.name ?? undefined } - ) - } catch (error) { - // Discovery already persisted status and cached tools; a workflow-sync - // failure is a secondary propagation and must not fail the refresh with - // a 500. Surface it as zero workflows updated instead. - logger.warn(`[${requestId}] Tool schema sync failed after successful discovery`, { - error: getErrorMessage(error), - }) - } - } - - const now = new Date() - - /** - * Deliberately leaves `updatedAt` alone, matching the invariant - * `McpService.updateServerStatus` holds: `updatedAt` means "when the - * server's configuration last changed", and it is one of the public - * list's keyset sorts. A refresh stamping it moves the row to the head - * of `sortBy=updatedAt` under an in-flight page, so a caller walking the - * list while anyone presses this button sees servers duplicated across - * pages and others skipped. Refresh liveness is already published - * through `lastToolsRefresh`, `lastConnected`, and `lastError`. - */ - const [refreshedServer] = await db - .update(mcpServers) - .set({ - lastToolsRefresh: now, - }) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) + const now = new Date() + + /** + * Deliberately leaves `updatedAt` alone, matching the invariant + * `McpService.updateServerStatus` holds: `updatedAt` means "when the + * server's configuration last changed", and it is one of the public + * list's keyset sorts. A refresh stamping it moves the row to the head + * of `sortBy=updatedAt` under an in-flight page, so a caller walking the + * list while anyone presses this button sees servers duplicated across + * pages and others skipped. Refresh liveness is already published + * through `lastToolsRefresh`, `lastConnected`, and `lastError`. + */ + const [refreshedServer] = await db + .update(mcpServers) + .set({ + lastToolsRefresh: now, + }) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) ) - .returning({ - connectionStatus: mcpServers.connectionStatus, - lastConnected: mcpServers.lastConnected, - lastError: mcpServers.lastError, - toolCount: mcpServers.toolCount, - }) + ) + .returning({ + connectionStatus: mcpServers.connectionStatus, + lastConnected: mcpServers.lastConnected, + lastError: mcpServers.lastError, + toolCount: mcpServers.toolCount, + }) - let connectionStatus = refreshedServer?.connectionStatus ?? 'error' - let lastError = refreshedServer ? refreshedServer.lastError : discoveryError - const toolCount = refreshedServer?.toolCount ?? discoveredTools.length + let connectionStatus = refreshedServer?.connectionStatus ?? 'error' + let lastError = refreshedServer ? refreshedServer.lastError : discoveryError + const toolCount = refreshedServer?.toolCount ?? discoveredTools.length - if (discoveryError !== null && connectionStatus === 'connected') { - const newerSuccessWonRace = - refreshedServer?.lastConnected != null && - refreshedServer.lastConnected > discoveryStartedAt + if (discoveryError !== null && connectionStatus === 'connected') { + const newerSuccessWonRace = + refreshedServer?.lastConnected != null && + refreshedServer.lastConnected > discoveryStartedAt - if (!newerSuccessWonRace) { - connectionStatus = 'disconnected' - lastError = discoveryError - } - } - - if (connectionStatus === 'connected') { - await mcpService.clearCache(workspaceId) + if (!newerSuccessWonRace) { + connectionStatus = 'disconnected' + lastError = discoveryError } + } - return createMcpSuccessResponse({ - status: connectionStatus, - toolCount, - lastConnected: refreshedServer?.lastConnected?.toISOString() || null, - error: lastError, - workflowsUpdated: syncResult.updatedCount, - updatedWorkflowIds: syncResult.updatedWorkflowIds, - }) - } catch (error) { - logger.error(`[${requestId}] Error refreshing MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to refresh MCP server', 500) + if (connectionStatus === 'connected') { + await mcpService.clearCache(workspaceId) } + + return createMcpSuccessResponse({ + status: connectionStatus, + toolCount, + lastConnected: refreshedServer?.lastConnected?.toISOString() || null, + error: lastError, + workflowsUpdated: syncResult.updatedCount, + updatedWorkflowIds: syncResult.updatedWorkflowIds, + }) + } catch (error) { + logger.error(`[${requestId}] Error refreshing MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to refresh MCP server', 500) } - ) + }) ) diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index f55d123d245..5a8a4073c5c 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -24,7 +24,10 @@ export const dynamic = 'force-dynamic' * PATCH - Update an MCP server in the workspace (requires write or admin permission) */ export const PATCH = withRouteHandler( - withMcpAuth<{ id: string }>('write')( + withMcpAuth<{ id: string }>( + 'write', + 'mcp_tools.use' + )( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index b542a70a668..daccfa311b0 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -29,172 +29,173 @@ export const dynamic = 'force-dynamic' * GET - List all registered MCP servers for the workspace */ export const GET = withRouteHandler( - withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId, permission }) => { - try { - logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) - - const rows = await db - .select() - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - - /** - * Header values are the upstream credential and are stored unencrypted, so - * they are withheld from anyone who cannot already rewrite them. Editors and - * admins still receive them because the settings form round-trips the - * existing values on save. - */ - const includeHeaderValues = permissionSatisfies(permission, 'write') - const servers = rows.map((row) => projectInternalMcpServer(row, { includeHeaderValues })) - - logger.info( - `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}` - ) - return createMcpSuccessResponse({ servers }) - } catch (error) { - logger.error(`[${requestId}] Error listing MCP servers:`, error) - return createMcpErrorResponse(toError(error), 'Failed to list MCP servers', 500) - } + withMcpAuth( + 'read', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId, requestId, permission }) => { + try { + logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) + + const rows = await db + .select() + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + + /** + * Header values are the upstream credential and are stored unencrypted, so + * they are withheld from anyone who cannot already rewrite them. Editors and + * admins still receive them because the settings form round-trips the + * existing values on save. + */ + const includeHeaderValues = permissionSatisfies(permission, 'write') + const servers = rows.map((row) => projectInternalMcpServer(row, { includeHeaderValues })) + + logger.info( + `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}` + ) + return createMcpSuccessResponse({ servers }) + } catch (error) { + logger.error(`[${requestId}] Error listing MCP servers:`, error) + return createMcpErrorResponse(toError(error), 'Failed to list MCP servers', 500) } - ) + }) ) /** * POST - Register a new MCP server for the workspace (requires write permission) */ export const POST = withRouteHandler( - withMcpAuth('write')( - async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { - try { - const rawBody = await readMcpJsonBodyWithLimit(request) - const parsedBody = createMcpServerBodySchema.safeParse(rawBody) - - if (!parsedBody.success) { - return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) - } - - const body = parsedBody.data - - logger.info(`[${requestId}] Registering MCP server:`, { - name: body.name, - transport: body.transport, - workspaceId, - }) - - const sourceParam = body.source as string | undefined - const source = - sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined - if (!body.url) { - return createMcpErrorResponse( - new Error('url is required'), - 'Missing required parameter', - 400 - ) - } - const result = await performCreateMcpServer({ - workspaceId, - userId, - actorName: userName, - actorEmail: userEmail, - name: body.name, - description: body.description, - transport: body.transport, - url: body.url, - headers: body.headers, - timeout: body.timeout, - retries: body.retries, - enabled: body.enabled, - source, - authType: body.authType, - oauthClientId: body.oauthClientId || null, - oauthClientIdProvided: body.oauthClientId !== undefined, - oauthClientSecret: body.oauthClientSecret, - oauthClientSecretProvided: body.oauthClientSecret !== undefined, - request, - }) - if (!result.success || !result.serverId) { - return createMcpErrorResponse( - new Error(result.error || 'Failed to register MCP server'), - result.error || 'Failed to register MCP server', - mcpOrchestrationStatus(result.errorCode) - ) - } - - logger.info( - `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${result.serverId})` - ) + withMcpAuth( + 'write', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { + try { + const rawBody = await readMcpJsonBodyWithLimit(request) + const parsedBody = createMcpServerBodySchema.safeParse(rawBody) + + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) + } - return createMcpSuccessResponse( - result.updated - ? { serverId: result.serverId, updated: true, authType: result.authType } - : { serverId: result.serverId, authType: result.authType }, - result.updated ? 200 : 201 + const body = parsedBody.data + + logger.info(`[${requestId}] Registering MCP server:`, { + name: body.name, + transport: body.transport, + workspaceId, + }) + + const sourceParam = body.source as string | undefined + const source = + sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined + if (!body.url) { + return createMcpErrorResponse( + new Error('url is required'), + 'Missing required parameter', + 400 + ) + } + const result = await performCreateMcpServer({ + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + source, + authType: body.authType, + oauthClientId: body.oauthClientId || null, + oauthClientIdProvided: body.oauthClientId !== undefined, + oauthClientSecret: body.oauthClientSecret, + oauthClientSecretProvided: body.oauthClientSecret !== undefined, + request, + }) + if (!result.success || !result.serverId) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to register MCP server'), + result.error || 'Failed to register MCP server', + mcpOrchestrationStatus(result.errorCode) ) - } catch (error) { - const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) - if (bodyErrorResponse) return bodyErrorResponse - logger.error(`[${requestId}] Error registering MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500) } + + logger.info( + `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${result.serverId})` + ) + + return createMcpSuccessResponse( + result.updated + ? { serverId: result.serverId, updated: true, authType: result.authType } + : { serverId: result.serverId, authType: result.authType }, + result.updated ? 200 : 201 + ) + } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse + logger.error(`[${requestId}] Error registering MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500) } - ) + }) ) /** * DELETE - Delete an MCP server from the workspace (requires write permission) */ export const DELETE = withRouteHandler( - withMcpAuth('write')( - async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { - try { - const { searchParams } = new URL(request.url) - const queryValidation = deleteMcpServerByQuerySchema.safeParse( - Object.fromEntries(searchParams) - ) - if (!queryValidation.success) return validationErrorResponse(queryValidation.error) - const query = queryValidation.data - const serverId = query.serverId - const sourceParam = query.source - const source = - sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined - - if (!serverId) { - return createMcpErrorResponse( - new Error('serverId parameter is required'), - 'Missing required parameter', - 400 - ) - } - - logger.info( - `[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}` + withMcpAuth( + 'write', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { + try { + const { searchParams } = new URL(request.url) + const queryValidation = deleteMcpServerByQuerySchema.safeParse( + Object.fromEntries(searchParams) + ) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + const query = queryValidation.data + const serverId = query.serverId + const sourceParam = query.source + const source = + sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined + + if (!serverId) { + return createMcpErrorResponse( + new Error('serverId parameter is required'), + 'Missing required parameter', + 400 ) + } - const result = await performDeleteMcpServer({ - workspaceId, - userId, - actorName: userName, - actorEmail: userEmail, - serverId, - source, - request, - }) - if (!result.success || !result.server) { - return createMcpErrorResponse( - new Error(result.error || 'Failed to delete MCP server'), - result.error || 'Failed to delete MCP server', - mcpOrchestrationStatus(result.errorCode) - ) - } - - logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) - - return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) - } catch (error) { - logger.error(`[${requestId}] Error deleting MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to delete MCP server', 500) + logger.info(`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`) + + const result = await performDeleteMcpServer({ + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + serverId, + source, + request, + }) + if (!result.success || !result.server) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to delete MCP server'), + result.error || 'Failed to delete MCP server', + mcpOrchestrationStatus(result.errorCode) + ) } + + logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) + + return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) + } catch (error) { + logger.error(`[${requestId}] Error deleting MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to delete MCP server', 500) } - ) + }) ) diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index 6a12a8346ae..3f147f75998 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -84,7 +84,10 @@ function sanitizeConnectionError(error: unknown): string { * POST - Test connection to an MCP server before registering it */ export const POST = withRouteHandler( - withMcpAuth('write')(async (request: NextRequest, { userId, workspaceId, requestId }) => { + withMcpAuth( + 'write', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = mcpServerTestBodySchema.safeParse(rawBody) diff --git a/apps/sim/app/api/mcp/tools/discover/route.ts b/apps/sim/app/api/mcp/tools/discover/route.ts index a592dc116bb..d1323e5a333 100644 --- a/apps/sim/app/api/mcp/tools/discover/route.ts +++ b/apps/sim/app/api/mcp/tools/discover/route.ts @@ -51,7 +51,10 @@ async function settleWithConcurrency( } export const GET = withRouteHandler( - withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { + withMcpAuth( + 'read', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const { searchParams } = new URL(request.url) const queryValidation = mcpToolDiscoveryQuerySchema.safeParse( @@ -107,7 +110,10 @@ export const GET = withRouteHandler( ) export const POST = withRouteHandler( - withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { + withMcpAuth( + 'read', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = refreshMcpToolsBodySchema.safeParse(rawBody) diff --git a/apps/sim/app/api/mcp/tools/stored/route.ts b/apps/sim/app/api/mcp/tools/stored/route.ts index 3606e05115d..5329f578a5b 100644 --- a/apps/sim/app/api/mcp/tools/stored/route.ts +++ b/apps/sim/app/api/mcp/tools/stored/route.ts @@ -14,7 +14,10 @@ const logger = createLogger('McpStoredToolsAPI') export const dynamic = 'force-dynamic' export const GET = withRouteHandler( - withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { + withMcpAuth( + 'read', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { logger.info(`[${requestId}] Fetching stored MCP tools for workspace ${workspaceId}`) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index d765c4985f7..56eda6a9c5a 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -36,61 +36,65 @@ interface RouteParams { * GET - Get a specific workflow MCP server with its tools */ export const GET = withRouteHandler( - withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { - try { - const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) - - logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`) - - const [server] = await db - .select({ - id: workflowMcpServer.id, - workspaceId: workflowMcpServer.workspaceId, - createdBy: workflowMcpServer.createdBy, - name: workflowMcpServer.name, - description: workflowMcpServer.description, - isPublic: workflowMcpServer.isPublic, - createdAt: workflowMcpServer.createdAt, - updatedAt: workflowMcpServer.updatedAt, - }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) + withMcpAuth( + 'read', + 'deploy.mcp' + )(async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) + + logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`) + + const [server] = await db + .select({ + id: workflowMcpServer.id, + workspaceId: workflowMcpServer.workspaceId, + createdBy: workflowMcpServer.createdBy, + name: workflowMcpServer.name, + description: workflowMcpServer.description, + isPublic: workflowMcpServer.isPublic, + createdAt: workflowMcpServer.createdAt, + updatedAt: workflowMcpServer.updatedAt, + }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) ) - .limit(1) + ) + .limit(1) - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } - const tools = await db - .select() - .from(workflowMcpTool) - .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) + const tools = await db + .select() + .from(workflowMcpTool) + .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) - logger.info( - `[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools` - ) + logger.info( + `[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools` + ) - return createMcpSuccessResponse({ server, tools }) - } catch (error) { - logger.error(`[${requestId}] Error getting workflow MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to get workflow MCP server', 500) - } + return createMcpSuccessResponse({ server, tools }) + } catch (error) { + logger.error(`[${requestId}] Error getting workflow MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to get workflow MCP server', 500) } - ) + }) ) /** * PATCH - Update a workflow MCP server */ export const PATCH = withRouteHandler( - withMcpAuth('write')( + withMcpAuth( + 'write', + 'deploy.mcp' + )( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, @@ -147,7 +151,10 @@ export const PATCH = withRouteHandler( * DELETE - Delete a workflow MCP server and all its tools */ export const DELETE = withRouteHandler( - withMcpAuth('write')( + withMcpAuth( + 'write', + 'deploy.mcp' + )( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index 1bb3325d489..0105df0a387 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -34,59 +34,63 @@ interface RouteParams { * GET - Get a specific tool */ export const GET = withRouteHandler( - withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { - try { - const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params) - - logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`) - - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) + withMcpAuth( + 'read', + 'deploy.mcp' + )(async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params) + + logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`) + + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) ) - .limit(1) + ) + .limit(1) - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } - const [tool] = await db - .select() - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.id, toolId), - eq(workflowMcpTool.serverId, serverId), - isNull(workflowMcpTool.archivedAt) - ) + const [tool] = await db + .select() + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.id, toolId), + eq(workflowMcpTool.serverId, serverId), + isNull(workflowMcpTool.archivedAt) ) - .limit(1) + ) + .limit(1) - if (!tool) { - return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) - } - - return createMcpSuccessResponse({ tool }) - } catch (error) { - logger.error(`[${requestId}] Error getting tool:`, error) - return createMcpErrorResponse(toError(error), 'Failed to get tool', 500) + if (!tool) { + return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) } + + return createMcpSuccessResponse({ tool }) + } catch (error) { + logger.error(`[${requestId}] Error getting tool:`, error) + return createMcpErrorResponse(toError(error), 'Failed to get tool', 500) } - ) + }) ) /** * PATCH - Update a tool's configuration */ export const PATCH = withRouteHandler( - withMcpAuth('write')( + withMcpAuth( + 'write', + 'deploy.mcp' + )( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, @@ -144,7 +148,10 @@ export const PATCH = withRouteHandler( * DELETE - Remove a tool from an MCP server */ export const DELETE = withRouteHandler( - withMcpAuth('write')( + withMcpAuth( + 'write', + 'deploy.mcp' + )( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index 9ddf39ca214..b97d5bb3233 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -33,67 +33,71 @@ interface RouteParams { * GET - List all tools for a workflow MCP server */ export const GET = withRouteHandler( - withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { - try { - const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) - - logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`) - - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) + withMcpAuth( + 'read', + 'deploy.mcp' + )(async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) + + logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`) + + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) ) - .limit(1) + ) + .limit(1) - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } - const tools = await db - .select({ - id: workflowMcpTool.id, - serverId: workflowMcpTool.serverId, - workflowId: workflowMcpTool.workflowId, - toolName: workflowMcpTool.toolName, - toolDescription: workflowMcpTool.toolDescription, - parameterSchema: workflowMcpTool.parameterSchema, - parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides, - createdAt: workflowMcpTool.createdAt, - updatedAt: workflowMcpTool.updatedAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - isDeployed: workflow.isDeployed, - }) - .from(workflowMcpTool) - .leftJoin( - workflow, - and(eq(workflowMcpTool.workflowId, workflow.id), isNull(workflow.archivedAt)) - ) - .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) + const tools = await db + .select({ + id: workflowMcpTool.id, + serverId: workflowMcpTool.serverId, + workflowId: workflowMcpTool.workflowId, + toolName: workflowMcpTool.toolName, + toolDescription: workflowMcpTool.toolDescription, + parameterSchema: workflowMcpTool.parameterSchema, + parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides, + createdAt: workflowMcpTool.createdAt, + updatedAt: workflowMcpTool.updatedAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + isDeployed: workflow.isDeployed, + }) + .from(workflowMcpTool) + .leftJoin( + workflow, + and(eq(workflowMcpTool.workflowId, workflow.id), isNull(workflow.archivedAt)) + ) + .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) - logger.info(`[${requestId}] Found ${tools.length} tools for server ${serverId}`) + logger.info(`[${requestId}] Found ${tools.length} tools for server ${serverId}`) - return createMcpSuccessResponse({ tools }) - } catch (error) { - logger.error(`[${requestId}] Error listing tools:`, error) - return createMcpErrorResponse(toError(error), 'Failed to list tools', 500) - } + return createMcpSuccessResponse({ tools }) + } catch (error) { + logger.error(`[${requestId}] Error listing tools:`, error) + return createMcpErrorResponse(toError(error), 'Failed to list tools', 500) } - ) + }) ) /** * POST - Add a workflow as a tool to an MCP server */ export const POST = withRouteHandler( - withMcpAuth('write')( + withMcpAuth( + 'write', + 'deploy.mcp' + )( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, diff --git a/apps/sim/app/api/mcp/workflow-servers/route.test.ts b/apps/sim/app/api/mcp/workflow-servers/route.test.ts index c05aed0fbf6..44042f6a9c5 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.test.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.test.ts @@ -1,18 +1,18 @@ /** * @vitest-environment node + * + * The `deploy.mcp` gate this route used to carry inline now lives on + * `withMcpAuth`, where its twelve siblings inherit it — see + * `lib/mcp/middleware.test.ts` for the gate itself and + * `app/api/mcp/capability-declarations.test.ts` for what each route declares. + * What is left here is the handler's own behavior with the gate passed. */ -import { - permissionGroupScopeMock, - permissionGroupScopeMockFns, - resetDbChainMock, -} from '@sim/testing' +import { resetDbChainMock } from '@sim/testing' import type { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockPerformCreate } = vi.hoisted(() => ({ mockPerformCreate: vi.fn() })) -const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig - vi.mock('@/lib/mcp/middleware', () => ({ readMcpJsonBodyWithLimit: (request: NextRequest) => request.json(), mcpBodyReadErrorResponse: () => null, @@ -44,9 +44,6 @@ vi.mock('@/lib/mcp/orchestration', () => ({ performCreateWorkflowMcpServer: mockPerformCreate, })) -vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) - -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { POST } from '@/app/api/mcp/workflow-servers/route' function createRequest() { @@ -57,7 +54,7 @@ function createRequest() { }) as NextRequest } -describe('workflow MCP servers POST route — deploy.mcp capability gate', () => { +describe('workflow MCP servers POST route', () => { afterAll(() => { resetDbChainMock() }) @@ -72,24 +69,7 @@ describe('workflow MCP servers POST route — deploy.mcp capability gate', () => }) }) - it('refuses to create a workflow MCP server when the group withholds deploy.mcp', async () => { - resolveGroupConfigMock.mockResolvedValue({ - ...DEFAULT_PERMISSION_GROUP_CONFIG, - hideDeployMcp: true, - }) - - const response = await POST(createRequest(), { params: Promise.resolve({}) }) - - expect(response.status).toBe(403) - await expect(response.json()).resolves.toMatchObject({ - error: "MCP server deployment is not available under your organization's permission group", - }) - expect(mockPerformCreate).not.toHaveBeenCalled() - }) - - it('creates the server when a group governs the user but withholds nothing', async () => { - resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) - + it('creates the server through the orchestration helper', async () => { const response = await POST(createRequest(), { params: Promise.resolve({}) }) expect(response.status).toBe(201) @@ -97,14 +77,4 @@ describe('workflow MCP servers POST route — deploy.mcp capability gate', () => expect.objectContaining({ workspaceId: 'workspace-1', name: 'Deploy bot' }) ) }) - - /** A personal workspace, or any non-enterprise organization, is governed by no group. */ - it('creates the server when no permission group governs the user', async () => { - resolveGroupConfigMock.mockResolvedValue(null) - - const response = await POST(createRequest(), { params: Promise.resolve({}) }) - - expect(response.status).toBe(201) - expect(mockPerformCreate).toHaveBeenCalledTimes(1) - }) }) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index a6c9609dfdd..16722ea657e 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -17,10 +17,6 @@ import { createMcpSuccessResponse, mcpOrchestrationStatus, } from '@/lib/mcp/utils' -import { - capabilityRefusal, - isWorkspaceCapabilityWithheld, -} from '@/lib/permission-groups/capability-assertions' const logger = createLogger('WorkflowMcpServersAPI') @@ -30,7 +26,10 @@ export const dynamic = 'force-dynamic' * GET - List all workflow MCP servers for the workspace */ export const GET = withRouteHandler( - withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { + withMcpAuth( + 'read', + 'deploy.mcp' + )(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { logger.info(`[${requestId}] Listing workflow MCP servers for workspace ${workspaceId}`) @@ -101,68 +100,57 @@ export const GET = withRouteHandler( * POST - Create a new workflow MCP server */ export const POST = withRouteHandler( - withMcpAuth('write')( - async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { - try { - const rawBody = await readMcpJsonBodyWithLimit(request) - const parsedBody = createWorkflowMcpServerBodySchema.safeParse(rawBody) - - if (!parsedBody.success) { - return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) - } - - const body = parsedBody.data - - /** - * permission-group-enforced: deploy.mcp — this route calls the - * orchestration helper directly instead of the application use case, so - * the capability declared on `createWorkflowDeploymentServer` never - * fires here. Gating both is what keeps the two doors agreeing; the - * alternative is migrating this handler to the use case, which is worth - * doing and is not a reason to leave the second door open meanwhile. - */ - if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'deploy.mcp')) { - return createMcpErrorResponse(null, capabilityRefusal('deploy.mcp'), 403) - } + withMcpAuth( + 'write', + 'deploy.mcp' + )(async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { + try { + const rawBody = await readMcpJsonBodyWithLimit(request) + const parsedBody = createWorkflowMcpServerBodySchema.safeParse(rawBody) - logger.info(`[${requestId}] Creating workflow MCP server:`, { - name: body.name, - workspaceId, - workflowIds: body.workflowIds, - }) + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) + } - const result = await performCreateWorkflowMcpServer({ - workspaceId, - userId, - actorName: userName, - actorEmail: userEmail, - name: body.name, - description: body.description, - isPublic: body.isPublic, - workflowIds: body.workflowIds, - }) - if (!result.success || !result.server) { - return createMcpErrorResponse( - new Error(result.error || 'Failed to create workflow MCP server'), - result.error || 'Failed to create workflow MCP server', - mcpOrchestrationStatus(result.errorCode) - ) - } + const body = parsedBody.data + + logger.info(`[${requestId}] Creating workflow MCP server:`, { + name: body.name, + workspaceId, + workflowIds: body.workflowIds, + }) + + const result = await performCreateWorkflowMcpServer({ + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + name: body.name, + description: body.description, + isPublic: body.isPublic, + workflowIds: body.workflowIds, + }) + if (!result.success || !result.server) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to create workflow MCP server'), + result.error || 'Failed to create workflow MCP server', + mcpOrchestrationStatus(result.errorCode) + ) + } - const { server } = result - const addedTools = result.addedTools || [] + const { server } = result + const addedTools = result.addedTools || [] - logger.info( - `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})` - ) + logger.info( + `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})` + ) - return createMcpSuccessResponse({ server, addedTools }, 201) - } catch (error) { - const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) - if (bodyErrorResponse) return bodyErrorResponse - logger.error(`[${requestId}] Error creating workflow MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500) - } + return createMcpSuccessResponse({ server, addedTools }, 201) + } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse + logger.error(`[${requestId}] Error creating workflow MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500) } - ) + }) ) diff --git a/apps/sim/lib/mcp/middleware.test.ts b/apps/sim/lib/mcp/middleware.test.ts new file mode 100644 index 00000000000..6f1a15efecc --- /dev/null +++ b/apps/sim/lib/mcp/middleware.test.ts @@ -0,0 +1,163 @@ +/** + * @vitest-environment node + * + * The gate lives on the middleware, so this is where it is proved. Thirteen raw + * MCP management routes sit behind `withMcpAuth` and only the workflow-server + * create handler ever grew a capability check of its own; asserting per route + * would have reproduced exactly that, so these assertions are about the wrapper + * every one of them shares. + */ +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' +import type { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + permissions: vi.fn(), +})) + +vi.mock('@/lib/auth/hybrid', async () => { + const AuthType = { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' } as const + return { + AuthType, + checkSessionOrInternalAuth: mocks.auth, + capabilityGovernedAuthUserId: (auth: { + userId?: string + authType?: string + apiKeyType?: string + }) => { + if (!auth?.userId) return null + if (auth.authType === AuthType.SESSION) return auth.userId + return auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal' + ? auth.userId + : null + }, + } +}) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mocks.permissions, +})) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { withMcpAuth } from '@/lib/mcp/middleware' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +const handler = vi.fn(async () => Response.json({ ok: true }) as never) + +function request() { + return new Request('http://localhost:3000/api/mcp/anything?workspaceId=workspace-1', { + method: 'POST', + }) as NextRequest +} + +function call(capability: 'deploy.mcp' | 'mcp_tools.use' | 'none') { + return withMcpAuth('write', capability)(handler)(request(), { + params: Promise.resolve({}), + }) +} + +describe('withMcpAuth permission-group gate', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.auth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mocks.permissions.mockResolvedValue('admin') + resolveGroupConfigMock.mockResolvedValue(null) + }) + + it('refuses a session caller whose group withholds the declared capability', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployMcp: true, + }) + + const response = await call('deploy.mcp') + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ + error: "MCP server deployment is not available under your organization's permission group", + }) + expect(handler).not.toHaveBeenCalled() + }) + + /** + * The two capabilities are separate keys, so a group withholding one must not + * refuse a route declaring the other — that would make the gate a blanket MCP + * switch rather than the two doors `mcpServerOperations` describes. + */ + it('admits a route whose declared capability the group does not withhold', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployMcp: true, + }) + + const response = await call('mcp_tools.use') + + expect(response.status).toBe(200) + expect(handler).toHaveBeenCalled() + }) + + it('admits a caller no permission group governs', async () => { + const response = await call('deploy.mcp') + + expect(response.status).toBe(200) + expect(handler).toHaveBeenCalled() + }) + + /** + * The executor exemption. An internal JWT's `userId` is the subject the + * executor embedded, so resolving it would hand the run actor's grants to a + * credential that bears no person — the same rule + * `capabilityGovernedAuthUserId` states for every other surface. + */ + it('passes a non-user-bearing internal JWT ungated', async () => { + mocks.auth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployMcp: true, + }) + + const response = await call('deploy.mcp') + + expect(response.status).toBe(200) + expect(handler).toHaveBeenCalled() + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) + + it('resolves no group at all for a route declaring no capability', async () => { + const response = await call('none') + + expect(response.status).toBe(200) + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) + + /** + * A capability refusal handed to a non-member would confirm the workspace + * exists and name which modules the organization withholds; the role failure + * conceals both, so it has to come first. + */ + it('answers the role failure, not the capability refusal, for a non-member', async () => { + mocks.permissions.mockResolvedValue(null) + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployMcp: true, + }) + + const response = await call('deploy.mcp') + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ + error: 'Insufficient permissions', + }) + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/mcp/middleware.ts b/apps/sim/lib/mcp/middleware.ts index 6987f57e960..88be4b04c39 100644 --- a/apps/sim/lib/mcp/middleware.ts +++ b/apps/sim/lib/mcp/middleware.ts @@ -2,7 +2,12 @@ import { createLogger } from '@sim/logger' import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import type { NextRequest, NextResponse } from 'next/server' -import { type AuthTypeValue, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { + type AuthTypeValue, + capabilityGovernedAuthUserId, + checkSessionOrInternalAuth, + type AuthResult as HybridAuthResult, +} from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { assertContentLengthWithinLimit, @@ -10,6 +15,11 @@ import { readStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' import { createMcpErrorResponse } from '@/lib/mcp/utils' +import type { StaticPermissionGroupCapability } from '@/lib/permission-groups/capabilities' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('McpAuthMiddleware') @@ -18,6 +28,52 @@ const parsedBodies = new WeakMap() export type McpPermissionLevel = 'read' | 'write' | 'admin' +/** + * The permission-group capability an MCP management route requires, or `'none'` + * when no group governs it. + * + * Required at every call site, and `'none'` spelled out rather than omitted, + * for the same reason `capability` is required on `defineWorkspaceOperation` + * and on `V1RouteCapability` in the v1 middleware: an absent declaration cannot be told apart + * from an unreviewed one. That is exactly how this surface came to gate + * `deploy.mcp` on one of its thirteen routes — the create handler grew an + * inline check and its twelve siblings, including the one that flips a server + * public, silently did not. + * + * Each route's value is the one its `/api/v2` twin already declares in + * `mcpServerOperations`; this surface does not get a mapping of its own. + */ +export type McpRouteCapability = StaticPermissionGroupCapability | 'none' + +/** + * The permission-group gate for an MCP management route. + * + * Only a user-bearing credential carries capabilities. + * `checkSessionOrInternalAuth` rejects `x-api-key` outright, so the two kinds + * that reach here are a browser session and the executor's internal JWT — and + * the JWT's `userId` is the subject the executor embedded, a value that must not + * hand the run's actor's grants to a caller the executor exemption deliberately + * passes ungated. {@link capabilityGovernedAuthUserId} is the one place that + * distinction is read, so this cannot drift from the funnel's own rule. + * + * Never called before the role check. A capability refusal handed to a + * non-member would confirm the workspace exists and disclose which modules the + * organization withholds; the role failure conceals both. + */ +async function capabilityRefusalResponse( + auth: HybridAuthResult, + workspaceId: string, + capability: McpRouteCapability +): Promise { + if (capability === 'none') return null + const userId = capabilityGovernedAuthUserId(auth) + if (!userId) return null + if (!(await isWorkspaceCapabilityWithheld(userId, workspaceId, capability))) return null + + logger.warn('MCP request blocked by permission group', { workspaceId, userId, capability }) + return createMcpErrorResponse(null, capabilityRefusal(capability), 403) +} + export interface McpAuthContext { userId: string userName?: string | null @@ -118,7 +174,8 @@ export function mcpBodyReadErrorResponse( */ async function validateMcpAuth( request: NextRequest, - permissionLevel: McpPermissionLevel + permissionLevel: McpPermissionLevel, + capability: McpRouteCapability ): Promise { const requestId = generateRequestId() @@ -194,6 +251,11 @@ async function validateMcpAuth( } } + const capabilityFailure = await capabilityRefusalResponse(auth, workspaceId, capability) + if (capabilityFailure) { + return { success: false, errorResponse: capabilityFailure } + } + return { success: true, context: { @@ -246,18 +308,20 @@ function getPermissionErrorMessage(permissionLevel: McpPermissionLevel): string * Higher-order function that wraps MCP route handlers with authentication middleware * * @param permissionLevel - Required permission level ('read', 'write', or 'admin') + * @param capability - The permission-group capability the route requires, or + * `'none'` with a reason. See {@link McpRouteCapability}. * @returns Middleware wrapper function - * */ export function withMcpAuth>( - permissionLevel: McpPermissionLevel = 'read' + permissionLevel: McpPermissionLevel, + capability: McpRouteCapability ) { return function middleware(handler: McpRouteHandler) { return async function wrappedHandler( request: NextRequest, routeContext: { params: Promise } ): Promise { - const authResult = await validateMcpAuth(request, permissionLevel) + const authResult = await validateMcpAuth(request, permissionLevel, capability) if (!authResult.success) { return (authResult as AuthFailure).errorResponse From 5052c9ab944d9580bed61819799c32f83f5459db Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:39:57 -0700 Subject: [PATCH 157/179] docs(permission-groups): record the data-drain policy and make the cost hint honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data drains are an organization-admin instrument, deliberately above member permission groups like the audit-log surface — the reader of a drained record is the organization, not a member. Recorded on the drain access gate so the next auditor finds a decision, not a gap. The hideCostInfo hint no longer promises that ALL exports omit spend. --- apps/sim/lib/data-drains/access.ts | 8 +++++++- apps/sim/lib/permission-groups/fields.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/data-drains/access.ts b/apps/sim/lib/data-drains/access.ts index 5a1db375444..8399011a73a 100644 --- a/apps/sim/lib/data-drains/access.ts +++ b/apps/sim/lib/data-drains/access.ts @@ -24,7 +24,13 @@ export type DrainAccessResult = /** * Auth + membership + role + enterprise-plan gate shared by every data-drain * route. Owner/admin role is required for reads as well as writes since drain - * configs expose customer bucket names and webhook URLs. On Sim Cloud the + * configs expose customer bucket names and webhook URLs. + * + * Deliberately above member permission groups, like the audit-log surface: + * the reader of a drained record is the organization, not a member, so the + * per-member log projections (`hideCostInfo`, `hideTraceSpans`) do not apply + * to what a drain serializes, and no group capability gates configuring one — + * the owner/admin requirement is the whole access story. On Sim Cloud the * gate is the Enterprise plan; on self-hosted it's `DATA_DRAINS_ENABLED`, * which 404s when unset so a newer image doesn't silently expose drains. */ diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index 77478cdf6b7..79b2d92e6fa 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -397,7 +397,7 @@ export const PERMISSION_GROUP_FIELDS = { id: 'hide-cost-info', label: 'Execution Cost', category: 'Logs', - hint: 'Withhold execution cost. Logs and exports omit cost and token spend.', + hint: 'Withhold execution cost. Logs and member exports omit cost and token spend; organization-level data drains, configurable by org admins only, are not projected.', }), disableKnowledgeBaseCreation: booleanRestriction('capability', { scope: 'workspace', From 64fb148cbf4bb1f201801a299efec7a3d5018770 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 23:46:14 -0700 Subject: [PATCH 158/179] test(v1): pin the persistence primitive's governance argument on import The primitive gained a required governance parameter; these assertions pinned the old arity. Asserting the value rather than anything() also pins that a keyType-less v1 credential imports ungoverned, matching the real helper. --- apps/sim/app/api/v1/workflows/import/route.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/v1/workflows/import/route.test.ts b/apps/sim/app/api/v1/workflows/import/route.test.ts index 0c85588eda9..99e68fb508d 100644 --- a/apps/sim/app/api/v1/workflows/import/route.test.ts +++ b/apps/sim/app/api/v1/workflows/import/route.test.ts @@ -272,6 +272,7 @@ describe('POST /api/v1/workflows/import', () => { expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith( 'wf-new', expect.anything(), + { workspaceId: WORKSPACE_ID, subjectUserId: null }, expect.anything() ) }) @@ -375,7 +376,12 @@ describe('POST /api/v1/workflows/import', () => { await POST(makeRequest(validBody())) const tx = { update: mockDbUpdate } - expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith('wf-new', expect.anything(), tx) + expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith( + 'wf-new', + expect.anything(), + { workspaceId: WORKSPACE_ID, subjectUserId: null }, + tx + ) expect(mockDbUpdate).toHaveBeenCalled() }) From f15e07e29b072e09c74286f654134d9b99ed976d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 00:55:31 -0700 Subject: [PATCH 159/179] fix(permission-groups): render the raw inbox and table export refusals through the shared builder --- .../app/api/table/[tableId]/export-async/route.ts | 8 +++----- .../app/api/table/[tableId]/export/download/route.ts | 8 +++----- apps/sim/app/api/workspaces/[id]/inbox/route.ts | 10 ++++------ .../app/api/workspaces/[id]/inbox/senders/route.ts | 12 +++++------- .../sim/app/api/workspaces/[id]/inbox/tasks/route.ts | 8 +++----- 5 files changed, 18 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts index 9f25b4d5ecf..29855f25f06 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.ts @@ -9,10 +9,8 @@ import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - capabilityRefusal, - isWorkspaceCapabilityWithheld, -} from '@/lib/permission-groups/capability-assertions' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { captureServerEvent } from '@/lib/posthog/server' import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' @@ -57,7 +55,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro // permission-group-enforced: tables.export — raw route that queries directly and predates the operation boundary if (await isWorkspaceCapabilityWithheld(authResult.userId, workspaceId, 'tables.export')) { - return NextResponse.json({ error: capabilityRefusal('tables.export') }, { status: 403 }) + return capabilityRefusalResponse('tables.export') } const jobId = generateId() diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.ts b/apps/sim/app/api/table/[tableId]/export/download/route.ts index f0f0f253fb3..3d461ea4286 100644 --- a/apps/sim/app/api/table/[tableId]/export/download/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/download/route.ts @@ -5,10 +5,8 @@ import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - capabilityRefusal, - isWorkspaceCapabilityWithheld, -} from '@/lib/permission-groups/capability-assertions' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { getTableJob } from '@/lib/table/jobs/service' import type { TableExportJobPayload } from '@/lib/table/types' import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' @@ -56,7 +54,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou * every colleague's. */ if (await isWorkspaceCapabilityWithheld(authResult.userId, workspaceId, 'tables.export')) { - return NextResponse.json({ error: capabilityRefusal('tables.export') }, { status: 403 }) + return capabilityRefusalResponse('tables.export') } const job = await getTableJob(tableId, jobId) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index 9fae2dc0d73..5b53c629cb1 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -10,10 +10,8 @@ import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { disableInbox, enableInbox, updateInboxAddress } from '@/lib/mothership/inbox/lifecycle' -import { - capabilityRefusal, - isWorkspaceCapabilityWithheld, -} from '@/lib/permission-groups/capability-assertions' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InboxConfigAPI') @@ -33,7 +31,7 @@ export const GET = withRouteHandler( // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { - return NextResponse.json({ error: capabilityRefusal('inbox.use') }, { status: 403 }) + return capabilityRefusalResponse('inbox.use') } const [wsResult, statsResult, entitled] = await Promise.all([ @@ -105,7 +103,7 @@ export const PATCH = withRouteHandler( // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { - return NextResponse.json({ error: capabilityRefusal('inbox.use') }, { status: 403 }) + return capabilityRefusalResponse('inbox.use') } const parsed = await parseRequest(updateInboxConfigContract, req, context) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts index a587e752284..3530aca8edb 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts @@ -8,10 +8,8 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - capabilityRefusal, - isWorkspaceCapabilityWithheld, -} from '@/lib/permission-groups/capability-assertions' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InboxSendersAPI') @@ -37,7 +35,7 @@ export const GET = withRouteHandler( // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { - return NextResponse.json({ error: capabilityRefusal('inbox.use') }, { status: 403 }) + return capabilityRefusalResponse('inbox.use') } const [senders, members] = await Promise.all([ @@ -98,7 +96,7 @@ export const POST = withRouteHandler( // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { - return NextResponse.json({ error: capabilityRefusal('inbox.use') }, { status: 403 }) + return capabilityRefusalResponse('inbox.use') } try { @@ -162,7 +160,7 @@ export const DELETE = withRouteHandler( // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { - return NextResponse.json({ error: capabilityRefusal('inbox.use') }, { status: 403 }) + return capabilityRefusalResponse('inbox.use') } try { diff --git a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts index 9bc0eac950e..02fd9057214 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts @@ -6,10 +6,8 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - capabilityRefusal, - isWorkspaceCapabilityWithheld, -} from '@/lib/permission-groups/capability-assertions' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const GET = withRouteHandler( @@ -40,7 +38,7 @@ export const GET = withRouteHandler( // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { - return NextResponse.json({ error: capabilityRefusal('inbox.use') }, { status: 403 }) + return capabilityRefusalResponse('inbox.use') } const queryResult = inboxTasksQuerySchema.safeParse( From 37674183927af5700f947c38b4f9c23543ef9e45 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 00:55:35 -0700 Subject: [PATCH 160/179] fix(operations): carve the principal-wide capability out of the operation-declarable union --- apps/sim/lib/core/application/index.ts | 2 + apps/sim/lib/core/application/operation.ts | 37 ++++++++++++++++++- .../lib/credentials/application/operations.ts | 11 ++---- apps/sim/lib/table/application/operations.ts | 8 ++-- 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index 4992bbd3ea5..52cecb7dbb4 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -20,9 +20,11 @@ export { assertOperationCapability, assertOperationPrincipal, defineOperation, + type OperationDeclarableCapability, type OperationUseCase, type PrincipalKind, type PrincipalScopedOperation, + type PrincipalWideCapability, type UndelegatedPrincipalKind, } from '@/lib/core/application/operation' export type { diff --git a/apps/sim/lib/core/application/operation.ts b/apps/sim/lib/core/application/operation.ts index 79bed813edb..dc712d55a50 100644 --- a/apps/sim/lib/core/application/operation.ts +++ b/apps/sim/lib/core/application/operation.ts @@ -5,6 +5,36 @@ import { type StaticPermissionGroupCapability, } from '@/lib/permission-groups/capabilities' +/** + * A capability that governs the PRINCIPAL rather than any one operation. + * + * `personal_api_key.use` asks whether this caller may hold a personal API key + * at all. It is answered once per request in the authorization funnel's + * personal-key branch — and in `resolvePersonalKeyGroupRefusal` for v1, which + * authorizes in its own middleware — for every operation alike, ahead of and + * independently of whatever module capability the operation names. + * + * Excluded from {@link OperationDeclarableCapability} because an operation that + * named it would be wrong either way: withheld, it would double-apply a refusal + * the funnel has already made in the caller's own words; and a session caller + * holding no API key at all would be refused an ordinary operation over a + * setting about credentials they are not using. + */ +export type PrincipalWideCapability = 'personal_api_key.use' + +/** + * The capabilities an operation may name — every static rule except the + * principal-wide ones, which no operation declares because the funnel asks them + * for all operations at once. + */ +export type OperationDeclarableCapability = Exclude< + StaticPermissionGroupCapability, + PrincipalWideCapability +> + +/** The runtime half of {@link PrincipalWideCapability}, for the builders' guard. */ +const PRINCIPAL_WIDE_CAPABILITIES: readonly PrincipalWideCapability[] = ['personal_api_key.use'] + export interface ApplicationOperation { readonly id: Id /** @@ -28,7 +58,7 @@ export interface ApplicationOperation { * `defineWorkspaceOperation` and {@link defineOperation} keep runtime guards * and `check:permission-group-enforcement` keeps reading the source. */ - readonly capability: StaticPermissionGroupCapability | 'none' + readonly capability: OperationDeclarableCapability | 'none' } /** @@ -77,6 +107,11 @@ export function assertOperationCapability(operation: ApplicationOperation): void ) } if (operation.capability === 'none') return + if (PRINCIPAL_WIDE_CAPABILITIES.includes(operation.capability as PrincipalWideCapability)) { + throw new Error( + `Operation ${operation.id} declares principal-wide capability ${operation.capability}; the authorization funnel's personal-key branch already applies it to every operation` + ) + } const rule = CAPABILITY_RULES[operation.capability] if (!rule) { throw new Error(`Operation ${operation.id} names unknown capability ${operation.capability}`) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index cdada704975..4bda143e590 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -1,9 +1,6 @@ -import type { ApplicationOperation } from '@/lib/core/application' +import type { ApplicationOperation, OperationDeclarableCapability } from '@/lib/core/application' import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' -import { - CAPABILITY_RULES, - type StaticPermissionGroupCapability, -} from '@/lib/permission-groups/capabilities' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' import { CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION } from '@/lib/resource-policies/registry' export type CredentialRole = 'member' | 'admin' @@ -204,12 +201,12 @@ export const credentialOperations = { export interface CredentialUserOperation extends ApplicationOperation { readonly principalKinds: readonly ['session'] - readonly capability: StaticPermissionGroupCapability | 'none' + readonly capability: OperationDeclarableCapability | 'none' } function defineCredentialUserOperation( id: Id, - capability: StaticPermissionGroupCapability | 'none' + capability: OperationDeclarableCapability | 'none' ): CredentialUserOperation { if (!id.trim()) throw new Error('Credential user operation ID must not be empty') if (capability === undefined) { diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index 484231f79a0..2f9aaa110aa 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -1,5 +1,5 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -import type { StaticPermissionGroupCapability } from '@/lib/permission-groups/capabilities' +import type { OperationDeclarableCapability } from '@/lib/core/application/operation' const ALL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], @@ -51,7 +51,7 @@ function writeOperation(id: Id) { */ function toolWriteOperation( id: Id, - capability: StaticPermissionGroupCapability + capability: OperationDeclarableCapability ) { return defineWorkspaceOperation({ id, @@ -74,7 +74,7 @@ function toolReadOperation(id: Id) { function internalExecutorReadOperation( id: Id, - capability: StaticPermissionGroupCapability + capability: OperationDeclarableCapability ) { return defineWorkspaceOperation({ id, @@ -97,7 +97,7 @@ function internalExecutorWriteOperation(id: Id) { function delegatedWriteOperation( id: Id, - capability: StaticPermissionGroupCapability + capability: OperationDeclarableCapability ) { return defineWorkspaceOperation({ id, From 7f8f73b810a23b99f8581c0f0c82e818841adb3e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 00:55:36 -0700 Subject: [PATCH 161/179] fix(contracts): treat a null numeric query value as omitted, not zero --- apps/sim/lib/api/contracts/primitives.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index 81590fca6ef..82f06553bdf 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -568,9 +568,14 @@ export const booleanQueryFlagSchema = z.preprocess( * An empty value is a caller sending an unfilled form field, not a question * about cost. * + * `null` is dropped for the same reason and by the same arithmetic: a client + * that spells an unset bound as `null` rather than by omitting the key — + * `requestJson` parses the query object client-side, so a `null` field reaches + * this schema as itself — would otherwise be handed `Number(null) === 0`. + * * An explicit `0` is preserved: `?minCost=0` is a real bound the caller typed. */ -export const optionalNumberQuerySchema = z.preprocess( - (value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), - z.coerce.number().optional() -) +export const optionalNumberQuerySchema = z.preprocess((value) => { + if (value === null) return undefined + return typeof value === 'string' && value.trim() === '' ? undefined : value +}, z.coerce.number().optional()) From 89617507c4c871ef768ae2cc0e47d861ec95a57f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 00:55:37 -0700 Subject: [PATCH 162/179] docs(v1): name the explicit capability declaration in the exemption comments --- apps/sim/app/api/v1/audit-logs/[id]/route.ts | 4 +++- apps/sim/app/api/v1/files/[fileId]/route.ts | 11 ++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts index bea7a9b0272..3ba25fdbfb1 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -35,7 +35,9 @@ export const revalidate = 0 * GET /api/v1/audit-logs/[id] — Read one audit log entry. * * permission-group-exempt: none — same as the list. `audit_logs.read_detail` is - * an organization-admin operation and declares no capability. + * an organization-admin operation that carries an explicit `capability: 'none'` + * declaration, which is the reviewed answer; an operation that names none at all + * is what the operation validator rejects. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index 2367b6ee467..7b79984c1fc 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -29,11 +29,12 @@ interface FileRouteParams { /** * GET /api/v1/files/[fileId] — Download file content. * - * permission-group-exempt: none is declared here because this handler already - * runs through the application funnel — `downloadWorkspaceFileStream` is the - * `files.download` operation, which declares `files.use` and has it applied by - * `authorizeWorkspaceOperation` for the personal-API-key principal. Adding a - * middleware gate would check the same capability twice. + * permission-group-exempt: none — no separate ROUTE-level gate is needed, not + * because the policy is unstated. This handler runs through the application + * funnel: `downloadWorkspaceFileStream` is the `files.download` operation, which + * declares `files.use` and has it applied by `authorizeWorkspaceOperation` for + * the personal-API-key principal. A middleware gate here would check the same + * capability twice. */ export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { const requestId = generateRequestId() From ddec387c12ad0aa11499de272175edb31e0795d3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 00:55:37 -0700 Subject: [PATCH 163/179] fix(v1/logs): keep the group refusal's detail, project period spend, skip withheld materialization --- apps/sim/app/api/v1/logs/[id]/route.ts | 11 ++++---- .../v1/logs/executions/[executionId]/route.ts | 11 ++++---- apps/sim/app/api/v1/logs/meta.ts | 26 ++++++++++++++++++- apps/sim/app/api/v1/logs/route.ts | 21 ++++++++++----- apps/sim/app/api/v1/middleware.ts | 25 ++++++++++++++++++ apps/sim/lib/api/contracts/v1/shared.ts | 3 ++- 6 files changed, 79 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts index ebb9b443ac1..201abd1b0ea 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -11,12 +11,13 @@ import { resolveLogFieldProjection, } from '@/lib/logs/log-projection' import { getPublicWorkflowLog } from '@/lib/logs/public-queries' -import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { createApiResponse, getUserLimits, projectUserLimits } from '@/app/api/v1/logs/meta' import { capabilityGovernedUserId, checkRateLimit, + concealedWorkspaceAccessResponse, createRateLimitResponse, - validateWorkspaceAccess, + resolveWorkspaceAccess, } from '@/app/api/v1/middleware' const logger = createLogger('V1LogDetailsAPI') @@ -47,9 +48,9 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Log not found' }, { status: 404 }) } - const accessError = await validateWorkspaceAccess(rateLimit, userId, log.workspaceId, 'none') + const accessError = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId, 'none') if (accessError) { - return NextResponse.json({ error: 'Log not found' }, { status: 404 }) + return concealedWorkspaceAccessResponse(accessError, 'Log not found') } /** @@ -101,7 +102,7 @@ export const GET = withRouteHandler( } // Get user's workflow execution limits and usage - const limits = await getUserLimits(userId) + const limits = projectUserLimits(await getUserLimits(userId), projection) // Create response with limits information const apiResponse = createApiResponse({ data: response }, limits, rateLimit) diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts index 3b3bfa3822b..fede0a24e16 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts @@ -6,12 +6,13 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { projectCostTotal, resolveLogFieldProjection } from '@/lib/logs/log-projection' import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { sanitizeExecutionSnapshotState } from '@/lib/logs/snapshot-sanitizer' -import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { createApiResponse, getUserLimits, projectUserLimits } from '@/app/api/v1/logs/meta' import { capabilityGovernedUserId, checkRateLimit, + concealedWorkspaceAccessResponse, createRateLimitResponse, - validateWorkspaceAccess, + resolveWorkspaceAccess, } from '@/app/api/v1/middleware' const logger = createLogger('V1ExecutionAPI') @@ -48,14 +49,14 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) } - const accessError = await validateWorkspaceAccess( + const accessError = await resolveWorkspaceAccess( rateLimit, userId, workflowLog.workspaceId, 'none' ) if (accessError) { - return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) + return concealedWorkspaceAccessResponse(accessError, 'Workflow execution not found') } /** `logs.cost` is a projection, not a gate — see `resolveLogFieldProjection`. */ @@ -94,7 +95,7 @@ export const GET = withRouteHandler( logger.debug(`Workflow state contains ${countWorkflowStateBlocks(workflowState)} blocks`) // Get user's workflow execution limits and usage - const limits = await getUserLimits(userId) + const limits = projectUserLimits(await getUserLimits(userId), projection) // Create response with limits information const apiResponse = createApiResponse( diff --git a/apps/sim/app/api/v1/logs/meta.ts b/apps/sim/app/api/v1/logs/meta.ts index 47d374c7a8d..e131ca994da 100644 --- a/apps/sim/app/api/v1/logs/meta.ts +++ b/apps/sim/app/api/v1/logs/meta.ts @@ -3,6 +3,7 @@ import { checkServerSideUsageLimits } from '@/lib/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage' import { RateLimiter } from '@/lib/core/rate-limiter' +import type { LogFieldProjection } from '@/lib/logs/log-projection' export interface UserLimits { workflowExecutionRateLimit: { @@ -20,7 +21,8 @@ export interface UserLimits { } } usage: { - currentPeriodCost: number + /** `null` when the caller's permission group withholds spend — see {@link projectUserLimits}. */ + currentPeriodCost: number | null limit: number plan: string isExceeded: boolean @@ -64,6 +66,28 @@ export async function getUserLimits(userId: string): Promise { } } +/** + * Withholds the caller's period spend from the `limits` envelope when their + * permission group withholds `logs.cost`. + * + * `hideCostInfo` withholds cost and token spend, and on a personal key the + * keyholder IS the governed member: blanking every run's `cost` while the same + * response reports what those runs added up to this period withholds nothing. + * A workspace key resolves no group at all (`resolveLogFieldProjection` returns + * the empty projection for it), so a shared credential still reports the billed + * account's usage. + * + * `limit`, `plan` and `isExceeded` stay: they are the caller's entitlement and + * their own execution eligibility — the reason a run would be refused — not a + * spend figure. + * + * permission-group-enforced: logs.cost + */ +export function projectUserLimits(limits: UserLimits, projection: LogFieldProjection): UserLimits { + if (!projection.hideCostInfo) return limits + return { ...limits, usage: { ...limits.usage, currentPeriodCost: null } } +} + export function createApiResponse( data: T, limits: UserLimits, diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index a8da4c32ce3..2322a06c34d 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -15,7 +15,7 @@ import { import { decodePublicLogCursor, listPublicWorkflowLogs } from '@/lib/logs/public-queries' import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' -import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { createApiResponse, getUserLimits, projectUserLimits } from '@/app/api/v1/logs/meta' import { capabilityGovernedUserId, checkRateLimit, @@ -126,15 +126,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { order: params.order, } + /** + * `withheldExecutionData` strips `traceSpans` AND `finalOutput`, so under + * `hideTraceSpans` both opt-in payload fields project to nothing. Reading + * the projection here rather than after the fetch keeps the surface from + * selecting every row's execution blob out of the trace store and + * materializing it only to delete it. + */ + const needsMaterialize = + params.details === 'full' && + (params.includeFinalOutput || params.includeTraceSpans) && + !projection.hideTraceSpans + const { data, nextCursor } = await listPublicWorkflowLogs({ filters, limit: params.limit, - includeExecutionData: params.details === 'full', + includeExecutionData: needsMaterialize, }) - const needsMaterialize = - params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans) - const buildBase = (log: (typeof data)[number]) => { const result: any = { id: log.id, @@ -187,7 +196,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) : data.map(buildBase) - const limits = await getUserLimits(userId) + const limits = projectUserLimits(await getUserLimits(userId), projection) const response = createApiResponse( { diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index ec26a57e682..dbde4f2837a 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -507,6 +507,31 @@ export async function checkWorkspaceScope( return failure ? workspaceAccessErrorResponse(failure) : null } +/** + * The response a surface that conceals an inaccessible workspace should answer + * a {@link resolveWorkspaceAccess} failure with. + * + * The log surfaces answer "not found" rather than "forbidden" so a stranger + * cannot use them to probe which workspaces and executions exist. A + * permission-group refusal is the one failure that has nothing left to conceal: + * both group keys run only AFTER the caller's workspace role verified, so the + * caller is already known to be a member of the workspace being asked about, + * and the refusal names how their own organization configured their cohort. + * Flattening it into the concealing 404 costs the client the remedy — the key + * looks broken rather than switched off — and buys no secrecy. + * + * `details` is the discriminator because it is set on exactly the two post-role + * group refusals; the pre-role scope failures carry none and keep concealing. + */ +export function concealedWorkspaceAccessResponse( + failure: WorkspaceAccessError, + notFoundMessage: string +): NextResponse { + return failure.details + ? workspaceAccessErrorResponse(failure) + : NextResponse.json({ error: notFoundMessage }, { status: 404 }) +} + /** Renders a {@link WorkspaceAccessError} as the v1 `{ error, details? }` body. */ function workspaceAccessErrorResponse(failure: WorkspaceAccessError): NextResponse { return NextResponse.json( diff --git a/apps/sim/lib/api/contracts/v1/shared.ts b/apps/sim/lib/api/contracts/v1/shared.ts index 9502e57ee5f..9c7235c0b50 100644 --- a/apps/sim/lib/api/contracts/v1/shared.ts +++ b/apps/sim/lib/api/contracts/v1/shared.ts @@ -22,7 +22,8 @@ export const v1UserLimitsSchema = z.object({ }), }), usage: z.object({ - currentPeriodCost: z.number(), + /** `null` when the caller's permission group withholds spend (`logs.cost`). */ + currentPeriodCost: z.number().nullable(), limit: z.number(), plan: z.string(), isExceeded: z.boolean(), From 7cf6d697e0319a72cd4304b1431ee8a539396336 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 00:55:38 -0700 Subject: [PATCH 164/179] fix(api-keys): seed the create-key form when the modal opens, not at page mount --- .../settings/components/api-keys/api-keys.tsx | 11 +++++++ .../create-api-key-modal.tsx | 29 +++++++++++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index 9565aa04ba5..c1e97c0355e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -124,6 +124,17 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { * `useUserPermissionConfig` retries and refetches on remount, which is what * makes the gate self-healing rather than sticky. * + * `isSuccess` and not `isSuccess && !isFetching`, on purpose. A background + * refetch of an already-answered policy keeps the cached answer, and the + * fail-closed window that matters — the first load, where there is nothing + * cached — is already covered because `isSuccess` is false until the first + * response. Re-closing on every refetch would instead blank the personal-key + * affordance and flip the create default to `workspace` on each window focus, + * for a policy that changes on the order of never. This gate is the + * affordance; `/api/workspaces/[id]/api-keys` is the enforcement, and it + * re-reads the group on the request itself, so the seconds of staleness cost + * a user a refused create at worst. + * * The `!workspaceId` arm covers the account plane, which renders this * component as `scope='personal'` outside `/workspace/[workspaceId]`: the * hook is disabled there and nothing reads the result — a personal key is diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx index 789bd75ec1d..6f0127f4b9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx @@ -54,6 +54,29 @@ export function CreateApiKeyModal({ const [showNewKeyDialog, setShowNewKeyDialog] = useState(false) const createApiKeyMutation = useCreateApiKey() + /** + * The form is seeded when the modal OPENS, not when this component mounts. + * + * It mounts with its host page, and on the settings page `defaultKeyType` is + * computed from a permission-group policy that is still loading at that + * moment — so it starts as the fail-closed `'workspace'` and only becomes + * `'personal'` once the query answers. Seeding once at mount left a non-admin + * holding `'workspace'`, which they may not create, with no type selector + * rendered to change it and Create permanently disabled. + * + * Adjusting during render off the previous `open` rather than in an effect: + * there is no external system to synchronize with, only a prop transition. + */ + const [wasOpen, setWasOpen] = useState(open) + if (open !== wasOpen) { + setWasOpen(open) + if (open) { + setKeyName('') + setKeyType(defaultKeyType) + setCreateError(null) + } + } + const handleCreateKey = async () => { const trimmedName = keyName.trim() if (!trimmedName) return @@ -81,9 +104,6 @@ export function CreateApiKeyModal({ setNewKey(data.key) setShowNewKeyDialog(true) - setKeyName('') - setKeyType(defaultKeyType) - setCreateError(null) onOpenChange(false) onKeyCreated?.(data.key) } catch (error: unknown) { @@ -99,9 +119,6 @@ export function CreateApiKeyModal({ const handleClose = () => { onOpenChange(false) - setKeyName('') - setKeyType(defaultKeyType) - setCreateError(null) } return ( From 37237c33a6542c266ae29c852faf56dd5e165eae Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 00:57:01 -0700 Subject: [PATCH 165/179] fix(capabilities): gate the raw table and webhook routes on the governed subject An internal executor JWT carries the run actor's id, so reading auth.userId bare at a capability gate applies that person's permission group to a delegation the executor exemption deliberately passes ungated. Derive the subject with capabilityGovernedAuthUserId and skip when it names nobody. --- .../executor-capability-exemption.test.ts | 158 ++++++++++++++++++ apps/sim/app/api/table/import-async/route.ts | 15 +- apps/sim/app/api/table/jobs/route.ts | 13 +- .../webhooks/[id]/reactivation-gate.test.ts | 95 +++++++++++ apps/sim/app/api/webhooks/[id]/route.ts | 11 +- 5 files changed, 284 insertions(+), 8 deletions(-) create mode 100644 apps/sim/app/api/table/executor-capability-exemption.test.ts create mode 100644 apps/sim/app/api/webhooks/[id]/reactivation-gate.test.ts diff --git a/apps/sim/app/api/table/executor-capability-exemption.test.ts b/apps/sim/app/api/table/executor-capability-exemption.test.ts new file mode 100644 index 00000000000..fe9f283edaa --- /dev/null +++ b/apps/sim/app/api/table/executor-capability-exemption.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + * + * The raw `/api/table/**` routes that authenticate with + * `checkSessionOrInternalAuth` accept an internal executor JWT, whose `userId` + * is the subject the executor embedded rather than a person asking for + * anything. Reading it bare applies that person's permission group to a + * delegation the executor exemption deliberately passes ungated — so these pin + * the derivation (`capabilityGovernedAuthUserId`) at each gate, on a group + * whose config would refuse if it were consulted. + */ +import { + hybridAuthMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + listWorkspaceExportJobs: vi.fn(), + checkWorkspaceAccess: vi.fn(), + getUserEntityPermissions: vi.fn(), + createTable: vi.fn(), + listTables: vi.fn(), + getWorkspaceTableLimits: vi.fn(), + findActiveFolder: vi.fn(), + getUserSettings: vi.fn(), + runDetached: vi.fn(), + runTableImport: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@/lib/table/jobs/service', () => ({ + listWorkspaceExportJobs: mocks.listWorkspaceExportJobs, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.checkWorkspaceAccess, + getUserEntityPermissions: mocks.getUserEntityPermissions, +})) +vi.mock('@/lib/table', () => ({ + createTable: mocks.createTable, + deleteTable: vi.fn(), + getWorkspaceTableLimits: mocks.getWorkspaceTableLimits, + listTables: mocks.listTables, + releaseJobClaim: vi.fn(), + sanitizeName: (name: string) => name, + TABLE_LIMITS: { MAX_TABLE_NAME_LENGTH: 64 }, +})) +vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mocks.runTableImport })) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mocks.findActiveFolder })) +vi.mock('@/lib/users/queries', () => ({ getUserSettings: mocks.getUserSettings })) +vi.mock('@/lib/core/utils/background', () => ({ runDetached: mocks.runDetached })) +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { POST as importAsync } from '@/app/api/table/import-async/route' +import { GET as listJobs } from '@/app/api/table/jobs/route' + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' +const TABLE_ID = '22222222-2222-4222-8222-222222222222' +const ACTOR_ID = 'run-actor' + +/** The run's actor, embedded in the executor's internal JWT. */ +function authenticateAsExecutor() { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: ACTOR_ID, + authType: 'internal_jwt', + }) +} + +/** The same person, calling the same route from their own browser session. */ +function authenticateAsSession() { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: ACTOR_ID, + authType: 'session', + }) +} + +function getExportJobs() { + return listJobs( + new NextRequest(`http://localhost/api/table/jobs?workspaceId=${WORKSPACE_ID}&type=export`) + ) +} + +function startImport() { + return importAsync( + new NextRequest('http://localhost/api/table/import-async', { + method: 'POST', + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + fileKey: `workspace/${WORKSPACE_ID}/upload.csv`, + fileName: 'upload.csv', + }), + headers: { 'content-type': 'application/json' }, + }) + ) +} + +describe('the subject the raw table routes gate on', () => { + beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mocks.checkWorkspaceAccess.mockResolvedValue({ hasAccess: true }) + mocks.getUserEntityPermissions.mockResolvedValue('admin') + mocks.listWorkspaceExportJobs.mockResolvedValue([{ id: 'job-1' }]) + mocks.listTables.mockResolvedValue([]) + mocks.getWorkspaceTableLimits.mockResolvedValue({ maxTables: 100 }) + mocks.getUserSettings.mockResolvedValue({ timezone: 'UTC' }) + mocks.createTable.mockResolvedValue({ id: TABLE_ID }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTablesTab: true, + disableTableExport: true, + }) + }) + + describe('an executor delegation carrying the actor’s id', () => { + beforeEach(authenticateAsExecutor) + + it('lists the workspace’s export jobs without consulting the actor’s group', async () => { + const response = await getExportJobs() + + expect(await response.json()).toEqual({ success: true, data: { jobs: [{ id: 'job-1' }] } }) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('starts an import without consulting the actor’s group', async () => { + const response = await startImport() + + expect(response.status).toBe(200) + expect(mocks.createTable).toHaveBeenCalled() + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + }) + + describe('the same person on their own session', () => { + beforeEach(authenticateAsSession) + + it('is handed an empty export tray', async () => { + const response = await getExportJobs() + + expect(await response.json()).toEqual({ success: true, data: { jobs: [] } }) + expect(mocks.listWorkspaceExportJobs).not.toHaveBeenCalled() + }) + + it('is refused the import, and no table is created', async () => { + const response = await startImport() + + expect(response.status).toBe(403) + expect(mocks.createTable).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index 84bf6a27810..17a6b0be553 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -3,7 +3,7 @@ import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { importTableAsyncContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' @@ -54,9 +54,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { * and predates the operation boundary. An import always ends in a new table, * so it is creation, not ordinary use. `tables.create` subsumes `tables.use`: * its rule is denied by `disableTableCreation` OR `hideTablesTab`, so gating - * on it still refuses a group that hides Tables entirely. + * on it still refuses a group that hides Tables entirely. Keyed to the + * governed subject, which names nobody for an internal-JWT executor call — + * the same rule the synchronous `import-csv` route applies. Not re-read before + * `createTable` below: `resolvePermissionGroupConfig` is memoized per request + * (`withPermissionGroupScope`), so a second call in this handler returns the + * promise this one started and could not observe a revocation. */ - if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'tables.create')) { + const governedUserId = capabilityGovernedAuthUserId(authResult) + if ( + governedUserId && + (await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'tables.create')) + ) { return capabilityRefusalResponse('tables.create') } // The fileKey is client-supplied — ensure it points at this workspace's storage prefix so a diff --git a/apps/sim/app/api/table/jobs/route.ts b/apps/sim/app/api/table/jobs/route.ts index 5d81b1ed68a..f2225b308cd 100644 --- a/apps/sim/app/api/table/jobs/route.ts +++ b/apps/sim/app/api/table/jobs/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { listTableJobsContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' @@ -43,8 +43,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { * export file. Withheld as an empty list rather than a refusal: the caller has * no exports they may act on, and erroring the tray would report a failure * where the honest answer is that there is nothing to show. + * + * Keyed to the governed subject, which names nobody for an internal-JWT + * executor call: `authResult.userId` there is the subject the executor + * embedded, so reading it bare would hand the run's actor's group to a caller + * the executor exemption deliberately passes ungated. */ - if (await isWorkspaceCapabilityWithheld(authResult.userId, workspaceId, 'tables.export')) { + const governedUserId = capabilityGovernedAuthUserId(authResult) + if ( + governedUserId && + (await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'tables.export')) + ) { return NextResponse.json({ success: true, data: { jobs: [] } }) } diff --git a/apps/sim/app/api/webhooks/[id]/reactivation-gate.test.ts b/apps/sim/app/api/webhooks/[id]/reactivation-gate.test.ts new file mode 100644 index 00000000000..292e1d48ca0 --- /dev/null +++ b/apps/sim/app/api/webhooks/[id]/reactivation-gate.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { webhook } from '@sim/db/schema' +import { + auditMock, + createMockRequest, + hybridAuthMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + posthogServerMock, + queueTableRows, + resetDbChainMock, + telemetryMock, + workflowAuthzMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@/lib/core/telemetry', () => telemetryMock) +vi.mock('@/lib/posthog/server', () => posthogServerMock) +vi.mock('@/lib/webhooks/provider-subscriptions', () => ({ cleanupExternalWebhook: vi.fn() })) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { PATCH } from '@/app/api/webhooks/[id]/route' + +const ACTOR_ID = 'actor-1' + +function reactivate() { + return PATCH(createMockRequest('PATCH', { isActive: true }), { + params: Promise.resolve({ id: 'webhook-1' }), + }) +} + +/** The single joined read the PATCH handler issues. */ +function queueDormantWebhook(): void { + queueTableRows(webhook, [ + { + webhook: { id: 'webhook-1', isActive: false, failedCount: 0 }, + workflow: { id: 'workflow-1', userId: ACTOR_ID, workspaceId: 'workspace-1' }, + }, + ]) +} + +/** + * `triggers.webhook` is withheld from the actor's group. Flipping a dormant + * webhook back on is the act the key names, so a session belonging to that + * person must be refused — and an executor delegation carrying the same id + * must not be, since it holds the actor's role and none of their capabilities. + */ +describe('the subject the webhook reactivation gate is decided about', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'workflow-1' }, + workspacePermission: 'write', + }) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableWebhookTriggers: true, + }) + }) + + it('refuses the actor’s own session', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: ACTOR_ID, + authType: 'session', + }) + queueDormantWebhook() + + const response = await reactivate() + + expect(response.status).toBe(403) + }) + + it('lets an internal executor JWT through without consulting that group', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: ACTOR_ID, + authType: 'internal_jwt', + }) + queueDormantWebhook() + + const response = await reactivate() + + expect(response.status).toBe(200) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 2a607e1eaf5..5b3b7571f06 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -15,7 +15,7 @@ import { updateWebhookContract, } from '@/lib/api/contracts/webhooks' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -150,10 +150,15 @@ export const PATCH = withRouteHandler( * dormant webhook back on. Only this direction: deactivating must stay * open, or a policy change would strand a member with a live webhook * they cannot turn off. + * + * Keyed to the governed subject rather than `auth.userId`: an internal + * executor JWT embeds the run's actor, and gating on it would apply that + * person's capabilities to a delegation that carries only their role. */ - if (isActive) { + const governedUserId = capabilityGovernedAuthUserId(auth) + if (isActive && governedUserId) { const withheld = await isWorkspaceCapabilityWithheld( - userId, + governedUserId, webhooks[0].workflow.workspaceId ?? '', 'triggers.webhook' ) From 0ca67153bab55dc5d6fefeae76fd51da5441a12e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 01:00:49 -0700 Subject: [PATCH 166/179] fix(copilot): read a failed auto-approval lookup as withheld, not as a turn abort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capability lookup that seeds the per-request tool-permission state was awaited bare, so a transient resolution failure rejected out of runCopilotLifecycle before any permission card could be drawn — an interactive turn died over a database hiccup. Reads as withheld instead, matching the decision endpoint: no stored always-allow is loaded, nothing durable is remembered, and every gated call still asks its one-time question. --- .../lib/copilot/request/lifecycle/run.test.ts | 23 +++++++++++++++++++ apps/sim/lib/copilot/request/lifecycle/run.ts | 17 ++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 5c71cc27327..5e698416cba 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -1093,6 +1093,29 @@ describe('runCopilotLifecycle', () => { expect(mockGetAutoAllowedTools).not.toHaveBeenCalled() }) + /** + * A failed lookup is the endpoint's reading — withheld — not a rejection. + * Letting it throw would abort the turn before any card is drawn, over a + * database hiccup, on the one surface that has a human to ask. + */ + it('reads a failed capability lookup as withheld instead of aborting the turn', async () => { + setEnvFlags({ isCopilotToolPermissionsEnabled: true }) + mockGetUserPermissionConfig.mockRejectedValue(new Error('permission group lookup failed')) + mockGetAutoAllowedTools.mockResolvedValue(new Set(['terminal_run'])) + let captured: StreamingContext | undefined + mockRunStreamLoop.mockImplementation(async (_u, _o, context: StreamingContext) => { + captured = context + }) + + await runMothershipTurn() + + expect(mockRunStreamLoop).toHaveBeenCalledOnce() + expect(captured?.toolPermissions.enabled).toBe(true) + expect(captured?.toolPermissions.autoAllowPermitted).toBe(false) + expect(captured?.toolPermissions.autoAllowed.size).toBe(0) + expect(mockGetAutoAllowedTools).not.toHaveBeenCalled() + }) + it('stays off for the workflow-scoped copilot even with the flag on', async () => { // That panel has no permission card, so gating there would hang the turn // on a prompt nothing draws. diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 99271bf68fb..1e48ca9e1d9 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -1,7 +1,7 @@ import type { Context } from '@opentelemetry/api' import { createLogger } from '@sim/logger' import type { PermissionType } from '@sim/platform-authz/workspace' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' import { interruptibleSleep, sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { omit } from '@sim/utils/object' @@ -215,6 +215,13 @@ async function resolveToolPermissions( * "always allow" before the key was set would otherwise keep the prompt * silenced forever, so the stored list is not even loaded once the group * withholds the capability. + * + * A failed lookup reads as withheld, matching the decision endpoint: letting + * it reject would abort the whole turn here, before any card is drawn, over a + * database hiccup — the turn is interactive by construction at this point, so + * there is a human to ask. Withholding keeps the capability fail-closed + * without wedging the turn: no stored always-allow is loaded, nothing durable + * is remembered, and every gated call still asks its one-time question. */ const withheld = options.userId && options.workspaceId @@ -222,7 +229,13 @@ async function resolveToolPermissions( options.userId, options.workspaceId, 'copilot.tool_auto_approval' - ) + ).catch((error) => { + logger.warn('Could not resolve the tool auto-approval capability; prompting every time', { + workspaceId: options.workspaceId, + error: getErrorMessage(error), + }) + return true + }) : false if (withheld) { return { enabled: true, autoAllowed: new Set(), autoAllowPermitted: false } From 808489e2cd21c7173f1824fa1dfb371777ee1335 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 01:01:01 -0700 Subject: [PATCH 167/179] fix(knowledge): gate a manual connector sync on the persisted connector type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing a connector type from the allowlist stopped new connectors of that type but left every existing one manually re-syncable, so the withdrawn source kept being pulled in on demand. A manual sync is a fresh act by a person, so it now asserts knowledge.connectors against the type stored on the connector. Pausing and deleting stay ungated for the reason the update use case records — nothing here strands a connector — and the scheduled continuation, which runs executeSync directly and never reaches this use case, is untouched. --- .../knowledge/application/connectors.test.ts | 84 +++++++++++++++++++ .../lib/knowledge/application/connectors.ts | 21 +++++ 2 files changed, 105 insertions(+) diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index 189c0b3ede1..599f7f42ba3 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -746,5 +746,89 @@ describe('knowledge connector application use cases', () => { expect(mocks.createConnector).toHaveBeenCalledTimes(1) }) + + /** + * A manual sync re-runs the pull, so an admin who has since removed the + * source from the allowlist has withdrawn it. The type comes off the + * persisted connector, which is the only place the request names it. + */ + describe('manual sync', () => { + const syncInput = { + knowledgeBaseId: 'knowledge-a', + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + resolveBillingAttribution: mocks.resolveBilling, + } + + beforeEach(() => { + mocks.resolveConnector.mockResolvedValue({ + ...connectorContext, + workspaceId: 'workspace-a', + knowledgeBaseId: 'knowledge-a', + knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' }, + connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' }, + }) + }) + + it('refuses a sync of a connector whose type the group no longer names', async () => { + allowOnly(['google_drive']) + + await expect( + syncKnowledgeConnector.execute({ principal: delegatedPrincipal, input: syncInput }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: capabilityRefusal('knowledge.connectors'), + }) + + expect(mocks.syncConnector).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('permits the sync while the group still names the persisted type', async () => { + allowOnly(['confluence']) + mocks.syncConnector.mockResolvedValueOnce({ success: true }) + + await syncKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: syncInput, + }) + + expect(mocks.syncConnector).toHaveBeenCalledTimes(1) + }) + + /** + * Pausing and deleting stay reachable: the point is to stop the member + * re-running the pull, never to strand the connector. + */ + it('still lets the same caller pause and delete the withheld connector', async () => { + allowOnly(['google_drive']) + mocks.updateConnector.mockResolvedValueOnce({ + success: true, + connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' }, + }) + mocks.deleteConnector.mockResolvedValueOnce({ + success: true, + documentsDeleted: 0, + documentsKept: 1, + }) + + await updateKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: { + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + updates: { status: 'paused' }, + resolveBillingAttribution: mocks.resolveBilling, + }, + }) + await deleteKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: { connectorId: 'connector-b', assertedWorkspaceId: 'workspace-a' }, + }) + + expect(mocks.updateConnector).toHaveBeenCalledTimes(1) + expect(mocks.deleteConnector).toHaveBeenCalledTimes(1) + }) + }) }) }) diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index 0dbe5b4ad98..5e704149b80 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -493,12 +493,33 @@ export const deleteKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ }), }) +/** + * Gated by `knowledge.connectors` on the *persisted* type, unlike + * {@link updateKnowledgeConnector}: a manual sync is a fresh act by a person + * pulling the external corpus in again, so an admin who has since removed the + * source from the allowlist has withdrawn it. Pausing and deleting stay + * available for the reason recorded on the update use case — nothing here + * strands a connector, it only stops a member re-running the pull by hand. + * + * Only the manual path passes through this use case. The scheduled continuation + * of an existing connector runs `executeSync` from the sync engine directly + * (`background/knowledge-connector-sync.ts`) and is untouched, matching the + * webhook precedent: passive continuation keeps running, a person re-initiating + * it is gated. An actorless caller resolves no group and passes through, as + * {@link assertConnectorTypeAllowed} documents. + */ export const syncKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.syncConnector, resolveContext: ({ input }: { input: SyncKnowledgeConnectorInput }) => resolveActiveKnowledgeConnectorContext(input), async execute({ principal, input, context, request }) { const workspaceId = requireConnectorWorkspaceId(context) + // permission-group-enforced: knowledge.connectors — needs the persisted connector type, which the funnel never sees + await assertConnectorTypeAllowed( + resolvePrincipalSubjectUserId(principal), + workspaceId, + context.connector.connectorType + ) const outcome = await performSyncKnowledgeConnector({ knowledgeBase: connectorTarget(context), connectorId: context.connectorId, From f3a36cc3746ec0364a4d5d76bac13a3a5ea96e93 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 01:01:08 -0700 Subject: [PATCH 168/179] fix(credentials): project the user-global membership listing per credential workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every credential names a workspace, and credentials.list withholds those rows inside it under integrations.manage. The user-global memberships endpoint names no workspace, so its own gate reads the caller's organization default group and returned rows the workspace-scoped listing hides. Each row is now projected against the group governing this same user in the workspace holding the credential — the person's own group, not a bystander's. Leaving a membership stays ungoverned by it: that revokes the caller's own access and grants nothing, so gating it would strand them inside the share. --- .../application/credential-members.ts | 39 ++++- .../application/membership-projection.test.ts | 158 ++++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/credentials/application/membership-projection.test.ts diff --git a/apps/sim/lib/credentials/application/credential-members.ts b/apps/sim/lib/credentials/application/credential-members.ts index 561ae6a130d..58a2262ce60 100644 --- a/apps/sim/lib/credentials/application/credential-members.ts +++ b/apps/sim/lib/credentials/application/credential-members.ts @@ -15,6 +15,7 @@ import { removeCredentialMember, upsertCredentialMember, } from '@/lib/credentials/members' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' interface CredentialMemberResourceInput { @@ -124,10 +125,46 @@ export const removeCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ }, }) +/** + * Every credential names a workspace (`credential.workspace_id` is NOT NULL), so + * the rows this user-global listing returns are workspace resources reached + * without naming a workspace. The endpoint's own gate resolves the caller's + * organization default group, which is right for the *act* — it belongs to the + * person, not to any one workspace — but it cannot answer per row. + * + * So each row is projected against the group governing **this same user** in the + * workspace holding that credential. That is the person's own group, not a + * bystander's: `credentials.list` withholds exactly these rows from them in that + * workspace under `integrations.manage`, and a listing that names no workspace + * must not be the way back to what the workspace-scoped listing hides. + * + * Only the projection. Leaving a membership stays ungoverned by the workspace + * group on purpose: it revokes the caller's own access and grants nothing, so + * gating it would strand a member inside a credential share they can no longer + * see — the same reasoning that keeps pausing a knowledge connector available + * after its type leaves the allowlist. + * + * The capability is read off the operation rather than spelled out here, so the + * projection follows the declaration if it is ever renamed. + */ export const listCredentialMembershipsUseCase = defineAuthorizedCredentialUserUseCase({ operation: credentialUserOperations.listMemberships, async execute({ principal }) { - return { memberships: await listCredentialMembershipsForUser(principal.userId) } + const memberships = await listCredentialMembershipsForUser(principal.userId) + const capability = credentialUserOperations.listMemberships.capability + if (capability === 'none') return { memberships } + const workspaceIds = [...new Set(memberships.map((membership) => membership.workspaceId))] + const withheld = new Set() + await Promise.all( + workspaceIds.map(async (workspaceId) => { + if (await isWorkspaceCapabilityWithheld(principal.userId, workspaceId, capability)) { + withheld.add(workspaceId) + } + }) + ) + return { + memberships: memberships.filter((membership) => !withheld.has(membership.workspaceId)), + } }, }) diff --git a/apps/sim/lib/credentials/application/membership-projection.test.ts b/apps/sim/lib/credentials/application/membership-projection.test.ts new file mode 100644 index 00000000000..9f92845b680 --- /dev/null +++ b/apps/sim/lib/credentials/application/membership-projection.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + * + * `GET /api/credentials/memberships` names no workspace, so its own gate reads + * the caller's organization default group. Every credential it returns does name + * one (`credential.workspace_id` is NOT NULL), and `credentials.list` withholds + * those same rows inside the workspace under `integrations.manage`. These pin + * that the user-global listing is not the way back to what the workspace-scoped + * listing hides — projected against this user's own group in each workspace, + * never a bystander's — and that leaving a membership stays available. + */ +import { authMockFns, createMockRequest, permissionGroupScopeMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserOrganization, mockGetOrgPermissionConfig, mockList, mockLeave } = vi.hoisted( + () => ({ + mockGetUserOrganization: vi.fn(), + mockGetOrgPermissionConfig: vi.fn(), + mockList: vi.fn(), + mockLeave: vi.fn(), + }) +) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: vi.fn(), + getUserPermissionConfigForOrganization: mockGetOrgPermissionConfig, + resolveVerifiedUserAccessControlContext: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/credentials/members', () => ({ + leaveCredentialMembership: mockLeave, + listCredentialMembers: vi.fn(), + listCredentialMembershipsForUser: mockList, + removeCredentialMember: vi.fn(), + upsertCredentialMember: vi.fn(), +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { DELETE, GET } from '@/app/api/credentials/memberships/route' + +const USER_ID = 'user-1' +const GOVERNED_WORKSPACE = 'workspace-governed' +const OPEN_WORKSPACE = 'workspace-open' + +const mockGetSession = authMockFns.mockGetSession +const mockResolveConfig = permissionGroupScopeMock.resolvePermissionGroupConfig + +function membership(id: string, workspaceId: string) { + return { + membershipId: `membership-${id}`, + credentialId: id, + workspaceId, + type: 'oauth' as const, + displayName: id, + providerId: 'google', + role: 'member' as const, + status: 'active' as const, + joinedAt: null, + } +} + +function callList() { + return GET( + createMockRequest('GET', undefined, {}, 'http://localhost/api/credentials/memberships'), + { params: Promise.resolve({}) } + ) +} + +function callLeave(credentialId: string) { + return DELETE( + createMockRequest( + 'DELETE', + undefined, + {}, + `http://localhost/api/credentials/memberships?credentialId=${credentialId}` + ), + { params: Promise.resolve({}) } + ) +} + +describe('credential membership listing under a workspace group', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveConfig.mockReset() + mockGetSession.mockResolvedValue({ user: { id: USER_ID }, session: { id: 'session-1' } }) + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'org-1', + role: 'member', + memberId: 'member-1', + }) + mockGetOrgPermissionConfig.mockResolvedValue(null) + mockList.mockResolvedValue([ + membership('cred-governed', GOVERNED_WORKSPACE), + membership('cred-open', OPEN_WORKSPACE), + ]) + mockLeave.mockResolvedValue(undefined) + mockResolveConfig.mockImplementation(async (_userId: string, workspaceId: string) => + workspaceId === GOVERNED_WORKSPACE + ? { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideIntegrationsTab: true } + : null + ) + }) + + it('drops the rows whose workspace withholds Integrations from this user', async () => { + const response = await callList() + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.memberships.map((row: { credentialId: string }) => row.credentialId)).toEqual([ + 'cred-open', + ]) + }) + + it('resolves the group as the caller themself, in each credential’s workspace', async () => { + await callList() + + expect(mockResolveConfig).toHaveBeenCalledWith(USER_ID, GOVERNED_WORKSPACE, undefined) + expect(mockResolveConfig).toHaveBeenCalledWith(USER_ID, OPEN_WORKSPACE, undefined) + }) + + it('asks once per workspace, not once per credential', async () => { + mockList.mockResolvedValue([ + membership('cred-a', GOVERNED_WORKSPACE), + membership('cred-b', GOVERNED_WORKSPACE), + membership('cred-c', OPEN_WORKSPACE), + ]) + + await callList() + + expect(mockResolveConfig).toHaveBeenCalledTimes(2) + }) + + it('returns every row when no group governs the caller anywhere', async () => { + mockResolveConfig.mockResolvedValue(null) + + const response = await callList() + + const body = await response.json() + expect(body.memberships).toHaveLength(2) + }) + + /** + * Leaving revokes the caller's own access and grants nothing, so a workspace + * that hides the module must not strand them inside the share. + */ + it('still lets the member leave a credential in the withholding workspace', async () => { + const response = await callLeave('cred-governed') + + expect(response.status).toBe(200) + expect(mockLeave).toHaveBeenCalledWith({ userId: USER_ID, credentialId: 'cred-governed' }) + }) +}) From 8db3684ec8c5cdd788cbd78e8e42813576ebbe9b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 01:01:14 -0700 Subject: [PATCH 169/179] fix(invitations): check the organization scope on an organization-kind resend too An organization-kind invitation always admits the invitee to its stamped organization, whichever workspaces it also grants. The resend gate checked only the granted workspaces whenever the invitation carried any, so an explicit workspace group permitting invitations carried a member into an organization whose default group withheld them. The organization scope is now checked as well as, not instead of, the grants for that kind. A workspace-kind invitation admits nobody to the organization by itself and keeps the per-grant check, falling back to the organization when it has no grants. --- .../api/invitations/[id]/resend/route.test.ts | 50 +++++++++++++++++++ .../app/api/invitations/[id]/resend/route.ts | 27 +++++++--- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/api/invitations/[id]/resend/route.test.ts b/apps/sim/app/api/invitations/[id]/resend/route.test.ts index eaca10f230a..63d2ae7e39d 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.test.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.test.ts @@ -155,6 +155,56 @@ describe('POST /api/invitations/[id]/resend', () => { expect(mockValidateInvitationsAllowed).not.toHaveBeenCalled() }) + /** + * An organization-kind invitation always admits the invitee to its stamped + * organization, whichever workspaces it also grants — so the organization + * scope is checked as well as, not instead of, the grants. Gating only the + * grants would let an explicit workspace group that permits invitations carry + * a member into an organization whose default group withholds them. + */ + it('checks the organization scope as well as the grants for an organization invitation', async () => { + mockGetInvitationById.mockResolvedValue({ ...workspaceInvitation, kind: 'organization' }) + + const response = await callResend() + + expect(response.status).toBe(200) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + organizationId: 'organization-1', + }) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + workspaceId: 'workspace-1', + }) + }) + + it('refuses an organization invitation the organization default group withholds, even when its granted workspace allows', async () => { + mockGetInvitationById.mockResolvedValue({ ...workspaceInvitation, kind: 'organization' }) + mockValidateInvitationsAllowed.mockImplementation( + async (_userId: string, scope: { organizationId?: string }) => { + if (scope.organizationId) throw new MockInvitationsNotAllowedError() + } + ) + + const response = await callResend() + + expect(response.status).toBe(403) + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(mockPersistInvitationResend).not.toHaveBeenCalled() + }) + + /** + * A workspace-kind invitation admits nobody to the organization by itself, so + * its grants remain the whole scope. + */ + it('leaves a granted workspace invitation checked per workspace only', async () => { + const response = await callResend() + + expect(response.status).toBe(200) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledTimes(1) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + workspaceId: 'workspace-1', + }) + }) + it('resolves the organization default group for an invitation with no grants', async () => { mockGetInvitationById.mockResolvedValue({ ...workspaceInvitation, diff --git a/apps/sim/app/api/invitations/[id]/resend/route.ts b/apps/sim/app/api/invitations/[id]/resend/route.ts index 5bc7dabd6e3..a366c61dd80 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.ts @@ -80,22 +80,33 @@ export const POST = withRouteHandler( * which is why this is not the webhook active-config carve-out, where the * reachability already exists and the edit only adjusts it. * - * Scoped exactly as creation is: each granted workspace resolves the - * group governing the caller there, and an organization-only invitation - * with no grants falls back to the organization's default group. Run - * after the admin check above, for the reason + * Each granted workspace resolves the group governing the caller there, + * exactly as creation does. The organization scope is checked *as well* + * for an organization-kind invitation, not instead: that kind always + * admits the invitee to its stamped organization (`lib/invitations/core.ts` + * documents it on `resolveInvitationJoinTarget`), so the resend performs an + * organization-level admission whichever workspaces it also grants. Gating + * only the grants would let an explicit workspace group that permits + * invitations carry a member into an organization whose default group + * withholds them — and would leave the organization-level act ungated the + * moment such an invitation carried any grant at all. A workspace-kind + * invitation admits nobody to the organization by itself, so it keeps the + * per-grant check, falling back to the organization when it has no grants. + * + * Run after the admin check above, for the reason * `resolveWorkspaceInvitationContext` records — the refusal names an * organization setting, so it must not reach someone with no admin reach. */ try { - for (const grant of inv.grants) { - await validateInvitationsAllowed(session.user.id, { workspaceId: grant.workspaceId }) - } - if (inv.grants.length === 0 && inv.organizationId) { + const organizationScoped = inv.kind === 'organization' || inv.grants.length === 0 + if (organizationScoped && inv.organizationId) { await validateInvitationsAllowed(session.user.id, { organizationId: inv.organizationId, }) } + for (const grant of inv.grants) { + await validateInvitationsAllowed(session.user.id, { workspaceId: grant.workspaceId }) + } } catch (error) { if (error instanceof InvitationsNotAllowedError) { logger.warn('Invitation resend blocked by permission group', { invitationId: id }) From e2511591749c94465af74a7003e8cbfeb3fec0d6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 01:02:28 -0700 Subject: [PATCH 170/179] fix(logs): decide every log projection about the governed subject resolvePrincipalSubjectUserId returns an executor delegation's subjectUserId, so the six principal-holding log surfaces applied the run actor's logs.cost and logs.trace_spans group to a delegation the authorization funnel had already passed ungated - and assertLogCostQueryAllowed turned that into an outright refusal of a cost-sorted read. State the rule once as logProjectionSubjectUserId and derive every projection subject from it; attribution ids are left alone. --- .../lib/logs/application/get-public-log.ts | 8 +++-- .../lib/logs/application/list-logs.test.ts | 29 ++++++++++++++++++ apps/sim/lib/logs/application/list-logs.ts | 27 ++++++++--------- .../lib/logs/application/list-public-logs.ts | 3 +- .../application/read-execution-snapshot.ts | 8 +++-- .../logs/application/read-log-detail.test.ts | 24 ++++++++++++++- .../lib/logs/application/read-log-detail.ts | 4 +-- apps/sim/lib/logs/log-projection.ts | 30 +++++++++++++++++-- .../application/list-workflow-runs.ts | 14 ++++----- .../application/read-workflow-run.test.ts | 4 +-- .../application/read-workflow-run.ts | 4 +-- .../application/workflow-runs.test.ts | 2 +- 12 files changed, 119 insertions(+), 38 deletions(-) diff --git a/apps/sim/lib/logs/application/get-public-log.ts b/apps/sim/lib/logs/application/get-public-log.ts index 89e9b4bc355..7b7f9406bff 100644 --- a/apps/sim/lib/logs/application/get-public-log.ts +++ b/apps/sim/lib/logs/application/get-public-log.ts @@ -9,7 +9,11 @@ import { logDelegationAuthorization } from '@/lib/logs/application/authorization import { logOperations } from '@/lib/logs/application/operations' import { buildCostLedger } from '@/lib/logs/cost-ledger' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' -import { projectExecutionData, resolveLogFieldProjection } from '@/lib/logs/log-projection' +import { + logProjectionSubjectUserId, + projectExecutionData, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import { getPublicWorkflowLog, getPublicWorkflowLogScope } from '@/lib/logs/public-queries' import { sanitizeExecutionSnapshotState } from '@/lib/logs/snapshot-sanitizer' import { @@ -107,7 +111,7 @@ export const getPublicLog = defineAuthorizedWorkspaceUseCase({ * member whose group withholds both everywhere else. */ const projection = await resolveLogFieldProjection( - viewerUserId, + logProjectionSubjectUserId(principal), context.workspaceId, context.workspaceOrganizationId ) diff --git a/apps/sim/lib/logs/application/list-logs.test.ts b/apps/sim/lib/logs/application/list-logs.test.ts index 730b2b9fab2..2e534ab544b 100644 --- a/apps/sim/lib/logs/application/list-logs.test.ts +++ b/apps/sim/lib/logs/application/list-logs.test.ts @@ -127,4 +127,33 @@ describe('listLogsUseCase', () => { expect(resolveGroupConfigMock).not.toHaveBeenCalled() expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ hideCostInfo: false })) }) + + /** + * An executor delegation names the person who triggered the run, but carries + * their role and none of their capabilities — the exemption the authorization + * funnel already applied on the way in. Projecting on them here would be a + * second, contrary decision about the same principal, and a cost-sorted read + * would not merely lose a column: `assertLogCostQueryAllowed` would refuse the + * run's own listing outright. + */ + it('reads whole for a run delegated by a member whose group withholds spend', async () => { + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + await listLogsUseCase.execute({ + principal: { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: WORKSPACE_ID, + delegationId: 'delegation-1', + audience: 'sim:logs', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + } as Principal, + input: { ...(INPUT as object), sortBy: 'cost' } as never, + }) + + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ hideCostInfo: false })) + }) }) diff --git a/apps/sim/lib/logs/application/list-logs.ts b/apps/sim/lib/logs/application/list-logs.ts index e5279760f54..f4294009bd5 100644 --- a/apps/sim/lib/logs/application/list-logs.ts +++ b/apps/sim/lib/logs/application/list-logs.ts @@ -1,4 +1,3 @@ -import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import type { ListLogsResponse } from '@/lib/api/contracts/logs' import { defineAuthorizedWorkspaceUseCase, type OperationUseCase } from '@/lib/core/application' import { asOrchestrationError } from '@/lib/core/orchestration/types' @@ -8,9 +7,11 @@ import { } from '@/lib/logs/application/authorization' import { logOperations } from '@/lib/logs/application/operations' import { type ListLogsParams, readLogs } from '@/lib/logs/list-logs' -import { assertLogCostQueryAllowed } from '@/lib/logs/log-projection' -import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' -import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { + assertLogCostQueryAllowed, + logProjectionSubjectUserId, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' const authorizedListLogsUseCase = defineAuthorizedWorkspaceUseCase({ @@ -23,18 +24,14 @@ const authorizedListLogsUseCase = defineAuthorizedWorkspaceUseCase({ * permission-group-enforced: logs.cost — the list carries the same run * total the detail does, so withholding it only on the detail would hide * nothing. A projection rather than a refusal, for the reason given in - * `read-log-detail.ts`. + * `read-log-detail.ts`, resolved through the shared helper every other log + * surface reads so the subject and the rule cannot drift apart here. */ - const viewerUserId = resolvePrincipalSubjectUserId(principal) - const permissionConfig = viewerUserId - ? await resolvePermissionGroupConfig( - viewerUserId, - context.workspaceId, - context.workspaceOrganizationId - ) - : null - - const hideCostInfo = capabilityDeniedBy('logs.cost', permissionConfig) + const { hideCostInfo } = await resolveLogFieldProjection( + logProjectionSubjectUserId(principal), + context.workspaceId, + context.workspaceOrganizationId + ) /** * The list's own `sortBy=cost` and `costOperator`/`costValue` select on the * very figure the row above blanks, so they have to be refused rather than diff --git a/apps/sim/lib/logs/application/list-public-logs.ts b/apps/sim/lib/logs/application/list-public-logs.ts index d6c119f0a37..a5020c61538 100644 --- a/apps/sim/lib/logs/application/list-public-logs.ts +++ b/apps/sim/lib/logs/application/list-public-logs.ts @@ -10,6 +10,7 @@ import { resolveLogFolderScope } from '@/lib/logs/folder-scope' import { assertLogCostQueryAllowed, type LogFieldProjection, + logProjectionSubjectUserId, projectExecutionData, resolveLogFieldProjection, } from '@/lib/logs/log-projection' @@ -89,7 +90,7 @@ export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ * by a second surface reading the same list. */ const projection = await resolveLogFieldProjection( - viewerUserId, + logProjectionSubjectUserId(principal), context.workspaceId, context.workspaceOrganizationId ) diff --git a/apps/sim/lib/logs/application/read-execution-snapshot.ts b/apps/sim/lib/logs/application/read-execution-snapshot.ts index 3c7733fa377..15d45119c63 100644 --- a/apps/sim/lib/logs/application/read-execution-snapshot.ts +++ b/apps/sim/lib/logs/application/read-execution-snapshot.ts @@ -12,7 +12,11 @@ import { import { logOperations } from '@/lib/logs/application/operations' import { hydrateChildTraces } from '@/lib/logs/execution/hydrate-child-traces' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' -import { projectCostTotal, resolveLogFieldProjection } from '@/lib/logs/log-projection' +import { + logProjectionSubjectUserId, + projectCostTotal, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' import { type ActiveWorkspaceApplicationContext, @@ -150,7 +154,7 @@ const authorizedReadExecutionSnapshotUseCase = defineAuthorizedWorkspaceUseCase( * permission-group-enforced: logs.cost */ const projection = await resolveLogFieldProjection( - resolvePrincipalSubjectUserId(principal), + logProjectionSubjectUserId(principal), context.workspaceId, context.workspaceOrganizationId ) diff --git a/apps/sim/lib/logs/application/read-log-detail.test.ts b/apps/sim/lib/logs/application/read-log-detail.test.ts index ef5289c1eef..8960be8fd09 100644 --- a/apps/sim/lib/logs/application/read-log-detail.test.ts +++ b/apps/sim/lib/logs/application/read-log-detail.test.ts @@ -143,7 +143,7 @@ describe('readLogDetailUseCase', () => { resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) await readLogDetailUseCase.execute({ - principal: HUMAN_PRINCIPAL, + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { workspaceId: WORKSPACE_ID, lookupColumn: 'executionId', lookupValue: EXECUTION_ID }, }) @@ -152,6 +152,28 @@ describe('readLogDetailUseCase', () => { ) }) + /** + * The same person's group, reached through the run they triggered rather than + * through their own session. The delegation carries their role and none of + * their capabilities — `authorizeWorkspaceOperation` already passed it + * ungated — so projecting on it would withhold from a run on a group the + * funnel declined to apply. Attribution still names them. + */ + it('leaves spend in place for a run delegated by that same person', async () => { + queueLogRow() + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + await readLogDetailUseCase.execute({ + principal: HUMAN_PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, lookupColumn: 'executionId', lookupValue: EXECUTION_ID }, + }) + + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + expect(mocks.readLogDetail).toHaveBeenCalledWith( + expect.objectContaining({ viewerUserId: 'user-1', hideCostInfo: false }) + ) + }) + it('leaves spend in place when no group withholds it', async () => { queueLogRow() diff --git a/apps/sim/lib/logs/application/read-log-detail.ts b/apps/sim/lib/logs/application/read-log-detail.ts index ddd56478b99..8c2c0c0137f 100644 --- a/apps/sim/lib/logs/application/read-log-detail.ts +++ b/apps/sim/lib/logs/application/read-log-detail.ts @@ -11,7 +11,7 @@ import { } from '@/lib/logs/application/authorization' import { logOperations } from '@/lib/logs/application/operations' import { readLogDetail } from '@/lib/logs/fetch-log-detail' -import { resolveLogFieldProjection } from '@/lib/logs/log-projection' +import { logProjectionSubjectUserId, resolveLogFieldProjection } from '@/lib/logs/log-projection' import { type ActiveWorkspaceApplicationContext, resolveActiveWorkspaceApplicationContext, @@ -88,7 +88,7 @@ const authorizedReadLogDetailUseCase = defineAuthorizedWorkspaceUseCase({ * the v1 public API reads too — see {@link resolveLogFieldProjection}. */ const projection = await resolveLogFieldProjection( - viewerUserId, + logProjectionSubjectUserId(principal), context.workspaceId, context.workspaceOrganizationId ) diff --git a/apps/sim/lib/logs/log-projection.ts b/apps/sim/lib/logs/log-projection.ts index cd9cab02ecd..50bb9eb79a5 100644 --- a/apps/sim/lib/logs/log-projection.ts +++ b/apps/sim/lib/logs/log-projection.ts @@ -1,3 +1,5 @@ +import type { Principal } from '@sim/auth/principal' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { withheldExecutionData, withheldSpendData } from '@/lib/logs/fetch-log-detail' import { refuseCapability } from '@/lib/permission-groups/capabilities' import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' @@ -23,13 +25,37 @@ export const NO_LOG_FIELD_PROJECTION: LogFieldProjection = { hideCostInfo: false, } +/** + * The person a log projection is decided about. + * + * Deliberately NOT the attribution id a use case carries alongside it. An + * executor delegation names the run's actor for attribution and for authorizing + * the objects the run may materialize, but it carries that person's role and + * none of their capabilities — the exemption + * {@link capabilityGovernedPrincipalUserId} states and + * `authorizeWorkspaceOperation` already applied by the time a use case runs. + * Projecting on the actor would withhold fields from a run on a group the + * funnel declined to apply, and {@link assertLogCostQueryAllowed} would refuse + * the read outright — a refusal, not a projection, which is the thing the + * executor exemption exists to prevent. + * + * Every log surface holding a `Principal` derives its subject here, so the rule + * is stated once rather than re-decided per projection. + */ +export function logProjectionSubjectUserId(principal: Principal): string | null { + return capabilityGovernedPrincipalUserId(principal) +} + /** * The projection a viewer's permission group imposes on a workspace's logs. * * `viewerUserId` is `null` when no group governs the request — an actorless run * (a schedule, or a webhook with no external subject) reading its own - * workspace's logs, and a workspace API key, which authorizes as the workspace - * and whose reported user id is only the key's creator. Both read whole. + * workspace's logs, a workspace API key, which authorizes as the workspace and + * whose reported user id is only the key's creator, and an executor delegation, + * which carries a role and no capabilities. All read whole. Callers holding a + * `Principal` derive this through {@link logProjectionSubjectUserId} rather + * than passing whichever user id is nearest. * * The one place the two capabilities are read, so the internal/v2 detail path * and the v1 public API cannot drift: two copies of a redaction rule is how one diff --git a/apps/sim/lib/workflows/application/list-workflow-runs.ts b/apps/sim/lib/workflows/application/list-workflow-runs.ts index 5fd4a64b6cc..5e4c078cfbc 100644 --- a/apps/sim/lib/workflows/application/list-workflow-runs.ts +++ b/apps/sim/lib/workflows/application/list-workflow-runs.ts @@ -1,5 +1,4 @@ -import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' -import { resolveLogFieldProjection } from '@/lib/logs/log-projection' +import { logProjectionSubjectUserId, resolveLogFieldProjection } from '@/lib/logs/log-projection' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -22,13 +21,14 @@ export const listWorkflowRuns = defineAuthorizedWorkflowUseCase({ * withholds on every other log surface, so it is projected here rather than * in the presenter — the withholding travels with the read. * - * `resolvePrincipalSubjectUserId` returns `undefined` for a workspace API - * key, which represents no user and therefore no group; the key's creator is - * never substituted. This listing publishes no cost sort or filter, so there - * is no query surface to refuse alongside the value. + * {@link logProjectionSubjectUserId} names nobody for a workspace API key, + * which represents no user and therefore no group — the key's creator is + * never substituted — nor for an executor delegation, which carries a role + * and no capabilities. This listing publishes no cost sort or filter, so + * there is no query surface to refuse alongside the value. */ const projection = await resolveLogFieldProjection( - resolvePrincipalSubjectUserId(principal), + logProjectionSubjectUserId(principal), context.workspaceId, context.workspaceOrganizationId ) diff --git a/apps/sim/lib/workflows/application/read-workflow-run.test.ts b/apps/sim/lib/workflows/application/read-workflow-run.test.ts index 3f9f4a6ec12..10e8e25c54c 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.test.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.test.ts @@ -101,9 +101,7 @@ describe('readWorkflowRun projection subject', () => { await readWorkflowRun.execute({ principal: workspaceKey, input: input([]) }) - expect(mocks.getStatus).toHaveBeenCalledWith( - expect.objectContaining({ viewerUserId: undefined }) - ) + expect(mocks.getStatus).toHaveBeenCalledWith(expect.objectContaining({ viewerUserId: null })) }) }) diff --git a/apps/sim/lib/workflows/application/read-workflow-run.ts b/apps/sim/lib/workflows/application/read-workflow-run.ts index b553783e95a..cc235d56c7c 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.ts @@ -1,10 +1,10 @@ -import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import { isValidUuid } from '@sim/utils/id' import { OrchestrationError } from '@/lib/core/orchestration/types' import { FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, FunctionalOutputsUnavailableError, } from '@/lib/logs/execution/functional-outputs' +import { logProjectionSubjectUserId } from '@/lib/logs/log-projection' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowRunApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -75,7 +75,7 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ selectedOutputs: input.selectedOutputs, workspaceId: context.workspaceId, workspaceOrganizationId: context.workspaceOrganizationId, - viewerUserId: resolvePrincipalSubjectUserId(principal), + viewerUserId: logProjectionSubjectUserId(principal), }) if (!projected) throw new OrchestrationError('not_found', 'Run not found') const { status, projection } = projected diff --git a/apps/sim/lib/workflows/application/workflow-runs.test.ts b/apps/sim/lib/workflows/application/workflow-runs.test.ts index 13364b75e46..c299a8fcdd2 100644 --- a/apps/sim/lib/workflows/application/workflow-runs.test.ts +++ b/apps/sim/lib/workflows/application/workflow-runs.test.ts @@ -131,7 +131,7 @@ describe('workflow run application use cases', () => { selectedOutputs: ['4f1c2b3a-0000-4000-8000-000000000001.value'], workspaceId: 'workspace-1', workspaceOrganizationId: null, - viewerUserId: undefined, + viewerUserId: null, }) }) From 524d8166cc6215b6ee1bae4d46df6efdf75e96d9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 01:03:53 -0700 Subject: [PATCH 171/179] fix(logs): stop the migration erasing legacy runs' token counts stripSpanCosts also runs inside backfill-trace-spans.ts, which stores what it returns, so extending it to clear tokens turned a read-time projection into permanent data loss for every authorized reader of a migrated run. Split the two: stripSpanCosts keeps its persistence contract (dollars live in the ledger, spans keep structure, timing and tokens), and the joined cross-workspace child projection gets stripJoinedChildTraceSpend, which is never written back. --- .../execution/hydrate-child-traces.test.ts | 2 +- .../logs/execution/hydrate-child-traces.ts | 11 +- .../lib/logs/execution/trace-store.test.ts | 105 ++++++++++++------ apps/sim/lib/logs/execution/trace-store.ts | 59 +++++++--- 4 files changed, 124 insertions(+), 53 deletions(-) diff --git a/apps/sim/lib/logs/execution/hydrate-child-traces.test.ts b/apps/sim/lib/logs/execution/hydrate-child-traces.test.ts index 0af90346a48..58a0de0555c 100644 --- a/apps/sim/lib/logs/execution/hydrate-child-traces.test.ts +++ b/apps/sim/lib/logs/execution/hydrate-child-traces.test.ts @@ -32,7 +32,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ vi.mock('@/lib/logs/execution/trace-store', () => ({ materializeExecutionDataForDisplay: mockMaterialize, - stripSpanCosts: (spans: unknown) => { + stripJoinedChildTraceSpend: (spans: unknown) => { if (!Array.isArray(spans)) return for (const span of spans) { if (span && typeof span === 'object') { diff --git a/apps/sim/lib/logs/execution/hydrate-child-traces.ts b/apps/sim/lib/logs/execution/hydrate-child-traces.ts index cb4281eb92e..b47f7adbf69 100644 --- a/apps/sim/lib/logs/execution/hydrate-child-traces.ts +++ b/apps/sim/lib/logs/execution/hydrate-child-traces.ts @@ -6,7 +6,7 @@ import { inArray } from 'drizzle-orm' import { flattenWorkflowChildren } from '@/lib/logs/execution/trace-spans/span-factory' import { materializeExecutionDataForDisplay, - stripSpanCosts, + stripJoinedChildTraceSpend, } from '@/lib/logs/execution/trace-store' import type { TraceSpan } from '@/lib/logs/types' @@ -261,10 +261,11 @@ export async function hydrateChildTraces( // The same flattening the in-process workflow-in-workflow path uses, so a // cross-workspace child nests identically to a local one. const children = flattenWorkflowChildren(childSpans) - // The child's spend is billed to the SOURCE workspace and was never rolled - // into this run's total, so leaving per-span cost here would make the - // waterfall's numbers contradict the run cost shown above it. - stripSpanCosts(children) + // The child's spend is billed to the SOURCE workspace and was never + // rolled into this run's total, so leaving any of it here would make the + // waterfall's numbers contradict the run cost shown above it. Tokens go + // with the dollars — see {@link stripJoinedChildTraceSpend}. + stripJoinedChildTraceSpend(children) span.children = children span.childTraceAccess = 'granted' diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index 03d2f2015c1..9c2a4c88253 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -31,6 +31,7 @@ import { projectExecutionDataForDisplay, RESOLVED_SECRET_PROVENANCE_KEY, SECRET_PROJECTION_VERSION, + stripJoinedChildTraceSpend, stripSpanCosts, TRACE_STORE_REF_KEY, } from '@/lib/logs/execution/trace-store' @@ -775,43 +776,49 @@ describe('stored provenance display reporting', () => { }) /** - * `stripSpanCosts` is what stands between a joined cross-workspace child run and - * the parent's reader: the child's spend is billed to the SOURCE workspace and - * was never rolled into this run's total, so anything it leaves behind is spend - * the reader was never meant to see. + * The two strips are not the same removal, and the difference is whether the + * result is written. + * + * `stripJoinedChildTraceSpend` is what stands between a joined cross-workspace + * child run and the parent's reader: the child's spend is billed to the SOURCE + * workspace and was never rolled into this run's total, so anything it leaves + * behind is spend the reader was never meant to see — and it never persists. + * `stripSpanCosts` runs inside `backfill-trace-spans.ts`, which stores what it + * returns, so anything IT clears is gone for every authorized reader of that run + * forever. Only the dollars belong in that set. */ -describe('stripSpanCosts', () => { - function spanWithSpend() { - return [ - { - id: 'span-1', - name: 'agent', - cost: { total: 0.5 }, - tokens: { total: 900 }, - providerTiming: { - duration: 5, - segments: [ - { type: 'model', name: 'gpt-4', tokens: { total: 900 }, cost: { total: 0.5 } }, - { type: 'tool', name: 'search' }, - ], - }, - children: [ - { - id: 'span-2', - name: 'model', - cost: { total: 0.2 }, - tokens: { total: 400 }, - providerTiming: { segments: [{ type: 'model', tokens: { total: 400 } }] }, - }, +function spanWithSpend() { + return [ + { + id: 'span-1', + name: 'agent', + cost: { total: 0.5 }, + tokens: { total: 900 }, + providerTiming: { + duration: 5, + segments: [ + { type: 'model', name: 'gpt-4', tokens: { total: 900 }, cost: { total: 0.5 } }, + { type: 'tool', name: 'search' }, ], }, - ] - } + children: [ + { + id: 'span-2', + name: 'model', + cost: { total: 0.2 }, + tokens: { total: 400 }, + providerTiming: { segments: [{ type: 'model', tokens: { total: 400 } }] }, + }, + ], + }, + ] +} +describe('stripJoinedChildTraceSpend', () => { it('clears the span roll-up and the provider-timing segments that itemize it', () => { const spans = spanWithSpend() - stripSpanCosts(spans) + stripJoinedChildTraceSpend(spans) expect(spans[0].cost).toBeUndefined() expect(spans[0].tokens).toBeUndefined() @@ -828,7 +835,7 @@ describe('stripSpanCosts', () => { it('reaches the segments of nested children too', () => { const spans = spanWithSpend() - stripSpanCosts(spans) + stripJoinedChildTraceSpend(spans) const child = spans[0].children[0] expect(child.cost).toBeUndefined() @@ -841,7 +848,41 @@ describe('stripSpanCosts', () => { it('leaves a span with no provider timing alone', () => { const spans = [{ id: 'span-1', name: 'api', cost: { total: 0.1 } }] - expect(() => stripSpanCosts(spans)).not.toThrow() + expect(() => stripJoinedChildTraceSpend(spans)).not.toThrow() expect(spans[0]).toMatchObject({ id: 'span-1', name: 'api' }) }) }) + +describe('stripSpanCosts', () => { + it('clears cost at both levels and through children', () => { + const spans = spanWithSpend() + + stripSpanCosts(spans) + + expect(spans[0].cost).toBeUndefined() + expect(spans[0].children[0].cost).toBeUndefined() + expect( + (spans[0].providerTiming.segments as Array>)[0].cost + ).toBeUndefined() + }) + + /** + * The migration stores what this returns. A legacy run's token counts are + * ordinary trace detail its authorized readers have always had, and the ledger + * — not the span — is where dollars live, so erasing them buys nothing and + * cannot be undone. + */ + it('keeps the token counts the migration is about to persist', () => { + const spans = spanWithSpend() + + stripSpanCosts(spans) + + expect(spans[0].tokens).toEqual({ total: 900 }) + expect(spans[0].children[0].tokens).toEqual({ total: 400 }) + const segments = spans[0].providerTiming.segments as Array> + expect(segments[0].tokens).toEqual({ total: 900 }) + expect( + (spans[0].children[0].providerTiming.segments as Array>)[0].tokens + ).toEqual({ total: 400 }) + }) +}) diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index fef1d9149e9..97595878508 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -100,12 +100,18 @@ function workflowIdFromStorageKey(key: string | undefined): string | undefined { } /** - * Recursively removes `cost` from trace spans before persistence. Cost lives in - * exactly one place — the usage_log ledger — so persisted spans carry only - * structure, timing, and tokens (KTD7). Must run AFTER `calculateCostSummary` - * has consumed span costs in memory. + * Recursively removes spend from trace spans, in place. + * + * `tokens` is optional because the two callers withhold different things. + * Persistence withholds only dollars — cost lives in exactly one place, the + * usage_log ledger (KTD7), so a stored span carries structure, timing and + * tokens. A joined cross-workspace child run withholds the whole amount: its + * token counts are the same spend in another unit, recoverable by anyone who + * knows the model's rate. + * + * Must run AFTER `calculateCostSummary` has consumed span costs in memory. */ -export function stripSpanCosts(spans: unknown): void { +function stripSpanSpendFields(spans: unknown, options: { tokens: boolean }): void { if (!Array.isArray(spans)) return for (const span of spans) { if (!span || typeof span !== 'object') continue @@ -116,17 +122,37 @@ export function stripSpanCosts(spans: unknown): void { providerTiming?: unknown } if ('cost' in record) record.cost = undefined - /** - * Tokens as well as dollars: a span's token counts are the spend in another - * unit, so clearing only `cost` left the amount recoverable by anyone who - * knows the model's rate. - */ - if ('tokens' in record) record.tokens = undefined - stripProviderTimingSegmentCosts(record.providerTiming) - if (Array.isArray(record.children)) stripSpanCosts(record.children) + if (options.tokens && 'tokens' in record) record.tokens = undefined + stripProviderTimingSegmentSpend(record.providerTiming, options) + if (Array.isArray(record.children)) stripSpanSpendFields(record.children, options) } } +/** + * Removes per-span `cost` before persistence, leaving tokens in place. + * + * The one strip that WRITES: `backfill-trace-spans.ts` runs it over a legacy + * row's spans and stores the result, so anything it clears is gone for every + * authorized reader of that run, forever. Only cost belongs in that set — the + * ledger owns the dollars, and the spans have never been the place they live. + */ +export function stripSpanCosts(spans: unknown): void { + stripSpanSpendFields(spans, { tokens: false }) +} + +/** + * Removes cost AND token counts from a joined child run's spans, in memory. + * + * The child's spend is billed to the SOURCE workspace and was never rolled into + * the parent run's total, so leaving any of it would publish spend the reader + * was never meant to see and make the waterfall contradict the run cost above + * it. A read-time projection only: these spans are hydrated onto a response and + * never written back. + */ +export function stripJoinedChildTraceSpend(spans: unknown): void { + stripSpanSpendFields(spans, { tokens: true }) +} + /** * The same removal one level down, in `providerTiming.segments`. * @@ -135,7 +161,10 @@ export function stripSpanCosts(spans: unknown): void { * left the whole figure itemized underneath it, which is strictly more than the * span published in the first place. */ -function stripProviderTimingSegmentCosts(providerTiming: unknown): void { +function stripProviderTimingSegmentSpend( + providerTiming: unknown, + options: { tokens: boolean } +): void { if (!providerTiming || typeof providerTiming !== 'object') return const segments = (providerTiming as { segments?: unknown }).segments if (!Array.isArray(segments)) return @@ -143,7 +172,7 @@ function stripProviderTimingSegmentCosts(providerTiming: unknown): void { if (!segment || typeof segment !== 'object') continue const record = segment as { cost?: unknown; tokens?: unknown } if ('cost' in record) record.cost = undefined - if ('tokens' in record) record.tokens = undefined + if (options.tokens && 'tokens' in record) record.tokens = undefined } } From 59d14cd72e521c48c3c29a7b280eacd3f2284bd8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 01:04:21 -0700 Subject: [PATCH 172/179] test: cover the converged refusals, the log projections, and the modal seeding --- .../[tableId]/export-async/route.test.ts | 1 + .../[tableId]/export/download/route.test.ts | 135 ++++-------------- apps/sim/app/api/v1/capability-gate.test.ts | 13 +- .../executions/[executionId]/route.test.ts | 93 ++++++++++-- apps/sim/app/api/v1/logs/projection.test.ts | 117 ++++++++++++++- .../api/workspaces/[id]/inbox/route.test.ts | 10 +- .../[id]/inbox/siblings.capability.test.ts | 103 +++++++++++++ .../create-api-key-modal.test.tsx | 112 +++++++++++++++ apps/sim/lib/api/contracts/primitives.test.ts | 27 ++++ .../lib/core/application/operation.test.ts | 13 ++ 10 files changed, 498 insertions(+), 126 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/inbox/siblings.capability.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.test.tsx diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts index 503c9c4bde6..b9bc711b714 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts @@ -136,6 +136,7 @@ describe('POST /api/table/[tableId]/export-async', () => { expect(response.status).toBe(403) expect(await response.json()).toEqual({ error: "Exporting a table is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, }) expect(mockMarkTableJobRunning).not.toHaveBeenCalled() expect(mockRunTableExport).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts index 23690550609..38bb5a62470 100644 --- a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts @@ -5,25 +5,21 @@ import { createTableDefinition, hybridAuthMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckAccess, - mockGetTableJob, - mockGeneratePresignedDownloadUrl, - mockGetUserPermissionConfig, -} = vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockGetTableJob: vi.fn(), - mockGeneratePresignedDownloadUrl: vi.fn(), - mockGetUserPermissionConfig: vi.fn(), -})) +const { mockCheckAccess, mockGetTableJob, mockGetUserPermissionConfig, mockPresign } = vi.hoisted( + () => ({ + mockCheckAccess: vi.fn(), + mockGetTableJob: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), + mockPresign: vi.fn(), + }) +) vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) - vi.mock('@/lib/table/jobs/service', () => ({ getTableJob: mockGetTableJob })) vi.mock('@/lib/uploads/core/storage-service', () => ({ - generatePresignedDownloadUrl: mockGeneratePresignedDownloadUrl, + generatePresignedDownloadUrl: mockPresign, })) vi.mock('@/app/api/table/utils', async () => { const { NextResponse } = await import('next/server') @@ -37,126 +33,51 @@ vi.mock('@/app/api/table/utils', async () => { import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { GET } from '@/app/api/table/[tableId]/export/download/route' -function makeRequest(query: Record, tableId = 'tbl_1') { - const qs = new URLSearchParams(query).toString() - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/export/download?${qs}`) +const table = createTableDefinition({ id: 'tbl_1', workspaceId: 'workspace-1' }) + +function makeRequest(tableId = 'tbl_1') { + const req = new NextRequest( + `http://localhost:3000/api/table/${tableId}/export/download?workspaceId=workspace-1&jobId=job-1` + ) return GET(req, { params: Promise.resolve({ tableId }) }) } -const validQuery = { workspaceId: 'workspace-1', jobId: 'job_1' } - describe('GET /api/table/[tableId]/export/download', () => { beforeEach(() => { vi.clearAllMocks() hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-1', - authType: 'session', }) - mockCheckAccess.mockResolvedValue({ ok: true, table: createTableDefinition() }) + mockCheckAccess.mockResolvedValue({ ok: true, table }) + mockGetUserPermissionConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) mockGetTableJob.mockResolvedValue({ - id: 'job_1', type: 'export', status: 'ready', - payload: { format: 'csv', resultKey: 'workspace/workspace-1/exports/tbl_1/job_1/people.csv' }, + payload: { resultKey: 'exports/tbl_1.csv', fileName: 'tbl_1.csv' }, }) - mockGeneratePresignedDownloadUrl.mockResolvedValue('https://storage.example/signed-url') + mockPresign.mockResolvedValue('https://example.com/signed') }) - it('resolves a ready export to a presigned URL', async () => { - const response = await makeRequest(validQuery) - const data = await response.json() - + it('presigns a ready export', async () => { + const response = await makeRequest() expect(response.status).toBe(200) - expect(data.data).toEqual({ url: 'https://storage.example/signed-url', fileName: 'people.csv' }) - expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith( - 'workspace/workspace-1/exports/tbl_1/job_1/people.csv', - 'workspace' - ) - }) - - it('404s when the job is missing or not an export', async () => { - mockGetTableJob.mockResolvedValue({ id: 'job_1', type: 'delete', status: 'ready', payload: {} }) - const response = await makeRequest(validQuery) - expect(response.status).toBe(404) - }) - - it('409s when the export is not ready yet', async () => { - mockGetTableJob.mockResolvedValue({ - id: 'job_1', - type: 'export', - status: 'running', - payload: { format: 'csv' }, - }) - const response = await makeRequest(validQuery) - expect(response.status).toBe(409) - }) - - it('410s when the result file is gone from the payload', async () => { - mockGetTableJob.mockResolvedValue({ - id: 'job_1', - type: 'export', - status: 'ready', - payload: { format: 'csv' }, - }) - const response = await makeRequest(validQuery) - expect(response.status).toBe(410) - }) - - it('returns 401 when unauthenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) - const response = await makeRequest(validQuery) - expect(response.status).toBe(401) - }) - - it('returns 400 on workspace mismatch', async () => { - const response = await makeRequest({ ...validQuery, workspaceId: 'other-ws' }) - expect(response.status).toBe(400) - }) -}) - -describe('tables.export capability', () => { - beforeEach(() => { - vi.clearAllMocks() - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - }) - mockCheckAccess.mockResolvedValue({ - ok: true, - table: createTableDefinition({ id: 'tbl_1', workspaceId: 'workspace-1' }), - }) - mockGetTableJob.mockResolvedValue({ - type: 'export', - status: 'ready', - payload: { resultKey: 'exports/tbl_1.csv' }, - }) - mockGeneratePresignedDownloadUrl.mockResolvedValue('https://example.test/signed') - mockGetUserPermissionConfig.mockResolvedValue(null) }) - it('refuses when the group withholds table export', async () => { + it('refuses with the structured capability detail when the group withholds tables.export', async () => { mockGetUserPermissionConfig.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, disableTableExport: true, }) - const response = await makeRequest(validQuery) + const response = await makeRequest() expect(response.status).toBe(403) - expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled() - }) - - it('refuses a group that withholds the Tables module outright', async () => { - mockGetUserPermissionConfig.mockResolvedValue({ - ...DEFAULT_PERMISSION_GROUP_CONFIG, - hideTablesTab: true, + expect(await response.json()).toEqual({ + error: "Exporting a table is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, }) - - expect((await makeRequest(validQuery)).status).toBe(403) - }) - - it('allows an ungoverned caller', async () => { - expect((await makeRequest(validQuery)).status).toBe(200) + expect(mockGetTableJob).not.toHaveBeenCalled() + expect(mockPresign).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v1/capability-gate.test.ts b/apps/sim/app/api/v1/capability-gate.test.ts index 287239fe7e3..b367393a6a9 100644 --- a/apps/sim/app/api/v1/capability-gate.test.ts +++ b/apps/sim/app/api/v1/capability-gate.test.ts @@ -95,10 +95,15 @@ vi.mock('@/lib/logs/public-queries', () => ({ vi.mock('@/lib/logs/execution/trace-store', () => ({ materializeExecutionDataForDisplay: vi.fn(), })) -vi.mock('@/app/api/v1/logs/meta', () => ({ - getUserLimits: vi.fn(async () => ({})), - createApiResponse: (body: unknown) => ({ body, headers: {} }), -})) +vi.mock('@/app/api/v1/logs/meta', async () => { + const { projectUserLimits } = + await vi.importActual('@/app/api/v1/logs/meta') + return { + getUserLimits: vi.fn(async () => ({ usage: {} })), + projectUserLimits, + createApiResponse: (body: unknown) => ({ body, headers: {} }), + } +}) vi.mock('@/lib/workflows/deployments/queries', () => ({ getDeploymentWorkflowTarget: mockGetDeploymentWorkflowTarget, })) diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts index bfde46a4b91..5a6ef965d8a 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts @@ -1,12 +1,13 @@ /** * @vitest-environment node */ +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ checkRateLimit: vi.fn(), - validateWorkspaceAccess: vi.fn(), + resolveWorkspaceAccess: vi.fn(), getPublicWorkflowLog: vi.fn(), getUserLimits: vi.fn(), })) @@ -20,18 +21,36 @@ vi.mock('@/app/api/v1/middleware', () => ({ capabilityGovernedUserId: (rateLimit: { keyType?: string; userId?: string }) => rateLimit.keyType === 'personal' ? (rateLimit.userId ?? null) : null, checkRateLimit: mocks.checkRateLimit, + /** Mirrors the real helper: only a post-role group refusal carries `details`. */ + concealedWorkspaceAccessResponse: ( + failure: { status: number; message: string; details?: unknown }, + notFoundMessage: string + ) => + failure.details + ? NextResponse.json( + { error: failure.message, details: failure.details }, + { status: failure.status } + ) + : NextResponse.json({ error: notFoundMessage }, { status: 404 }), createRateLimitResponse: () => NextResponse.json({ error: 'Rate limit' }, { status: 429 }), - validateWorkspaceAccess: mocks.validateWorkspaceAccess, + resolveWorkspaceAccess: mocks.resolveWorkspaceAccess, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + vi.mock('@/lib/logs/public-queries', () => ({ getPublicWorkflowLog: mocks.getPublicWorkflowLog, })) -vi.mock('@/app/api/v1/logs/meta', () => ({ - getUserLimits: mocks.getUserLimits, - createApiResponse: (data: T, limits: L) => ({ body: { ...data, limits }, headers: {} }), -})) +vi.mock('@/app/api/v1/logs/meta', async () => { + const { projectUserLimits } = + await vi.importActual('@/app/api/v1/logs/meta') + return { + getUserLimits: mocks.getUserLimits, + projectUserLimits, + createApiResponse: (data: T, limits: L) => ({ body: { ...data, limits }, headers: {} }), + } +}) /** * Overrides the global stub, whose empty `subBlocks` would let the sanitizer @@ -54,6 +73,7 @@ vi.mock('@/blocks/registry', () => ({ getBlockByToolName: vi.fn(() => undefined), })) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { GET } from '@/app/api/v1/logs/executions/[executionId]/route' const rateLimit = { @@ -93,8 +113,11 @@ describe('GET /api/v1/logs/executions/[executionId]', () => { beforeEach(() => { vi.clearAllMocks() mocks.checkRateLimit.mockResolvedValue(rateLimit) - mocks.validateWorkspaceAccess.mockResolvedValue(null) - mocks.getUserLimits.mockResolvedValue({ usage: { plan: 'free' } }) + mocks.resolveWorkspaceAccess.mockResolvedValue(null) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(null) + mocks.getUserLimits.mockResolvedValue({ + usage: { plan: 'free', currentPeriodCost: 12.5, limit: 50, isExceeded: false }, + }) mocks.getPublicWorkflowLog.mockResolvedValue({ workflowId: 'workflow-1', workspaceId: 'workspace-1', @@ -137,8 +160,60 @@ describe('GET /api/v1/logs/executions/[executionId]', () => { totalDurationMs: 1000, cost: { total: 0.01 }, }, - limits: { usage: { plan: 'free' } }, + limits: { usage: { plan: 'free', currentPeriodCost: 12.5 } }, + }) + }) + + it("conceals an ordinary access failure behind the surface's not-found", async () => { + mocks.resolveWorkspaceAccess.mockResolvedValueOnce({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', }) + + const { request, context } = requestFor('execution-1') + const response = await GET(request, context) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Workflow execution not found' }) + }) + + /** + * Both group keys answer only after the caller's workspace role verified, so + * the caller is already known to be a member: the refusal names their own + * organization's setting and conceals nothing a 404 would protect. + */ + it('preserves the structured detail of a post-role permission-group refusal', async () => { + mocks.resolveWorkspaceAccess.mockResolvedValueOnce({ + status: 403, + code: 'FORBIDDEN', + message: 'Personal API keys are disabled for this workspace', + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }) + + const { request, context } = requestFor('execution-1') + const response = await GET(request, context) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: 'Personal API keys are disabled for this workspace', + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }) + }) + + it('withholds period spend alongside the run total when the group withholds logs.cost', async () => { + mocks.checkRateLimit.mockResolvedValue({ ...rateLimit, keyType: 'personal' }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + + const { request, context } = requestFor('execution-1') + const body = await (await GET(request, context)).json() + + expect(body.executionMetadata.cost).toBeNull() + expect(body.limits.usage.currentPeriodCost).toBeNull() + expect(body.limits.usage).toMatchObject({ plan: 'free', limit: 50, isExceeded: false }) }) it('reports a missing snapshot as not found', async () => { diff --git a/apps/sim/app/api/v1/logs/projection.test.ts b/apps/sim/app/api/v1/logs/projection.test.ts index 99b9328c487..16eafc243d5 100644 --- a/apps/sim/app/api/v1/logs/projection.test.ts +++ b/apps/sim/app/api/v1/logs/projection.test.ts @@ -65,10 +65,20 @@ vi.mock('@/lib/logs/execution/trace-store', () => ({ vi.mock('@/lib/logs/snapshot-sanitizer', () => ({ sanitizeExecutionSnapshotState: (state: unknown) => state, })) -vi.mock('@/app/api/v1/logs/meta', () => ({ - getUserLimits: vi.fn(async () => ({})), - createApiResponse: (body: unknown) => ({ body, headers: {} }), -})) +vi.mock('@/app/api/v1/logs/meta', async () => { + const { projectUserLimits } = + await vi.importActual('@/app/api/v1/logs/meta') + return { + getUserLimits: vi.fn(async () => ({ + usage: { currentPeriodCost: 4.25, limit: 50, plan: 'pro', isExceeded: false }, + })), + projectUserLimits, + createApiResponse: (body: unknown, limits: unknown) => ({ + body: { ...(body as object), limits }, + headers: {}, + }), + } +}) import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { GET as getLogDetail } from '@/app/api/v1/logs/[id]/route' @@ -205,6 +215,63 @@ describe('GET /api/v1/logs?details=full', () => { expect(log.finalOutput).toEqual(EXECUTION_DATA.finalOutput) }) + /** + * `withheldExecutionData` strips `traceSpans` and `finalOutput` alike, so + * under `hideTraceSpans` neither opt-in field survives — reading every row's + * blob out of the trace store to delete it is pure cost. + */ + it('neither selects nor materializes execution data it is going to withhold', async () => { + governedBy({ hideTraceSpans: true }) + + await listFull() + + expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( + expect.objectContaining({ includeExecutionData: false }) + ) + expect(mockMaterialize).not.toHaveBeenCalled() + }) + + it('still materializes for a group that withholds only spend', async () => { + governedBy({ hideCostInfo: true }) + + await listFull() + + expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( + expect.objectContaining({ includeExecutionData: true }) + ) + expect(mockMaterialize).toHaveBeenCalled() + }) + + /** + * On a personal key the keyholder IS the governed member, so blanking every + * run's cost while the envelope reports what those runs added up to this + * period withholds nothing. `limit`, `plan` and `isExceeded` stay: they are + * the caller's entitlement and eligibility, not a spend figure. + */ + it('withholds the period spend in the limits envelope alongside the run totals', async () => { + governedBy({ hideCostInfo: true }) + + const body = await (await listFull()).json() + + expect(body.limits.usage.currentPeriodCost).toBeNull() + expect(body.limits.usage).toMatchObject({ limit: 50, plan: 'pro', isExceeded: false }) + }) + + it('reports the period spend when no group withholds it', async () => { + const body = await (await listFull()).json() + + expect(body.limits.usage.currentPeriodCost).toBe(4.25) + }) + + it('reports the period spend to a workspace API key, which resolves no group', async () => { + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) + governedBy({ hideCostInfo: true }) + + const body = await (await listFull()).json() + + expect(body.limits.usage.currentPeriodCost).toBe(4.25) + }) + it('withholds nothing from a workspace API key, whose creator has no say', async () => { mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) governedBy({ hideTraceSpans: true, hideCostInfo: true }) @@ -369,3 +436,45 @@ describe('GET /api/v1/logs/executions/[executionId]', () => { expect(body.executionMetadata.cost).toEqual({ total: 0.75 }) }) }) + +/** + * The log surfaces answer "not found" for a workspace the caller cannot reach, + * so a stranger cannot probe which ones exist. A permission-group refusal is + * the one failure with nothing left to conceal: it runs only after the role + * check passed, so the caller is already a known member being told how their + * own organization configured their cohort. + */ +describe('v1 log surfaces and the personal-key group refusal', () => { + beforeEach(() => { + governedBy({ disablePersonalApiKeys: true }) + }) + + it.each([ + ['GET /api/v1/logs/[id]', () => readDetail()], + ['GET /api/v1/logs/executions/[executionId]', () => readExecution()], + ])('%s keeps the structured detail rather than flattening it to 404', async (_name, call) => { + const response = await call() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: expect.any(String), + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }) + }) + + it.each([ + ['GET /api/v1/logs/[id]', () => readDetail(), 'Log not found'], + [ + 'GET /api/v1/logs/executions/[executionId]', + () => readExecution(), + 'Workflow execution not found', + ], + ])('%s still conceals a caller with no workspace role', async (_name, call, message) => { + mockGetUserEntityPermissions.mockResolvedValue(null) + + const response = await call() + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: message }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts index 60b5d1d0f3e..0c9e4ecf029 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts @@ -151,7 +151,10 @@ describe('Inbox inbox.use capability gate', () => { const response = await GET(getRequest(), context) expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + await expect(response.json()).resolves.toEqual({ + error: REFUSAL, + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) }) it('refuses to update the inbox config, leaving the row untouched', async () => { @@ -160,7 +163,10 @@ describe('Inbox inbox.use capability gate', () => { const response = await PATCH(patchRequest(), context) expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + await expect(response.json()).resolves.toEqual({ + error: REFUSAL, + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) expect(dbChainMockFns.set).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/siblings.capability.test.ts b/apps/sim/app/api/workspaces/[id]/inbox/siblings.capability.test.ts new file mode 100644 index 00000000000..7c934e5abf2 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/inbox/siblings.capability.test.ts @@ -0,0 +1,103 @@ +/** + * @vitest-environment node + */ +import { + authMockFns, + createMockRequest, + dbChainMock, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserEntityPermissions, mockHasWorkspaceInboxAccess } = vi.hoisted(() => ({ + mockGetUserEntityPermissions: vi.fn(), + mockHasWorkspaceInboxAccess: vi.fn(), +})) + +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceInboxAccess: mockHasWorkspaceInboxAccess, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { + DELETE as DELETE_SENDER, + GET as GET_SENDERS, + POST as POST_SENDER, +} from '@/app/api/workspaces/[id]/inbox/senders/route' +import { GET as GET_TASKS } from '@/app/api/workspaces/[id]/inbox/tasks/route' + +const context = { params: Promise.resolve({ id: 'workspace-1' }) } +const REFUSAL = "The inbox is not available under your organization's permission group" +const BLOCKED = { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' } +const BASE_URL = 'http://localhost:3000/api/workspaces/workspace-1/inbox' + +/** + * The inbox surface spreads one capability over three route files and six + * handlers. Asserted together because a refusal that drifts on one of them is + * invisible to a test that only reads `/inbox` — which is how the sibling + * handlers came to omit `details.code` in the first place. + */ +describe('inbox.use refusals converge across the sibling routes', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockHasWorkspaceInboxAccess.mockResolvedValue(true) + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideInboxTab: true, + }) + }) + + it.each([ + [ + 'GET /inbox/tasks', + () => GET_TASKS(createMockRequest('GET', undefined, undefined, `${BASE_URL}/tasks`), context), + ], + [ + 'GET /inbox/senders', + () => + GET_SENDERS(createMockRequest('GET', undefined, undefined, `${BASE_URL}/senders`), context), + ], + [ + 'POST /inbox/senders', + () => + POST_SENDER( + createMockRequest( + 'POST', + { email: 'someone@example.com' }, + undefined, + `${BASE_URL}/senders` + ), + context + ), + ], + [ + 'DELETE /inbox/senders', + () => + DELETE_SENDER( + createMockRequest('DELETE', { senderId: 'sender-1' }, undefined, `${BASE_URL}/senders`), + context + ), + ], + ])('%s returns the structured capability refusal', async (_name, call) => { + const response = await call() + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: REFUSAL, details: BLOCKED }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.test.tsx new file mode 100644 index 00000000000..4e6cc481a21 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.test.tsx @@ -0,0 +1,112 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { primaryActions } = vi.hoisted(() => ({ + primaryActions: { current: [] as { label: string; disabled: boolean }[] }, +})) + +vi.mock('@sim/emcn', () => ({ + ButtonGroup: ({ children }: { children: ReactNode }) =>
{children}
, + ButtonGroupItem: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModal: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalBody: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalError: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalField: ({ value, onChange }: { value?: string; onChange?: (v: string) => void }) => ( + onChange?.(event.target.value)} + /> + ), + ChipModalFooter: ({ primaryAction }: { primaryAction: { label: string; disabled: boolean } }) => { + primaryActions.current.push(primaryAction) + return
+ }, + ChipModalHeader: ({ children }: { children: ReactNode }) =>
{children}
, + SecretReveal: () =>
, +})) + +vi.mock('@/hooks/queries/api-keys', () => ({ + useCreateApiKey: () => ({ isPending: false, mutateAsync: vi.fn() }), +})) + +import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal' + +let container: HTMLDivElement +let root: Root + +/** The success dialog renders a second footer, so pick the create one. */ +function latestCreateAction() { + return primaryActions.current.filter((action) => action.label === 'Create').at(-1)! +} + +async function render(props: { + open: boolean + defaultKeyType: 'personal' | 'workspace' + allowPersonalApiKeys: boolean +}) { + await act(async () => { + root.render( + + ) + }) +} + +async function typeName() { + const input = container.querySelector('[data-testid="key-name"]')! + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set! + setter.call(input, 'CI key') + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +describe('CreateApiKeyModal key-type seeding', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + primaryActions.current = [] + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() + }) + + /** + * The settings page mounts this modal closed, while its permission-group + * query is still pending — so `defaultKeyType` is the fail-closed + * `'workspace'` and only becomes `'personal'` once the policy answers. A + * non-admin has no type selector rendered, so a selection seeded at mount is + * one they can never change and never create with. + */ + it('adopts the default the policy resolved to, not the one present at mount', async () => { + await render({ open: false, defaultKeyType: 'workspace', allowPersonalApiKeys: true }) + await render({ open: false, defaultKeyType: 'personal', allowPersonalApiKeys: true }) + await render({ open: true, defaultKeyType: 'personal', allowPersonalApiKeys: true }) + await typeName() + + expect(latestCreateAction().disabled).toBe(false) + }) + + it('still refuses a workspace key a non-admin may not create', async () => { + await render({ open: false, defaultKeyType: 'workspace', allowPersonalApiKeys: false }) + await render({ open: true, defaultKeyType: 'workspace', allowPersonalApiKeys: false }) + await typeName() + + expect(latestCreateAction().disabled).toBe(true) + }) +}) diff --git a/apps/sim/lib/api/contracts/primitives.test.ts b/apps/sim/lib/api/contracts/primitives.test.ts index 280df88de61..28c78731278 100644 --- a/apps/sim/lib/api/contracts/primitives.test.ts +++ b/apps/sim/lib/api/contracts/primitives.test.ts @@ -7,6 +7,7 @@ import { customPatternSchema, isCanonicalBase64, MAX_ID_LENGTH, + optionalNumberQuerySchema, organizationIdSchema, organizationRoleSchema, piiStagePolicySchema, @@ -345,3 +346,29 @@ describe('withMissingFieldMessage', () => { expect(retrofitted.safeParse('ok').success).toBe(true) }) }) + +describe('optionalNumberQuerySchema', () => { + it('keeps a real numeric bound, including an explicit zero', () => { + expect(optionalNumberQuerySchema.parse('0')).toBe(0) + expect(optionalNumberQuerySchema.parse('2.5')).toBe(2.5) + expect(optionalNumberQuerySchema.parse(7)).toBe(7) + }) + + it('drops an omitted or present-but-empty value', () => { + expect(optionalNumberQuerySchema.parse(undefined)).toBeUndefined() + expect(optionalNumberQuerySchema.parse('')).toBeUndefined() + expect(optionalNumberQuerySchema.parse(' ')).toBeUndefined() + }) + + /** + * `z.coerce.number()` reads `null` as `0`, which would turn a client that + * spells an unset bound as `null` into a caller asking a cost question. + */ + it('drops null rather than coercing it to a zero bound', () => { + expect(optionalNumberQuerySchema.parse(null)).toBeUndefined() + }) + + it('still rejects a non-numeric value', () => { + expect(optionalNumberQuerySchema.safeParse('abc').success).toBe(false) + }) +}) diff --git a/apps/sim/lib/core/application/operation.test.ts b/apps/sim/lib/core/application/operation.test.ts index 7a773eece1d..ba609d93178 100644 --- a/apps/sim/lib/core/application/operation.test.ts +++ b/apps/sim/lib/core/application/operation.test.ts @@ -100,4 +100,17 @@ describe('assertOperationCapability', () => { 'Operation meta.parameterized declares parameterized capability deploy.chat.auth_mode; assert it from the use case instead' ) }) + + it('refuses the principal-wide capability the funnel applies to every operation', () => { + expect(() => + defineOperation({ + id: 'meta.principal_wide', + // @ts-expect-error personal_api_key.use is not an OperationDeclarableCapability + capability: 'personal_api_key.use', + principalKinds: ['session'], + }) + ).toThrow( + "Operation meta.principal_wide declares principal-wide capability personal_api_key.use; the authorization funnel's personal-key branch already applies it to every operation" + ) + }) }) From 3661860bb1a18b83fc762e6b6d6d5e86f03b205f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 01:07:14 -0700 Subject: [PATCH 173/179] fix(executor): keep the gate subject across pause, drain and the run cache Three ways a run's governed subject was lost after being resolved correctly once: - serializePauseSnapshot enumerates the metadata it rebuilds and did not list capabilityGovernedUserId, so a paused run resumed gating on the billing actor (governedSubjectUserId reads absence as 'not declared'). - continueCascadeAfterResume carried the paused cell's subject into the next group even when that group held another dispatch's unclaimed pre-stamp, which both drain points in workflow-column-execution already read off the stamp. - getPermissionConfig memoizes on the ExecutionContext with no subject key, while validateModelProvider/validateBlockType passed the actor positionally - so the agent handler's model check cached the billing actor's group and every later assertPermissionsAllowed silently reused it. Derive the subject inside getPermissionConfig so the memo is correct by construction. --- apps/sim/background/resume-execution.ts | 14 +++- .../resume-governed-subject.test.ts | 45 +++++++++++++ .../access-control/utils/permission-check.ts | 20 +++++- .../utils/permission-gate-subject.test.ts | 65 ++++++++++++++++++- .../execution/snapshot-serializer.test.ts | 44 +++++++++++++ .../executor/execution/snapshot-serializer.ts | 9 +++ 6 files changed, 192 insertions(+), 5 deletions(-) diff --git a/apps/sim/background/resume-execution.ts b/apps/sim/background/resume-execution.ts index 4fa33ffd4b5..9b2f7d4f584 100644 --- a/apps/sim/background/resume-execution.ts +++ b/apps/sim/background/resume-execution.ts @@ -401,6 +401,7 @@ async function continueCascadeAfterResume( const { getTableById } = await import('@/lib/table/service') const { getRowById } = await import('@/lib/table/rows/service') const { pickNextEligibleGroupForRow } = await import('@/lib/table/workflow-columns') + const { readStampedCapabilitySubject } = await import('@/lib/table/rows/executions') const { runRowCascadeLoop } = await import('@/background/workflow-column-execution') const freshTable = await getTableById(cellContext.tableId) @@ -409,6 +410,8 @@ async function continueCascadeAfterResume( if (!freshRow) return const next = pickNextEligibleGroupForRow(freshTable, freshRow, cellContext.groupId) if (!next) return + const nextExec = freshRow.executions?.[next.id] + const isQueuedMarker = nextExec?.status === 'pending' && nextExec.executionId == null await runRowCascadeLoop( { tableId: cellContext.tableId, @@ -424,8 +427,17 @@ async function continueCascadeAfterResume( * cascades into. Reconstructing this from the resume payload is not * possible — `payload.userId` is the resumer/attribution, not the gate — * so it rides the pause snapshot instead. + * + * Unless the next group carries another dispatch's unclaimed pre-stamp: + * that is an explicit request from someone else that this cascade happens + * to be draining, and it runs under the subject persisted with it. The + * same decision both drain points in `workflow-column-execution.ts` make; + * a resume that skipped it would hand a stranger's request the paused + * cell's gate. */ - capabilityGovernedUserId: cellContext.capabilityGovernedUserId, + capabilityGovernedUserId: isQueuedMarker + ? await readStampedCapabilitySubject(cellContext.rowId, next.id) + : cellContext.capabilityGovernedUserId, }, signal ) diff --git a/apps/sim/background/resume-governed-subject.test.ts b/apps/sim/background/resume-governed-subject.test.ts index 1817f61cfd4..e4bb051f606 100644 --- a/apps/sim/background/resume-governed-subject.test.ts +++ b/apps/sim/background/resume-governed-subject.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ writeWorkflowGroupState: vi.fn(), createWorkflowCellProgressWriter: vi.fn(), runRowCascadeLoop: vi.fn(), + readStampedCapabilitySubject: vi.fn(), })) vi.mock('@trigger.dev/sdk', () => ({ task: mocks.task, timeout: { None: 'none' } })) @@ -32,6 +33,9 @@ vi.mock('@/lib/table/workflow-columns', () => ({ })) vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) vi.mock('@/lib/table/rows/service', () => ({ getRowById: mocks.getRowById })) +vi.mock('@/lib/table/rows/executions', () => ({ + readStampedCapabilitySubject: mocks.readStampedCapabilitySubject, +})) vi.mock('@/lib/table/cell-write', () => ({ buildCancelledExecution: vi.fn(), createWorkflowCellProgressWriter: mocks.createWorkflowCellProgressWriter, @@ -88,6 +92,7 @@ describe('resuming a paused table cell', () => { beforeAll(async () => { await Promise.all([ import('@/lib/table/cell-write'), + import('@/lib/table/rows/executions'), import('@/lib/table/rows/service'), import('@/lib/table/service'), import('@/lib/table/workflow-columns'), @@ -125,6 +130,7 @@ describe('resuming a paused table cell', () => { mocks.getTableById.mockResolvedValue(TABLE) mocks.getRowById.mockResolvedValue({ id: 'row-1', data: {}, executions: {} }) mocks.pickNextEligibleGroupForRow.mockReturnValue(NEXT_GROUP) + mocks.readStampedCapabilitySubject.mockResolvedValue('other-dispatchers-member') mocks.writeWorkflowGroupState.mockResolvedValue('wrote') mocks.createWorkflowCellProgressWriter.mockReturnValue({ onBlockComplete: vi.fn(), @@ -167,6 +173,45 @@ describe('resuming a paused table cell', () => { expect(cascadePayload.capabilityGovernedUserId).not.toBe(PAYLOAD.userId) }, 20_000) + /** + * The next group is not a dependency this cascade satisfied — it carries + * another dispatch's unclaimed pre-stamp, an explicit request from someone + * else that this cascade happens to be draining. Carrying the paused cell's + * subject into it would run a stranger's request under the wrong denylist, + * which is the decision both drain points in `workflow-column-execution.ts` + * already make off the stamp. + */ + it('runs another dispatch’s queued marker under the subject stamped with it', async () => { + mocks.getRowById.mockResolvedValue({ + id: 'row-1', + data: {}, + executions: { 'group-2': { status: 'pending', executionId: null, workflowId: 'workflow-1' } }, + }) + + await executeResumeJob(PAYLOAD) + + expect(mocks.readStampedCapabilitySubject).toHaveBeenCalledWith('row-1', 'group-2') + const [cascadePayload] = mocks.runRowCascadeLoop.mock.calls[0] + expect(cascadePayload.capabilityGovernedUserId).toBe('other-dispatchers-member') + }, 20_000) + + /** A claimed cell is ordinary dependency work and keeps the paused subject. */ + it('keeps the paused cell’s subject for a marker another worker already claimed', async () => { + mocks.getRowById.mockResolvedValue({ + id: 'row-1', + data: {}, + executions: { + 'group-2': { status: 'pending', executionId: 'execution-9', workflowId: 'workflow-1' }, + }, + }) + + await executeResumeJob(PAYLOAD) + + expect(mocks.readStampedCapabilitySubject).not.toHaveBeenCalled() + const [cascadePayload] = mocks.runRowCascadeLoop.mock.calls[0] + expect(cascadePayload.capabilityGovernedUserId).toBe('requesting-member') + }, 20_000) + it('resumes ungated when the paused cell had no acting person', async () => { mocks.findCellContextByExecutionId.mockResolvedValue({ tableId: 'table-1', diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index bc8eaf7fb90..6a3c189c95c 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -203,12 +203,23 @@ function governedSubjectUserId( * Cache-aware wrapper around `getUserPermissionConfig`. When an * `ExecutionContext` is provided, the resolved config is memoized on the * context so repeated checks during a single workflow run share one DB hit. + * + * The subject is resolved HERE rather than by each caller, because the memo is + * keyed by nothing but the context. `validateModelProvider` and + * `validateBlockType` take the actor's id positionally, so a run declaring a + * different gate subject had the first model check fill the cache with the + * BILLING actor's group — and every later `assertPermissionsAllowed`, having + * correctly resolved the governed subject, was handed that stale entry. Doing + * the derivation at the one place the config is loaded makes the memo correct + * by construction: within a run `capabilityGovernedUserId` is fixed, so every + * path resolves and caches the same person. */ async function getPermissionConfig( - userId: string | undefined, + actorUserId: string | undefined, workspaceId: string | undefined, ctx?: ExecutionContext ): Promise { + const userId = governedSubjectUserId(actorUserId, ctx) if (!userId || !workspaceId) { return mergeEnvAllowlist(null) } @@ -335,7 +346,7 @@ export async function validateModelProvider( return } - assertModelAllowed(config, model, { userId, workspaceId }) + assertModelAllowed(config, model, { userId: governedSubjectUserId(userId, ctx), workspaceId }) } export async function validateBlockType( @@ -357,7 +368,10 @@ export async function validateBlockType( return } - assertBlockTypeAllowed(config, blockType, { userId, workspaceId }) + assertBlockTypeAllowed(config, blockType, { + userId: governedSubjectUserId(userId, ctx), + workspaceId, + }) } const INVITATIONS_RULE = CAPABILITY_RULES['invitations.send'] diff --git a/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts b/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts index 2856f76200c..5c1cfdfb3bb 100644 --- a/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts +++ b/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts @@ -24,7 +24,11 @@ vi.mock('@/providers/utils', () => ({ })) import type { ExecutionContext } from '@/executor/types' -import { assertPermissionsAllowed, ToolNotAllowedError } from './permission-check' +import { + assertPermissionsAllowed, + ToolNotAllowedError, + validateModelProvider, +} from './permission-check' /** * A run's own metadata carries its gate subject. Only a trigger whose acting @@ -101,3 +105,62 @@ describe('the subject a run’s permission gate is decided about', () => { expect(mocks.getUserPermissionConfig).toHaveBeenCalledWith('user-123', 'workspace-1') }) }) + +/** + * The run-scoped memo on `ExecutionContext` is keyed by nothing but the + * context, so whichever check loads first decides whose group every later check + * on that run reads. `validateModelProvider` takes the actor positionally, and + * the agent handler calls it before the skill gate — so a delegated run whose + * gate subject differs from its billing actor had the model check cache the + * bystander's group and hand it to `assertPermissionsAllowed`, which had + * correctly resolved the governed subject and then never used it. + */ +describe('the group a run’s later gates read from its cache', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getUserPermissionConfig.mockResolvedValue({ + allowedModelProviders: ['openai'], + deniedTools: ['exa_search'], + }) + }) + + it('is the governed subject’s, even when a model check loaded it first', async () => { + const ctx = runDeclaring('requesting-member') + + await validateModelProvider('workspace-billing-owner', 'workspace-1', 'gpt-4', ctx) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledExactlyOnceWith( + 'requesting-member', + 'workspace-1' + ) + + await expect( + assertPermissionsAllowed({ + userId: 'workspace-billing-owner', + workspaceId: 'workspace-1', + toolId: 'exa_search', + ctx, + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledExactlyOnceWith( + 'requesting-member', + 'workspace-1' + ) + }) + + /** An actorless run consults no group, whichever check runs first. */ + it('is nobody’s when the run declares no acting person', async () => { + const ctx = runDeclaring(null) + + await validateModelProvider('workspace-billing-owner', 'workspace-1', 'gpt-4', ctx) + await assertPermissionsAllowed({ + userId: 'workspace-billing-owner', + workspaceId: 'workspace-1', + toolId: 'exa_search', + ctx, + }) + + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/executor/execution/snapshot-serializer.test.ts b/apps/sim/executor/execution/snapshot-serializer.test.ts index 5c4e546f87e..3cd2d9072aa 100644 --- a/apps/sim/executor/execution/snapshot-serializer.test.ts +++ b/apps/sim/executor/execution/snapshot-serializer.test.ts @@ -335,4 +335,48 @@ describe('serializePauseSnapshot', () => { expect(serialized.metadata.includeThinking).toBeUndefined() expect(serialized.metadata.includeToolCalls).toBeUndefined() }) + + /** + * A table cell dispatched by a workspace API key bills the workspace's + * billing owner and is gated on the member who asked. Losing the gate's + * subject on the way into the snapshot would resume the run against that + * bystander's group — `governedSubjectUserId` reads an absent field as "not + * declared" and falls back to the actor. + */ + it('preserves a gate subject that differs from the billing actor', () => { + const context = createContext({ + metadata: { + ...createContext().metadata, + userId: 'workspace-billing-owner', + capabilityGovernedUserId: 'requesting-member', + }, + }) + + const snapshot = serializePauseSnapshot(context, ['next-block']) + const serialized = JSON.parse(snapshot.snapshot) + + expect(serialized.metadata.userId).toBe('workspace-billing-owner') + expect(serialized.metadata.capabilityGovernedUserId).toBe('requesting-member') + }) + + /** A declared `null` is the actorless run, and is not the same as absence. */ + it('preserves a declared actorless gate subject as null', () => { + const context = createContext({ + metadata: { + ...createContext().metadata, + userId: 'workspace-billing-owner', + capabilityGovernedUserId: null, + }, + }) + + const serialized = JSON.parse(serializePauseSnapshot(context, ['next-block']).snapshot) + + expect(serialized.metadata.capabilityGovernedUserId).toBeNull() + }) + + it('declares nothing for a run whose caller is its only person', () => { + const serialized = JSON.parse(serializePauseSnapshot(createContext(), ['next-block']).snapshot) + + expect(serialized.metadata.capabilityGovernedUserId).toBeUndefined() + }) }) diff --git a/apps/sim/executor/execution/snapshot-serializer.ts b/apps/sim/executor/execution/snapshot-serializer.ts index 11c327f4db8..784502a1bc5 100644 --- a/apps/sim/executor/execution/snapshot-serializer.ts +++ b/apps/sim/executor/execution/snapshot-serializer.ts @@ -271,6 +271,15 @@ export function serializePauseSnapshot( workflowId: context.workflowId, workspaceId, userId: metadataFromContext?.userId ?? '', + /** + * The gate's subject, tri-state and carried verbatim. `userId` above is the + * billing/rate actor, and for a trigger with no acting person it names the + * workspace's billing owner — so a rebuild that dropped this would resume a + * paused run gating on that bystander (`governedSubjectUserId` reads an + * absent field as "not declared" and falls back to the actor). A declared + * `null` is the actorless run and must survive as `null`, not as absence. + */ + capabilityGovernedUserId: metadataFromContext?.capabilityGovernedUserId, principal, billingAttribution: metadataFromContext?.billingAttribution, sessionUserId: metadataFromContext?.sessionUserId, From d9228a404c73c53f1872da5321f697cde8160715 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 01:10:10 -0700 Subject: [PATCH 174/179] fix(users): close the pre-stamp race in account deletion, and announce what it stopped A dispatcher that passed its status read could stamp a new cell marker after the bulk cancel and before the user delete; ON DELETE SET NULL then made it indistinguishable from an actorless request and a sibling worker drained it with no per-tool gate. Take FOR UPDATE on the departing user's row first: the foreign key a stamp checks needs FOR KEY SHARE on that same row, so every concurrent stamp either commits where the marker cancel still sees it, or blocks and is refused. Both cancels also bypassed the ordinary cancel path and so published nothing, leaving collaborators watching a dispatch that will never advance. Return what each stopped and publish the same terminal dispatch and cell events after the commit. --- apps/sim/lib/table/rows/executions.ts | 21 ++- ...count-deletion-cancel-announcement.test.ts | 123 ++++++++++++++++++ apps/sim/lib/users/account-deletion.ts | 106 ++++++++++++++- 3 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 apps/sim/lib/users/account-deletion-cancel-announcement.test.ts diff --git a/apps/sim/lib/table/rows/executions.ts b/apps/sim/lib/table/rows/executions.ts index 0d441bc1505..3ed355573c6 100644 --- a/apps/sim/lib/table/rows/executions.ts +++ b/apps/sim/lib/table/rows/executions.ts @@ -416,6 +416,13 @@ export async function readStampedCapabilitySubject( return stamped?.capabilityGovernedUserId ?? null } +/** One cell whose unclaimed marker {@link cancelPendingMarkersForGovernedSubject} stopped. */ +export interface CancelledCellMarker { + tableId: string + rowId: string + groupId: string +} + /** * Terminalizes every still-unstarted cell marker stamped with `userId`, in the * caller's transaction. @@ -435,13 +442,18 @@ export async function readStampedCapabilitySubject( * match anyway. The written state is the canonical cancel * (`buildCancelledExecution`), which every drain path's `isExecCancelled` check * already refuses to run. + * + * Returns what it stopped so the caller can announce it: this write is not the + * cancel path the UI listens to, and a collaborator watching the table would + * otherwise keep the cells on their in-flight pill until something else touched + * the row. */ export async function cancelPendingMarkersForGovernedSubject( trx: DbOrTx, userId: string -): Promise { +): Promise { const now = new Date() - await trx + return trx .update(tableRowExecutions) .set({ status: 'cancelled', @@ -457,6 +469,11 @@ export async function cancelPendingMarkersForGovernedSubject( inArray(tableRowExecutions.status, ['pending', 'queued']) ) ) + .returning({ + tableId: tableRowExecutions.tableId, + rowId: tableRowExecutions.rowId, + groupId: tableRowExecutions.groupId, + }) } /** diff --git a/apps/sim/lib/users/account-deletion-cancel-announcement.test.ts b/apps/sim/lib/users/account-deletion-cancel-announcement.test.ts new file mode 100644 index 00000000000..f2498b1180d --- /dev/null +++ b/apps/sim/lib/users/account-deletion-cancel-announcement.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + isSoleOwnerOfPaidOrganization: vi.fn(), + getPersonalSubscription: vi.fn(), + isUsingCloudStorage: vi.fn(), + appendTableEvent: vi.fn(), +})) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + isSoleOwnerOfPaidOrganization: mocks.isSoleOwnerOfPaidOrganization, +})) +vi.mock('@/lib/billing/core/plan', () => ({ + getHighestPriorityPersonalSubscription: mocks.getPersonalSubscription, +})) +vi.mock('@/lib/uploads', () => ({ + isUsingCloudStorage: mocks.isUsingCloudStorage, + StorageService: { deleteFiles: vi.fn(async () => ({ failed: [] })) }, +})) +vi.mock('@/lib/workspaces/utils', () => ({ + reassignBilledAccountForUser: vi.fn(async () => ({ unresolved: [] })), + reassignOwnedWorkspacesForUser: vi.fn(async () => ({ unresolved: [] })), +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: mocks.appendTableEvent })) + +import { deleteUserAccount } from '@/lib/users/account-deletion' + +const DISPATCH_ROWS = [ + { + id: 'tdsp_1', + tableId: 'table-1', + scope: { groupIds: ['group-1'] }, + cursor: 4, + mode: 'all', + isManualRun: true, + }, +] +const MARKER_ROWS = [{ tableId: 'table-1', rowId: 'row-1', groupId: 'group-2' }] + +describe('announcing the work a deleted account’s cancels stopped', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.isSoleOwnerOfPaidOrganization.mockResolvedValue({ isSoleOwner: false, name: null }) + mocks.getPersonalSubscription.mockResolvedValue(null) + mocks.isUsingCloudStorage.mockReturnValue(false) + mocks.appendTableEvent.mockResolvedValue(null) + // The two cancels are the only `.returning()` reads this teardown makes: no + // workspace is doomed, so the workspace-delete block never runs. + dbChainMockFns.returning.mockResolvedValueOnce(DISPATCH_ROWS).mockResolvedValueOnce(MARKER_ROWS) + }) + + /** + * These writes bypass the ordinary cancel path, which is what publishes the + * terminal events. Without them a collaborator in a surviving workspace keeps + * watching a dispatch that will never advance, and cells stay on their + * in-flight pill until something unrelated touches the row. + */ + it('publishes the same terminal dispatch and cell events a Stop would', async () => { + await deleteUserAccount('user-1') + + expect(mocks.appendTableEvent).toHaveBeenCalledWith({ + kind: 'dispatch', + tableId: 'table-1', + dispatchId: 'tdsp_1', + status: 'cancelled', + scope: { groupIds: ['group-1'] }, + cursor: 4, + mode: 'all', + isManualRun: true, + }) + expect(mocks.appendTableEvent).toHaveBeenCalledWith({ + kind: 'cell', + tableId: 'table-1', + rowId: 'row-1', + groupId: 'group-2', + status: 'cancelled', + executionId: null, + jobId: null, + error: 'Cancelled', + }) + }) + + /** + * The event log is not transactional, so an event published inside the + * transaction would announce a cancellation a rollback then undoes. + */ + it('publishes nothing when the teardown is rolled back', async () => { + dbChainMockFns.transaction.mockImplementationOnce(async () => { + throw new Error('rolled back') + }) + + await expect(deleteUserAccount('user-1')).rejects.toThrow('rolled back') + expect(mocks.appendTableEvent).not.toHaveBeenCalled() + }) + + /** + * A dispatcher that read its status as active a moment ago can still stamp a + * marker. Its insert's foreign key needs a `FOR KEY SHARE` on the departing + * user's row, which this `FOR UPDATE` conflicts with — so every concurrent + * stamp either commits before the marker cancel can miss it, or blocks until + * the `user` delete has landed and is refused outright. Without it a stamp + * landing between the cancel and the delete is nulled by `ON DELETE SET NULL` + * and drained by a sibling worker as actorless. + */ + it('locks the departing user’s row before cancelling anything it governs', async () => { + await deleteUserAccount('user-1') + + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + const lockedAt = Math.min(...dbChainMockFns.for.mock.invocationCallOrder) + const cancelledAt = Math.min( + ...dbChainMockFns.update.mock.calls + .map((call, index) => ({ call, index })) + .filter(({ call }) => call[0] === schemaMock.tableRunDispatches) + .map(({ index }) => dbChainMockFns.update.mock.invocationCallOrder[index]) + ) + expect(lockedAt).toBeLessThan(cancelledAt) + }) +}) diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts index 6b06c788db2..447beba1b30 100644 --- a/apps/sim/lib/users/account-deletion.ts +++ b/apps/sim/lib/users/account-deletion.ts @@ -23,7 +23,11 @@ import type { import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan' import { isSoleOwnerOfPaidOrganization } from '@/lib/billing/organizations/membership' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { cancelPendingMarkersForGovernedSubject } from '@/lib/table/rows/executions' +import { appendTableEvent, type TableEvent } from '@/lib/table/events' +import { + type CancelledCellMarker, + cancelPendingMarkersForGovernedSubject, +} from '@/lib/table/rows/executions' import type { StorageContext } from '@/lib/uploads' import { isUsingCloudStorage, StorageService } from '@/lib/uploads' import { @@ -511,6 +515,66 @@ async function purgeStorageObjects(batches: StorageKeyBatch[]): Promise { } } +/** One dispatch the deletion stopped, in the shape its terminal event needs. */ +interface CancelledDispatch { + id: string + tableId: string + scope: unknown + cursor: number + mode: string + isManualRun: boolean +} + +/** + * Publishes the terminal events for work the deletion cancelled. + * + * The cancels above are direct writes rather than the ordinary cancel path, so + * nothing had announced them: a collaborator in a surviving workspace kept + * watching a dispatch that will never advance and cells stuck on their in-flight + * pill. These are the same two events `markActiveDispatchesCancelled` and the + * cell writers publish, so the client reconciles exactly as it does for a Stop. + * + * After the commit, never inside it: an event announcing a rollback would be a + * lie, and the SSE log is not transactional. Failures are logged rather than + * raised — the account is already gone, and the periodic refetch is the backstop. + */ +async function announceCancelledTableWork( + dispatches: CancelledDispatch[], + markers: CancelledCellMarker[] +): Promise { + const events = [ + ...dispatches.map((dispatch) => + appendTableEvent({ + kind: 'dispatch' as const, + tableId: dispatch.tableId, + dispatchId: dispatch.id, + status: 'cancelled' as const, + scope: (dispatch.scope ?? undefined) as Extract['scope'], + cursor: dispatch.cursor, + mode: dispatch.mode as 'all' | 'incomplete' | 'new', + isManualRun: dispatch.isManualRun, + }) + ), + ...markers.map((marker) => + appendTableEvent({ + kind: 'cell' as const, + tableId: marker.tableId, + rowId: marker.rowId, + groupId: marker.groupId, + status: 'cancelled' as const, + executionId: null, + jobId: null, + error: 'Cancelled', + }) + ), + ] + const results = await Promise.allSettled(events) + const failed = results.filter((result) => result.status === 'rejected').length + if (failed > 0) { + logger.warn('Some cancellation events were not published during account deletion', { failed }) + } +} + /** * Erases an account and everything only it can reach. * @@ -547,6 +611,9 @@ export async function deleteUserAccount(userId: string): Promise { if (doomedWorkspaceIds.length > 0) { /** @@ -606,6 +673,29 @@ export async function deleteUserAccount(userId: string): Promise Date: Tue, 1 Sep 2026 01:12:55 -0700 Subject: [PATCH 175/179] docs(table): state the backfill legacy-payload tradeoff honestly A payload enqueued before capabilityGovernedUserId existed does not simply reproduce the pre-existing behavior: before the field, the cascaded cells gated on actorUserId. Record what null actually costs (one deploy's worth of in-flight jobs, loosened for a session-made change) and why the alternatives are worse - actorUserId is the billed account for a workspace-key change and indistinguishable from a human on the payload, and failing closed abandons the writes the schema change promised. --- .../table/backfill-governed-subject.test.ts | 10 ++++++++- apps/sim/lib/table/backfill-runner.ts | 21 +++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/table/backfill-governed-subject.test.ts b/apps/sim/lib/table/backfill-governed-subject.test.ts index 04e3553da16..40f03203deb 100644 --- a/apps/sim/lib/table/backfill-governed-subject.test.ts +++ b/apps/sim/lib/table/backfill-governed-subject.test.ts @@ -89,7 +89,15 @@ describe('backfill cascade governance', () => { ) }) - /** A change with no acting person still names one explicitly. */ + /** + * The one payload that can omit the field is a large backfill enqueued before + * it existed and still running after the deploy. `actorUserId` is not a + * recovery: `attributedUserId` yields the workspace's billed account for a + * change made by a workspace API key, and nothing here tells that apart from + * a human — so borrowing it would apply a bystander's denylist, the exact + * substitution this field removes. Null for one deploy's worth of in-flight + * jobs is the least wrong of the available answers. + */ it('keeps an absent subject null rather than borrowing the billing actor', async () => { queueOnePage() diff --git a/apps/sim/lib/table/backfill-runner.ts b/apps/sim/lib/table/backfill-runner.ts index 1ed604e5ddd..582add45b7c 100644 --- a/apps/sim/lib/table/backfill-runner.ts +++ b/apps/sim/lib/table/backfill-runner.ts @@ -60,8 +60,25 @@ export interface TableBackfillPayload { * Person whose permission group gates any cell the backfill's writes cascade * into. Separate from `actorUserId`, which is a billing attribution and names * the workspace billed account when the schema change carried no human. Null - * when the change had no acting person; absent on payloads enqueued before - * this field existed, which read as null — the pre-existing behavior. + * when the change had no acting person. + * + * Absent only on a payload enqueued before this field existed and still + * running after the deploy that added it — a backfill over more rows than + * `BACKFILL_ASYNC_THRESHOLD_ROWS`, mid-flight at the cutover. Such a payload + * reads as null, and that is a deliberate choice between two wrong answers + * rather than the status quo: before this field, the cascaded cells gated on + * `actorUserId`, so for a session-made change the window loosens the gate for + * as long as that one job runs. + * + * Falling back to `actorUserId` would close that and open a worse one. + * `attributedUserId` yields the workspace's billed account for a change made + * by a workspace API key, and nothing on the payload distinguishes that id + * from a human actor — so the fallback would apply a bystander's denylist, + * which is the substitution this field exists to remove. Failing closed + * instead would abandon the backfill's writes entirely, turning a bounded + * governance edge into visible data loss on runs the schema change promised + * to fill. Null is the least wrong of the three, and the window is one + * deploy long. */ capabilityGovernedUserId?: string | null } From 833aa54ddfe80aab01d673fb6301487eb61bf526 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 01:43:35 -0700 Subject: [PATCH 176/179] fix(invitations): gate the resend on what the invitation admits to, not its kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace-kind invitation whose granted workspace belongs to an organization joins the invitee to that organization exactly as an organization-kind one does — acceptance derives the member row from the workspace's LIVE organization — so keying the organization capability check on `kind === 'organization'` left every organization-backed workspace invitation performing an ungated organization admission. `resolveInvitationAdmissionOrganizationId` answers the question the gate actually has, from acceptance's own derivation: the live organization of the granted workspace for a workspace invitation, the stamped one otherwise, and nobody at all for an external intent or an escalation the stamped organization refuses — the three cases where acceptance creates no member row. Acceptance and the accept-screen preview now read the join target through the same helper, so the gate cannot drift from what the accept does. The refusal converges on `capabilityRefusalResponse('invitations.send')` so a client sees the same sentence and `details.code` as every other withheld capability instead of bare prose. --- .../api/invitations/[id]/resend/route.test.ts | 67 +++++++++- .../app/api/invitations/[id]/resend/route.ts | 36 ++--- apps/sim/lib/invitations/core.test.ts | 126 ++++++++++++++++++ apps/sim/lib/invitations/core.ts | 74 ++++++++-- 4 files changed, 268 insertions(+), 35 deletions(-) diff --git a/apps/sim/app/api/invitations/[id]/resend/route.test.ts b/apps/sim/app/api/invitations/[id]/resend/route.test.ts index 63d2ae7e39d..4418ace293b 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.test.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { MockInvitationsNotAllowedError, mockGetInvitationById, + mockResolveInvitationAdmissionOrganizationId, mockIsOrganizationOwnerOrAdmin, mockHasWorkspaceAdminAccess, mockGetWorkspaceWithOwner, @@ -24,6 +25,7 @@ const { } }, mockGetInvitationById: vi.fn(), + mockResolveInvitationAdmissionOrganizationId: vi.fn(), mockIsOrganizationOwnerOrAdmin: vi.fn(), mockHasWorkspaceAdminAccess: vi.fn(), mockGetWorkspaceWithOwner: vi.fn(), @@ -46,7 +48,10 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ validateInvitationsAllowed: mockValidateInvitationsAllowed, })) -vi.mock('@/lib/invitations/core', () => ({ getInvitationById: mockGetInvitationById })) +vi.mock('@/lib/invitations/core', () => ({ + getInvitationById: mockGetInvitationById, + resolveInvitationAdmissionOrganizationId: mockResolveInvitationAdmissionOrganizationId, +})) vi.mock('@/lib/invitations/send', () => ({ sendInvitationEmail: mockSendInvitationEmail, prepareInvitationResend: mockPrepareInvitationResend, @@ -90,7 +95,7 @@ const workspaceInvitation = { role: 'member', token: 'token-1', organizationId: 'organization-1', - membershipIntent: 'member', + membershipIntent: 'internal', grants: [{ workspaceId: 'workspace-1', permission: 'read' }], } @@ -104,6 +109,7 @@ describe('POST /api/invitations/[id]/resend', () => { vi.clearAllMocks() mockGetSession.mockResolvedValue({ user: { id: 'user-1', email: 'admin@example.com' } }) mockGetInvitationById.mockResolvedValue(workspaceInvitation) + mockResolveInvitationAdmissionOrganizationId.mockResolvedValue('organization-1') mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) mockHasWorkspaceAdminAccess.mockResolvedValue(true) mockGetWorkspaceWithOwner.mockResolvedValue({ @@ -131,12 +137,21 @@ describe('POST /api/invitations/[id]/resend', () => { expect(mockSendInvitationEmail).toHaveBeenCalled() }) - it('refuses the resend when the group withholds invitations', async () => { + /** + * The refusal carries the shared capability contract — the same sentence and + * `details.code` every other withheld capability answers with — so a client + * can tell a permission group apart from a role failure without parsing prose. + */ + it('refuses the resend with the shared capability refusal when the group withholds invitations', async () => { mockValidateInvitationsAllowed.mockRejectedValue(new MockInvitationsNotAllowedError()) const response = await callResend() expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "Sending invitations is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) expect(mockSendInvitationEmail).not.toHaveBeenCalled() expect(mockPersistInvitationResend).not.toHaveBeenCalled() }) @@ -192,10 +207,49 @@ describe('POST /api/invitations/[id]/resend', () => { }) /** - * A workspace-kind invitation admits nobody to the organization by itself, so - * its grants remain the whole scope. + * The scope follows what acceptance would DO, not the invitation's kind. A + * workspace-kind invitation whose granted workspace belongs to an organization + * joins the invitee to that organization exactly as an organization-kind one + * does, so keying the organization check on `kind === 'organization'` left + * every organization-backed workspace invitation performing an ungated + * organization admission. */ - it('leaves a granted workspace invitation checked per workspace only', async () => { + it('checks the organization an organization-backed workspace invitation admits to', async () => { + const response = await callResend() + + expect(response.status).toBe(200) + expect(mockResolveInvitationAdmissionOrganizationId).toHaveBeenCalledWith(workspaceInvitation) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + organizationId: 'organization-1', + }) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + workspaceId: 'workspace-1', + }) + }) + + it('refuses a workspace invitation whose admitting organization withholds invitations', async () => { + mockValidateInvitationsAllowed.mockImplementation( + async (_userId: string, scope: { organizationId?: string }) => { + if (scope.organizationId) throw new MockInvitationsNotAllowedError() + } + ) + + const response = await callResend() + + expect(response.status).toBe(403) + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(mockPersistInvitationResend).not.toHaveBeenCalled() + }) + + /** + * Nothing to gate at the organization scope when acceptance creates no member + * row there — an external invitation, or a personal workspace's — so the + * grants stay the whole scope rather than borrowing a stamped organization the + * invitee will never join. + */ + it('checks the grants alone when the invitation admits to no organization', async () => { + mockResolveInvitationAdmissionOrganizationId.mockResolvedValue(null) + const response = await callResend() expect(response.status).toBe(200) @@ -211,6 +265,7 @@ describe('POST /api/invitations/[id]/resend', () => { kind: 'organization', grants: [], }) + mockResolveInvitationAdmissionOrganizationId.mockResolvedValue('organization-1') mockGetOrganizationSubscription.mockResolvedValue({ status: 'active', plan: 'team' }) const response = await callResend() diff --git a/apps/sim/app/api/invitations/[id]/resend/route.ts b/apps/sim/app/api/invitations/[id]/resend/route.ts index a366c61dd80..2d8bb7f511b 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.ts @@ -12,12 +12,13 @@ import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers' import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getInvitationById } from '@/lib/invitations/core' +import { getInvitationById, resolveInvitationAdmissionOrganizationId } from '@/lib/invitations/core' import { persistInvitationResend, prepareInvitationResend, sendInvitationEmail, } from '@/lib/invitations/send' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { getWorkspaceWithOwner, hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' import { @@ -81,27 +82,30 @@ export const POST = withRouteHandler( * reachability already exists and the edit only adjusts it. * * Each granted workspace resolves the group governing the caller there, - * exactly as creation does. The organization scope is checked *as well* - * for an organization-kind invitation, not instead: that kind always - * admits the invitee to its stamped organization (`lib/invitations/core.ts` - * documents it on `resolveInvitationJoinTarget`), so the resend performs an - * organization-level admission whichever workspaces it also grants. Gating - * only the grants would let an explicit workspace group that permits - * invitations carry a member into an organization whose default group - * withholds them — and would leave the organization-level act ungated the - * moment such an invitation carried any grant at all. A workspace-kind - * invitation admits nobody to the organization by itself, so it keeps the - * per-grant check, falling back to the organization when it has no grants. + * exactly as creation does. The organization scope is checked *as well*, + * not instead, whenever the invitation ADMITS TO an organization — which + * is not the same question as its `kind`. A workspace-kind invitation + * whose granted workspace belongs to an organization joins the invitee to + * that organization exactly as an organization-kind one does, so keying + * this on the kind left every organization-backed workspace invitation + * performing an ungated organization admission. `resolveInvitationAdmission- + * OrganizationId` answers it from acceptance's own derivation: the live + * organization of the granted workspace for a workspace-kind invitation, + * the stamped one otherwise, and nobody at all when the intent is external + * or the stamped organization refuses the escalation — the three cases + * where acceptance creates no member row. Gating only the grants would let + * an explicit workspace group that permits invitations carry a member into + * an organization whose default group withholds them. * * Run after the admin check above, for the reason * `resolveWorkspaceInvitationContext` records — the refusal names an * organization setting, so it must not reach someone with no admin reach. */ try { - const organizationScoped = inv.kind === 'organization' || inv.grants.length === 0 - if (organizationScoped && inv.organizationId) { + const admissionOrganizationId = await resolveInvitationAdmissionOrganizationId(inv) + if (admissionOrganizationId) { await validateInvitationsAllowed(session.user.id, { - organizationId: inv.organizationId, + organizationId: admissionOrganizationId, }) } for (const grant of inv.grants) { @@ -110,7 +114,7 @@ export const POST = withRouteHandler( } catch (error) { if (error instanceof InvitationsNotAllowedError) { logger.warn('Invitation resend blocked by permission group', { invitationId: id }) - return NextResponse.json({ error: error.message }, { status: 403 }) + return capabilityRefusalResponse('invitations.send') } throw error } diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index ec014758f1c..0d28507befd 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -91,6 +91,7 @@ vi.mock('@sim/audit', () => auditMock) import { acceptInvitation, rejectInvitation, + resolveInvitationAdmissionOrganizationId, revokeInvitationAsAdmin, updateInvitation, } from '@/lib/invitations/core' @@ -2181,3 +2182,128 @@ describe('locked invitation mutations', () => { expect(dbChainMockFns.set).not.toHaveBeenCalled() }) }) + +/** + * What an invitation ADMITS TO, which is not its `kind`. The send-capability + * gates read this so they cannot let an invitation carry a member into an + * organization whose group withholds invitations, and so they cannot demand an + * organization's permission for an invitation that joins nobody to it. + */ +describe('resolveInvitationAdmissionOrganizationId', () => { + const invitation = { + id: 'invitation-1', + kind: 'workspace' as const, + email: 'invitee@example.com', + organizationId: 'organization-1', + membershipIntent: 'internal' as const, + inviterId: 'inviter-1', + role: 'member', + status: 'pending' as const, + token: 'token-1', + expiresAt: new Date('2026-12-01T00:00:00.000Z'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + grants: [ + { + id: 'grant-1', + workspaceId: 'workspace-1', + permission: 'read' as const, + workspaceName: 'Workspace', + }, + ], + organizationName: null, + inviterName: null, + inviterEmail: null, + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetUserOrganization.mockResolvedValue(null) + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + organizationId: 'organization-1', + billedAccountUserId: 'owner-1', + }) + }) + + it('names the organization a granted workspace belongs to, for a workspace invitation', async () => { + expect(await resolveInvitationAdmissionOrganizationId(invitation)).toBe('organization-1') + }) + + it('names nobody when the granted workspace belongs to no organization', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + organizationId: null, + billedAccountUserId: 'owner-1', + }) + + expect(await resolveInvitationAdmissionOrganizationId(invitation)).toBeNull() + }) + + /** + * The workspace moved after the invite went out. Acceptance escalates into the + * new organization only when the inviter currently holds admin standing there, + * so the gate has to ask the same question of the same organization. + */ + it('follows a moved workspace into its live organization when the inviter may escalate', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + organizationId: 'organization-2', + billedAccountUserId: 'owner-1', + }) + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'organization-2', + role: 'admin', + }) + + expect(await resolveInvitationAdmissionOrganizationId(invitation)).toBe('organization-2') + }) + + it('names nobody when the escalation acceptance would refuse is the only join on offer', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + organizationId: 'organization-2', + billedAccountUserId: 'owner-1', + }) + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'organization-2', + role: 'member', + }) + + expect(await resolveInvitationAdmissionOrganizationId(invitation)).toBeNull() + }) + + /** + * An organization invitation joins its STAMPED organization whatever its + * granted workspaces do — the join target is never re-derived from a workspace + * whose organization can change after send. + */ + it('keeps an organization invitation on its stamped organization', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + organizationId: 'organization-2', + billedAccountUserId: 'owner-1', + }) + + expect( + await resolveInvitationAdmissionOrganizationId({ ...invitation, kind: 'organization' }) + ).toBe('organization-1') + }) + + it('names nobody for an external invitation, which creates no membership', async () => { + expect( + await resolveInvitationAdmissionOrganizationId({ + ...invitation, + membershipIntent: 'external', + }) + ).toBeNull() + expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled() + }) + + it('falls back to the stamped organization for an invitation with no grants', async () => { + expect(await resolveInvitationAdmissionOrganizationId({ ...invitation, grants: [] })).toBe( + 'organization-1' + ) + }) +}) diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 40b2026a0ca..f0cb61d32c2 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -250,6 +250,27 @@ export function isInvitationExpired(inv: Pick return new Date() > new Date(inv.expiresAt) } +/** + * The organization acceptance will land the invitee's membership in, before the + * gates that can downgrade the join to external. + * + * Workspace-kind invitations take the granted workspace's LIVE organization — + * the workspace is what was shared, and its organization can change after the + * invite goes out. Organization-kind invitations take their STAMPED one, which + * a granted workspace's move must never redirect. Acceptance, the accept-screen + * preview, and the resend gate all read the target here so none of them can + * disagree about which organization an invitation admits to. + */ +function invitationJoinTargetOrganizationId( + inv: Pick, + primaryWorkspace: Pick | null +): string | null { + if (inv.kind === 'workspace' && inv.grants.length > 0 && primaryWorkspace) { + return primaryWorkspace.organizationId + } + return inv.organizationId +} + /** * A workspace invitation only escalates into an EXISTING organization when * that organization matches what was stamped at send time — a workspace that @@ -281,6 +302,40 @@ async function stampedOrganizationAllowsEscalation( ) } +/** + * The organization ACCEPTANCE of this invitation would admit the invitee to, or + * `null` when acceptance creates no membership anywhere. + * + * Read by the send-capability gates, which have to key on what an invitation + * ADMITS TO rather than on its `kind`: a workspace-kind invitation whose granted + * workspace belongs to an organization joins the invitee to that organization + * exactly as an organization-kind one does ({@link acceptLockedInvitation} + * creates the member row from this same target), so gating those on their grants + * alone would let a workspace group that permits invitations carry a member into + * an organization whose default group withholds them. + * + * Mirrors acceptance's own decision, one clause at a time: an external + * membership intent creates no member row, and an escalation the stamped + * organization does not allow is downgraded to external before one is created. + * Both are read through the predicates acceptance uses, so a change there + * reaches this gate too. The reads are unlocked — a race resolves at accept + * time, where the locks are. + */ +export async function resolveInvitationAdmissionOrganizationId( + inv: InvitationWithGrants, + executor?: DbOrTx +): Promise { + if (inv.membershipIntent === 'external') return null + const primaryGrantWorkspaceId = inv.grants[0]?.workspaceId + const primaryWorkspace = primaryGrantWorkspaceId + ? await getWorkspaceWithOwner(primaryGrantWorkspaceId, executor ? { executor } : undefined) + : null + const organizationId = invitationJoinTargetOrganizationId(inv, primaryWorkspace) + if (!organizationId) return null + if (!(await stampedOrganizationAllowsEscalation(inv, organizationId, executor ?? db))) return null + return organizationId +} + /** * True when a member-role organization invitation still has at least one * granted workspace inside the organization it was stamped with. All grants @@ -362,18 +417,12 @@ export async function getInvitationJoinPreview( workspaceIdsToMove: [], }) - let workspaceOrganizationId = inv.organizationId - let billedAccountUserId: string | null = null const primaryGrantWorkspaceId = inv.grants[0]?.workspaceId - if (primaryGrantWorkspaceId) { - const primaryWorkspace = await getWorkspaceWithOwner(primaryGrantWorkspaceId) - if (primaryWorkspace) { - billedAccountUserId = primaryWorkspace.billedAccountUserId - if (inv.kind === 'workspace') { - workspaceOrganizationId = primaryWorkspace.organizationId - } - } - } + const primaryWorkspace = primaryGrantWorkspaceId + ? await getWorkspaceWithOwner(primaryGrantWorkspaceId) + : null + const billedAccountUserId = primaryWorkspace?.billedAccountUserId ?? null + const workspaceOrganizationId = invitationJoinTargetOrganizationId(inv, primaryWorkspace) /** * Personal-workspace invites only produce an organization through billing's @@ -858,11 +907,10 @@ async function acceptLockedInvitation( */ const primaryGrant = inv.grants[0] let billingOwnerUserId = inv.inviterId - let workspaceOrganizationId = inv.organizationId if (primaryGrant && lockPlan.primaryWorkspace && inv.kind === 'workspace') { billingOwnerUserId = lockPlan.primaryWorkspace.billedAccountUserId - workspaceOrganizationId = lockPlan.primaryWorkspace.organizationId } + const workspaceOrganizationId = invitationJoinTargetOrganizationId(inv, lockPlan.primaryWorkspace) if ( shouldJoinOrganization && From 0a33b64e5ceae782b899a87d2fa64008c9c767f6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 01:43:46 -0700 Subject: [PATCH 177/179] fix(table): key the export-download gate to the governed subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last raw `/api/table/**` capability gate still reading `authResult.userId` bare. An internal executor JWT presents the run's actor, not somebody asking for a file, so this refused a delegation the executor exemption passes ungated — and refused the download of an export the same run was allowed to start (`export-async`) and to list (`jobs`), both of which already derive `capabilityGovernedAuthUserId`. The role check is untouched: it still runs on the id the credential presents. --- .../[tableId]/export/download/route.test.ts | 25 +++++++++++++++++++ .../table/[tableId]/export/download/route.ts | 15 +++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts index 38bb5a62470..119ac118050 100644 --- a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts @@ -48,6 +48,7 @@ describe('GET /api/table/[tableId]/export/download', () => { hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-1', + authType: 'session', }) mockCheckAccess.mockResolvedValue({ ok: true, table }) mockGetUserPermissionConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) @@ -80,4 +81,28 @@ describe('GET /api/table/[tableId]/export/download', () => { expect(mockGetTableJob).not.toHaveBeenCalled() expect(mockPresign).not.toHaveBeenCalled() }) + + /** + * An internal executor JWT presents the run's actor, not somebody asking for + * a file: reading it bare would apply that person's group to a delegation the + * executor exemption passes ungated, and refuse the download of an export the + * same run was allowed to start and to list. + */ + it('hands the executor its export without consulting the actor’s group', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockGetUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableExport: true, + }) + + const response = await makeRequest() + + expect(response.status).toBe(200) + expect(mockGetUserPermissionConfig).not.toHaveBeenCalled() + expect(mockPresign).toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.ts b/apps/sim/app/api/table/[tableId]/export/download/route.ts index 3d461ea4286..e14c770162f 100644 --- a/apps/sim/app/api/table/[tableId]/export/download/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/download/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { exportDownloadContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' @@ -52,8 +52,19 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou * export. Gating only the job that produces one leaves this route handing the * file to anyone who can name a `jobId`, and the workspace job listing names * every colleague's. + * + * Keyed to the governed subject, which names nobody for an internal-JWT + * executor call, exactly as the listing and the job that produced this file + * are: `authResult.userId` there is the subject the executor embedded, so + * reading it bare would apply the run's actor's group to a delegation the + * executor exemption deliberately passes ungated — and would refuse the + * download of an export the same run was allowed to start. */ - if (await isWorkspaceCapabilityWithheld(authResult.userId, workspaceId, 'tables.export')) { + const governedUserId = capabilityGovernedAuthUserId(authResult) + if ( + governedUserId && + (await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'tables.export')) + ) { return capabilityRefusalResponse('tables.export') } From fffab5096fb8c89666a3114383a1ae52be870e09 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 01:43:54 -0700 Subject: [PATCH 178/179] fix(logs): stop the completion write persisting per-segment dollars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `copyTraceSpansWithoutCosts` dropped the span's own `cost` and nothing else, so every completed run persisted the same dollars itemized underneath it in `providerTiming.segments` — the duplicate the ledger owns, and exactly what the legacy backfill had already learned to clear. Both writers now run one removal rule: the copy isolates the nodes the strip writes to (span, children, providerTiming, its segments) and hands them to `stripSpanCosts`, so persistence and backfill cannot answer differently about what a stored span may carry. Tokens survive on both paths — they are trace detail, not dollars. --- .../lib/logs/execution/trace-store.test.ts | 58 +++++++++++++++++++ apps/sim/lib/logs/execution/trace-store.ts | 49 ++++++++++++++-- 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index 9c2a4c88253..299cbfda3fa 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -25,6 +25,7 @@ vi.mock('@/lib/execution/payloads/store', () => ({ })) import { + copyTraceSpansWithoutCosts, externalizeExecutionData, materializeExecutionData, materializeExecutionDataForDisplayWithBlockOutputs, @@ -35,6 +36,7 @@ import { stripSpanCosts, TRACE_STORE_REF_KEY, } from '@/lib/logs/execution/trace-store' +import type { TraceSpan } from '@/lib/logs/types' const CONTEXT = { workspaceId: 'workspace-1', @@ -886,3 +888,59 @@ describe('stripSpanCosts', () => { ).toEqual({ total: 400 }) }) }) + +/** + * The COMPLETION write. `stripSpanCosts` only ever ran over legacy rows the + * backfill touched; every normal run went through this copy, which used to drop + * the span's own `cost` and leave the same dollars itemized underneath it in + * `providerTiming.segments`. Both writers now share one removal rule, so the + * two cannot answer differently about what a persisted span may carry. + */ +describe('copyTraceSpansWithoutCosts', () => { + it('clears the segment dollars the completion write used to persist', () => { + const spans = spanWithSpend() as unknown as TraceSpan[] + + const persisted = copyTraceSpansWithoutCosts(spans) + + const [span] = persisted as Array> + expect(span.cost).toBeUndefined() + expect(span.providerTiming.segments[0].cost).toBeUndefined() + expect(span.children[0].cost).toBeUndefined() + expect(span.children[0].providerTiming.segments[0].cost).toBeUndefined() + }) + + it('keeps the token counts and the segment identity a trace is read for', () => { + const spans = spanWithSpend() as unknown as TraceSpan[] + + const [span] = copyTraceSpansWithoutCosts(spans) as unknown as Array> + + expect(span.tokens).toEqual({ total: 900 }) + expect(span.children[0].tokens).toEqual({ total: 400 }) + expect(span.providerTiming.segments[0]).toMatchObject({ + type: 'model', + name: 'gpt-4', + tokens: { total: 900 }, + }) + expect(span.providerTiming.duration).toBe(5) + }) + + /** + * The strip runs in place, so the copy has to reach every node it writes to. + * Sharing the `providerTiming` with the caller would blank the segments of the + * spans the rest of the run still holds in memory. + */ + it('leaves the caller’s in-memory spans untouched', () => { + const spans = spanWithSpend() as unknown as TraceSpan[] + + copyTraceSpansWithoutCosts(spans) + + const [span] = spans as unknown as Array> + expect(span.cost).toEqual({ total: 0.5 }) + expect(span.providerTiming.segments[0].cost).toEqual({ total: 0.5 }) + expect(span.children[0].cost).toEqual({ total: 0.2 }) + }) + + it('returns undefined for no spans', () => { + expect(copyTraceSpansWithoutCosts(undefined)).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index 97595878508..4851910dab4 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -176,12 +176,51 @@ function stripProviderTimingSegmentSpend( } } -/** Creates a persistence-owned span tree with per-span cost fields removed. */ +/** + * Copies exactly the nodes {@link stripSpanSpendFields} writes to — each span, + * its children, its `providerTiming`, and that timing's segments — and shares + * every other value with the caller's tree. Enough isolation for the strip to + * run in place without reaching the in-memory spans the rest of the run still + * holds, and no deep clone of the payloads hanging off a span. + */ +function copySpanTreeForStrip(spans: TraceSpan[]): TraceSpan[] { + return spans.map((span) => { + const copy: TraceSpan = { ...span } + if (Array.isArray(copy.children)) copy.children = copySpanTreeForStrip(copy.children) + if (copy.providerTiming && typeof copy.providerTiming === 'object') { + const { segments } = copy.providerTiming + copy.providerTiming = { + ...copy.providerTiming, + ...(Array.isArray(segments) + ? { + segments: segments.map((segment) => + segment && typeof segment === 'object' ? { ...segment } : segment + ), + } + : {}), + } + } + return copy + }) +} + +/** + * Creates a persistence-owned span tree with spend removed, for the COMPLETION + * write. + * + * Runs the same {@link stripSpanCosts} the legacy backfill does, over a copy — + * one removal rule for both writers, which is the point: this used to drop the + * span's own `cost` and nothing else, so every completed run persisted the + * itemized dollars underneath it in `providerTiming.segments`, which the backfill + * had already learned to clear. Tokens survive, on both paths: the ledger owns + * the dollars, and a span's token counts are trace detail the reader is entitled + * to. + */ export function copyTraceSpansWithoutCosts(spans?: TraceSpan[]): TraceSpan[] | undefined { - return spans?.map(({ cost: _cost, children, ...span }) => ({ - ...span, - ...(children ? { children: copyTraceSpansWithoutCosts(children) } : {}), - })) + if (!spans) return undefined + const copy = copySpanTreeForStrip(spans) + stripSpanCosts(copy) + return copy } /** From d53b621a3636350960856d11d17f19a8aec84c16 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 10:24:11 -0700 Subject: [PATCH 179/179] style: restore biome 2.0.6 formatting the merge reverted Five files staging reformatted under the upgraded biome came back to their pre-upgrade bytes in the merge; all five hunks are formatting only (ASI-guard blank lines, CSS font-family wrapping). Without this, format:check fails on the merge commit. --- apps/docs/app/global.css | 25 +++++++++++-------- .../handlers/agent/agent-handler.test.ts | 1 - apps/sim/instrumentation-client.ts | 1 - apps/sim/lib/workflows/editing/engine.ts | 1 - .../lib/workflows/persistence/duplicate.ts | 1 - 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 4e493df5ce9..aa2e6f740c8 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -54,8 +54,9 @@ body { nominally references; loading a webfont here would make docs the odd one out, not the aligned one. If the app ever wires that font up for real, add the var back in both places at once. */ - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", + monospace; } /* Pure white light mode background */ @@ -243,14 +244,16 @@ body { /* Font family utilities */ .font-sans { - font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, - "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-family: + var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Helvetica Neue", Arial, sans-serif; } /* Platform UI font — Season Sans, used by the chip chrome to match the main app */ .font-season { - font-family: var(--font-season), system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, - "Noto Sans", sans-serif; + font-family: + var(--font-season), system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", + sans-serif; } :root { @@ -445,8 +448,9 @@ html #nd-sidebar button:not([aria-label*="ollapse"]):not([aria-label*="xpand"]) padding: 5px 0.5rem !important; /* 30px tall overall — the app's chip pill, at its px-2 */ font-weight: 400 !important; border-radius: 0.5rem !important; /* platform rounded-lg */ - font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, - "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important; + font-family: + var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Helvetica Neue", Arial, sans-serif !important; } /* Sidebar text — platform --text-body */ @@ -904,8 +908,9 @@ video { #nd-page:has(.api-page-header) div:not(.font-mono), #nd-page:has(.api-page-header) label:not(.font-mono), #nd-page:has(.api-page-header) button:not(.font-mono) { - font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, - "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-family: + var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Helvetica Neue", Arial, sans-serif; } /* Method badge pills — shared background colors (page + sidebar) */ diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 5d7591cc673..c3026f5db06 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -430,7 +430,6 @@ describe('AgentBlockHandler', () => { ).applyRoutingCost(streaming, 0.002) // The drain settles the model cost afterwards. - ;(output as { cost: unknown }).cost = { input: 0.01, output: 0.02, total: 0.03 } expect(output.cost).toEqual({ diff --git a/apps/sim/instrumentation-client.ts b/apps/sim/instrumentation-client.ts index 4164f5e225f..b8a6f912f68 100644 --- a/apps/sim/instrumentation-client.ts +++ b/apps/sim/instrumentation-client.ts @@ -104,7 +104,6 @@ if (typeof window !== 'undefined') { /** * Global event tracking function */ - ;(window as any).__SIM_TELEMETRY_ENABLED = telemetryEnabled ;(window as any).__SIM_TRACK_EVENT = (eventName: string, properties?: any) => { if (!telemetryEnabled) return diff --git a/apps/sim/lib/workflows/editing/engine.ts b/apps/sim/lib/workflows/editing/engine.ts index 68754056277..ba98f968ac4 100644 --- a/apps/sim/lib/workflows/editing/engine.ts +++ b/apps/sim/lib/workflows/editing/engine.ts @@ -257,7 +257,6 @@ export function applyOperationsToWorkflowState( removeInvalidScopeEdges(modifiedState, skippedItems) // Regenerate loops and parallels after modifications - ;(modifiedState as any).loops = generateLoopBlocks((modifiedState as any).blocks) ;(modifiedState as any).parallels = generateParallelBlocks((modifiedState as any).blocks) diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 5d6ad4329c0..30b394c3f42 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -410,7 +410,6 @@ export async function duplicateWorkflow( updatedConfig = structuredClone(subflow.config) as LoopConfig | ParallelConfig // Update the config ID to match the new subflow ID - ;(updatedConfig as any).id = newSubflowId /**