Skip to content

Commit aee6a90

Browse files
committed
fix(api): pin two authorization invariants, and say what these reads are scoped to
Nothing asserted that a secret list is narrowed to its caller's own rows. The query is mocked, so its canned result proved nothing about what was asked for, and removing the narrowing left the suite green. The same was true of the refusal that keeps a workspace key — which acts for a workspace, not a person — out of a personal secret list: with the policy flipped the use case resolved and returned secrets. Both are pinned by the arguments the query is built from. Personal secrets remain invisible from a workspace the caller reaches through inherited organization access. Their metadata has no canonical row, only per-workspace mirrors written for a narrower set of workspaces than a caller can authorize into, so a list scoped to one workspace cannot find them while set and delete, which key on the person, work. Dropping the scope would return one row per mirror and break pagination, and a mirror's timestamps describe when it was written rather than when the secret was made, so the sort has no meaning. Closing it is a storage change; the divergence is recorded where the read happens and stated in the contract rather than left implied. Account-wide billing is requested by omitting the workspace, which is indistinguishable from an ordinary request, so a workspace key asking for it is answered with its own workspace rather than refused. The refusal belongs where the intent exists, which is the client; the contract now says what omission means for each kind of key, and that a workspace-scoped page reports its members in aggregate and names none of them. A run is addressed by an id that already names its workspace, so that read takes no workspace and refuses one that is sent. Unlike its siblings, nothing said so.
1 parent 42e4f5a commit aee6a90

7 files changed

Lines changed: 177 additions & 6 deletions

File tree

apps/sim/lib/api/contracts/v2/billing.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const v2BillingStatusQuerySchema = z
4242
workspaceId: workspaceIdSchema
4343
.optional()
4444
.describe(
45-
'Workspace whose payer should be resolved. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers.'
45+
'Workspace whose payer should be resolved. Omitting it selects account scope — the payer behind the calling *account*, across every workspace — and only a personal API key has an account to select. A workspace API key that omits it still resolves its own workspace, because omission is the same request as sending that key its own id; it is not a way to widen a workspace key. The response `workspaceId` reports which was resolved and is `null` only on account scope. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers.'
4646
),
4747
})
4848
.strict()
@@ -172,7 +172,7 @@ export const v2BillingLogsQuerySchema = z
172172
workspaceId: workspaceIdSchema
173173
.optional()
174174
.describe(
175-
"Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers."
175+
"Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. Omitting it does not widen a workspace API key: that key has no account behind it, so an omitted id is the same request as its own id and the page still covers exactly one workspace, reported as `scope: workspace`. A ledger spanning every workspace an account touches requires a personal API key. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers."
176176
),
177177
period: usageLogPeriodSchema
178178
.optional()
@@ -278,7 +278,8 @@ export const v2BillingLogEntrySchema = z
278278
.meta({
279279
id: 'V2BillingLogEntry',
280280
title: 'Billing log entry',
281-
description: 'One credit-consuming usage event in the billing ledger.',
281+
description:
282+
'One credit-consuming usage event in the billing ledger. No field identifies the member who incurred it: on `workspace` scope a page is every member’s usage in aggregate, not a per-member breakdown, so it reconciles a workspace’s spend without publishing who spent it.',
282283
})
283284
export type V2BillingLogEntry = z.output<typeof v2BillingLogEntrySchema>
284285

@@ -292,7 +293,7 @@ export type V2BillingLogEntry = z.output<typeof v2BillingLogEntrySchema>
292293
export const v2BillingLogsScopeSchema = z
293294
.enum(['user', 'workspace'])
294295
.describe(
295-
"Whose usage this page reports. `user` — the events of the person whose personal API key made the request, narrowed by `workspaceId` when one was given; this omits other members' usage. `workspace` — every member's events for the workspace a workspace API key is pinned to."
296+
"Whose usage this page reports. `user` — the events of the person whose personal API key made the request, narrowed by `workspaceId` when one was given; this omits other members' usage. `workspace` — every member's events for the workspace a workspace API key is pinned to, in aggregate: no entry names the member it belongs to."
296297
)
297298

