Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions apps/sim/app/api/secrets/references/route.test.ts
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)
})
})
25 changes: 25 additions & 0 deletions apps/sim/app/api/secrets/references/route.ts
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,
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -16,15 +23,24 @@ 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 (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className={cn(PAGE_HEADER_BAR, 'justify-between')}>
{back}
{actions ? <div className={HEADER_ACTION_CLUSTER}>{actions}</div> : null}
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex w-full max-w-[48rem] flex-col gap-7 pb-6'>{children}</div>
<div className='mx-auto flex w-full max-w-[48rem] flex-col gap-7 pb-6'>
{/* Same element, class and column position the settings shell gives its page title. */}
{title ? <h1 className='text-[var(--text-body)] text-lg'>{title}</h1> : null}
{children}
</div>
</div>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SecretReferencesPanel } from './secret-references-panel'
Comment thread
icecrasher321 marked this conversation as resolved.
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>
)
}
Comment thread
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>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading