-
Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(secrets): show where a secret is referenced, beside its usage log #6947
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
3e69d25
feat(secrets): show where a secret is referenced, beside its usage log
icecrasher321 4a9248a
fix(secrets): close the reference-scan scope bypass and bound its output
icecrasher321 fda586f
fix(secrets): cover unicode whitespace, legacy keys, and the shadowed…
icecrasher321 1ebee83
fix(secrets): re-check the reference gate's volatile input after the …
icecrasher321 157339e
fix(secrets): accept JSON-escaped whitespace in the reference prefilter
icecrasher321 619d3f1
feat(secrets): land the References link on the block, and name its field
icecrasher321 bb347cf
fix(secrets): make the reference prefilter exactly as tight as the au…
icecrasher321 39c5167
fix(secrets): cap the reference scan on results, not candidates
icecrasher321 f0a61d9
fix(secrets): drop the paging that caused drift, and stop false trunc…
icecrasher321 83afe7c
fix(secrets): gate the deep-link release on the workflow being ready
icecrasher321 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| /** | ||
| * @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' | ||
|
|
||
| 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' | ||
| ) | ||
| ) | ||
|
|
||
| expect(response.status).toBe(400) | ||
| 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 | ||
| * "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) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| 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, | ||
| }), | ||
| useCase: listSecretReferencesUseCase, | ||
| /** The scan's shape is already the wire shape — nothing to project or serialize. */ | ||
| present: (scan) => scan, | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
1 change: 1 addition & 0 deletions
1
...[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { SecretReferencesPanel } from './secret-references-panel' | ||
187 changes: 187 additions & 0 deletions
187
...ngs/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| 'use client' | ||
|
|
||
| import { Wrench } from '@sim/emcn/icons' | ||
| import { McpIcon } from '@/components/icons' | ||
| 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 { | ||
| 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 { focusBlockParam } from '@/app/workspace/[workspaceId]/w/[workflowId]/search-params' | ||
| import { getBlock } from '@/blocks/registry' | ||
| 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 | ||
| } | ||
|
|
||
| /** | ||
| * 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}` | ||
| } | ||
|
|
||
| /** | ||
| * 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. | ||
| */ | ||
| 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, | ||
| shadowed, | ||
| }: SecretReferencesPanelProps) { | ||
| const { data, isPending, isError } = useSecretReferences( | ||
| { workspaceId, name: secretName }, | ||
| !shadowed | ||
| ) | ||
|
|
||
| if (shadowed) { | ||
| return ( | ||
| <SettingsEmptyState variant='inline'> | ||
| Overridden by a workspace variable, so every reference to this name resolves to that | ||
| variable instead. | ||
| </SettingsEmptyState> | ||
| ) | ||
| } | ||
|
|
||
| if (isError) { | ||
| return ( | ||
| <SettingsEmptyState variant='inline' tone='error'> | ||
| Could not load references. | ||
| </SettingsEmptyState> | ||
| ) | ||
| } | ||
|
|
||
| if (isPending) { | ||
| return <SettingsEmptyState variant='inline'>Loading…</SettingsEmptyState> | ||
| } | ||
|
|
||
| if (data.workflows.length === 0 && data.resources.length === 0) { | ||
| return ( | ||
| <SettingsEmptyState variant='inline'> | ||
| {data.truncated ? TRUNCATED_NOTE : 'This secret is not referenced in any workflow.'} | ||
| </SettingsEmptyState> | ||
| ) | ||
| } | ||
|
icecrasher321 marked this conversation as resolved.
|
||
|
|
||
| return ( | ||
| <div className='flex flex-col gap-7'> | ||
| {data.workflows.map((workflow) => ( | ||
| <DetailSection key={workflow.workflowId} title={workflow.workflowName}> | ||
| <div className={RESOURCE_LIST_STACK}> | ||
| {workflow.blocks.map((block) => { | ||
| const BlockIcon = getBlock(block.blockType)?.icon | ||
| return ( | ||
| <SettingsResourceRow | ||
| key={block.blockId} | ||
| iconVariant='custom' | ||
| /** | ||
| * The brand-tinted block tile, so a block reads here exactly as it does on an | ||
| * integrations row. A block icon is a brand mark, not a glyph: the tile owns | ||
| * its fill and picks the contrasting icon colour, which is why it is never a | ||
| * bare icon under `--text-icon`. An unregistered type has no tile, and the | ||
| * row drops the whole slot for a nullish icon. | ||
| */ | ||
| icon={ | ||
| BlockIcon ? ( | ||
| <IntegrationTile blockType={block.blockType} icon={BlockIcon} /> | ||
| ) : undefined | ||
| } | ||
| title={block.blockName} | ||
| description={fieldLabel(block.blockType, block.field)} | ||
| href={blockHref(workspaceId, workflow.workflowId, block.blockId)} | ||
| clickLabel={`Open ${block.blockName} in ${workflow.workflowName}`} | ||
| navigable | ||
| /> | ||
| ) | ||
| })} | ||
| </div> | ||
| </DetailSection> | ||
| ))} | ||
|
|
||
| {data.resources.length > 0 && ( | ||
| <DetailSection title='Custom tools and MCP servers'> | ||
| <div className={RESOURCE_LIST_STACK}> | ||
| {data.resources.map((resource) => ( | ||
| <SettingsResourceRow | ||
| key={resourceKey(resource)} | ||
| icon={ | ||
| resource.kind === 'mcp-server' ? ( | ||
| <McpIcon className='text-[var(--text-icon)]' /> | ||
| ) : ( | ||
| <Wrench className='text-[var(--text-icon)]' /> | ||
| ) | ||
| } | ||
| iconFilled | ||
| title={resource.name} | ||
| description={resource.field} | ||
| href={resourceHref(workspaceId, resource)} | ||
| clickLabel={`Open ${resource.name}`} | ||
| navigable | ||
| /> | ||
| ))} | ||
| </div> | ||
| </DetailSection> | ||
| )} | ||
|
|
||
| {data.truncated && <p className='text-[var(--text-muted)] text-caption'>{TRUNCATED_NOTE}</p>} | ||
| </div> | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.