298299
export const v2ListBillingLogsContract = defineRouteContract({

apps/sim/lib/api/contracts/v2/logs.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,9 @@ export const v2LogDetailSchema = z
307307
export type V2LogDetail = z.output<typeof v2LogDetailSchema>
308308

309309
export const v2LogParamsSchema = z.object({
310-
runId: runIdSchema.describe('Unique workflow run identifier.'),
310+
runId: runIdSchema.describe(
311+
'Unique workflow run identifier. A run is addressed globally by this id: unlike the list and statistics routes, this route takes no workspace. The run carries its own workspace and the caller is authorized against that one, so a run the caller cannot reach is concealed as a 404 rather than filtered out.'
312+
),
311313
})
312314

313315
/**
@@ -679,6 +681,18 @@ export const v2ListLogsContract = defineRouteContract({
679681
},
680682
})
681683

684+
/**
685+
* Deliberately without a workspace input, unlike {@link v2ListLogsContract} and
686+
* `v2GetLogStatsContract`, which both require one.
687+
*
688+
* A run id is globally unique, so the run names its own workspace and the caller
689+
* is authorized against that canonical scope. Accepting a workspace here would
690+
* offer a filter that can only ever agree with the scope already derived, or
691+
* disagree with it — and a caller who read the asymmetry as "this route ignores
692+
* the workspace I asked for" is reading a flag that was never part of the
693+
* request. `query: noInputSchema` is what makes that visible: a stray
694+
* `workspaceId` is refused as an unrecognized key rather than dropped.
695+
*/
682696
export const v2GetLogContract = defineRouteContract({
683697
method: 'GET',
684698
path: '/api/v2/logs/[runId]',

apps/sim/lib/api/contracts/v2/secrets.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@ export type V2SecretSortBy = (typeof v2SecretSortFields)[number]
9696
export const v2ListSecretsQuerySchema = z
9797
.object({
9898
workspaceId: workspaceIdSchema.describe('Workspace whose secret metadata should be listed.'),
99-
scope: v2SecretScopeSchema.optional().describe('Restrict results to one ownership scope.'),
99+
scope: v2SecretScopeSchema
100+
.optional()
101+
.describe(
102+
"Restrict results to one ownership scope. Personal results are not the caller's full personal set: this list reads the per-workspace credential mirrors of a personal secret, and a mirror exists only for workspaces the caller holds an explicit membership or ownership of. A personal secret is therefore omitted here when the caller reaches this workspace through inherited organization access, even though the same secret can be set and deleted from it. Prefer listing from a workspace the caller is an explicit member of until the mirrors are replaced by canonical personal-secret metadata."
103+
),
100104
search: v2SearchSchema.describe('Case-insensitive substring match against the secret name.'),
101105
...v2SortFields(v2SecretSortFields, { sortBy: 'name', sortOrder: 'asc' }),
102106
...v2PaginationFields({ description: 'Maximum secrets to return per page.' }),

apps/sim/lib/billing/application/authorized-billing-read-use-case.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,27 @@ function requireBillingReadPrincipal(
4747
}
4848
}
4949

50+
/**
51+
* Account scope is reachable only by a personal API key, and that is a property
52+
* of what the two kinds of key are rather than a check performed here.
53+
*
54+
* Omitting `workspaceId` is how a caller asks for account scope, and it is also
55+
* the plainest request anyone makes — `GET /api/v2/billing/status` with no query
56+
* at all. The two are byte-identical on the wire, so a workspace API key that
57+
* omits the id cannot be told apart from one that never had an opinion, and this
58+
* function pins it to `principal.workspaceId` in both cases. It deliberately
59+
* does not refuse the omission: a workspace key has no account to widen to, so
60+
* there is no wider answer being withheld, and refusing would `403` every
61+
* unparameterised billing read a workspace key makes.
62+
*
63+
* The consequence is that a caller cannot *assert* account scope over this
64+
* surface, only imply it. A client that means "account-wide" must therefore
65+
* establish that it holds a personal key — `GET /api/v2/meta` reports `keyType`
66+
* — and refuse locally, rather than relying on a refusal that this layer has no
67+
* signal to produce. The answer still says which scope it resolved:
68+
* `workspaceId` is `null` only on the account branch, and the ledger reports
69+
* `scope`.
70+
*/
5071
async function resolveBillingReadScope(
5172
principal: BillingReadPrincipal,
5273
operation: BillingReadOperation,

apps/sim/lib/logs/application/public-log-use-cases.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,60 @@ describe('public log application use cases', () => {
324324
)
325325
})
326326

327+
/**
328+
* The run id is the whole of the request — the route takes no workspace — so
329+
* the only thing standing between a personal key and any run in the system is
330+
* that the permission is resolved against the run's own workspace. Resolving
331+
* it against anything the caller supplied, or skipping it once the key
332+
* authenticates, turns a globally unique id into a global read.
333+
*/
334+
it('resolves the personal-key permission against the run workspace', async () => {
335+
await getPublicLog.execute({
336+
principal: { kind: 'personal_api_key', userId: 'user-9', keyId: 'key-9' },
337+
input: { runId: 'run-1' },
338+
})
339+
340+
expect(mocks.resolvePermission).toHaveBeenCalledWith(
341+
'user-9',
342+
'workspace-1',
343+
null,
344+
undefined,
345+
expect.anything()
346+
)
347+
})
348+
349+
it('rejects a personal key with no permission on the run workspace', async () => {
350+
mocks.resolvePermission.mockResolvedValueOnce(null)
351+
352+
await expect(
353+
getPublicLog.execute({
354+
principal: { kind: 'personal_api_key', userId: 'user-9', keyId: 'key-9' },
355+
input: { runId: 'run-1' },
356+
})
357+
).rejects.toMatchObject({ code: 'forbidden' })
358+
359+
expect(mocks.getLog).not.toHaveBeenCalled()
360+
expect(mocks.materialize).not.toHaveBeenCalled()
361+
})
362+
363+
/**
364+
* A workspace that disallows personal keys must refuse them here too: the
365+
* detail read is the one log route a personal key can reach without naming a
366+
* workspace, so a gap in this branch is not covered by the list route's.
367+
*/
368+
it('rejects a personal key in a workspace that disallows them', async () => {
369+
mocks.loadWorkspace.mockResolvedValueOnce({ ...workspaceContext, allowPersonalApiKeys: false })
370+
371+
await expect(
372+
getPublicLog.execute({
373+
principal: { kind: 'personal_api_key', userId: 'user-9', keyId: 'key-9' },
374+
input: { runId: 'run-1' },
375+
})
376+
).rejects.toMatchObject({ code: 'forbidden' })
377+
378+
expect(mocks.getLog).not.toHaveBeenCalled()
379+
})
380+
327381
it('rejects a workspace key outside the run workspace before materialization', async () => {
328382
await expect(
329383
getPublicLog.execute({

apps/sim/lib/secrets/application/use-cases.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Principal } from '@sim/auth/principal'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66
import type {
77
DeleteSecretInput,
8+
ListSecretsInput,
89
ListSecretUsageInput,
910
SetSecretInput,
1011
} from '@/lib/secrets/application/use-cases'
@@ -193,6 +194,64 @@ describe('secret application use cases', () => {
193194
expect(result.values).toEqual({})
194195
})
195196

197+
/**
198+
* The list's cross-user boundary is one SQL predicate — `ownedEnvSecretsOnly` — and nothing
199+
* else keeps another user's `env_personal` row out of the page. Dropping it would leave every
200+
* other assertion in this file green, because the query is mocked and its canned rows prove
201+
* nothing about what was asked for. So the pin is on the composed argument, per scope.
202+
*/
203+
it.each([
204+
['personal' as const, ['env_personal']],
205+
['workspace' as const, ['env_workspace']],
206+
[undefined, ['env_workspace', 'env_personal']],
207+
])("restricts the list to the caller's own secrets for scope %s", async (scope, types) => {
208+
await listSecretsUseCase.execute({
209+
principal: session,
210+
input: {
211+
workspaceId: workspace.workspaceId,
212+
scope,
213+
sortBy: 'name',
214+
sortOrder: 'asc',
215+
limit: 50,
216+
},
217+
})
218+
219+
expect(mocks.listCredentials).toHaveBeenCalledTimes(1)
220+
const [args] = mocks.listCredentials.mock.calls[0]
221+
expect(args.ownedEnvSecretsOnly).toBe(true)
222+
expect(args.types).toEqual(types)
223+
/** Taken from the principal, never from caller-supplied input. */
224+
expect(args.userId).toBe(session.userId)
225+
expect(args.workspaceId).toBe(workspace.workspaceId)
226+
})
227+
228+
it('never reads secret metadata for a workspace API key, which has no personal identity', async () => {
229+
const execute = listSecretsUseCase.execute as (args: {
230+
principal: Principal
231+
input: ListSecretsInput
232+
}) => Promise<unknown>
233+
234+
await expect(
235+
execute({
236+
principal: {
237+
kind: 'workspace_api_key',
238+
workspaceId: workspace.workspaceId,
239+
keyId: 'workspace-key-1',
240+
},
241+
input: {
242+
workspaceId: workspace.workspaceId,
243+
scope: 'personal',
244+
sortBy: 'name',
245+
sortOrder: 'asc',
246+
limit: 50,
247+
},
248+
})
249+
).rejects.toMatchObject({ code: 'forbidden' })
250+
251+
expect(mocks.loadContext).not.toHaveBeenCalled()
252+
expect(mocks.listCredentials).not.toHaveBeenCalled()
253+
})
254+
196255
it('rejects workspace keys before resolving or reading secret state', async () => {
197256
const execute = setSecretUseCase.execute as (args: {
198257
principal: Principal

apps/sim/lib/secrets/application/use-cases.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,24 @@ function credentialTypes(scope?: SecretScope) {
6666
* The secret's public `name` is stored as the credential `displayName`, so the
6767
* caller-facing `name` sort aliases to that column here. The alias must not
6868
* escape into the cursor's sort stamp — see {@link listSecretsUseCase}.
69+
*
70+
* KNOWN DIVERGENCE — personal scope is workspace-bound here and nowhere else.
71+
* A personal secret's value is user-global (`environment.variables`, keyed by
72+
* user alone), so {@link setSecretUseCase} and {@link deleteSecretUseCase} both
73+
* act on it without reference to a workspace. This read cannot: `credential`
74+
* carries a NOT NULL `workspaceId`, so a personal secret has no canonical
75+
* metadata row — only per-workspace mirrors, written for the workspaces
76+
* `getUserWorkspaceIds` returns (explicit `permissions` rows plus owned
77+
* workspaces). That set is NARROWER than the set of workspaces a caller can
78+
* authorize into, which also includes inherited organization access. In such a
79+
* workspace there is no mirror, so this returns nothing while `set` and
80+
* `delete` still work — the caller can create a secret they cannot list.
81+
*
82+
* {@link getPersonalSecretMetadata} already works around the same hole for the
83+
* write's response projection. Closing it for reads is a storage change, not a
84+
* scoping change: personal-secret metadata needs one canonical home (a nullable
85+
* `credential.workspaceId`, or a dedicated table) before this query can drop the
86+
* workspace predicate without double-counting mirrors or inventing timestamps.
6987
*/
7088
async function listSecretMetadata(params: {
7189
workspaceId: string

0 commit comments

Comments
 (0)