Skip to content

Commit 1848e66

Browse files
committed
feat(secrets): carry visible secret values on the v2 list and document visibility
1 parent f48aa52 commit 1848e66

15 files changed

Lines changed: 385 additions & 24 deletions

File tree

apps/docs/content/docs/en/platform/credentials.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,27 @@ Click **Details** on any secret row to open its detail view.
119119
From here you can:
120120

121121
- View the **Key** and edit the **Value**
122+
- Toggle **Visibility** — show the value unmasked in run output; see [Visibility](#visibility)
122123
- Edit the **Description** — an optional note telling teammates what the secret is for. Workspace secrets only; a personal secret is not shared, so it has none
123124
- Manage **Members** — invite teammates by email and assign them an **Admin** or **Member** role
124125
- Open **See usage** — where this secret has actually been used
125126

126127
Click **Save** to apply changes, or **Back** to return to the list.
127128

129+
### Visibility
130+
131+
By default, a secret's resolved value is masked everywhere Sim shows run output (see [Execution log protection](#execution-log-protection)). For values that aren't actually sensitive — a staging key, a shared base URL — that masking makes your own logs harder to read.
132+
133+
**Show value in logs and Chat** turns masking off for one workspace secret. With it on:
134+
135+
- Run logs, Chat, and code output show the real value instead of `{{KEY}}`
136+
- Files a run writes with the value in them stay readable and attachable
137+
- The Secrets API list includes the value for this secret, so external agents can read it directly instead of scraping logs
138+
139+
The value becomes visible to **anyone who can see this workspace's runs** — including publicly shared log links and log exports, and regardless of member restrictions on the secret itself. Only turn it on for values you'd be comfortable printing in a log.
140+
141+
The switch applies to future runs only. Logs written while the secret was masked stay masked, and anything written while it was visible keeps the value even if you turn masking back on. If another secret holds the same value, that value stays masked — masking always wins a conflict. Workspace secrets only; the same people who can edit the description can flip it.
142+
128143
### See usage
129144

130145
**See usage** lists the runs that resolved this secret: when it was last used, what used it (a workflow, the Sim agent, or an MCP server), how it was triggered, who it resolved under, and a link to the most recent run in Logs. Rows are grouped by day, so a workflow on a schedule reads as one row per day rather than thousands.

apps/docs/openapi-v2-resources.json

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2447,7 +2447,7 @@
24472447
"get": {
24482448
"operationId": "listSecrets",
24492449
"summary": "List Secrets",
2450-
"description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.",
2450+
"description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Rows for workspace secrets marked visible (unredacted) include the stored value; every other row is metadata-only and no other response ever carries a value. A workspace API key is rejected with `403`; use a personal API key.",
24512451
"tags": ["Secrets"],
24522452
"parameters": [
24532453
{
@@ -5581,7 +5581,7 @@
55815581
}
55825582
]
55835583
},
5584-
"V2Secret": {
5584+
"V2SecretWithValue": {
55855585
"type": "object",
55865586
"properties": {
55875587
"name": {
@@ -5627,6 +5627,10 @@
56275627
"format": "date-time",
56285628
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
56295629
"description": "ISO 8601 timestamp when the secret was last updated."
5630+
},
5631+
"value": {
5632+
"description": "The stored secret value. Present only when the workspace secret is marked visible (unredacted); omitted for every other secret.",
5633+
"type": "string"
56305634
}
56315635
},
56325636
"required": [
@@ -5639,16 +5643,16 @@
56395643
"updatedAt"
56405644
],
56415645
"additionalProperties": false,
5642-
"title": "Secret metadata",
5643-
"description": "Public secret metadata without the stored secret value."
5646+
"title": "Secret metadata with visible value",
5647+
"description": "Secret metadata; the stored value is included only for a workspace secret marked visible (unredacted)."
56445648
},
56455649
"ListSecretsResponse": {
56465650
"type": "object",
56475651
"properties": {
56485652
"data": {
56495653
"type": "array",
56505654
"items": {
5651-
"$ref": "#/components/schemas/V2Secret"
5655+
"$ref": "#/components/schemas/V2SecretWithValue"
56525656
},
56535657
"description": "Items in the current page."
56545658
},
@@ -5667,7 +5671,7 @@
56675671
"required": ["data", "nextCursor"],
56685672
"additionalProperties": false,
56695673
"title": "List secrets response",
5670-
"description": "Secret metadata visible to the caller without stored values.",
5674+
"description": "Secret metadata visible to the caller; visible (unredacted) workspace secrets carry their value.",
56715675
"examples": [
56725676
{
56735677
"data": [
@@ -5679,12 +5683,83 @@
56795683
"role": "admin",
56805684
"createdAt": "2026-06-01T09:14:00.000Z",
56815685
"updatedAt": "2026-06-20T14:02:11.000Z"
5686+
},
5687+
{
5688+
"name": "STAGING_BASE_URL",
5689+
"scope": "workspace",
5690+
"description": "Staging environment base URL.",
5691+
"unredacted": true,
5692+
"role": "member",
5693+
"createdAt": "2026-06-03T11:30:00.000Z",
5694+
"updatedAt": "2026-06-21T08:45:09.000Z",
5695+
"value": "https://staging.example.com"
56825696
}
56835697
],
56845698
"nextCursor": null
56855699
}
56865700
]
56875701
},
5702+
"V2Secret": {
5703+
"type": "object",
5704+
"properties": {
5705+
"name": {
5706+
"type": "string",
5707+
"minLength": 1,
5708+
"maxLength": 255,
5709+
"pattern": "^[A-Za-z0-9_]+$",
5710+
"description": "Secret name containing only letters, numbers, and underscores."
5711+
},
5712+
"scope": {
5713+
"type": "string",
5714+
"enum": ["workspace", "personal"],
5715+
"description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace."
5716+
},
5717+
"description": {
5718+
"anyOf": [
5719+
{
5720+
"type": "string"
5721+
},
5722+
{
5723+
"type": "null"
5724+
}
5725+
],
5726+
"description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience."
5727+
},
5728+
"unredacted": {
5729+
"type": "boolean",
5730+
"description": "Whether the workspace secret opts out of redaction, so its value appears in plaintext in run logs and model-visible content. Always false for a personal secret."
5731+
},
5732+
"role": {
5733+
"type": "string",
5734+
"enum": ["admin", "member"],
5735+
"description": "Caller role for the secret."
5736+
},
5737+
"createdAt": {
5738+
"type": "string",
5739+
"format": "date-time",
5740+
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
5741+
"description": "ISO 8601 timestamp when the secret was created."
5742+
},
5743+
"updatedAt": {
5744+
"type": "string",
5745+
"format": "date-time",
5746+
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
5747+
"description": "ISO 8601 timestamp when the secret was last updated."
5748+
}
5749+
},
5750+
"required": [
5751+
"name",
5752+
"scope",
5753+
"description",
5754+
"unredacted",
5755+
"role",
5756+
"createdAt",
5757+
"updatedAt"
5758+
],
5759+
"additionalProperties": false,
5760+
"title": "Secret metadata",
5761+
"description": "Public secret metadata without the stored secret value."
5762+
},
56885763
"SetSecretResponse": {
56895764
"type": "object",
56905765
"properties": {

apps/sim/app/api/v2/secrets/[name]/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ const secret = {
8383
updatedAt: new Date('2026-01-02T00:00:00Z'),
8484
hasServiceAccountKey: false,
8585
role: 'admin' as const,
86+
unredacted: false,
8687
}
8788
const context = { params: Promise.resolve({ name: SECRET_NAME }) }
8889

apps/sim/app/api/v2/secrets/route.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const secret = {
8181
updatedAt: new Date('2026-01-02T00:00:00Z'),
8282
hasServiceAccountKey: false,
8383
role: 'admin' as const,
84+
unredacted: false,
8485
}
8586

8687
describe('GET /api/v2/secrets', () => {
@@ -92,6 +93,7 @@ describe('GET /api/v2/secrets', () => {
9293
mocks.gate.mockResolvedValue(null)
9394
mocks.list.mockResolvedValue({
9495
secrets: [secret],
96+
values: {},
9597
userId: 'user-1',
9698
nextCursorKeys: null,
9799
sortBy: 'name',
@@ -114,14 +116,15 @@ describe('GET /api/v2/secrets', () => {
114116
name: 'STRIPE_API_KEY',
115117
scope: 'workspace',
116118
description: null,
119+
unredacted: false,
117120
role: 'admin',
118121
createdAt: '2026-01-01T00:00:00.000Z',
119122
updatedAt: '2026-01-02T00:00:00.000Z',
120123
},
121124
],
122125
nextCursor: null,
123126
})
124-
expect(JSON.stringify(body)).not.toContain('value')
127+
expect(JSON.stringify(body)).not.toContain('"value"')
125128
expect(mocks.list).toHaveBeenCalledWith({
126129
principal: PRINCIPAL,
127130
input: {
@@ -138,6 +141,41 @@ describe('GET /api/v2/secrets', () => {
138141
})
139142
})
140143

144+
it('carries the stored value for exactly the rows marked visible', async () => {
145+
mocks.list.mockResolvedValue({
146+
secrets: [
147+
secret,
148+
{
149+
...secret,
150+
id: 'secret-3',
151+
displayName: 'STAGING_BASE_URL',
152+
envKey: 'STAGING_BASE_URL',
153+
unredacted: true,
154+
},
155+
],
156+
values: { STAGING_BASE_URL: 'https://staging.example.com' },
157+
userId: 'user-1',
158+
nextCursorKeys: null,
159+
sortBy: 'name',
160+
sortOrder: 'asc',
161+
})
162+
163+
const response = await GET(
164+
new NextRequest(`http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}`, {
165+
headers: { 'x-api-key': 'key' },
166+
})
167+
)
168+
const body = await response.json()
169+
170+
expect(response.status).toBe(200)
171+
expect(body.data[0]).not.toHaveProperty('value')
172+
expect(body.data[1]).toMatchObject({
173+
name: 'STAGING_BASE_URL',
174+
unredacted: true,
175+
value: 'https://staging.example.com',
176+
})
177+
})
178+
141179
/**
142180
* Pins the binding end-to-end — the mint in `present` and the read in
143181
* `mapInput` — because the contract-level sweep only checks a hand-maintained
@@ -157,6 +195,7 @@ describe('GET /api/v2/secrets', () => {
157195
description: 'leaked from a workspace mirror',
158196
},
159197
],
198+
values: {},
160199
userId: 'user-1',
161200
nextCursorKeys: null,
162201
sortBy: 'name',
@@ -178,6 +217,7 @@ describe('GET /api/v2/secrets', () => {
178217
it('refuses a cursor minted under a different filter', async () => {
179218
mocks.list.mockResolvedValue({
180219
secrets: [secret],
220+
values: {},
181221
userId: 'user-1',
182222
nextCursorKeys: ['STRIPE_API_KEY', 'secret-1'],
183223
sortBy: 'name',
@@ -209,6 +249,7 @@ describe('GET /api/v2/secrets', () => {
209249
it('resumes a cursor replayed under the filters it was minted with', async () => {
210250
mocks.list.mockResolvedValue({
211251
secrets: [secret],
252+
values: {},
212253
userId: 'user-1',
213254
nextCursorKeys: ['STRIPE_API_KEY', 'secret-1'],
214255
sortBy: 'name',

apps/sim/app/api/v2/secrets/route.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ function secretCursorFilters(query: { workspaceId: string; scope?: string; searc
2323
})
2424
}
2525

26-
/** GET /api/v2/secrets — List secret names and metadata without reading their values. */
26+
/** GET /api/v2/secrets — List secret metadata; visible (unredacted) secrets carry their value. */
2727
export const GET = defineV2JsonRoute({
2828
contract: v2ListSecretsContract,
2929
operation: secretOperations.list,
@@ -40,8 +40,10 @@ export const GET = defineV2JsonRoute({
4040
),
4141
}),
4242
useCase: listSecretsUseCase,
43-
present: ({ secrets, userId, nextCursorKeys }, { query }) => ({
44-
data: secrets.map((secret) => toV2Secret(secret, userId)),
43+
present: ({ secrets, values, userId, nextCursorKeys }, { query }) => ({
44+
data: secrets.map((secret) =>
45+
toV2Secret(secret, userId, secret.envKey ? values[secret.envKey] : undefined)
46+
),
4547
nextCursor: writeSortedCursor(
4648
nextCursorKeys,
4749
query.sortBy,
Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
1-
import type { V2Secret } from '@/lib/api/contracts/v2/secrets'
1+
import type { V2SecretWithValue } from '@/lib/api/contracts/v2/secrets'
22
import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries'
33

4-
/** Serialize environment credential metadata as a secret without exposing its stored value. */
5-
export function toV2Secret(row: VisibleWorkspaceCredential, userId: string): V2Secret {
4+
/**
5+
* Serialize environment credential metadata as a secret. The stored value is
6+
* attached only when supplied AND the row is a workspace secret marked visible
7+
* (unredacted) — the guard here, not only at the caller, so no code path can
8+
* hand a value to a row whose flag does not disclose it.
9+
*/
10+
export function toV2Secret(
11+
row: VisibleWorkspaceCredential,
12+
userId: string,
13+
value?: string
14+
): V2SecretWithValue {
615
if (!row.envKey || (row.type !== 'env_workspace' && row.type !== 'env_personal')) {
716
throw new Error(`Credential ${row.id} is not a secret`)
817
}
918
if (row.type === 'env_personal' && row.envOwnerUserId !== userId) {
1019
throw new Error(`Personal secret ${row.id} is not owned by the caller`)
1120
}
1221

22+
const unredacted = row.type === 'env_workspace' ? row.unredacted : false
1323
return {
1424
name: row.envKey,
1525
scope: row.type === 'env_workspace' ? 'workspace' : 'personal',
1626
description: row.type === 'env_workspace' ? row.description : null,
17-
unredacted: row.type === 'env_workspace' ? row.unredacted : false,
27+
unredacted,
1828
role: row.role,
1929
createdAt: row.createdAt.toISOString(),
2030
updatedAt: row.updatedAt.toISOString(),
31+
...(value !== undefined && unredacted ? { value } : {}),
2132
}
2233
}

apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
261261
<Label htmlFor='secret-unredacted'>Show value in logs and Chat</Label>
262262
<p className='text-[var(--text-muted)] text-caption'>
263263
{
264-
'The value is visible to anyone who can see this workspace’s runs, including shared log links.'
264+
'The value is visible to anyone who can see this workspace’s runs — in logs, Chat, and the API, including shared log links.'
265265
}
266266
</p>
267267
</div>

apps/sim/lib/api/contracts/v2/openapi/resources.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,17 @@ const SECRET_EXAMPLE = {
250250
updatedAt: '2026-06-20T14:02:11.000Z',
251251
} as const
252252

253+
const VISIBLE_SECRET_EXAMPLE = {
254+
name: 'STAGING_BASE_URL',
255+
scope: 'workspace',
256+
description: 'Staging environment base URL.',
257+
unredacted: true,
258+
role: 'member',
259+
createdAt: '2026-06-03T11:30:00.000Z',
260+
updatedAt: '2026-06-21T08:45:09.000Z',
261+
value: 'https://staging.example.com',
262+
} as const
263+
253264
type ResourceTag =
254265
| 'Workspaces'
255266
| 'MCP Servers'
@@ -1140,7 +1151,7 @@ const declaredRoutes = [
11401151
resourceOperation('Secrets', {
11411152
operationId: 'listSecrets',
11421153
summary: 'List Secrets',
1143-
description: `List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. ${WORKSPACE_API_KEY_DENIED}`,
1154+
description: `List workspace and caller-owned personal secret metadata with opaque cursor pagination. Rows for workspace secrets marked visible (unredacted) include the stored value; every other row is metadata-only and no other response ever carries a value. ${WORKSPACE_API_KEY_DENIED}`,
11441155
errors: RESOURCE_ERRORS,
11451156
success: { description: 'Secret metadata visible to the caller.' },
11461157
}),
@@ -1155,8 +1166,8 @@ const declaredRoutes = [
11551166
v2ListSecretsContract.response.schema,
11561167
'ListSecretsResponse',
11571168
'List secrets response',
1158-
'Secret metadata visible to the caller without stored values.',
1159-
[{ data: [SECRET_EXAMPLE], nextCursor: null }]
1169+
'Secret metadata visible to the caller; visible (unredacted) workspace secrets carry their value.',
1170+
[{ data: [SECRET_EXAMPLE, VISIBLE_SECRET_EXAMPLE], nextCursor: null }]
11601171
),
11611172
}
11621173
),

0 commit comments

Comments
 (0)