From 3e69d2544f6bffd7523e021dec9d2dd872b186b2 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 21 Aug 2026 13:15:15 -0700 Subject: [PATCH 01/10] feat(secrets): show where a secret is referenced, beside its usage log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "See usage" answered who has run something with a key. It could not answer the question a rotation actually starts from — where is this wired in — because a secret four blocks depend on but nothing has executed yet has no usage rows at all, so the panel read "This secret has not been used yet" for a live key. The usage view now carries two tabs. Logs is the existing trail, unchanged and still the default, since that is what the header action has always opened. References is new: the blocks that name the secret as {{KEY}}, grouped under their workflow, then the custom tools and MCP servers whose own bodies carry it. Detection is the workspace-fork remapper's. remapSubBlocks already walks nested tool-input params, resolves canonical basic/advanced pairs, and skips dormant and condition-hidden members, so calling it per block inherits every rule a fork already obeys. Only the aggregation is new: scanWorkflowReferences collapses its output to unique (kind, sourceId) pairs and discards the workflow — right for building a mapping table, wrong for locating a key. Nothing under ee/workspace-forking changed. - Candidates come from strpos(sub_blocks::text, name) > 0, deliberately not LIKE: `_` is a LIKE single-character wildcard and nearly every env key contains one, so SB_ACTION_ROUTER_SECRET would match text it does not occur in. The prefilter can over-match but never under-match; the scanner decides. The plan is an index scan on workflow by workspace, nested-looped into workflow_blocks, so cost tracks the workspace rather than the table. - Scope gates the read but does not narrow it. A {{KEY}} names a key, not a scope, so the same sites answer for a workspace secret and the personal one it shadows; narrowing here would report a personal secret as unreferenced the moment a workspace variable of the same name existed. - References reports one field per block, not a list. The remapper dedupes a block's references by (kind, sourceId), so a block naming the secret twice yields one entry — the type says so and a test pins it, because the row renders that field as its whole description. - Reads live state, not deployed: a draft workflow referencing the key must show. Blocks are capped and the cap is reported as `truncated` rather than silently trimming the list. - Authorization is the existing usage gate, renamed requireSecretTrailReadAccess and shared verbatim, so the two tabs can never disagree about who may look. UI is existing primitives only — ChipModalTabs for the strip, DetailSection per workflow over RESOURCE_LIST_STACK rows, IntegrationTile for the block glyph so a block reads here as it does on an integrations row, SettingsEmptyState for the gates. No new component, no new class. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/api/secrets/references/route.test.ts | 104 ++++++++ apps/sim/app/api/secrets/references/route.ts | 26 ++ .../secret-references-panel/index.ts | 1 + .../secret-references-panel.tsx | 143 ++++++++++ .../secrets/[credentialId]/search-params.ts | 16 ++ .../secrets/[credentialId]/secret-detail.tsx | 48 +++- apps/sim/hooks/queries/credentials.ts | 29 ++ .../hooks/queries/utils/credential-keys.ts | 9 + apps/sim/lib/api/contracts/secrets.ts | 56 ++++ .../sim/lib/secrets/application/operations.ts | 11 + apps/sim/lib/secrets/application/use-cases.ts | 46 +++- apps/sim/lib/secrets/references/scan.test.ts | 252 ++++++++++++++++++ apps/sim/lib/secrets/references/scan.ts | 227 ++++++++++++++++ scripts/check-api-validation-contracts.ts | 4 +- 14 files changed, 957 insertions(+), 15 deletions(-) create mode 100644 apps/sim/app/api/secrets/references/route.test.ts create mode 100644 apps/sim/app/api/secrets/references/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx create mode 100644 apps/sim/lib/secrets/references/scan.test.ts create mode 100644 apps/sim/lib/secrets/references/scan.ts diff --git a/apps/sim/app/api/secrets/references/route.test.ts b/apps/sim/app/api/secrets/references/route.test.ts new file mode 100644 index 00000000000..9a459a3a324 --- /dev/null +++ b/apps/sim/app/api/secrets/references/route.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ listReferences: vi.fn() })) + +vi.mock('@/lib/secrets/application/use-cases', () => ({ + listSecretReferencesUseCase: { + operation: { id: 'secrets.references' }, + execute: mocks.listReferences, + }, +})) + +import { GET } from '@/app/api/secrets/references/route' + +const url = + 'http://localhost/api/secrets/references?workspaceId=workspace-1&name=API_KEY&scope=workspace' + +describe('GET /api/secrets/references', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'admin-1' }, + session: { id: 'session-1' }, + }) + }) + + it('returns the workflows, blocks, and resources a secret is wired into', async () => { + mocks.listReferences.mockResolvedValue({ + workflows: [ + { + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + blocks: [ + { blockId: 'block-1', blockName: 'Fetch orders', blockType: 'api', field: 'apiKey' }, + ], + }, + ], + resources: [{ id: 'tool-1', kind: 'custom-tool', name: 'Order lookup', field: 'code' }], + truncated: false, + }) + + const response = await GET(createMockRequest('GET', undefined, {}, url)) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + workflows: [ + { + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + blocks: [ + { blockId: 'block-1', blockName: 'Fetch orders', blockType: 'api', field: 'apiKey' }, + ], + }, + ], + resources: [{ id: 'tool-1', kind: 'custom-tool', name: 'Order lookup', field: 'code' }], + truncated: false, + }) + }) + + it('returns empty lists for a secret referenced nowhere', async () => { + mocks.listReferences.mockResolvedValue({ workflows: [], resources: [], truncated: false }) + + const response = await GET(createMockRequest('GET', undefined, {}, url)) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ workflows: [], resources: [], truncated: false }) + }) + + it('rejects a request that names no secret', async () => { + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost/api/secrets/references?workspaceId=workspace-1&scope=workspace' + ) + ) + + expect(response.status).toBe(400) + expect(mocks.listReferences).not.toHaveBeenCalled() + }) + + /** + * The use case gates the read behind the same permission that reveals the value. A refusal + * has to reach the client as a refusal — surfacing it as an empty list would read as + * "referenced nowhere" and invite deleting a live key. + */ + it('surfaces the use case refusal rather than an empty list', async () => { + const { ForbiddenOperationError } = await import('@/lib/core/application/forbidden') + mocks.listReferences.mockRejectedValue( + new ForbiddenOperationError( + 'SECRET_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required to view this secret usage' + ) + ) + + const response = await GET(createMockRequest('GET', undefined, {}, url)) + + expect(response.status).toBe(403) + }) +}) diff --git a/apps/sim/app/api/secrets/references/route.ts b/apps/sim/app/api/secrets/references/route.ts new file mode 100644 index 00000000000..da66ee5ff3a --- /dev/null +++ b/apps/sim/app/api/secrets/references/route.ts @@ -0,0 +1,26 @@ +import { getSecretReferencesContract } from '@/lib/api/contracts/secrets' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { secretOperations } from '@/lib/secrets/application/operations' +import { listSecretReferencesUseCase } from '@/lib/secrets/application/use-cases' + +/** GET /api/secrets/references — where one secret is wired in, for the credential detail panel. */ +export const GET = defineInternalJsonRoute({ + contract: getSecretReferencesContract, + auth: internalSessionAuth, + operation: secretOperations.references, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + name: query.name, + scope: query.scope, + }), + useCase: listSecretReferencesUseCase, + /** The scan's shape is already the wire shape — nothing to project or serialize. */ + present: (scan) => scan, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/index.ts new file mode 100644 index 00000000000..c9da881f044 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/index.ts @@ -0,0 +1 @@ +export { SecretReferencesPanel } from './secret-references-panel' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx new file mode 100644 index 00000000000..1665fcc87fb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx @@ -0,0 +1,143 @@ +'use client' + +import { Wrench } from '@sim/emcn/icons' +import { McpIcon } from '@/components/icons' +import type { SecretReferenceResourcePayload, SecretUsageScope } from '@/lib/api/contracts' +import { DetailSection } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { + customToolIdParam, + mcpServerIdParam, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { getBlock } from '@/blocks/registry' +import { useSecretReferences } from '@/hooks/queries/credentials' + +interface SecretReferencesPanelProps { + workspaceId: string + secretName: string + scope: SecretUsageScope +} + +/** A custom tool and an MCP server can each carry the key more than once, so `id` alone is not a key. */ +function resourceKey(resource: SecretReferenceResourcePayload): string { + return `${resource.kind}:${resource.id}:${resource.field}` +} + +/** + * The settings page that owns the resource, deep-linked to its detail through the same param + * that page reads — so a cascade row navigates like a block row instead of dead-ending. + */ +function resourceHref(workspaceId: string, resource: SecretReferenceResourcePayload): string { + const settings = `/workspace/${workspaceId}/settings` + return resource.kind === 'mcp-server' + ? `${settings}/mcp?${mcpServerIdParam.key}=${encodeURIComponent(resource.id)}` + : `${settings}/custom-tools?${customToolIdParam.key}=${encodeURIComponent(resource.id)}` +} + +/** + * Where one secret is wired in: the blocks that name it as `{{KEY}}`, grouped under their + * workflow, then the custom tools and MCP servers whose own bodies carry it. + * + * The companion to the Logs tab, and the half of the question runs cannot answer — a secret + * four blocks depend on but nothing has executed yet has an empty trail and a full list here. + */ +export function SecretReferencesPanel({ + workspaceId, + secretName, + scope, +}: SecretReferencesPanelProps) { + const { data, isPending, isError } = useSecretReferences({ workspaceId, name: secretName, scope }) + + if (isError) { + return ( + + Could not load references. + + ) + } + + if (isPending) { + return Loading… + } + + if (data.workflows.length === 0 && data.resources.length === 0) { + return ( + + This secret is not referenced in any workflow. + + ) + } + + return ( +
+ {data.workflows.map((workflow) => ( + +
+ {workflow.blocks.map((block) => { + const BlockIcon = getBlock(block.blockType)?.icon + return ( + + ) : undefined + } + title={block.blockName} + description={block.field} + href={`/workspace/${workspaceId}/w/${workflow.workflowId}`} + clickLabel={`Open ${workflow.workflowName}`} + navigable + /> + ) + })} +
+
+ ))} + + {data.resources.length > 0 && ( + +
+ {data.resources.map((resource) => ( + + ) : ( + + ) + } + iconFilled + title={resource.name} + description={resource.field} + href={resourceHref(workspaceId, resource)} + clickLabel={`Open ${resource.name}`} + navigable + /> + ))} +
+
+ )} + + {data.truncated && ( +

+ This secret is referenced in more places than can be listed here. +

+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params.ts index d7585860cc6..c657b225780 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params.ts @@ -15,3 +15,19 @@ export const secretDetailViewUrlKeys = { history: 'push', clearOnDefault: true, } as const + +/** + * Active tab inside the usage view, so a shared `secret-view=usage` link can land on either + * reading. Defaults to `logs`, which is what the header's "See usage" opened before References + * existed — the action's name still promises the trail. + */ +export const secretUsageTabParam = { + key: 'usage-tab', + parser: parseAsStringLiteral(['references', 'logs'] as const).withDefault('logs'), +} as const + +/** Tab view-state: clean URLs, no back-stack churn. */ +export const secretUsageTabUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx index 989411cd199..0d94eff46ed 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { Chip, ChipCopyInput, ChipLink, ChipTextarea } from '@sim/emcn' +import { Chip, ChipCopyInput, ChipLink, ChipModalTabs, ChipTextarea } from '@sim/emcn' import { ArrowLeft, Clock, Key, Send } from '@sim/emcn/icons' import { useQueryState } from 'nuqs' import { SaveDiscardChips } from '@/components/settings/save-discard-actions' @@ -20,10 +20,13 @@ import { import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field' import { useSecretValue } from '@/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SecretReferencesPanel } from '@/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel' import { SecretUsagePanel } from '@/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-usage-panel' import { secretDetailViewParam, secretDetailViewUrlKeys, + secretUsageTabParam, + secretUsageTabUrlKeys, } from '@/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params' import { useWorkspaceCredential } from '@/hooks/queries/credentials' @@ -32,6 +35,18 @@ interface SecretDetailProps { credentialId: string } +type SecretUsageTab = 'references' | 'logs' + +/** + * References first: it answers where the key is wired in, which is what a rotation starts from, + * and it has an answer even for a secret nothing has run yet. Logs still opens by default, since + * that is what "See usage" showed before this tab existed. + */ +const SECRET_USAGE_TABS = [ + { value: 'references', label: 'References' }, + { value: 'logs', label: 'Logs' }, +] as const + export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { const secretsHref = `/workspace/${workspaceId}/settings/secrets` @@ -44,6 +59,10 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { ...secretDetailViewParam.parser, ...secretDetailViewUrlKeys, }) + const [usageTab, setUsageTab] = useQueryState(secretUsageTabParam.key, { + ...secretUsageTabParam.parser, + ...secretUsageTabUrlKeys, + }) const valueField = useSecretValue({ workspaceId, credential }) @@ -160,13 +179,22 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { * Usage is a destination reached from the header, the same shape as the Forks tab's * "See activity" — it replaces the secret rather than expanding inside it, so the two * readings never compete for the same column. Back returns with `replace`, since opening - * already pushed. + * already pushed, and clears the tab in the same batched write so no `?usage-tab=` lingers + * on the secret's own URL. */ if (canViewUsage && view === 'usage') { + const secretName = credential.envKey || '' + const scope = isPersonal ? 'personal' : 'workspace' return ( void setView(null, { history: 'replace' })}> + { + void setView(null, { history: 'replace' }) + void setUsageTab(null, { history: 'replace' }) + }} + > {credential.envKey || credential.displayName} } @@ -176,11 +204,17 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { title='Usage' subtitle={credential.envKey || credential.displayName} /> - void setUsageTab(value as SecretUsageTab)} + aria-label='Secret usage views' /> + {usageTab === 'references' ? ( + + ) : ( + + )} ) } diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index ae181b450e4..6964a5efb91 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -8,6 +8,7 @@ import { createCredentialDraftContract, createWorkspaceCredentialContract, deleteWorkspaceCredentialContract, + getSecretReferencesContract, getSecretUsageContract, getWorkspaceCredentialContract, listWorkspaceCredentialMembersContract, @@ -335,3 +336,31 @@ export function useSecretUsage({ workspaceId, name, scope }: SecretUsageParams, staleTime: SECRET_USAGE_STALE_TIME, }) } + +/** + * References only move when someone edits a workflow, a custom tool, or an MCP server — far + * less often than the usage trail, which every run appends to. A longer window keeps the scan + * (which reads every candidate block in the workspace) off the wire on tab switches. + */ +export const SECRET_REFERENCES_STALE_TIME = 5 * 60 * 1000 + +/** Reads where one secret is wired in. Only credential admins are authorized server-side. */ +export function useSecretReferences( + { workspaceId, name, scope }: SecretUsageParams, + enabled = true +) { + return useQuery({ + queryKey: workspaceCredentialKeys.references(workspaceId, name, scope), + queryFn: ({ signal }) => + requestJson(getSecretReferencesContract, { + query: { + workspaceId: workspaceId as string, + name: name as string, + scope: scope as SecretUsageScope, + }, + signal, + }), + enabled: Boolean(workspaceId && name && scope) && enabled, + staleTime: SECRET_REFERENCES_STALE_TIME, + }) +} diff --git a/apps/sim/hooks/queries/utils/credential-keys.ts b/apps/sim/hooks/queries/utils/credential-keys.ts index c81b662fcab..6ef79eb808b 100644 --- a/apps/sim/hooks/queries/utils/credential-keys.ts +++ b/apps/sim/hooks/queries/utils/credential-keys.ts @@ -33,4 +33,13 @@ export const workspaceCredentialKeys = { scope ?? 'all', name ?? '', ] as const, + /** Keyed like {@link usage} — references are found by name, so the credential id is not the key. */ + references: (workspaceId?: string, name?: string, scope?: string) => + [ + ...workspaceCredentialKeys.all, + 'references', + workspaceId ?? 'none', + scope ?? 'all', + name ?? '', + ] as const, } diff --git a/apps/sim/lib/api/contracts/secrets.ts b/apps/sim/lib/api/contracts/secrets.ts index 2aaea797f2a..1ee8ae3e907 100644 --- a/apps/sim/lib/api/contracts/secrets.ts +++ b/apps/sim/lib/api/contracts/secrets.ts @@ -43,6 +43,62 @@ export const getSecretUsageContract = defineRouteContract({ }, }) +/** Ceilings on one scan's payload, matching the caps the scanner reports `truncated` against. */ +const SECRET_REFERENCE_MAX_WORKFLOWS = 2000 +const SECRET_REFERENCE_MAX_BLOCKS = 2000 +const SECRET_REFERENCE_MAX_RESOURCES = 400 + +export const secretReferencesQuerySchema = z.object({ + workspaceId: z.string().min(1, 'workspaceId is required'), + name: z.string().min(1, 'Secret name is required'), + /** Selects the authorization check, not the scan — a `{{KEY}}` reference names no scope. */ + scope: secretUsageScopeSchema, +}) + +export const secretReferenceBlockSchema = z.object({ + blockId: z.string().min(1, 'blockId cannot be empty'), + blockName: z.string(), + blockType: z.string().min(1, 'blockType cannot be empty'), + /** A sub-block key carrying the reference — one per block, not necessarily the only one. */ + field: z.string().min(1, 'field cannot be empty'), +}) + +export const secretReferenceWorkflowSchema = z.object({ + workflowId: z.string().min(1, 'workflowId cannot be empty'), + workflowName: z.string(), + blocks: z + .array(secretReferenceBlockSchema) + .min(1, 'A referencing workflow must name at least one block') + .max(SECRET_REFERENCE_MAX_BLOCKS), +}) + +export const secretReferenceResourceSchema = z.object({ + id: z.string().min(1, 'resource id cannot be empty'), + kind: z.enum(['custom-tool', 'mcp-server']), + name: z.string(), + /** Where inside the resource the reference lives — `code`, `url`, or `header: X`. */ + field: z.string().min(1, 'field cannot be empty'), +}) + +export const getSecretReferencesContract = defineRouteContract({ + method: 'GET', + path: '/api/secrets/references', + query: secretReferencesQuerySchema, + response: { + mode: 'json', + schema: z.object({ + workflows: z.array(secretReferenceWorkflowSchema).max(SECRET_REFERENCE_MAX_WORKFLOWS), + resources: z.array(secretReferenceResourceSchema).max(SECRET_REFERENCE_MAX_RESOURCES), + /** True when a scan cap was hit, so the lists are a prefix rather than the whole set. */ + truncated: z.boolean(), + }), + }, +}) + export type SecretUsageScope = z.output export type SecretUsageQuery = z.input export type SecretUsageEntryPayload = z.output +export type SecretReferencesQuery = z.input +export type SecretReferenceWorkflowPayload = z.output +export type SecretReferenceBlockPayload = z.output +export type SecretReferenceResourcePayload = z.output diff --git a/apps/sim/lib/secrets/application/operations.ts b/apps/sim/lib/secrets/application/operations.ts index 67c113dee8d..00f408fd84e 100644 --- a/apps/sim/lib/secrets/application/operations.ts +++ b/apps/sim/lib/secrets/application/operations.ts @@ -31,6 +31,17 @@ export const secretOperations = { workspaceApiKey: 'deny', principalKinds: HUMAN_API_PRINCIPAL_KINDS, }), + /** + * Reading where a secret is wired in names workflows, blocks, and the tools and servers that + * carry it — the same shape of disclosure as {@link usage}, so it takes the same floor and + * the same narrowing in the use case. + */ + references: defineWorkspaceOperation({ + id: 'secrets.references', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: HUMAN_API_PRINCIPAL_KINDS, + }), } as const export type SecretOperation = (typeof secretOperations)[keyof typeof secretOperations] diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index c4d948f47c6..aa86f5f8789 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -19,6 +19,7 @@ import { setWorkspaceSecret, } from '@/lib/credentials/secret-values' import { secretOperations } from '@/lib/secrets/application/operations' +import { scanSecretReferences } from '@/lib/secrets/references/scan' import { getSecretUsage } from '@/lib/secrets/usage/queries' import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -365,15 +366,19 @@ export interface ListSecretUsageInput { } /** - * Gates the usage trail behind the same permission that reveals the value. + * Gates a secret's trails — its usage log and its reference list — behind the same permission + * that reveals the value. * - * The trail names workflows, people, and run ids. Someone who may use a secret but not read - * it has no claim on that, and letting a Member enumerate who else uses a key would hand back - * a slice of exactly what the value masking withholds. Workspace secrets therefore require + * The usage trail names workflows, people, and run ids; the reference list names workflows, + * blocks, and the tools and servers that carry the key. Someone who may use a secret but not + * read it has no claim on either, and letting a Member enumerate who else uses a key would hand + * back a slice of exactly what the value masking withholds. Workspace secrets therefore require * workspace-admin or credential-admin on that key — the same predicate * `maskWorkspaceEnvForViewer` applies — while a personal secret is only ever the caller's own. + * + * One predicate for both reads, so the two views can never disagree about who may see what. */ -async function requireSecretUsageReadAccess(params: { +async function requireSecretTrailReadAccess(params: { workspaceId: string name: string scope: SecretScope @@ -405,7 +410,7 @@ export const listSecretUsageUseCase = defineAuthorizedWorkspaceUseCase({ authorizationOptions, async execute({ principal, input, context }) { const userId = principalUserId(principal) - await requireSecretUsageReadAccess({ + await requireSecretTrailReadAccess({ workspaceId: context.workspaceId, name: input.name, scope: input.scope, @@ -427,3 +432,32 @@ export const listSecretUsageUseCase = defineAuthorizedWorkspaceUseCase({ }) }, }) + +export interface ListSecretReferencesInput { + workspaceId: string + name: string + scope: SecretScope +} + +export const listSecretReferencesUseCase = defineAuthorizedWorkspaceUseCase({ + operation: secretOperations.references, + resolveContext: ({ input }: { input: ListSecretReferencesInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + await requireSecretTrailReadAccess({ + workspaceId: context.workspaceId, + name: input.name, + scope: input.scope, + userId: principalUserId(principal), + }) + + /** + * `scope` gates the read above but does not narrow it: a `{{KEY}}` in a workflow names a + * key, not a scope, so the same reference sites answer for a workspace secret and for the + * personal one it shadows. Narrowing by scope here would report a personal secret as + * unreferenced the moment a workspace variable of the same name existed. + */ + return scanSecretReferences({ workspaceId: context.workspaceId, name: input.name }) + }, +}) diff --git a/apps/sim/lib/secrets/references/scan.test.ts b/apps/sim/lib/secrets/references/scan.test.ts new file mode 100644 index 00000000000..6ad2dfa7325 --- /dev/null +++ b/apps/sim/lib/secrets/references/scan.test.ts @@ -0,0 +1,252 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { scanSecretReferences } from '@/lib/secrets/references/scan' + +/** A stored block row as the scan reads it, with one short-input sub-block. */ +function blockRow(overrides: { + blockId: string + blockName: string + workflowId: string + workflowName: string + subBlocks: Record + blockType?: string +}) { + return { + blockType: 'agent', + data: {}, + ...overrides, + } +} + +function shortInput(key: string, value: unknown) { + return { [key]: { id: key, type: 'short-input', value } } +} + +describe('scanSecretReferences', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('groups referencing blocks under their workflow', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Fetch orders', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY}}'), + }), + blockRow({ + blockId: 'block-2', + blockName: 'Post results', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('headers', 'Bearer {{API_KEY}}'), + }), + blockRow({ + blockId: 'block-3', + blockName: 'Notify', + workflowId: 'workflow-2', + workflowName: 'Alerting', + subBlocks: shortInput('token', '{{API_KEY}}'), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows).toEqual([ + { + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + blocks: [ + { blockId: 'block-1', blockName: 'Fetch orders', blockType: 'agent', field: 'apiKey' }, + { blockId: 'block-2', blockName: 'Post results', blockType: 'agent', field: 'headers' }, + ], + }, + { + workflowId: 'workflow-2', + workflowName: 'Alerting', + blocks: [{ blockId: 'block-3', blockName: 'Notify', blockType: 'agent', field: 'token' }], + }, + ]) + expect(scan.truncated).toBe(false) + }) + + /** + * The SQL prefilter is a literal substring test, so a scan for `API_KEY` also reads every + * block naming `API_KEY_TEST`. Reporting those would send someone rotating one key to edit + * blocks that never touch it, so the scanner — not the prefilter — decides. + */ + it('drops a block whose reference only shares a prefix with the name', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Staging call', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY_TEST}}'), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows).toEqual([]) + }) + + /** A prefilter hit with no `{{ }}` around the name is prose, not a reference. */ + it('drops a block that only names the secret in free text', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Docs', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('systemPrompt', 'Ask the admin for the API_KEY value.'), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows).toEqual([]) + }) + + /** + * The fork remapper collapses a block's references to one per `(kind, sourceId)`, so a block + * naming the secret twice yields one entry, not two. Pinned here because the panel renders + * `field` as the row's whole description — if this ever became a list, the row would need to + * say so rather than silently naming one of several. + */ + it('reports one entry for a block that references the secret in two fields', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Fetch orders', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: { + ...shortInput('apiKey', '{{API_KEY}}'), + ...shortInput('headers', 'Bearer {{API_KEY}}'), + ...shortInput('url', 'https://example.com'), + }, + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + const blocks = scan.workflows[0]?.blocks ?? [] + expect(blocks).toHaveLength(1) + expect(['apiKey', 'headers']).toContain(blocks[0]?.field) + }) + + it('finds a reference nested inside a sub-block value', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Call API', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('params', [{ name: 'auth', value: '{{API_KEY}}' }]), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows[0]?.blocks[0]?.field).toBe('params') + }) + + it('tolerates whitespace inside the reference braces', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Call API', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{ API_KEY }}'), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows[0]?.blocks[0]?.field).toBe('apiKey') + }) + + /** + * One unreadable block must not blank the whole tab — the other blocks are still the honest + * answer, and a reference reported without the field that carries it is worse than omitted. + */ + it('skips a block whose sub-blocks cannot be scanned', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Corrupt', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: null as unknown as Record, + }), + blockRow({ + blockId: 'block-2', + blockName: 'Fetch orders', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY}}'), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows[0]?.blocks.map((block) => block.blockId)).toEqual(['block-2']) + }) + + it('reports custom tools and MCP servers that carry the secret', async () => { + queueTableRows(schemaMock.customTools, [ + { id: 'tool-1', title: 'Order lookup', code: 'fetch(url, { key: "{{API_KEY}}" })' }, + { id: 'tool-2', title: 'Unrelated', code: 'const label = "API_KEY"' }, + ]) + queueTableRows(schemaMock.mcpServers, [ + { + id: 'server-1', + name: 'Billing', + url: 'https://example.com?token={{API_KEY}}', + headers: { Authorization: 'Bearer {{API_KEY}}', 'X-Trace': 'on' }, + }, + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.resources).toEqual([ + { id: 'tool-1', kind: 'custom-tool', name: 'Order lookup', field: 'code' }, + { id: 'server-1', kind: 'mcp-server', name: 'Billing', field: 'url' }, + { id: 'server-1', kind: 'mcp-server', name: 'Billing', field: 'header: Authorization' }, + ]) + }) + + it('flags a scan that hit the block cap', async () => { + queueTableRows( + schemaMock.workflowBlocks, + Array.from({ length: 2001 }, (_, index) => + blockRow({ + blockId: `block-${index}`, + blockName: `Block ${index}`, + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY}}'), + }) + ) + ) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.truncated).toBe(true) + expect(scan.workflows[0]?.blocks).toHaveLength(2000) + }) + + it('returns nothing for a secret referenced nowhere', async () => { + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan).toEqual({ workflows: [], resources: [], truncated: false }) + }) +}) diff --git a/apps/sim/lib/secrets/references/scan.ts b/apps/sim/lib/secrets/references/scan.ts new file mode 100644 index 00000000000..24095d5d22d --- /dev/null +++ b/apps/sim/lib/secrets/references/scan.ts @@ -0,0 +1,227 @@ +import { db } from '@sim/db' +import { customTools, mcpServers, workflow, workflowBlocks } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, asc, eq, isNull, sql } from 'drizzle-orm' +import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids' +import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' +import { ENV_REF_PATTERN, remapSubBlocks } from '@/ee/workspace-forking/lib/remap/remap-references' + +const logger = createLogger('SecretReferenceScan') + +/** + * Cap on candidate blocks read in one scan. The prefilter already narrows to blocks whose stored + * JSON contains the name, so reaching this means a workspace genuinely wires the key into + * thousands of places — at which point a complete list is not the useful answer anyway. Reported + * back as {@link SecretReferenceScan.truncated} rather than silently dropped. + */ +const BLOCK_SCAN_LIMIT = 2000 + +/** Matching cap for each cascade table, which are far smaller than the block table. */ +const RESOURCE_SCAN_LIMIT = 200 + +export interface SecretReferenceBlock { + blockId: string + blockName: string + blockType: string + /** + * A sub-block key on this block whose value carries the reference — not necessarily the + * only one. The fork remapper collapses a block's references to one entry per + * `(kind, sourceId)`, so a block naming the secret in two fields reports one of them. The + * block is the unit the reader acts on; the field is there to locate it inside the block. + */ + field: string +} + +export interface SecretReferenceWorkflow { + workflowId: string + workflowName: string + blocks: SecretReferenceBlock[] +} + +/** + * One reference site inside a resource a workflow reaches through rather than a block field. + * An MCP server carrying the key in two headers yields two entries, so `id` alone is not + * unique — `(kind, id, field)` is. + */ +export interface SecretReferenceResource { + id: string + kind: 'custom-tool' | 'mcp-server' + name: string + /** Where inside the resource the reference lives — `code`, `url`, or `header: X`. */ + field: string +} + +export interface SecretReferenceScan { + workflows: SecretReferenceWorkflow[] + resources: SecretReferenceResource[] + /** True when a scan cap was hit, so the lists are a prefix rather than the whole set. */ + truncated: boolean +} + +interface ScanSecretReferencesParams { + workspaceId: string + name: string +} + +/** Whether `text` carries a `{{name}}` reference, using the fork remapper's own pattern. */ +function referencesEnvKey(text: string, name: string): boolean { + for (const match of text.matchAll(ENV_REF_PATTERN)) { + if (match[1] === name) return true + } + return false +} + +/** + * `strpos(haystack, needle) > 0` — a literal substring test. + * + * Deliberately not `LIKE '%name%'`: `_` is a LIKE single-character wildcard and every other + * env key contains one, so `SB_ACTION_ROUTER_SECRET` would match text it does not occur in. + * The prefilter may over-match (a bare name outside `{{ }}`, or a name that is a prefix of a + * different key) but can never under-match, because a real reference always contains the + * literal name. The scanners below re-check every candidate and are the authority. + */ +function containsLiteral(column: unknown, needle: string) { + return sql`strpos(${column}, ${needle}) > 0` +} + +/** + * Every place in a workspace that names one secret: the blocks that reference it as + * `{{KEY}}`, plus the custom tools and MCP servers whose own bodies carry it. + * + * Detection is the workspace-fork remapper's — {@link remapSubBlocks} already walks nested + * `tool-input` params, resolves canonical basic/advanced pairs, and skips dormant and + * condition-hidden members. Only the aggregation is new: `scanWorkflowReferences` collapses + * its output to unique `(kind, sourceId)` pairs, which is right for building a mapping table + * and wrong for answering "where is this wired in". + * + * The scan is name-based and therefore identical for a workspace and a personal secret — a + * `{{KEY}}` in a workflow names a key, not a scope, and resolves to whichever slice wins at + * run time. The detail page already reports shadowing separately. + */ +export async function scanSecretReferences({ + workspaceId, + name, +}: ScanSecretReferencesParams): Promise { + const [blocks, tools, servers] = await Promise.all([ + db + .select({ + blockId: workflowBlocks.id, + blockName: workflowBlocks.name, + blockType: workflowBlocks.type, + subBlocks: workflowBlocks.subBlocks, + data: workflowBlocks.data, + workflowId: workflow.id, + workflowName: workflow.name, + }) + .from(workflowBlocks) + .innerJoin(workflow, eq(workflow.id, workflowBlocks.workflowId)) + .where( + and( + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt), + containsLiteral(sql`${workflowBlocks.subBlocks}::text`, name) + ) + ) + .orderBy(asc(workflow.name), asc(workflow.id), asc(workflowBlocks.name)) + .limit(BLOCK_SCAN_LIMIT + 1), + db + .select({ id: customTools.id, title: customTools.title, code: customTools.code }) + .from(customTools) + .where(and(eq(customTools.workspaceId, workspaceId), containsLiteral(customTools.code, name))) + .orderBy(asc(customTools.title)) + .limit(RESOURCE_SCAN_LIMIT + 1), + db + .select({ + id: mcpServers.id, + name: mcpServers.name, + url: mcpServers.url, + headers: mcpServers.headers, + }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt), + // `headers` is a `json` column, so `::text` is the only safe read here — a jsonb + // operator would raise 42883 and abort the statement. + sql`(${containsLiteral(mcpServers.url, name)} OR ${containsLiteral(sql`${mcpServers.headers}::text`, name)})` + ) + ) + .orderBy(asc(mcpServers.name)) + .limit(RESOURCE_SCAN_LIMIT + 1), + ]) + + const truncated = + blocks.length > BLOCK_SCAN_LIMIT || + tools.length > RESOURCE_SCAN_LIMIT || + servers.length > RESOURCE_SCAN_LIMIT + + const workflows: SecretReferenceWorkflow[] = [] + const workflowIndex = new Map() + + for (const row of blocks.slice(0, BLOCK_SCAN_LIMIT)) { + let field: string | undefined + try { + const { references } = remapSubBlocks(row.subBlocks as SubBlockRecord, () => null, { + blockId: row.blockId, + blockName: row.blockName, + blockType: row.blockType, + canonicalModes: (row.data as { canonicalModes?: CanonicalModeOverrides } | null) + ?.canonicalModes, + }) + field = references.find( + (reference) => reference.kind === 'env-var' && reference.sourceId === name + )?.subBlockKey + } catch (error) { + // One malformed block must not blank the whole tab. The block is dropped rather than + // reported without the field that carries the reference, which would read as a + // reference we cannot locate — and the log names it so the shape can be fixed. + logger.error('Failed to scan block for secret references', { + blockId: row.blockId, + workflowId: row.workflowId, + error, + }) + continue + } + if (!field) continue + + let entry = workflowIndex.get(row.workflowId) + if (!entry) { + entry = { workflowId: row.workflowId, workflowName: row.workflowName, blocks: [] } + workflowIndex.set(row.workflowId, entry) + workflows.push(entry) + } + entry.blocks.push({ + blockId: row.blockId, + blockName: row.blockName, + blockType: row.blockType, + field, + }) + } + + const resources: SecretReferenceResource[] = [] + + for (const tool of tools.slice(0, RESOURCE_SCAN_LIMIT)) { + if (!referencesEnvKey(tool.code ?? '', name)) continue + resources.push({ id: tool.id, kind: 'custom-tool', name: tool.title, field: 'code' }) + } + + for (const server of servers.slice(0, RESOURCE_SCAN_LIMIT)) { + if (server.url && referencesEnvKey(server.url, name)) { + resources.push({ id: server.id, kind: 'mcp-server', name: server.name, field: 'url' }) + } + const headers = (server.headers ?? {}) as Record + for (const [headerName, headerValue] of Object.entries(headers)) { + if (typeof headerValue !== 'string') continue + if (!referencesEnvKey(headerValue, name)) continue + resources.push({ + id: server.id, + kind: 'mcp-server', + name: server.name, + field: `header: ${headerName}`, + }) + } + } + + return { workflows, resources, truncated } +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 2b92482ad92..3f66bbc8a2f 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1161, - zodRoutes: 1161, + totalRoutes: 1162, + zodRoutes: 1162, nonZodRoutes: 0, } as const From 4a9248a41318609f1338b35f280b99b2f79e0ac0 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 21 Aug 2026 13:38:39 -0700 Subject: [PATCH 02/10] fix(secrets): close the reference-scan scope bypass and bound its output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1. - use-cases.ts: `scope` was a caller-controlled assertion the reference scan never narrowed by, so `scope=personal` returned from the shared gate before any check and handed any workspace member the admin-gated reference map for any workspace secret. The usage trail can trust that scope because it filters the read by `secretOwnerUserId`; a name-based workspace-wide scan cannot. References now authorize on what the NAME resolves to — a workspace secret under that name is admin-gated outright, and absent one the caller must actually hold a personal secret of that name, which also stops a member enumerating arbitrary names. `scope` is dropped from the input, the contract, the hook and the query key rather than merely ignored: a parameter that does not exist cannot be asserted. The trail gate keeps its old name and a note saying why only a scope-narrowed read may reuse it. - scan.ts: the prefilter matched the bare name, so `API_KEY` also read every block holding `{{API_KEY_TEST}}` or the words "the API_KEY value" — and those false positives counted against the row cap, so on a workspace with enough of them genuine references sorted later were never read at all. It now matches the reference syntax (`{{name}}`, with the whitespace ENV_REF_PATTERN allows), so a candidate is a real occurrence and the cap means what it says. A name outside the env-key charset short-circuits, which is also what makes it safe to inline into the regex unescaped. Verified against a real workspace: the exact key still returns its 16 blocks, its prefix now returns 0 where it previously matched all 16, and a metacharacter name touches no query. - scan.ts: capping tool and server ROWS did not bound the output — one MCP server emits an entry per matching header plus one for its url, so 200 rows could expand past the contract's 400-entry bound and make the route reject its own response, turning a successful scan into a 500 and the tab into "Could not load references." Emission now stops at the bound and reports `truncated`. - secret-references-panel.tsx: the empty-state early return preceded the truncation banner, so a capped scan that filtered everything out claimed the secret was unreferenced. Both paths now share one note, and silence from a capped scan reads as absence of evidence rather than evidence of absence. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/api/secrets/references/route.test.ts | 24 +++++- apps/sim/app/api/secrets/references/route.ts | 1 - .../secret-references-panel.tsx | 26 +++--- .../secrets/[credentialId]/secret-detail.tsx | 2 +- apps/sim/hooks/queries/credentials.ts | 26 +++--- .../hooks/queries/utils/credential-keys.ts | 15 ++-- apps/sim/lib/api/contracts/secrets.ts | 14 +++- apps/sim/lib/secrets/application/use-cases.ts | 83 +++++++++++++++---- apps/sim/lib/secrets/references/scan.test.ts | 54 +++++++++++- apps/sim/lib/secrets/references/scan.ts | 83 ++++++++++++++----- 10 files changed, 246 insertions(+), 82 deletions(-) diff --git a/apps/sim/app/api/secrets/references/route.test.ts b/apps/sim/app/api/secrets/references/route.test.ts index 9a459a3a324..b15871f789a 100644 --- a/apps/sim/app/api/secrets/references/route.test.ts +++ b/apps/sim/app/api/secrets/references/route.test.ts @@ -15,8 +15,7 @@ vi.mock('@/lib/secrets/application/use-cases', () => ({ import { GET } from '@/app/api/secrets/references/route' -const url = - 'http://localhost/api/secrets/references?workspaceId=workspace-1&name=API_KEY&scope=workspace' +const url = 'http://localhost/api/secrets/references?workspaceId=workspace-1&name=API_KEY' describe('GET /api/secrets/references', () => { beforeEach(() => { @@ -75,7 +74,7 @@ describe('GET /api/secrets/references', () => { 'GET', undefined, {}, - 'http://localhost/api/secrets/references?workspaceId=workspace-1&scope=workspace' + 'http://localhost/api/secrets/references?workspaceId=workspace-1' ) ) @@ -83,6 +82,25 @@ describe('GET /api/secrets/references', () => { expect(mocks.listReferences).not.toHaveBeenCalled() }) + /** + * The contract carries no `scope`. It used to, and because a reference scan is name-based and + * never narrowed by scope, asserting `personal` skipped the admin gate outright — a member + * could read the reference map for any workspace secret. A stray `scope` must therefore reach + * neither the gate nor the scan. + */ + it('ignores a scope the caller tries to assert', async () => { + mocks.listReferences.mockResolvedValue({ workflows: [], resources: [], truncated: false }) + + const response = await GET(createMockRequest('GET', undefined, {}, `${url}&scope=personal`)) + + expect(response.status).toBe(200) + expect(mocks.listReferences).toHaveBeenCalledTimes(1) + expect(mocks.listReferences.mock.calls[0]?.[0]?.input).toEqual({ + workspaceId: 'workspace-1', + name: 'API_KEY', + }) + }) + /** * The use case gates the read behind the same permission that reveals the value. A refusal * has to reach the client as a refusal — surfacing it as an empty list would read as diff --git a/apps/sim/app/api/secrets/references/route.ts b/apps/sim/app/api/secrets/references/route.ts index da66ee5ff3a..24cf45f638e 100644 --- a/apps/sim/app/api/secrets/references/route.ts +++ b/apps/sim/app/api/secrets/references/route.ts @@ -18,7 +18,6 @@ export const GET = defineInternalJsonRoute({ mapInput: ({ query }) => ({ workspaceId: query.workspaceId, name: query.name, - scope: query.scope, }), useCase: listSecretReferencesUseCase, /** The scan's shape is already the wire shape — nothing to project or serialize. */ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx index 1665fcc87fb..1f512463f3e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx @@ -2,7 +2,7 @@ import { Wrench } from '@sim/emcn/icons' import { McpIcon } from '@/components/icons' -import type { SecretReferenceResourcePayload, SecretUsageScope } from '@/lib/api/contracts' +import type { SecretReferenceResourcePayload } from '@/lib/api/contracts' import { DetailSection } from '@/app/workspace/[workspaceId]/components/credential-detail' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { @@ -20,9 +20,15 @@ import { useSecretReferences } from '@/hooks/queries/credentials' interface SecretReferencesPanelProps { workspaceId: string secretName: string - scope: SecretUsageScope } +/** + * Shown when a capped scan produced nothing to list. Distinct from "not referenced": the scan + * stopped early, so silence here is absence of evidence, and saying otherwise would invite + * deleting a key that four blocks past the cap still depend on. + */ +const TRUNCATED_NOTE = 'This secret is referenced in more places than can be listed here.' + /** A custom tool and an MCP server can each carry the key more than once, so `id` alone is not a key. */ function resourceKey(resource: SecretReferenceResourcePayload): string { return `${resource.kind}:${resource.id}:${resource.field}` @@ -46,12 +52,8 @@ function resourceHref(workspaceId: string, resource: SecretReferenceResourcePayl * The companion to the Logs tab, and the half of the question runs cannot answer — a secret * four blocks depend on but nothing has executed yet has an empty trail and a full list here. */ -export function SecretReferencesPanel({ - workspaceId, - secretName, - scope, -}: SecretReferencesPanelProps) { - const { data, isPending, isError } = useSecretReferences({ workspaceId, name: secretName, scope }) +export function SecretReferencesPanel({ workspaceId, secretName }: SecretReferencesPanelProps) { + const { data, isPending, isError } = useSecretReferences({ workspaceId, name: secretName }) if (isError) { return ( @@ -68,7 +70,7 @@ export function SecretReferencesPanel({ if (data.workflows.length === 0 && data.resources.length === 0) { return ( - This secret is not referenced in any workflow. + {data.truncated ? TRUNCATED_NOTE : 'This secret is not referenced in any workflow.'} ) } @@ -133,11 +135,7 @@ export function SecretReferencesPanel({ )} - {data.truncated && ( -

- This secret is referenced in more places than can be listed here. -

- )} + {data.truncated &&

{TRUNCATED_NOTE}

} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx index 0d94eff46ed..83cdef0bf3e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx @@ -211,7 +211,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { aria-label='Secret usage views' /> {usageTab === 'references' ? ( - + ) : ( )} diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 6964a5efb91..6f64d56213f 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -344,23 +344,25 @@ export function useSecretUsage({ workspaceId, name, scope }: SecretUsageParams, */ export const SECRET_REFERENCES_STALE_TIME = 5 * 60 * 1000 -/** Reads where one secret is wired in. Only credential admins are authorized server-side. */ -export function useSecretReferences( - { workspaceId, name, scope }: SecretUsageParams, - enabled = true -) { +interface SecretReferencesParams { + workspaceId?: string + name?: string +} + +/** + * Reads where one secret is wired in. Takes no scope: a reference names a key, not a scope, and + * the server authorizes against what the name resolves to. Only credential admins of a workspace + * secret — or the owner of a personal one — are authorized server-side. + */ +export function useSecretReferences({ workspaceId, name }: SecretReferencesParams, enabled = true) { return useQuery({ - queryKey: workspaceCredentialKeys.references(workspaceId, name, scope), + queryKey: workspaceCredentialKeys.references(workspaceId, name), queryFn: ({ signal }) => requestJson(getSecretReferencesContract, { - query: { - workspaceId: workspaceId as string, - name: name as string, - scope: scope as SecretUsageScope, - }, + query: { workspaceId: workspaceId as string, name: name as string }, signal, }), - enabled: Boolean(workspaceId && name && scope) && enabled, + enabled: Boolean(workspaceId && name) && enabled, staleTime: SECRET_REFERENCES_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/utils/credential-keys.ts b/apps/sim/hooks/queries/utils/credential-keys.ts index 6ef79eb808b..db7c276b29f 100644 --- a/apps/sim/hooks/queries/utils/credential-keys.ts +++ b/apps/sim/hooks/queries/utils/credential-keys.ts @@ -33,13 +33,10 @@ export const workspaceCredentialKeys = { scope ?? 'all', name ?? '', ] as const, - /** Keyed like {@link usage} — references are found by name, so the credential id is not the key. */ - references: (workspaceId?: string, name?: string, scope?: string) => - [ - ...workspaceCredentialKeys.all, - 'references', - workspaceId ?? 'none', - scope ?? 'all', - name ?? '', - ] as const, + /** + * Keyed by name alone — references are found by name, so neither the credential id nor a + * scope narrows the result, and adding either would split one answer across cache entries. + */ + references: (workspaceId?: string, name?: string) => + [...workspaceCredentialKeys.all, 'references', workspaceId ?? 'none', name ?? ''] as const, } diff --git a/apps/sim/lib/api/contracts/secrets.ts b/apps/sim/lib/api/contracts/secrets.ts index 1ee8ae3e907..d4b94bebaf0 100644 --- a/apps/sim/lib/api/contracts/secrets.ts +++ b/apps/sim/lib/api/contracts/secrets.ts @@ -43,16 +43,24 @@ export const getSecretUsageContract = defineRouteContract({ }, }) -/** Ceilings on one scan's payload, matching the caps the scanner reports `truncated` against. */ +/** + * Ceilings on one scan's payload. These match the caps `lib/secrets/references/scan.ts` stops + * at — in particular `resources` is capped on EMITTED entries there, not on rows read, because + * one MCP server expands to an entry per matching header. Raising a bound here without raising + * the scanner's cap is harmless; lowering one below it makes the route reject its own response. + */ const SECRET_REFERENCE_MAX_WORKFLOWS = 2000 const SECRET_REFERENCE_MAX_BLOCKS = 2000 const SECRET_REFERENCE_MAX_RESOURCES = 400 +/** + * No `scope`, unlike the usage query. A `{{KEY}}` reference names a key and not a scope, so the + * scan is name-based and the use case authorizes against what the name resolves to. A scope here + * would be a caller-controlled assertion that nothing narrows by — a bypass, not an input. + */ export const secretReferencesQuerySchema = z.object({ workspaceId: z.string().min(1, 'workspaceId is required'), name: z.string().min(1, 'Secret name is required'), - /** Selects the authorization check, not the scan — a `{{KEY}}` reference names no scope. */ - scope: secretUsageScopeSchema, }) export const secretReferenceBlockSchema = z.object({ diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index aa86f5f8789..a250c35e9e1 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -366,24 +366,26 @@ export interface ListSecretUsageInput { } /** - * Gates a secret's trails — its usage log and its reference list — behind the same permission - * that reveals the value. + * Gates the usage trail behind the same permission that reveals the value. * - * The usage trail names workflows, people, and run ids; the reference list names workflows, - * blocks, and the tools and servers that carry the key. Someone who may use a secret but not - * read it has no claim on either, and letting a Member enumerate who else uses a key would hand - * back a slice of exactly what the value masking withholds. Workspace secrets therefore require + * The trail names workflows, people, and run ids. Someone who may use a secret but not read + * it has no claim on that, and letting a Member enumerate who else uses a key would hand back + * a slice of exactly what the value masking withholds. Workspace secrets therefore require * workspace-admin or credential-admin on that key — the same predicate * `maskWorkspaceEnvForViewer` applies — while a personal secret is only ever the caller's own. - * - * One predicate for both reads, so the two views can never disagree about who may see what. */ -async function requireSecretTrailReadAccess(params: { +async function requireSecretUsageReadAccess(params: { workspaceId: string name: string scope: SecretScope userId: string }): Promise { + /** + * Safe to trust the asserted scope here, and only here: the read below is NARROWED by it to + * `secretOwnerUserId`, so asserting `personal` for a workspace key returns the caller's own + * (empty) trail rather than the workspace one. A read that is not scope-narrowed must not + * reuse this — see {@link requireSecretReferencesReadAccess}. + */ if (params.scope === 'personal') return const [workspaceAccess, keyAccess] = await Promise.all([ @@ -410,7 +412,7 @@ export const listSecretUsageUseCase = defineAuthorizedWorkspaceUseCase({ authorizationOptions, async execute({ principal, input, context }) { const userId = principalUserId(principal) - await requireSecretTrailReadAccess({ + await requireSecretUsageReadAccess({ workspaceId: context.workspaceId, name: input.name, scope: input.scope, @@ -433,10 +435,61 @@ export const listSecretUsageUseCase = defineAuthorizedWorkspaceUseCase({ }, }) +/** + * Deliberately carries no `scope`. A reference is found by name, so a scope here could only be + * an assertion the caller controls and the read never narrows by — exactly the shape that made + * the first cut of this operation bypassable. The name alone decides both access and result. + */ export interface ListSecretReferencesInput { workspaceId: string name: string - scope: SecretScope +} + +/** + * Gates the reference scan on what the NAME resolves to, never on the scope the caller asserts. + * + * The usage trail can trust `scope` because it narrows the read by it — a personal request is + * filtered to `secretOwnerUserId`, so asserting `personal` for a workspace key returns nothing. + * A reference scan cannot: `{{KEY}}` names a key and not a scope, so the scan is name-based and + * workspace-wide by construction. Reusing the trail's gate therefore made `scope=personal` a + * bypass — it returns before any check, and a member could read the admin-gated map for any + * workspace secret by naming it under personal scope. + * + * So the asserted scope is discarded here and the canonical name decides: + * - a workspace secret exists under this name → only its admins (or a workspace admin) may look, + * which is the same predicate that reveals its value; + * - no workspace secret exists → the caller must actually hold a personal secret of that name, + * which stops a member enumerating arbitrary names for a map they have no claim to. + */ +async function requireSecretReferencesReadAccess(params: { + workspaceId: string + name: string + userId: string +}): Promise { + const [workspaceAccess, keyAccess] = await Promise.all([ + checkWorkspaceAccess(params.workspaceId, params.userId), + getWorkspaceEnvKeyAdminAccess({ + workspaceId: params.workspaceId, + envKeys: [params.name], + userId: params.userId, + }), + ]) + if (workspaceAccess.canAdmin || keyAccess.adminKeys.has(params.name)) return + + const forbidden = new ForbiddenOperationError( + 'SECRET_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required to view this secret usage' + ) + + // A workspace secret under this name is admin-gated outright — a personal secret the caller + // happens to hold under the same name does not unlock the workspace one's reference map. + if (keyAccess.knownKeys.has(params.name)) throw forbidden + + const owned = await getPersonalEnvCredentialMetadata({ + userId: params.userId, + envKey: params.name, + }) + if (!owned) throw forbidden } export const listSecretReferencesUseCase = defineAuthorizedWorkspaceUseCase({ @@ -445,17 +498,15 @@ export const listSecretReferencesUseCase = defineAuthorizedWorkspaceUseCase({ resolveWorkspaceContext(input.workspaceId), authorizationOptions, async execute({ principal, input, context }) { - await requireSecretTrailReadAccess({ + await requireSecretReferencesReadAccess({ workspaceId: context.workspaceId, name: input.name, - scope: input.scope, userId: principalUserId(principal), }) /** - * `scope` gates the read above but does not narrow it: a `{{KEY}}` in a workflow names a - * key, not a scope, so the same reference sites answer for a workspace secret and for the - * personal one it shadows. Narrowing by scope here would report a personal secret as + * Name-based, not scope-narrowed: the same reference sites answer for a workspace secret and + * for the personal one it shadows. Narrowing by scope would report a personal secret as * unreferenced the moment a workspace variable of the same name existed. */ return scanSecretReferences({ workspaceId: context.workspaceId, name: input.name }) diff --git a/apps/sim/lib/secrets/references/scan.test.ts b/apps/sim/lib/secrets/references/scan.test.ts index 6ad2dfa7325..1fa02fdacc9 100644 --- a/apps/sim/lib/secrets/references/scan.test.ts +++ b/apps/sim/lib/secrets/references/scan.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { scanSecretReferences } from '@/lib/secrets/references/scan' @@ -77,9 +77,9 @@ describe('scanSecretReferences', () => { }) /** - * The SQL prefilter is a literal substring test, so a scan for `API_KEY` also reads every - * block naming `API_KEY_TEST`. Reporting those would send someone rotating one key to edit - * blocks that never touch it, so the scanner — not the prefilter — decides. + * The SQL prefilter matches the reference syntax, so these never reach the scanner in + * production — but the scanner stays the authority, and these pin that it agrees. Reporting + * them would send someone rotating one key to edit blocks that never touch it. */ it('drops a block whose reference only shares a prefix with the name', async () => { queueTableRows(schemaMock.workflowBlocks, [ @@ -249,4 +249,50 @@ describe('scanSecretReferences', () => { expect(scan).toEqual({ workflows: [], resources: [], truncated: false }) }) + + /** + * A name outside the env-key charset can never sit inside `{{ }}`, so the scan short-circuits + * before touching the database — which is also what makes it safe to inline the name into the + * SQL regex without escaping. + */ + it('scans nothing for a name that cannot be an env reference', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Fetch orders', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY}}'), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API.*KEY' }) + + expect(scan).toEqual({ workflows: [], resources: [], truncated: false }) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + /** + * The row caps bound what is READ; one MCP server expands to an entry per matching header, so + * the emitted total is what has to stay inside the contract's array bound. Without this the + * route rejects its own response and a successful scan surfaces as a 500. + */ + it('caps emitted resources so the response bound holds', async () => { + const headers: Record = {} + for (let index = 0; index < 300; index++) headers[`X-Key-${index}`] = 'Bearer {{API_KEY}}' + queueTableRows( + schemaMock.mcpServers, + Array.from({ length: 3 }, (_, index) => ({ + id: `server-${index}`, + name: `Server ${index}`, + url: 'https://example.com', + headers, + })) + ) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.resources).toHaveLength(400) + expect(scan.truncated).toBe(true) + }) }) diff --git a/apps/sim/lib/secrets/references/scan.ts b/apps/sim/lib/secrets/references/scan.ts index 24095d5d22d..29c2cde6a57 100644 --- a/apps/sim/lib/secrets/references/scan.ts +++ b/apps/sim/lib/secrets/references/scan.ts @@ -9,16 +9,32 @@ import { ENV_REF_PATTERN, remapSubBlocks } from '@/ee/workspace-forking/lib/rema const logger = createLogger('SecretReferenceScan') /** - * Cap on candidate blocks read in one scan. The prefilter already narrows to blocks whose stored - * JSON contains the name, so reaching this means a workspace genuinely wires the key into - * thousands of places — at which point a complete list is not the useful answer anyway. Reported - * back as {@link SecretReferenceScan.truncated} rather than silently dropped. + * Cap on candidate blocks read in one scan. The prefilter matches the reference syntax itself, + * so a candidate is already a genuine `{{name}}` occurrence and reaching this cap means the + * workspace really does wire the key into thousands of blocks — at which point a complete list + * is not the useful answer anyway. Reported back as {@link SecretReferenceScan.truncated} + * rather than silently dropped. */ const BLOCK_SCAN_LIMIT = 2000 /** Matching cap for each cascade table, which are far smaller than the block table. */ const RESOURCE_SCAN_LIMIT = 200 +/** + * Ceiling on EMITTED resource entries, matching `secretReferenceResourceSchema`'s array bound in + * the secrets contract. Capping rows alone is not enough: one MCP server yields an entry per + * matching header plus one for its url, so 200 server rows can expand past the declared bound and + * make the route reject its own response. The producer stops at the bound instead. + */ +const RESOURCE_EMIT_LIMIT = 400 + +/** + * The env-key charset `ENV_REF_PATTERN` accepts. A name outside it can never appear inside + * `{{ }}`, so the scan short-circuits — which also means the name is safe to inline into the + * SQL regex below without escaping, since it cannot carry a metacharacter. + */ +const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ + export interface SecretReferenceBlock { blockId: string blockName: string @@ -72,16 +88,22 @@ function referencesEnvKey(text: string, name: string): boolean { } /** - * `strpos(haystack, needle) > 0` — a literal substring test. + * Matches the reference syntax itself — `{{name}}`, with the optional inner whitespace + * `ENV_REF_PATTERN` allows — rather than the bare name. + * + * Deliberately not `LIKE '%name%'`: `_` is a LIKE single-character wildcard and nearly every env + * key contains one, so `SB_ACTION_ROUTER_SECRET` would match text it does not occur in. And + * deliberately not a bare `strpos` either: that matched the name in prose and as a prefix of a + * longer key (`API_KEY` inside `{{API_KEY_TEST}}`), and those false positives were counted + * against the row cap — so on a workspace with enough of them, genuine references sorted later + * were never read at all. Matching the syntax makes every candidate a real occurrence, which is + * what makes the cap mean what it says. * - * Deliberately not `LIKE '%name%'`: `_` is a LIKE single-character wildcard and every other - * env key contains one, so `SB_ACTION_ROUTER_SECRET` would match text it does not occur in. - * The prefilter may over-match (a bare name outside `{{ }}`, or a name that is a prefix of a - * different key) but can never under-match, because a real reference always contains the - * literal name. The scanners below re-check every candidate and are the authority. + * The scanners below still re-check each candidate and remain the authority; this only decides + * what is worth reading. */ -function containsLiteral(column: unknown, needle: string) { - return sql`strpos(${column}, ${needle}) > 0` +function referencesKey(column: unknown, envKey: string) { + return sql`${column} ~ ${`\\{\\{[[:space:]]*${envKey}[[:space:]]*\\}\\}`}` } /** @@ -102,6 +124,9 @@ export async function scanSecretReferences({ workspaceId, name, }: ScanSecretReferencesParams): Promise { + // A name outside the env-key charset cannot appear inside `{{ }}`, so nothing can reference it. + if (!ENV_KEY_PATTERN.test(name)) return { workflows: [], resources: [], truncated: false } + const [blocks, tools, servers] = await Promise.all([ db .select({ @@ -119,7 +144,7 @@ export async function scanSecretReferences({ and( eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt), - containsLiteral(sql`${workflowBlocks.subBlocks}::text`, name) + referencesKey(sql`${workflowBlocks.subBlocks}::text`, name) ) ) .orderBy(asc(workflow.name), asc(workflow.id), asc(workflowBlocks.name)) @@ -127,7 +152,7 @@ export async function scanSecretReferences({ db .select({ id: customTools.id, title: customTools.title, code: customTools.code }) .from(customTools) - .where(and(eq(customTools.workspaceId, workspaceId), containsLiteral(customTools.code, name))) + .where(and(eq(customTools.workspaceId, workspaceId), referencesKey(customTools.code, name))) .orderBy(asc(customTools.title)) .limit(RESOURCE_SCAN_LIMIT + 1), db @@ -144,14 +169,14 @@ export async function scanSecretReferences({ isNull(mcpServers.deletedAt), // `headers` is a `json` column, so `::text` is the only safe read here — a jsonb // operator would raise 42883 and abort the statement. - sql`(${containsLiteral(mcpServers.url, name)} OR ${containsLiteral(sql`${mcpServers.headers}::text`, name)})` + sql`(${referencesKey(mcpServers.url, name)} OR ${referencesKey(sql`${mcpServers.headers}::text`, name)})` ) ) .orderBy(asc(mcpServers.name)) .limit(RESOURCE_SCAN_LIMIT + 1), ]) - const truncated = + let truncated = blocks.length > BLOCK_SCAN_LIMIT || tools.length > RESOURCE_SCAN_LIMIT || servers.length > RESOURCE_SCAN_LIMIT @@ -201,25 +226,45 @@ export async function scanSecretReferences({ const resources: SecretReferenceResource[] = [] + /** + * Stops at {@link RESOURCE_EMIT_LIMIT} rather than trusting the row caps to bound the output. + * One server expands to an entry per matching header, so the emitted total is what has to be + * checked against the contract's array bound — exceeding it would make the route reject its + * own response and turn a successful scan into a 500. + */ + const emitResource = (resource: SecretReferenceResource): boolean => { + if (resources.length >= RESOURCE_EMIT_LIMIT) { + truncated = true + return false + } + resources.push(resource) + return true + } + for (const tool of tools.slice(0, RESOURCE_SCAN_LIMIT)) { if (!referencesEnvKey(tool.code ?? '', name)) continue - resources.push({ id: tool.id, kind: 'custom-tool', name: tool.title, field: 'code' }) + if (!emitResource({ id: tool.id, kind: 'custom-tool', name: tool.title, field: 'code' })) break } for (const server of servers.slice(0, RESOURCE_SCAN_LIMIT)) { + if (resources.length >= RESOURCE_EMIT_LIMIT) { + truncated = true + break + } if (server.url && referencesEnvKey(server.url, name)) { - resources.push({ id: server.id, kind: 'mcp-server', name: server.name, field: 'url' }) + emitResource({ id: server.id, kind: 'mcp-server', name: server.name, field: 'url' }) } const headers = (server.headers ?? {}) as Record for (const [headerName, headerValue] of Object.entries(headers)) { if (typeof headerValue !== 'string') continue if (!referencesEnvKey(headerValue, name)) continue - resources.push({ + const emitted = emitResource({ id: server.id, kind: 'mcp-server', name: server.name, field: `header: ${headerName}`, }) + if (!emitted) break } } From fda586f1073302b65a811622d4bf09e90c20c4ec Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 21 Aug 2026 13:53:50 -0700 Subject: [PATCH 03/10] fix(secrets): cover unicode whitespace, legacy keys, and the shadowed-personal tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 — all three follow from round 1's own fixes. - scan.ts: the syntax prefilter anchored on `[[:space:]]`, but the two engines disagree about what whitespace is. `ENV_REF_PATTERN`'s `\s` accepts U+00A0, U+202F and U+3000; Postgres `[[:space:]]` matches only the ASCII set. So a value pasted with a non-breaking space inside the braces is a reference the executor resolves and the prefilter silently dropped — the one failure direction this feature must never take, since the answer it gives is "unused, safe to delete". Anchoring on `[^[:alnum:]_]` instead accepts every whitespace encoding while still rejecting a longer key on either side, and needs no code-point list that could drift. It can admit a non-reference like `{{-NAME-}}`; that costs one candidate row, and the scanner re-checks every candidate regardless. Erring loose here is deliberate. (Greptile's `{{\tAPI_KEY\t}}` example was already handled — tab is ASCII — but the unicode half of the finding was real.) - use-cases.ts: the gate read `keyAccess.knownKeys` as "a workspace secret exists under this name", but that set only covers names with an `env_workspace` credential row. A legacy value written before the ACL existed has no row and still wins at run time, so it fell through to the personal branch and handed a non-admin the reference map for exactly the oldest keys. It now reads the authoritative `workspace_environment.variables` map through a new `hasWorkspaceEnvValue`, which is documented against `knownKeys` so the two are not confused again. `getWorkspaceEnvKeyAdminAccess` keeps its existing contract — its `knownKeys` still answers the ACL question its other callers ask. - secret-references-panel.tsx: a personal secret shadowed by a same-named workspace variable could open the view (its owner may read their own Logs) but References always hit the workspace refusal and rendered a generic load error — a tab offered in a state where it cannot succeed. The refusal is correct; the tab now states the shadowing instead of asking for a map it will be denied, reusing the wording the detail page already shows. No request is made in that state. Co-Authored-By: Claude Opus 5 (1M context) --- .../secret-references-panel.tsx | 28 +++++++++++++-- .../secrets/[credentialId]/secret-detail.tsx | 6 +++- apps/sim/lib/credentials/environment.ts | 34 ++++++++++++++++++- apps/sim/lib/secrets/application/use-cases.ts | 17 ++++++++-- apps/sim/lib/secrets/references/scan.test.ts | 16 +++++++-- apps/sim/lib/secrets/references/scan.ts | 26 +++++++++----- 6 files changed, 109 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx index 1f512463f3e..3f9ae9705aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx @@ -20,6 +20,14 @@ import { useSecretReferences } from '@/hooks/queries/credentials' interface SecretReferencesPanelProps { workspaceId: string secretName: string + /** + * This personal secret is overridden by a workspace variable of the same name. References are + * name-based, so every `{{name}}` in the workspace resolves to the workspace variable — whose + * reference map is admin-gated. The owner may still read their own Logs, which is why the view + * is offered at all, so this tab explains the shadowing instead of asking the API for a map it + * will refuse. + */ + shadowed: boolean } /** @@ -52,8 +60,24 @@ function resourceHref(workspaceId: string, resource: SecretReferenceResourcePayl * The companion to the Logs tab, and the half of the question runs cannot answer — a secret * four blocks depend on but nothing has executed yet has an empty trail and a full list here. */ -export function SecretReferencesPanel({ workspaceId, secretName }: SecretReferencesPanelProps) { - const { data, isPending, isError } = useSecretReferences({ workspaceId, name: secretName }) +export function SecretReferencesPanel({ + workspaceId, + secretName, + shadowed, +}: SecretReferencesPanelProps) { + const { data, isPending, isError } = useSecretReferences( + { workspaceId, name: secretName }, + !shadowed + ) + + if (shadowed) { + return ( + + Overridden by a workspace variable, so every reference to this name resolves to that + variable instead. + + ) + } if (isError) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx index 83cdef0bf3e..d50120bfe73 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx @@ -211,7 +211,11 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { aria-label='Secret usage views' /> {usageTab === 'references' ? ( - + ) : ( )} diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index c43e1c2ba55..d9a8f6ff919 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -1,5 +1,11 @@ import { db } from '@sim/db' -import { credential, credentialMember, permissions, workspace } from '@sim/db/schema' +import { + credential, + credentialMember, + permissions, + workspace, + workspaceEnvironment, +} from '@sim/db/schema' import { permissionSatisfies } from '@sim/platform-authz/workspace' import { chunkArray } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' @@ -169,6 +175,32 @@ export async function getPersonalEnvKeyRawAccess(params: { return { ownedKeys, adminKeys } } +/** + * Whether the workspace holds a value under this env key, read from the authoritative + * `workspace_environment.variables` map. + * + * Deliberately NOT {@link getWorkspaceEnvKeyAdminAccess}'s `knownKeys`, which answers the + * narrower "does an `env_workspace` credential row exist". A legacy value written before the + * credential ACL existed has no such row yet still wins at run time, so a gate that reads + * `knownKeys` as "there is no workspace secret here" would let a non-admin through on exactly + * the keys that predate the ACL. Callers deciding whether a NAME belongs to the workspace must + * ask this; callers deciding who may administer an ACL keep asking `knownKeys`. + */ +export async function hasWorkspaceEnvValue(params: { + workspaceId: string + envKey: string +}): Promise { + const [row] = await db + .select({ variables: workspaceEnvironment.variables }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, params.workspaceId)) + .limit(1) + + const variables = row?.variables + if (!variables || typeof variables !== 'object') return false + return Object.hasOwn(variables as Record, params.envKey) +} + /** * For a set of workspace env keys, resolves which the caller may administer * (active `credential_member` with role `admin`) and which already have an diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index a250c35e9e1..329501e4572 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -7,6 +7,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { getPersonalEnvCredentialMetadata, getWorkspaceEnvKeyAdminAccess, + hasWorkspaceEnvValue, } from '@/lib/credentials/environment' import { listVisibleWorkspaceCredentials, @@ -481,9 +482,19 @@ async function requireSecretReferencesReadAccess(params: { 'Credential admin permission required to view this secret usage' ) - // A workspace secret under this name is admin-gated outright — a personal secret the caller - // happens to hold under the same name does not unlock the workspace one's reference map. - if (keyAccess.knownKeys.has(params.name)) throw forbidden + /** + * A workspace value under this name is admin-gated outright — a personal secret the caller + * happens to hold under the same name does not unlock the workspace one's reference map, + * and at run time the workspace value is the one that wins anyway. + * + * Read from the authoritative variables map rather than `keyAccess.knownKeys`: that set only + * covers names with an `env_workspace` credential row, so a legacy value written before the + * ACL existed would look like "no workspace secret here" and fall through to the personal + * branch — handing a non-admin the map for exactly the oldest keys. + */ + if (await hasWorkspaceEnvValue({ workspaceId: params.workspaceId, envKey: params.name })) { + throw forbidden + } const owned = await getPersonalEnvCredentialMetadata({ userId: params.userId, diff --git a/apps/sim/lib/secrets/references/scan.test.ts b/apps/sim/lib/secrets/references/scan.test.ts index 1fa02fdacc9..87ea1b8efdc 100644 --- a/apps/sim/lib/secrets/references/scan.test.ts +++ b/apps/sim/lib/secrets/references/scan.test.ts @@ -158,14 +158,26 @@ describe('scanSecretReferences', () => { expect(scan.workflows[0]?.blocks[0]?.field).toBe('params') }) - it('tolerates whitespace inside the reference braces', async () => { + /** + * `ENV_REF_PATTERN`'s `\s` spans more than ASCII, so a value pasted with a non-breaking or + * ideographic space inside the braces is a reference the executor resolves. Reporting it as + * unreferenced is the one failure direction this feature must never take. + */ + it.each([ + ['ascii space', '{{ API_KEY }}'], + ['tab', '{{\tAPI_KEY\t}}'], + ['newline', '{{\nAPI_KEY\n}}'], + ['non-breaking space', '{{ API_KEY }}'], + ['narrow non-breaking space', '{{ API_KEY }}'], + ['ideographic space', '{{ API_KEY }}'], + ])('tolerates %s inside the reference braces', async (_label, value) => { queueTableRows(schemaMock.workflowBlocks, [ blockRow({ blockId: 'block-1', blockName: 'Call API', workflowId: 'workflow-1', workflowName: 'Nightly sync', - subBlocks: shortInput('apiKey', '{{ API_KEY }}'), + subBlocks: shortInput('apiKey', value), }), ]) diff --git a/apps/sim/lib/secrets/references/scan.ts b/apps/sim/lib/secrets/references/scan.ts index 29c2cde6a57..22ca69a8382 100644 --- a/apps/sim/lib/secrets/references/scan.ts +++ b/apps/sim/lib/secrets/references/scan.ts @@ -88,22 +88,30 @@ function referencesEnvKey(text: string, name: string): boolean { } /** - * Matches the reference syntax itself — `{{name}}`, with the optional inner whitespace - * `ENV_REF_PATTERN` allows — rather than the bare name. + * Matches the name sitting inside `{{ }}` with only non-word characters between, rather than the + * bare name. * * Deliberately not `LIKE '%name%'`: `_` is a LIKE single-character wildcard and nearly every env * key contains one, so `SB_ACTION_ROUTER_SECRET` would match text it does not occur in. And * deliberately not a bare `strpos` either: that matched the name in prose and as a prefix of a - * longer key (`API_KEY` inside `{{API_KEY_TEST}}`), and those false positives were counted - * against the row cap — so on a workspace with enough of them, genuine references sorted later - * were never read at all. Matching the syntax makes every candidate a real occurrence, which is - * what makes the cap mean what it says. + * longer key (`API_KEY` inside `{{API_KEY_TEST}}`), and those false positives counted against the + * row cap — so on a workspace with enough of them, genuine references sorted later were never + * read at all. * - * The scanners below still re-check each candidate and remain the authority; this only decides - * what is worth reading. + * `[^[:alnum:]_]` rather than `[[:space:]]` because the two engines disagree about what + * whitespace is: `ENV_REF_PATTERN`'s `\s` accepts U+00A0, U+202F, U+3000 and friends, while + * Postgres `[[:space:]]` matches only the ASCII set — so a pasted non-breaking space inside the + * braces is a reference the executor resolves and a whitespace-class prefilter would silently + * drop. Excluding word characters instead accepts every whitespace encoding while still + * rejecting a longer key on either side, and needs no code-point list that could drift. + * + * The looser class can admit a non-reference like `{{-NAME-}}`; that costs a candidate row and + * nothing else, because the scanners below re-check every candidate and remain the authority. + * Erring loose is deliberate — a false positive is a wasted read, a false negative is this + * feature telling someone a live key is unused. */ function referencesKey(column: unknown, envKey: string) { - return sql`${column} ~ ${`\\{\\{[[:space:]]*${envKey}[[:space:]]*\\}\\}`}` + return sql`${column} ~ ${`\\{\\{[^[:alnum:]_]*${envKey}[^[:alnum:]_]*\\}\\}`}` } /** From 1ebee8367aa02e07d060950841c214f3c8dd1f04 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 21 Aug 2026 14:01:52 -0700 Subject: [PATCH 04/10] fix(secrets): re-check the reference gate's volatile input after the scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 3. The name-resolution gate reads whether a workspace value exists, then scans. A workspace secret created between the two makes the map now in hand admin-gated, so a personal owner could receive it without workspace-secret administration. The window is small and the data is derivable — a workspace member can already open every workflow and read its `{{KEY}}` references — but the gate's stated contract is that references follow the same predicate as revealing the value, and a point-in-time check that can be overtaken does not honour that. An advisory lock or a snapshot transaction would serialize a read-only view against secret writes for it, which is the wrong trade. Instead the one volatile input is re-read after the scan and the request fails closed if it flipped. `requireSecretReferencesReadAccess` now reports which branch authorized: an `admin` grant holds however the name resolves and pays nothing, while a `personal` grant — the only one resting on absence — is re-checked. A non-admin loses nothing they were entitled to keep; the request is refused the way it would have been a moment later. Adds a `listSecretReferencesUseCase` suite covering both denial paths, the legacy value, the personal owner, the admin short-circuit, and the race itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/secrets/application/use-cases.test.ts | 110 ++++++++++++++++++ apps/sim/lib/secrets/application/use-cases.ts | 41 ++++++- 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/secrets/application/use-cases.test.ts b/apps/sim/lib/secrets/application/use-cases.test.ts index 6bdccfc65a9..f0c10ea4a2b 100644 --- a/apps/sim/lib/secrets/application/use-cases.test.ts +++ b/apps/sim/lib/secrets/application/use-cases.test.ts @@ -21,6 +21,8 @@ const { mocks } = vi.hoisted(() => ({ deletePersonal: vi.fn(), listCredentials: vi.fn(), secretUsage: vi.fn(), + workspaceEnvValue: vi.fn(), + scanReferences: vi.fn(), audit: vi.fn(), }, })) @@ -47,6 +49,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ vi.mock('@/lib/credentials/environment', () => ({ getWorkspaceEnvKeyAdminAccess: mocks.keyAccess, getPersonalEnvCredentialMetadata: mocks.personalMetadata, + hasWorkspaceEnvValue: mocks.workspaceEnvValue, +})) +vi.mock('@/lib/secrets/references/scan', () => ({ + scanSecretReferences: mocks.scanReferences, })) vi.mock('@/lib/credentials/queries', () => ({ listVisibleWorkspaceCredentials: mocks.listCredentials, @@ -63,6 +69,8 @@ vi.mock('@/lib/credentials/secret-values', () => ({ import { deleteSecretUseCase, + type ListSecretReferencesInput, + listSecretReferencesUseCase, listSecretUsageUseCase, setSecretUseCase, } from '@/lib/secrets/application/use-cases' @@ -399,3 +407,105 @@ describe('listSecretUsageUseCase', () => { expect(mocks.keyAccess).not.toHaveBeenCalled() }) }) + +describe('listSecretReferencesUseCase', () => { + const execute = listSecretReferencesUseCase.execute as (args: { + principal: Principal + input: ListSecretReferencesInput + }) => Promise + + const input: ListSecretReferencesInput = { + workspaceId: workspace.workspaceId, + name: 'STRIPE_API_KEY', + } + const scan = { workflows: [], resources: [], truncated: false } + + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.scanReferences.mockResolvedValue(scan) + }) + + /** + * The map names workflows, blocks, tools and servers. A Member who may use the secret but not + * read it has no claim on it, so this must fail the same way the usage trail does. + */ + it('denies a member who is not an admin of a workspace key', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() }) + mocks.workspaceEnvValue.mockResolvedValue(true) + + await expect(execute({ principal: session, input })).rejects.toThrow( + 'Credential admin permission required to view this secret usage' + ) + expect(mocks.scanReferences).not.toHaveBeenCalled() + }) + + /** + * The gate reads the authoritative variables map, not `knownKeys`. A legacy value predating + * the credential ACL has no `env_workspace` row yet still wins at run time, so treating an + * empty `knownKeys` as "no workspace secret" would hand a non-admin exactly the oldest keys. + */ + it('denies a personal owner when a legacy workspace value shares the name', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() }) + mocks.workspaceEnvValue.mockResolvedValue(true) + mocks.personalMetadata.mockResolvedValue({ id: 'cred-1' }) + + await expect(execute({ principal: session, input })).rejects.toThrow( + 'Credential admin permission required to view this secret usage' + ) + expect(mocks.scanReferences).not.toHaveBeenCalled() + }) + + it('denies a caller who holds no secret of that name', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() }) + mocks.workspaceEnvValue.mockResolvedValue(false) + mocks.personalMetadata.mockResolvedValue(null) + + await expect(execute({ principal: session, input })).rejects.toThrow( + 'Credential admin permission required to view this secret usage' + ) + expect(mocks.scanReferences).not.toHaveBeenCalled() + }) + + it('allows the owner of a personal secret that no workspace value shadows', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() }) + mocks.workspaceEnvValue.mockResolvedValue(false) + mocks.personalMetadata.mockResolvedValue({ id: 'cred-1' }) + + await expect(execute({ principal: session, input })).resolves.toEqual(scan) + }) + + it('allows a credential admin of that key', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ + knownKeys: new Set(['STRIPE_API_KEY']), + adminKeys: new Set(['STRIPE_API_KEY']), + }) + + await expect(execute({ principal: session, input })).resolves.toEqual(scan) + // An admin stays authorized however the name resolves, so the volatile input is never read. + expect(mocks.workspaceEnvValue).not.toHaveBeenCalled() + }) + + /** + * A `personal` grant rests on no workspace value existing under the name. If one is created + * between the check and the scan, the map now in hand is admin-gated — so the condition is + * re-read after the scan and the request fails closed rather than returning it. + */ + it('refuses when a workspace value appears between the check and the scan', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() }) + mocks.personalMetadata.mockResolvedValue({ id: 'cred-1' }) + mocks.workspaceEnvValue.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + + await expect(execute({ principal: session, input })).rejects.toThrow( + 'Credential admin permission required to view this secret usage' + ) + expect(mocks.workspaceEnvValue).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index 329501e4572..188aa60e664 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -462,11 +462,18 @@ export interface ListSecretReferencesInput { * - no workspace secret exists → the caller must actually hold a personal secret of that name, * which stops a member enumerating arbitrary names for a map they have no claim to. */ +/** + * Which branch authorized the read. `personal` depends on no workspace value existing under the + * name — a condition another request can change — so only that branch needs re-checking after + * the scan. An `admin` caller stays authorized however the name resolves, and pays nothing. + */ +type SecretReferencesGrant = 'admin' | 'personal' + async function requireSecretReferencesReadAccess(params: { workspaceId: string name: string userId: string -}): Promise { +}): Promise { const [workspaceAccess, keyAccess] = await Promise.all([ checkWorkspaceAccess(params.workspaceId, params.userId), getWorkspaceEnvKeyAdminAccess({ @@ -475,7 +482,7 @@ async function requireSecretReferencesReadAccess(params: { userId: params.userId, }), ]) - if (workspaceAccess.canAdmin || keyAccess.adminKeys.has(params.name)) return + if (workspaceAccess.canAdmin || keyAccess.adminKeys.has(params.name)) return 'admin' const forbidden = new ForbiddenOperationError( 'SECRET_ADMIN_ACCESS_REQUIRED', @@ -501,6 +508,7 @@ async function requireSecretReferencesReadAccess(params: { envKey: params.name, }) if (!owned) throw forbidden + return 'personal' } export const listSecretReferencesUseCase = defineAuthorizedWorkspaceUseCase({ @@ -509,10 +517,11 @@ export const listSecretReferencesUseCase = defineAuthorizedWorkspaceUseCase({ resolveWorkspaceContext(input.workspaceId), authorizationOptions, async execute({ principal, input, context }) { - await requireSecretReferencesReadAccess({ + const userId = principalUserId(principal) + const grant = await requireSecretReferencesReadAccess({ workspaceId: context.workspaceId, name: input.name, - userId: principalUserId(principal), + userId, }) /** @@ -520,6 +529,28 @@ export const listSecretReferencesUseCase = defineAuthorizedWorkspaceUseCase({ * for the personal one it shadows. Narrowing by scope would report a personal secret as * unreferenced the moment a workspace variable of the same name existed. */ - return scanSecretReferences({ workspaceId: context.workspaceId, name: input.name }) + const scan = await scanSecretReferences({ + workspaceId: context.workspaceId, + name: input.name, + }) + + /** + * Re-check the one input that can change under us. A `personal` grant rests on no workspace + * value existing under this name; a workspace secret created between the check and the scan + * would make the very map now in hand admin-gated. The read is cheap and skipped entirely + * for an `admin` grant, and failing closed here costs a non-admin nothing they were entitled + * to keep — the request is simply refused the way it would have been a moment later. + */ + if ( + grant === 'personal' && + (await hasWorkspaceEnvValue({ workspaceId: context.workspaceId, envKey: input.name })) + ) { + throw new ForbiddenOperationError( + 'SECRET_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required to view this secret usage' + ) + } + + return scan }, }) From 157339e12ab168ddd2fcc6606eec5d85658e530a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 21 Aug 2026 14:10:57 -0700 Subject: [PATCH 05/10] fix(secrets): accept JSON-escaped whitespace in the reference prefilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 4. The prefilter reads a `::text` rendering of a JSON column, and `jsonb::text` renders a real tab inside a string value as the literal pair `\` `t`. `t` is alphanumeric, so `[^[:alnum:]_]` could not consume it and the row was discarded before `ENV_REF_PATTERN` ever ran — the References tab omitting a live reference and reporting `truncated: false` while doing it. Round 3's fix was verified against a raw text value rather than the JSON rendering, which is exactly why it looked correct: `E'{{\tAPI_KEY\t}}'` matches, `jsonb_build_object('v', E'{{\tAPI_KEY\t}}')::text` does not. The gap between `{{` and the name now accepts three encodings at once — raw characters (covering every Unicode space, which Postgres `[[:space:]]` misses), JSON two-character escapes, and `\uXXXX` (how a vertical tab survives the same rendering). Verified against the real jsonb rendering: tab, newline, carriage return, vertical tab and form feed all recover, U+00A0 / U+3000 / space / plain keep matching, and `{{API_KEY_TEST}}`, `{{MY_API_KEY}}` and prose are still rejected — so the row cap keeps meaning what it says. Plain-text columns (`custom_tools.code`, `mcp_servers.url`) carry no JSON escaping, but tool code is JavaScript source and can contain the same escape sequences literally, so the one predicate is right for every column. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/secrets/references/scan.ts | 36 +++++++++++++++---------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/secrets/references/scan.ts b/apps/sim/lib/secrets/references/scan.ts index 22ca69a8382..6941fed3290 100644 --- a/apps/sim/lib/secrets/references/scan.ts +++ b/apps/sim/lib/secrets/references/scan.ts @@ -88,8 +88,23 @@ function referencesEnvKey(text: string, name: string): boolean { } /** - * Matches the name sitting inside `{{ }}` with only non-word characters between, rather than the - * bare name. + * One unit of what may sit between `{{` and the name. + * + * Three encodings have to be accepted at once, because the prefilter reads a `::text` rendering + * of a JSON column and the two regex engines disagree about whitespace: + * - raw characters, including every Unicode space (`ENV_REF_PATTERN`'s `\s` accepts U+00A0, + * U+202F and U+3000, which Postgres `[[:space:]]` does not) — covered by `[^[:alnum:]_]`; + * - JSON two-character escapes, since `jsonb::text` renders a real tab as the literal pair + * `\` `t` and `t` is alphanumeric — covered by `\\[a-z]`; + * - JSON `\uXXXX` escapes, which is how a vertical tab survives the same rendering. + * + * Excluding word characters rather than enumerating whitespace means no code-point list to drift + * as `\s` evolves, while a longer key on either side (`_TEST`, `MY_`) still cannot be consumed. + */ +const REFERENCE_GAP = String.raw`(\\u[0-9a-fA-F]{4}|\\[a-z]|[^[:alnum:]_])` + +/** + * Matches the name sitting inside `{{ }}` with only {@link REFERENCE_GAP} units between. * * Deliberately not `LIKE '%name%'`: `_` is a LIKE single-character wildcard and nearly every env * key contains one, so `SB_ACTION_ROUTER_SECRET` would match text it does not occur in. And @@ -98,20 +113,13 @@ function referencesEnvKey(text: string, name: string): boolean { * row cap — so on a workspace with enough of them, genuine references sorted later were never * read at all. * - * `[^[:alnum:]_]` rather than `[[:space:]]` because the two engines disagree about what - * whitespace is: `ENV_REF_PATTERN`'s `\s` accepts U+00A0, U+202F, U+3000 and friends, while - * Postgres `[[:space:]]` matches only the ASCII set — so a pasted non-breaking space inside the - * braces is a reference the executor resolves and a whitespace-class prefilter would silently - * drop. Excluding word characters instead accepts every whitespace encoding while still - * rejecting a longer key on either side, and needs no code-point list that could drift. - * - * The looser class can admit a non-reference like `{{-NAME-}}`; that costs a candidate row and - * nothing else, because the scanners below re-check every candidate and remain the authority. - * Erring loose is deliberate — a false positive is a wasted read, a false negative is this - * feature telling someone a live key is unused. + * The gap can admit a non-reference like `{{-NAME-}}`; that costs a candidate row and nothing + * else, because the scanners below re-check every candidate with `ENV_REF_PATTERN` and remain the + * authority. Erring loose is deliberate — a false positive is a wasted read, a false negative is + * this feature telling someone a live key is unused. */ function referencesKey(column: unknown, envKey: string) { - return sql`${column} ~ ${`\\{\\{[^[:alnum:]_]*${envKey}[^[:alnum:]_]*\\}\\}`}` + return sql`${column} ~ ${`\\{\\{${REFERENCE_GAP}*${envKey}${REFERENCE_GAP}*\\}\\}`}` } /** From 619d3f16570a18f97621d3f1eca7d2c447cd7406 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 21 Aug 2026 14:57:33 -0700 Subject: [PATCH 06/10] feat(secrets): land the References link on the block, and name its field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback round. - Logs leads the tab strip. It was already the default tab; the order now says so. - The usage view drops its resource heading for a plain "Usage" title. The back chip already names the secret, so the tile and the subtitle underneath were saying it a second time. `CredentialDetailLayout` gains an optional `title` that renders the same element, class and column position the settings shell gives `SettingsPanel` — which is how the sibling Forks "Activity" view titles itself. Existing callers pass nothing and are unchanged. - A block row now lands on the block instead of the workflow's default framing. The editor had no URL params at all, so `?block=` is its first: read once on arrival, acted on, and stripped. It is a navigation signal rather than canvas state — the carve-out in sim-url-state.md is about pan, zoom, selection and drag, which are socket-synced or high-frequency; this is neither, and it rides in the link so a middle-click or reload keeps it where an in-memory handoff could not. The consuming effect mirrors the note-search reveal in the same file, including the three details that make that one work: read from `displayNodes` so a target arriving before its node mounts is retried on the mounting commit, route selection through `resolveSelectionConflicts`, and latch in a ref. It also claims `userFocusedWorkflowIdRef` the way a node click does, because `onInit` re-reads that inside its own rAF and would otherwise `fitView` over the camera — and that ref is reset by exactly the `workflowIdParam` change a deep link causes. The panel opens for free: `syncPanelWithSelection` already follows selection. `useSearchParams` needs a Suspense boundary and the editor's ancestry has none, so the read lives in a leaf under its own `fallback={null}` rather than wrapping the editor and adding a `loading.tsx` to its mount path. `next build` passes. - A tool-input reference showed `tools-tool-0-code`. Those `{subBlockId}-tool-{index}-{paramId}` keys are documented as an ephemeral, client-only projection of the canonical `tool-input` value and are not meant to be persisted, but older rows carry them — so the scanner reported whichever the record yielded last. They are dropped before scanning, which is right even where the two disagree: `tool.params` is what executes, so a mirror the canonical no longer matches describes a reference that no longer runs. - The row now shows the field's label from the block config rather than its storage id — "API Key", "Tools", "Code", "Bot Token" — falling back to the id when the block or field is unregistered. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/credential-detail-layout.tsx | 20 ++++++- .../secret-references-panel.tsx | 28 ++++++++-- .../secrets/[credentialId]/secret-detail.tsx | 21 +++----- .../focus-block-deep-link.tsx | 54 +++++++++++++++++++ .../components/focus-block-deep-link/index.ts | 1 + .../w/[workflowId]/search-params.ts | 19 +++++++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 49 +++++++++++++++++ apps/sim/lib/secrets/references/scan.test.ts | 24 +++++++++ apps/sim/lib/secrets/references/scan.ts | 41 +++++++++++--- 9 files changed, 231 insertions(+), 26 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/focus-block-deep-link.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/search-params.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx index 8efb140820d..b19f6aaea86 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx @@ -7,6 +7,13 @@ interface CredentialDetailLayoutProps { back: ReactNode /** Optional controls grouped at the end of the action bar. */ actions?: ReactNode + /** + * Page title, for a view whose subject is the view itself rather than a resource — the same + * slot `SettingsPanel` fills for a detail sub-view like the Forks "Activity" page. A surface + * that leads with a resource uses {@link CredentialDetailHeading} instead; the two are + * alternatives, not a pair. + */ + title?: ReactNode children: ReactNode } @@ -16,7 +23,12 @@ interface CredentialDetailLayoutProps { * supply the slots and body sections; all layout chrome lives here so callsites * stay free of bespoke styling. */ -export function CredentialDetailLayout({ back, actions, children }: CredentialDetailLayoutProps) { +export function CredentialDetailLayout({ + back, + actions, + title, + children, +}: CredentialDetailLayoutProps) { return (
@@ -24,7 +36,11 @@ export function CredentialDetailLayout({ back, actions, children }: CredentialDe {actions ?
{actions}
: null}
-
{children}
+
+ {/* Same element, class and column position the settings shell gives its page title. */} + {title ?

{title}

: null} + {children} +
) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx index 3f9ae9705aa..7f602da7e00 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx @@ -14,6 +14,7 @@ import { RESOURCE_LIST_STACK, SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { focusBlockParam } from '@/app/workspace/[workspaceId]/w/[workflowId]/search-params' import { getBlock } from '@/blocks/registry' import { useSecretReferences } from '@/hooks/queries/credentials' @@ -42,6 +43,27 @@ function resourceKey(resource: SecretReferenceResourcePayload): string { return `${resource.kind}:${resource.id}:${resource.field}` } +/** + * The field's own label from the block's config — "Tools", "API Key" — rather than the storage + * id the scanner reports. The id is how the value is keyed, not what the block calls it, and a + * reader looking for the field on the canvas is looking for the label. + * + * Falls back to the raw id when the block or field is unregistered, which is honest: an id the + * config cannot name is still better than naming nothing. + */ +function fieldLabel(blockType: string, field: string): string { + return getBlock(blockType)?.subBlocks?.find((subBlock) => subBlock.id === field)?.title ?? field +} + +/** + * The workflow, pointed at the block that carries the reference, so the canvas lands on it + * rather than on its default framing. The target rides in the link itself so it survives a + * middle-click or a reload, which an in-memory handoff could not. + */ +function blockHref(workspaceId: string, workflowId: string, blockId: string): string { + return `/workspace/${workspaceId}/w/${workflowId}?${focusBlockParam.key}=${encodeURIComponent(blockId)}` +} + /** * The settings page that owns the resource, deep-linked to its detail through the same param * that page reads — so a cascade row navigates like a block row instead of dead-ending. @@ -123,9 +145,9 @@ export function SecretReferencesPanel({ ) : undefined } title={block.blockName} - description={block.field} - href={`/workspace/${workspaceId}/w/${workflow.workflowId}`} - clickLabel={`Open ${workflow.workflowName}`} + description={fieldLabel(block.blockType, block.field)} + href={blockHref(workspaceId, workflow.workflowId, block.blockId)} + clickLabel={`Open ${block.blockName} in ${workflow.workflowName}`} navigable /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx index d50120bfe73..73eac2f7c7b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx @@ -37,14 +37,10 @@ interface SecretDetailProps { type SecretUsageTab = 'references' | 'logs' -/** - * References first: it answers where the key is wired in, which is what a rotation starts from, - * and it has an answer even for a secret nothing has run yet. Logs still opens by default, since - * that is what "See usage" showed before this tab existed. - */ +/** Logs leads: it is the default tab, and what "See usage" opened before References existed. */ const SECRET_USAGE_TABS = [ - { value: 'references', label: 'References' }, { value: 'logs', label: 'Logs' }, + { value: 'references', label: 'References' }, ] as const export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { @@ -178,9 +174,10 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { /** * Usage is a destination reached from the header, the same shape as the Forks tab's * "See activity" — it replaces the secret rather than expanding inside it, so the two - * readings never compete for the same column. Back returns with `replace`, since opening - * already pushed, and clears the tab in the same batched write so no `?usage-tab=` lingers - * on the secret's own URL. + * readings never compete for the same column. It leads with a plain page title like that + * view does: the back chip already names the secret, so a resource heading would say it + * twice. Back returns with `replace`, since opening already pushed, and clears the tab in + * the same batched write so no `?usage-tab=` lingers on the secret's own URL. */ if (canViewUsage && view === 'usage') { const secretName = credential.envKey || '' @@ -198,12 +195,8 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { {credential.envKey || credential.displayName} } + title='Usage' > - } - title='Usage' - subtitle={credential.envKey || credential.displayName} - /> void +} + +/** + * Reads the inbound `?block=` target and hands it to the canvas exactly once. + * + * Exists as its own component for one structural reason: `useQueryState` reads + * `useSearchParams`, which Next requires under a Suspense boundary. Confining that read here + * lets the editor keep its current mount path — no boundary around `Workflow` itself, no + * `loading.tsx` — and an inner boundary with `fallback={null}` is the sanctioned shape for a + * suspending leaf that renders nothing. + * + * Read-then-strip, behind a ref latch: the target is an instruction, not view-state, so once the + * canvas has it the param is cleared with `replace` so it neither lingers on the URL nor + * re-fires when the reader re-renders. Stripping is also what lets a second visit to the same + * block re-assert the camera rather than being swallowed as "already applied". + */ +export function FocusBlockDeepLink({ onTarget }: FocusBlockDeepLinkProps) { + const [blockId, setBlockId] = useQueryState(focusBlockParam.key, focusBlockParam.parser) + + /* Ref, so consuming the target does not depend on the caller memoizing `onTarget`. */ + const onTargetRef = useRef(onTarget) + useEffect(() => { + onTargetRef.current = onTarget + }, [onTarget]) + + /** + * Latched while a target is present and released when the param clears — the same shape the + * canvas's note reveal uses. A latch that only ever set would swallow the second visit to a + * block, since stripping returns the param to null and arriving again re-supplies the very + * same id. + */ + const appliedRef = useRef(false) + useEffect(() => { + if (!blockId) { + appliedRef.current = false + return + } + if (appliedRef.current) return + appliedRef.current = true + onTargetRef.current(blockId) + void setBlockId(null, { history: 'replace', scroll: false }) + }, [blockId, setBlockId]) + + return null +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/index.ts new file mode 100644 index 00000000000..d7e2577acc3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/index.ts @@ -0,0 +1 @@ +export { FocusBlockDeepLink } from './focus-block-deep-link' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/search-params.ts new file mode 100644 index 00000000000..15a2ca55ce7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/search-params.ts @@ -0,0 +1,19 @@ +import { parseAsString } from 'nuqs/server' + +/** + * `block` points an inbound link at one block, so a surface that knows where something lives — + * the secret References tab, naming the block that carries a `{{KEY}}` — can land the reader on + * it instead of on the workflow's default framing. + * + * The lone param on the editor route, and deliberately so: `.claude/rules/sim-url-state.md` keeps + * the canvas's own view-state (pan, zoom, selection, drag) in Zustand because it is + * socket-synced, high-frequency, or a persisted preference. This is none of those. It is a + * read-once navigation signal, consumed on arrival and stripped, in the same family as + * integrations' `?connect=` — not canvas state that lives in the URL. + * + * Nullable with no default: absent means "no target", which is the overwhelmingly common case. + */ +export const focusBlockParam = { + key: 'block', + parser: parseAsString, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index aa839056345..2dbb7652ec9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -66,6 +66,7 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector' import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors' import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' +import { FocusBlockDeepLink } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link' import { WorkflowSearchReplace } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace' import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls' import { @@ -4675,6 +4676,50 @@ const WorkflowContent = React.memo( focusBlockInView(node) }, [blocks, displayNodes, embedded, focusBlockInView, searchMatchBlockId, searchMatchId]) + /** + * Inbound `?block=` target, held until the canvas can act on it. State rather than a ref + * because the effect below has to re-run on the commit that finally mounts the node. + */ + const [deepLinkBlockId, setDeepLinkBlockId] = useState(null) + + useEffect(() => { + if (embedded || !deepLinkBlockId) return + + /* Same reason as the note reveal above: read from `displayNodes` so a target that lands + before its node mounts is retried on the mounting commit instead of dropped. A block id + that never mounts — deleted since the link was made — simply leaves the workflow at its + default framing, which is what the link did before it carried a target. */ + const node = displayNodes.find((candidate) => candidate.id === deepLinkBlockId) + if (!node) return + + setDeepLinkBlockId(null) + + /* Claim the framing before the canvas can re-init over it. `onInit` re-reads this ref + inside its own `requestAnimationFrame`, so setting it here suppresses the initial + `fitView` whenever this effect wins the race — and is harmless when it does not, since + `focusBlockInView` animates from wherever the fit left the camera. */ + userFocusedWorkflowIdRef.current = activeWorkflowId ?? workflowIdParam + + setDisplayNodes((currentNodes) => + resolveSelectionConflicts( + currentNodes.map((currentNode) => ({ + ...currentNode, + selected: currentNode.id === node.id, + })), + blocks + ) + ) + focusBlockInView(node) + }, [ + activeWorkflowId, + blocks, + deepLinkBlockId, + displayNodes, + embedded, + focusBlockInView, + workflowIdParam, + ]) + /** Handles edge selection with container context tracking and Shift-click multi-selection. */ const onEdgeClick = useCallback( (event: React.MouseEvent, edge: any) => { @@ -5141,6 +5186,10 @@ const WorkflowContent = React.memo( {!embedded && ( <> + {/* Renders nothing; the boundary is what `useSearchParams` needs. */} + + + diff --git a/apps/sim/lib/secrets/references/scan.test.ts b/apps/sim/lib/secrets/references/scan.test.ts index 87ea1b8efdc..e4134bc5d26 100644 --- a/apps/sim/lib/secrets/references/scan.test.ts +++ b/apps/sim/lib/secrets/references/scan.test.ts @@ -142,6 +142,30 @@ describe('scanSecretReferences', () => { expect(['apiKey', 'headers']).toContain(blocks[0]?.field) }) + /** + * `{subBlockId}-tool-{index}-{paramId}` keys are a client-only projection of the canonical + * `tool-input` value that older rows persisted anyway. Reporting one puts an internal key + * where the reader expects a field name, so the canonical sub-block has to win. + */ + it('reports the canonical field rather than a persisted tool mirror', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Agent 1', + workflowId: 'workflow-1', + workflowName: 'Exa Tool Demo', + subBlocks: { + ...shortInput('tools', [{ params: { code: 'const k = "{{API_KEY}}"' } }]), + ...shortInput('tools-tool-0-code', 'const k = "{{API_KEY}}"'), + }, + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows[0]?.blocks[0]?.field).toBe('tools') + }) + it('finds a reference nested inside a sub-block value', async () => { queueTableRows(schemaMock.workflowBlocks, [ blockRow({ diff --git a/apps/sim/lib/secrets/references/scan.ts b/apps/sim/lib/secrets/references/scan.ts index 6941fed3290..2fa657c8cb4 100644 --- a/apps/sim/lib/secrets/references/scan.ts +++ b/apps/sim/lib/secrets/references/scan.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, asc, eq, isNull, sql } from 'drizzle-orm' import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids' import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' +import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks' import { ENV_REF_PATTERN, remapSubBlocks } from '@/ee/workspace-forking/lib/remap/remap-references' const logger = createLogger('SecretReferenceScan') @@ -35,6 +36,28 @@ const RESOURCE_EMIT_LIMIT = 400 */ const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ +/** + * Drops the `{subBlockId}-tool-{index}-{paramId}` mirrors a tool row renders with. + * + * Those ids are documented as an ephemeral, client-only projection of the value held canonically + * at `tool.params[paramId]` inside the aggregate `tool-input` sub-block — they are not supposed + * to be persisted at all, but rows predating that rule still carry them. Left in, the scanner + * reports whichever the record happens to yield last, which surfaced an internal key like + * `tools-tool-0-code` where the reader expects a field. + * + * Removing them is right even when the two disagree: the canonical `tool.params` is what + * executes, so a mirror the canonical no longer matches describes a reference that no longer + * runs. + */ +function withoutToolMirrors(subBlocks: SubBlockRecord): SubBlockRecord { + const canonical: SubBlockRecord = {} + for (const [key, value] of Object.entries(subBlocks)) { + if (isSyntheticToolSubBlockId(key)) continue + canonical[key] = value + } + return canonical +} + export interface SecretReferenceBlock { blockId: string blockName: string @@ -203,13 +226,17 @@ export async function scanSecretReferences({ for (const row of blocks.slice(0, BLOCK_SCAN_LIMIT)) { let field: string | undefined try { - const { references } = remapSubBlocks(row.subBlocks as SubBlockRecord, () => null, { - blockId: row.blockId, - blockName: row.blockName, - blockType: row.blockType, - canonicalModes: (row.data as { canonicalModes?: CanonicalModeOverrides } | null) - ?.canonicalModes, - }) + const { references } = remapSubBlocks( + withoutToolMirrors(row.subBlocks as SubBlockRecord), + () => null, + { + blockId: row.blockId, + blockName: row.blockName, + blockType: row.blockType, + canonicalModes: (row.data as { canonicalModes?: CanonicalModeOverrides } | null) + ?.canonicalModes, + } + ) field = references.find( (reference) => reference.kind === 'env-var' && reference.sourceId === name )?.subBlockKey From bb347cf8aa6971b6649c2a4dae5b889ab11124ca Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 21 Aug 2026 15:08:59 -0700 Subject: [PATCH 07/10] fix(secrets): make the reference prefilter exactly as tight as the authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 6. The gap between `{{` and the name accepted any non-word character, so `{{-API_KEY-}}` and `{{"API_KEY"}}` matched in SQL while `ENV_REF_PATTERN` rejects them. The previous commit called that free — "costs a candidate row and nothing else" — which was wrong: a candidate row is a slot under BLOCK_SCAN_LIMIT, so enough near-misses sorted earlier exhaust the cap before a genuine reference is read, and the tab reports a live key as unused. That is the same failure the tightening in round 1 was meant to remove, reintroduced by the round 4 loosening that fixed JSON-escaped whitespace. The gap now enumerates exactly the whitespace `\s` accepts, in each encoding it can arrive in: `[[:space:]]` for raw ASCII, `\\[tnrf]` and `\\u000[bB]` for the JSON escapes, and an explicit class for the Unicode spaces Postgres emits verbatim but `[[:space:]]` does not match. That class is generated from a code-point table rather than written literally. Writing it by hand put a run of invisible characters in the source — a reviewer cannot check them, and a formatter or editor can silently mangle them. The table is the readable form and `toPgEscape` renders it. Verified against the real jsonb rendering, 17 cases: raw space, tab, newline, carriage return, vertical tab, form feed, U+00A0, U+202F, U+3000 and an embedded reference all match; `{{-API_KEY-}}`, `{{"API_KEY"}}`, `{{API_KEY_TEST}}`, `{{MY_API_KEY}}`, prose and an across-braces span all do not. Every candidate the SQL admits is now a real occurrence, so the cap counts references and nothing else. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/secrets/references/scan.ts | 56 ++++++++++++++++++------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/apps/sim/lib/secrets/references/scan.ts b/apps/sim/lib/secrets/references/scan.ts index 2fa657c8cb4..950b1d8f2ac 100644 --- a/apps/sim/lib/secrets/references/scan.ts +++ b/apps/sim/lib/secrets/references/scan.ts @@ -111,20 +111,47 @@ function referencesEnvKey(text: string, name: string): boolean { } /** - * One unit of what may sit between `{{` and the name. + * Code points JS `\s` matches beyond ASCII, as inclusive ranges. Spelled out as numbers and + * rendered to `\uXXXX` below rather than written literally, so the source stays readable ASCII + * instead of carrying a run of invisible characters no reviewer could check. + */ +const UNICODE_SPACE_RANGES: ReadonlyArray = [ + [0x00a0, 0x00a0], + [0x1680, 0x1680], + [0x2000, 0x200a], + [0x2028, 0x2029], + [0x202f, 0x202f], + [0x205f, 0x205f], + [0x3000, 0x3000], + [0xfeff, 0xfeff], +] + +/** A code point as the `\uXXXX` escape a Postgres regex understands. */ +function toPgEscape(codePoint: number): string { + return `\\u${codePoint.toString(16).padStart(4, '0')}` +} + +const UNICODE_SPACE_CLASS = UNICODE_SPACE_RANGES.map(([low, high]) => + low === high ? toPgEscape(low) : `${toPgEscape(low)}-${toPgEscape(high)}` +).join('') + +/** + * One unit of what may sit between `{{` and the name: exactly the whitespace + * `ENV_REF_PATTERN` accepts, in each encoding it can arrive in. * - * Three encodings have to be accepted at once, because the prefilter reads a `::text` rendering - * of a JSON column and the two regex engines disagree about whitespace: - * - raw characters, including every Unicode space (`ENV_REF_PATTERN`'s `\s` accepts U+00A0, - * U+202F and U+3000, which Postgres `[[:space:]]` does not) — covered by `[^[:alnum:]_]`; - * - JSON two-character escapes, since `jsonb::text` renders a real tab as the literal pair - * `\` `t` and `t` is alphanumeric — covered by `\\[a-z]`; - * - JSON `\uXXXX` escapes, which is how a vertical tab survives the same rendering. + * The prefilter reads a `::text` rendering of a JSON column, and the two regex engines + * disagree about whitespace, so all three forms are spelled out: `[[:space:]]` for raw ASCII; + * the JSON escapes, since `jsonb::text` renders a real tab as the literal pair `\\` `t` and a + * vertical tab as `\\u000b`; and the Unicode class above, which Postgres emits verbatim and + * `[[:space:]]` does not match though JS `\\s` does. * - * Excluding word characters rather than enumerating whitespace means no code-point list to drift - * as `\s` evolves, while a longer key on either side (`_TEST`, `MY_`) still cannot be consumed. + * Enumerating whitespace rather than excluding word characters costs a list to keep in step + * with `\\s`, and buys a prefilter exactly as tight as the authority. A looser gap admitted + * `{{-NAME-}}` and `{{"NAME"}}`, and those are not free: each occupies a row under + * {@link BLOCK_SCAN_LIMIT}, so a workspace with enough of them sorted earlier would exhaust + * the cap before a genuine reference was read — reporting a live key as unused. */ -const REFERENCE_GAP = String.raw`(\\u[0-9a-fA-F]{4}|\\[a-z]|[^[:alnum:]_])` +const REFERENCE_GAP = `([[:space:]]|\\\\[tnrf]|\\\\u000[bB]|[${UNICODE_SPACE_CLASS}])` /** * Matches the name sitting inside `{{ }}` with only {@link REFERENCE_GAP} units between. @@ -136,10 +163,9 @@ const REFERENCE_GAP = String.raw`(\\u[0-9a-fA-F]{4}|\\[a-z]|[^[:alnum:]_])` * row cap — so on a workspace with enough of them, genuine references sorted later were never * read at all. * - * The gap can admit a non-reference like `{{-NAME-}}`; that costs a candidate row and nothing - * else, because the scanners below re-check every candidate with `ENV_REF_PATTERN` and remain the - * authority. Erring loose is deliberate — a false positive is a wasted read, a false negative is - * this feature telling someone a live key is unused. + * The gap accepts exactly the whitespace `ENV_REF_PATTERN` does, so a candidate row is always a + * real occurrence and the cap counts only references. The scanners below still re-check each one + * and remain the authority; this decides what is worth reading. */ function referencesKey(column: unknown, envKey: string) { return sql`${column} ~ ${`\\{\\{${REFERENCE_GAP}*${envKey}${REFERENCE_GAP}*\\}\\}`}` From 39c51673053e20fdf49a47554bdd24c6f23acdee Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 21 Aug 2026 15:30:40 -0700 Subject: [PATCH 08/10] fix(secrets): cap the reference scan on results, not candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 7. The prefilter now matches reference syntax exactly, but `remapSubBlocks` filters further on semantics SQL cannot see: it drops dormant canonical members and condition-hidden fields. So a block whose only `{{KEY}}` sits in a hidden field is a genuine candidate that yields nothing, and with the cap counting candidates, enough of those sorted earlier displaced active references out of the answer. Unlike the previous two rounds this is not fixable by tightening the prefilter — no SQL predicate can evaluate canonical modes or field conditions. So the cap moves to what it should have counted all along: blocks REPORTED. Candidates are read a page at a time up to a ceiling far above the result limit, so filtered rows are absorbed as extra reads instead of taking result slots. Paging rather than one large read because the alternative is holding every candidate block's `sub_blocks` in memory at once; peak memory is now one page. `blockId` joins the ordering as a final tiebreak, since OFFSET paging over a non-unique sort can repeat or skip rows across pages — which here would double-report a block or silently lose one. This does not make the scan unconditionally complete, and the ceiling says so: bounded work and guaranteed completeness cannot both hold, so the only real choice is where the bound sits and whether it counts something the reader can see. It now counts results. Query plan re-checked with the OFFSET in place: still an index scan on workflow by workspace, nested-looped into workflow_blocks. Test added that pins the fix — 2,500 prose candidates sorted ahead of one real reference, which the previous cap dropped entirely. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/secrets/references/scan.test.ts | 30 +++++++ apps/sim/lib/secrets/references/scan.ts | 90 ++++++++++++++++---- 2 files changed, 104 insertions(+), 16 deletions(-) diff --git a/apps/sim/lib/secrets/references/scan.test.ts b/apps/sim/lib/secrets/references/scan.test.ts index e4134bc5d26..1df38450b6d 100644 --- a/apps/sim/lib/secrets/references/scan.test.ts +++ b/apps/sim/lib/secrets/references/scan.test.ts @@ -280,6 +280,36 @@ describe('scanSecretReferences', () => { expect(scan.workflows[0]?.blocks).toHaveLength(2000) }) + /** + * The cap counts what is reported, not what is read. The prefilter judges syntax only, so a + * block naming the secret in prose is a genuine candidate that the scanner then rejects — and + * when the cap counted candidates, enough of those sorted earlier pushed real references out of + * the answer entirely. + */ + it('does not let filtered candidates displace real references', async () => { + const noise = Array.from({ length: 2500 }, (_, index) => + blockRow({ + blockId: `noise-${index}`, + blockName: `Noise ${index}`, + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('systemPrompt', 'the API_KEY is configured elsewhere'), + }) + ) + const real = blockRow({ + blockId: 'block-real', + blockName: 'Fetch orders', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY}}'), + }) + queueTableRows(schemaMock.workflowBlocks, [...noise, real]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows[0]?.blocks.map((block) => block.blockId)).toEqual(['block-real']) + }) + it('returns nothing for a secret referenced nowhere', async () => { const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) diff --git a/apps/sim/lib/secrets/references/scan.ts b/apps/sim/lib/secrets/references/scan.ts index 950b1d8f2ac..1476f7eda47 100644 --- a/apps/sim/lib/secrets/references/scan.ts +++ b/apps/sim/lib/secrets/references/scan.ts @@ -10,13 +10,27 @@ import { ENV_REF_PATTERN, remapSubBlocks } from '@/ee/workspace-forking/lib/rema const logger = createLogger('SecretReferenceScan') /** - * Cap on candidate blocks read in one scan. The prefilter matches the reference syntax itself, - * so a candidate is already a genuine `{{name}}` occurrence and reaching this cap means the - * workspace really does wire the key into thousands of blocks — at which point a complete list - * is not the useful answer anyway. Reported back as {@link SecretReferenceScan.truncated} - * rather than silently dropped. + * Cap on blocks REPORTED — confirmed references, not rows read. + * + * Capping candidates instead made the answer depend on how many irrelevant rows happened to sort + * first. The prefilter can only judge syntax, while `remapSubBlocks` additionally drops dormant + * canonical members and condition-hidden fields — semantics no SQL predicate can replicate — so a + * block whose only `{{name}}` sits in a hidden field is a real candidate that yields nothing, and + * enough of them sorted earlier displaced active references out of the result. + */ +const BLOCK_RESULT_LIMIT = 2000 + +/** Candidate rows held in memory at once. One page, not the whole candidate set. */ +const BLOCK_CANDIDATE_PAGE = 200 + +/** + * Ceiling on candidate rows read across all pages, so a scan cannot walk an unbounded set. + * + * This is the irreducible bound: completeness and bounded work cannot both hold, so the choice is + * where the bound sits. It sits well above {@link BLOCK_RESULT_LIMIT} precisely so filtered rows + * are absorbed as extra reads rather than displacing results. */ -const BLOCK_SCAN_LIMIT = 2000 +const BLOCK_CANDIDATE_CEILING = 10_000 /** Matching cap for each cascade table, which are far smaller than the block table. */ const RESOURCE_SCAN_LIMIT = 200 @@ -192,7 +206,12 @@ export async function scanSecretReferences({ // A name outside the env-key charset cannot appear inside `{{ }}`, so nothing can reference it. if (!ENV_KEY_PATTERN.test(name)) return { workflows: [], resources: [], truncated: false } - const [blocks, tools, servers] = await Promise.all([ + /** + * One page of candidates. `blockId` is in the ordering as a final tiebreak: OFFSET paging over + * a non-unique sort can repeat or skip rows between pages, which here would double-report a + * block or silently lose one. + */ + const readCandidatePage = (offset: number) => db .select({ blockId: workflowBlocks.id, @@ -212,8 +231,16 @@ export async function scanSecretReferences({ referencesKey(sql`${workflowBlocks.subBlocks}::text`, name) ) ) - .orderBy(asc(workflow.name), asc(workflow.id), asc(workflowBlocks.name)) - .limit(BLOCK_SCAN_LIMIT + 1), + .orderBy( + asc(workflow.name), + asc(workflow.id), + asc(workflowBlocks.name), + asc(workflowBlocks.id) + ) + .limit(BLOCK_CANDIDATE_PAGE) + .offset(offset) + + const [tools, servers] = await Promise.all([ db .select({ id: customTools.id, title: customTools.title, code: customTools.code }) .from(customTools) @@ -241,15 +268,45 @@ export async function scanSecretReferences({ .limit(RESOURCE_SCAN_LIMIT + 1), ]) - let truncated = - blocks.length > BLOCK_SCAN_LIMIT || - tools.length > RESOURCE_SCAN_LIMIT || - servers.length > RESOURCE_SCAN_LIMIT + let truncated = tools.length > RESOURCE_SCAN_LIMIT || servers.length > RESOURCE_SCAN_LIMIT const workflows: SecretReferenceWorkflow[] = [] const workflowIndex = new Map() + let reported = 0 + let candidatesRead = 0 + + /** + * Pages the candidates rather than reading them all: the ceiling is high enough that filtered + * rows do not displace results, which would be far too many block records to hold at once. + */ + while (reported < BLOCK_RESULT_LIMIT && candidatesRead < BLOCK_CANDIDATE_CEILING) { + const page = await readCandidatePage(candidatesRead) + if (page.length === 0) break + candidatesRead += page.length + + for (const row of page) { + if (reported >= BLOCK_RESULT_LIMIT) break + scanCandidate(row) + } + + // A short page is the end of the candidate set; anything else means more remain. + if (page.length < BLOCK_CANDIDATE_PAGE) break + } + + /* Either bound stopping us early means the lists are a prefix of the real answer. */ + if (reported >= BLOCK_RESULT_LIMIT || candidatesRead >= BLOCK_CANDIDATE_CEILING) { + truncated = true + } - for (const row of blocks.slice(0, BLOCK_SCAN_LIMIT)) { + function scanCandidate(row: { + blockId: string + blockName: string + blockType: string + subBlocks: unknown + data: unknown + workflowId: string + workflowName: string + }): void { let field: string | undefined try { const { references } = remapSubBlocks( @@ -275,9 +332,9 @@ export async function scanSecretReferences({ workflowId: row.workflowId, error, }) - continue + return } - if (!field) continue + if (!field) return let entry = workflowIndex.get(row.workflowId) if (!entry) { @@ -291,6 +348,7 @@ export async function scanSecretReferences({ blockType: row.blockType, field, }) + reported += 1 } const resources: SecretReferenceResource[] = [] From f0a61d907bed22f594ec31ec17d1bffbcfcb2b89 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 21 Aug 2026 15:47:47 -0700 Subject: [PATCH 09/10] fix(secrets): drop the paging that caused drift, and stop false truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 8. - Paging removed. It bought headroom and paid with drift: `OFFSET` is positional, so a block renamed, inserted or deleted between page queries shifts the result set, and the scan skips a live reference or reports one twice. That is a worse failure than the one paging was added to fix, and it was self-inflicted last round. Candidates are read in one statement again — one statement is one snapshot, so neither skew nor duplication is possible — with the ceiling lowered to 4,000 so a single read stays a sane amount of memory. Result-capping survives, which was the actual point: filtered rows are still absorbed as extra reads rather than taking result slots. - `truncated` no longer fires on an exact landing. The block path now uses the limit-plus-one read and strict `>` the resource paths already used, so a scan that ends precisely on a bound reports complete instead of warning about references that were all returned. - The deep-link target is released when its block does not exist. It was cleared only on a match, so a link to a since-deleted block left the id set with the param already stripped: the effect re-checked on every canvas update forever and shadowed a later link to the same block. Once any node has mounted the canvas is populated, so an id still absent is gone and the target is dropped. Co-Authored-By: Claude Opus 5 (1M context) --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 14 +++- apps/sim/lib/secrets/references/scan.test.ts | 25 +++++++ apps/sim/lib/secrets/references/scan.ts | 74 +++++++++---------- 3 files changed, 72 insertions(+), 41 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 2dbb7652ec9..5c96c4cee40 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -4686,11 +4686,17 @@ const WorkflowContent = React.memo( if (embedded || !deepLinkBlockId) return /* Same reason as the note reveal above: read from `displayNodes` so a target that lands - before its node mounts is retried on the mounting commit instead of dropped. A block id - that never mounts — deleted since the link was made — simply leaves the workflow at its - default framing, which is what the link did before it carried a target. */ + before its node mounts is retried on the mounting commit instead of dropped. */ const node = displayNodes.find((candidate) => candidate.id === deepLinkBlockId) - if (!node) return + if (!node) { + /* Once any node has mounted the canvas is populated, so an id still missing belongs to a + block deleted since the link was made. Drop it rather than hold it: the param is + already stripped, so a target kept here would re-check on every canvas update forever + and shadow a later link to the same block. The workflow simply keeps its default + framing, which is what the link did before it carried a target. */ + if (displayNodes.length > 0) setDeepLinkBlockId(null) + return + } setDeepLinkBlockId(null) diff --git a/apps/sim/lib/secrets/references/scan.test.ts b/apps/sim/lib/secrets/references/scan.test.ts index 1df38450b6d..89f0345bf14 100644 --- a/apps/sim/lib/secrets/references/scan.test.ts +++ b/apps/sim/lib/secrets/references/scan.test.ts @@ -280,6 +280,31 @@ describe('scanSecretReferences', () => { expect(scan.workflows[0]?.blocks).toHaveLength(2000) }) + /** + * Landing exactly on a bound is a complete scan, not a truncated one. Claiming truncation + * there tells the reader references may be missing when every one was returned — the same + * "absence of evidence" note, on a scan that has none. + */ + it('does not claim truncation for a scan that ends exactly on the result limit', async () => { + queueTableRows( + schemaMock.workflowBlocks, + Array.from({ length: 2000 }, (_, index) => + blockRow({ + blockId: `block-${index}`, + blockName: `Block ${index}`, + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY}}'), + }) + ) + ) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows[0]?.blocks).toHaveLength(2000) + expect(scan.truncated).toBe(false) + }) + /** * The cap counts what is reported, not what is read. The prefilter judges syntax only, so a * block naming the secret in prose is a genuine candidate that the scanner then rejects — and diff --git a/apps/sim/lib/secrets/references/scan.ts b/apps/sim/lib/secrets/references/scan.ts index 1476f7eda47..3bbcfc93b6b 100644 --- a/apps/sim/lib/secrets/references/scan.ts +++ b/apps/sim/lib/secrets/references/scan.ts @@ -20,17 +20,19 @@ const logger = createLogger('SecretReferenceScan') */ const BLOCK_RESULT_LIMIT = 2000 -/** Candidate rows held in memory at once. One page, not the whole candidate set. */ -const BLOCK_CANDIDATE_PAGE = 200 - /** - * Ceiling on candidate rows read across all pages, so a scan cannot walk an unbounded set. + * Ceiling on candidate rows read, so a scan cannot walk an unbounded set. + * + * Sits above {@link BLOCK_RESULT_LIMIT} so semantically filtered rows — dormant members and + * condition-hidden fields, which the SQL cannot judge — are absorbed as extra reads rather than + * displacing results. The headroom is deliberately modest: every candidate carries its block's + * `sub_blocks`, so this is the memory bound as much as the work bound. * - * This is the irreducible bound: completeness and bounded work cannot both hold, so the choice is - * where the bound sits. It sits well above {@link BLOCK_RESULT_LIMIT} precisely so filtered rows - * are absorbed as extra reads rather than displacing results. + * It is also the irreducible one. Bounded work and guaranteed completeness cannot both hold, so + * the only real choices are where the bound sits and whether it counts something the reader can + * see. It counts results. */ -const BLOCK_CANDIDATE_CEILING = 10_000 +const BLOCK_CANDIDATE_CEILING = 4000 /** Matching cap for each cascade table, which are far smaller than the block table. */ const RESOURCE_SCAN_LIMIT = 200 @@ -207,11 +209,17 @@ export async function scanSecretReferences({ if (!ENV_KEY_PATTERN.test(name)) return { workflows: [], resources: [], truncated: false } /** - * One page of candidates. `blockId` is in the ordering as a final tiebreak: OFFSET paging over - * a non-unique sort can repeat or skip rows between pages, which here would double-report a - * block or silently lose one. + * All candidates in one read, up to the ceiling. + * + * Deliberately not paged. Paging bought headroom but paid for it with drift: `OFFSET` is + * positional, so a block renamed, inserted or deleted between pages shifts the result set and + * the scan silently skips a live reference or reports one twice. One statement is one snapshot, + * so neither can happen — and the ceiling is what bounds the read instead. + * + * `blockId` still closes the ordering, so repeated scans of an unchanged workspace return the + * same list in the same order rather than an arbitrary one among ties. */ - const readCandidatePage = (offset: number) => + const readCandidates = () => db .select({ blockId: workflowBlocks.id, @@ -237,10 +245,12 @@ export async function scanSecretReferences({ asc(workflowBlocks.name), asc(workflowBlocks.id) ) - .limit(BLOCK_CANDIDATE_PAGE) - .offset(offset) + // Limit-plus-one, like the resource reads: the extra row is how "there were more" is known + // without claiming truncation on a set that ended exactly on the bound. + .limit(BLOCK_CANDIDATE_CEILING + 1) - const [tools, servers] = await Promise.all([ + const [candidates, tools, servers] = await Promise.all([ + readCandidates(), db .select({ id: customTools.id, title: customTools.title, code: customTools.code }) .from(customTools) @@ -268,34 +278,24 @@ export async function scanSecretReferences({ .limit(RESOURCE_SCAN_LIMIT + 1), ]) - let truncated = tools.length > RESOURCE_SCAN_LIMIT || servers.length > RESOURCE_SCAN_LIMIT + /** Every bound is `> limit` on a limit-plus-one read, so landing exactly on one is not truncation. */ + let truncated = + tools.length > RESOURCE_SCAN_LIMIT || + servers.length > RESOURCE_SCAN_LIMIT || + candidates.length > BLOCK_CANDIDATE_CEILING const workflows: SecretReferenceWorkflow[] = [] const workflowIndex = new Map() let reported = 0 - let candidatesRead = 0 - - /** - * Pages the candidates rather than reading them all: the ceiling is high enough that filtered - * rows do not displace results, which would be far too many block records to hold at once. - */ - while (reported < BLOCK_RESULT_LIMIT && candidatesRead < BLOCK_CANDIDATE_CEILING) { - const page = await readCandidatePage(candidatesRead) - if (page.length === 0) break - candidatesRead += page.length - for (const row of page) { - if (reported >= BLOCK_RESULT_LIMIT) break - scanCandidate(row) + for (const row of candidates.slice(0, BLOCK_CANDIDATE_CEILING)) { + /* Reporting stops at the result limit, but the read does not: the remaining candidates are + still worth walking only insofar as they cannot add results, so stop here and say so. */ + if (reported >= BLOCK_RESULT_LIMIT) { + truncated = true + break } - - // A short page is the end of the candidate set; anything else means more remain. - if (page.length < BLOCK_CANDIDATE_PAGE) break - } - - /* Either bound stopping us early means the lists are a prefix of the real answer. */ - if (reported >= BLOCK_RESULT_LIMIT || candidatesRead >= BLOCK_CANDIDATE_CEILING) { - truncated = true + scanCandidate(row) } function scanCandidate(row: { From 83afe7c935a45160b927e8ddcbd9c245c6e5f8e8 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 21 Aug 2026 15:53:40 -0700 Subject: [PATCH 10/10] fix(secrets): gate the deep-link release on the workflow being ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 9. Round 8 released a deep-link target once `displayNodes` was non-empty, reading that as "the canvas is populated, so a missing id is deleted". It is not: arriving from another workflow the store still holds that graph, so nodes are present while the linked workflow is still hydrating — and a valid `?block=` target was dropped before its own blocks ever mounted. The file already had the right predicate. `isWorkflowReady` pins `hydration.phase === 'ready'`, `hydration.workflowId === workflowIdParam` and `activeWorkflowId === workflowIdParam`, which is exactly "the graph now loaded is this workflow's". Absence is only conclusive under that, and a node count never was — it says something mounted, not whose. This is the second fix to this release condition in two rounds, both from guessing at a readiness signal instead of using the one the component already computes for the same question. Co-Authored-By: Claude Opus 5 (1M context) --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 5c96c4cee40..14bb7d539c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -4689,12 +4689,16 @@ const WorkflowContent = React.memo( before its node mounts is retried on the mounting commit instead of dropped. */ const node = displayNodes.find((candidate) => candidate.id === deepLinkBlockId) if (!node) { - /* Once any node has mounted the canvas is populated, so an id still missing belongs to a - block deleted since the link was made. Drop it rather than hold it: the param is - already stripped, so a target kept here would re-check on every canvas update forever - and shadow a later link to the same block. The workflow simply keeps its default - framing, which is what the link did before it carried a target. */ - if (displayNodes.length > 0) setDeepLinkBlockId(null) + /* Absent is only conclusive once THIS workflow's graph is the one loaded. `isWorkflowReady` + is that test — it pins `hydration.workflowId` and `activeWorkflowId` to the id in the + URL — where a count of mounted nodes is not: arriving from another workflow, the store + still holds that graph, so nodes are present while the linked workflow is still + hydrating and a valid target would be thrown away. + + Releasing it matters because the param is already stripped: a target held forever would + re-check on every canvas update and shadow a later link to the same block. Dropping it + leaves the default framing, which is what the link did before it carried a target. */ + if (isWorkflowReady) setDeepLinkBlockId(null) return } @@ -4723,6 +4727,7 @@ const WorkflowContent = React.memo( displayNodes, embedded, focusBlockInView, + isWorkflowReady, workflowIdParam, ])