Skip to content

Commit a51592a

Browse files
committed
Merge branch 'pgx/p2' into feat/permission-groups-coverage
2 parents 23a6d17 + 414302c commit a51592a

12 files changed

Lines changed: 658 additions & 99 deletions

File tree

‎apps/sim/app/api/v1/capability-gate.test.ts‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,9 +271,10 @@ describe('v1 permission-group capability gate', () => {
271271

272272
/**
273273
* `personal_api_key.use` refuses a *principal kind* rather than a module, so it
274-
* is asserted in `resolveWorkspaceScope` — before, and separately from, the
275-
* capability the route declares. A workspace key is not a personal key, and its
276-
* creator's group must not decide whether it may be used.
274+
* is asserted separately from the capability the route declares — but, like
275+
* every other group key, only after the workspace role check. A workspace key
276+
* is not a personal key, and its creator's group must not decide whether it
277+
* may be used.
277278
*/
278279
describe('personal_api_key.use — the key kind, not the module', () => {
279280
it('refuses a personal key whose group disables personal API keys', async () => {
@@ -296,6 +297,42 @@ describe('v1 permission-group capability gate', () => {
296297
expect(response.status).toBe(200)
297298
expect(mockListTables).toHaveBeenCalledWith(WORKSPACE_ID)
298299
})
300+
301+
/**
302+
* The group key runs behind the role check, so a stranger to the workspace
303+
* is answered with the concealed role failure rather than with a refusal
304+
* naming how an organization configured one of its cohorts. Asked the other
305+
* way round, a caller with no reach into the workspace at all learns that
306+
* the workspace's organization runs a group, and that the group withholds
307+
* personal keys.
308+
*
309+
* The workspace COLUMN still answers first — it names no group, needs no
310+
* query, and is the answer whatever the role turns out to be — which is the
311+
* split `authorizeWorkspaceOperation` makes and the next case pins.
312+
*/
313+
it('answers a non-member on role, not on the group that withholds personal keys', async () => {
314+
mockGetUserEntityPermissions.mockResolvedValue(null)
315+
governedBy({ disablePersonalApiKeys: true })
316+
317+
const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`))
318+
const body = await response.json()
319+
320+
expect(response.status).toBe(403)
321+
expect(body.error).toBe('Access denied')
322+
expect(mockListTables).not.toHaveBeenCalled()
323+
})
324+
325+
it("answers a non-member on the workspace's own column, which names no group", async () => {
326+
mockGetUserEntityPermissions.mockResolvedValue(null)
327+
mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false })
328+
329+
const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`))
330+
const body = await response.json()
331+
332+
expect(response.status).toBe(403)
333+
expect(body.error).toMatch(/personal API key/i)
334+
expect(mockListTables).not.toHaveBeenCalled()
335+
})
299336
})
300337

301338
it('refuses on role before capability, so a non-member learns nothing about the group', async () => {

‎apps/sim/app/api/v1/middleware.test.ts‎

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
* `used = limit - remaining` gets a negative number.
88
*/
99

10-
import { createMockRequest } from '@sim/testing'
10+
import {
11+
createMockRequest,
12+
permissionGroupScopeMock,
13+
permissionGroupScopeMockFns,
14+
resetPermissionGroupScopeMock,
15+
} from '@sim/testing'
1116
import { beforeEach, describe, expect, it, vi } from 'vitest'
1217
import { z } from 'zod'
1318
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
@@ -17,13 +22,21 @@ import {
1722
recordRateLimitSnapshot,
1823
} from '@/lib/api/server/rate-limit-context'
1924

20-
const { mockAuthenticateV1Request, mockGetSubscription, mockCheckRateLimit, mockGetRateLimit } =
21-
vi.hoisted(() => ({
22-
mockAuthenticateV1Request: vi.fn(),
23-
mockGetSubscription: vi.fn(),
24-
mockCheckRateLimit: vi.fn(),
25-
mockGetRateLimit: vi.fn(),
26-
}))
25+
const {
26+
mockAuthenticateV1Request,
27+
mockGetSubscription,
28+
mockCheckRateLimit,
29+
mockGetRateLimit,
30+
mockGetUserEntityPermissions,
31+
mockGetWorkspaceBillingSettings,
32+
} = vi.hoisted(() => ({
33+
mockAuthenticateV1Request: vi.fn(),
34+
mockGetSubscription: vi.fn(),
35+
mockCheckRateLimit: vi.fn(),
36+
mockGetRateLimit: vi.fn(),
37+
mockGetUserEntityPermissions: vi.fn(),
38+
mockGetWorkspaceBillingSettings: vi.fn(),
39+
}))
2740

2841
vi.mock('@/app/api/v1/auth', () => ({
2942
authenticateV1Request: mockAuthenticateV1Request,
@@ -40,9 +53,22 @@ vi.mock('@/lib/core/rate-limiter', () => ({
4053
},
4154
}))
4255

56+
vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock)
57+
58+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
59+
getUserEntityPermissions: mockGetUserEntityPermissions,
60+
}))
61+
62+
vi.mock('@/lib/workspaces/utils', () => ({
63+
getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings,
64+
getWorkspaceBilledAccountUserId: vi.fn(async () => 'billed-user'),
65+
}))
66+
67+
import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields'
4368
import {
4469
authenticateRequest,
4570
checkRateLimit,
71+
checkWorkspaceScope,
4672
createRateLimitResponse,
4773
v1ValidationErrorResponse,
4874
} from '@/app/api/v1/middleware'
@@ -274,3 +300,79 @@ describe('rate-limit snapshot context', () => {
274300
expect(getRateLimitHeaders(req)).toBeNull()
275301
})
276302
})
303+
304+
/**
305+
* The table routes authorize with `checkWorkspaceScope` and then a domain
306+
* helper (`checkAccess`) that runs the workspace ROLE check. `checkAccess`
307+
* gates the module (`tables.use`), never the key kind, so `personal_api_key.use`
308+
* has to be asked in the wrapper — which means the wrapper has to order itself
309+
* behind the role, because nothing downstream will.
310+
*
311+
* The workspace column keeps answering first: it names no group, so refusing on
312+
* it tells a stranger only what the workspace itself is set to.
313+
*/
314+
describe('checkWorkspaceScope', () => {
315+
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
316+
const USER_ID = 'user-1'
317+
318+
function personalKeyRateLimit() {
319+
return {
320+
allowed: true,
321+
remaining: 1,
322+
limit: 1,
323+
resetAt: new Date(),
324+
userId: USER_ID,
325+
keyType: 'personal' as const,
326+
principal: { kind: 'personal_api_key' as const, userId: USER_ID, keyId: 'key-1' },
327+
}
328+
}
329+
330+
function withholdsPersonalKeys() {
331+
permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({
332+
...DEFAULT_PERMISSION_GROUP_CONFIG,
333+
disablePersonalApiKeys: true,
334+
})
335+
}
336+
337+
beforeEach(() => {
338+
vi.clearAllMocks()
339+
resetPermissionGroupScopeMock()
340+
mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true })
341+
mockGetUserEntityPermissions.mockResolvedValue('admin')
342+
})
343+
344+
it('refuses a member whose group withholds personal API keys', async () => {
345+
withholdsPersonalKeys()
346+
347+
const response = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID)
348+
349+
expect(response).not.toBeNull()
350+
expect(response?.status).toBe(403)
351+
await expect(response?.json()).resolves.toMatchObject({
352+
error: expect.stringMatching(/personal API key/i),
353+
})
354+
})
355+
356+
it('leaves a non-member to the downstream role check rather than naming the group', async () => {
357+
mockGetUserEntityPermissions.mockResolvedValue(null)
358+
withholdsPersonalKeys()
359+
360+
const response = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID)
361+
362+
expect(response).toBeNull()
363+
expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled()
364+
})
365+
366+
it("still refuses a non-member on the workspace's own column, which names no group", async () => {
367+
mockGetUserEntityPermissions.mockResolvedValue(null)
368+
mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false })
369+
370+
const response = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID)
371+
372+
expect(response).not.toBeNull()
373+
expect(response?.status).toBe(403)
374+
await expect(response?.json()).resolves.toMatchObject({
375+
error: expect.stringMatching(/personal API key/i),
376+
})
377+
})
378+
})

‎apps/sim/app/api/v1/middleware.ts‎

Lines changed: 81 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,15 @@ export async function resolveCapabilityRefusal(
344344
* - A personal key is rejected when the workspace has disabled personal API
345345
* keys (`allowPersonalApiKeys = false`). Other surfaces enforcing the same
346346
* policy share `PERSONAL_KEY_DENIED`.
347+
*
348+
* Both are properties of the workspace rather than of any group, so both run
349+
* ahead of the role check, exactly as `authorizeWorkspaceOperation` runs the
350+
* `allowPersonalApiKeys` column ahead of `requireCurrentHumanRole`: they need
351+
* no group to resolve, and refusing a key the workspace has switched off is the
352+
* answer whatever the caller's role turns out to be.
353+
*
354+
* The group half of the same policy is NOT here — see
355+
* {@link resolvePersonalKeyGroupRefusal}.
347356
*/
348357
export async function resolveWorkspaceScope(
349358
rateLimit: RateLimitResult,
@@ -370,40 +379,68 @@ export async function resolveWorkspaceScope(
370379
message: PERSONAL_KEY_DENIED,
371380
}
372381
}
373-
374-
/**
375-
* permission-group-enforced: personal_api_key.use — v1 authorizes in this
376-
* middleware rather than through the application funnel, so the group check
377-
* the funnel applies has to be repeated here or the same key that v2
378-
* refuses would still work against v1.
379-
*/
380-
const governedUserId = capabilityGovernedUserId(rateLimit)
381-
if (governedUserId) {
382-
const withheld = await isWorkspaceCapabilityWithheld(
383-
governedUserId,
384-
requestedWorkspaceId,
385-
'personal_api_key.use'
386-
)
387-
if (withheld) {
388-
return {
389-
status: 403,
390-
code: 'FORBIDDEN',
391-
message: PERSONAL_KEY_DENIED,
392-
}
393-
}
394-
}
395382
}
396383

397384
return null
398385
}
399386

400387
/**
401-
* Core workspace-access check: key scope, then the user's workspace permission
402-
* level, then the permission-group capability the route declares. Returns a
403-
* structured failure or null on success.
388+
* The group half of the personal-key policy: `personal_api_key.use`, repeated
389+
* here because v1 authorizes in this middleware rather than through the
390+
* application funnel, and without it the same key that v2 refuses would still
391+
* work against v1.
392+
*
393+
* It answers only AFTER the caller's workspace role has been verified, which is
394+
* the ordering `authorizeWorkspaceOperation` uses and the reason
395+
* {@link resolveCapabilityRefusal}'s contract says never to run a group key
396+
* ahead of the role: the refusal names how an organization configured one
397+
* cohort, and handing that to a caller with no reach into the workspace tells a
398+
* stranger about the organization's configuration. The column check above may
399+
* stay early precisely because it names no group.
404400
*
405-
* Capability comes last, matching `authorizeWorkspaceOperation` — see
406-
* {@link resolveCapabilityRefusal} for why the ordering is load-bearing.
401+
* `roleVerifiedFor` is the user id a caller has already checked, not a boolean,
402+
* so a caller that verified some OTHER subject's role cannot vouch for this
403+
* one. When it does not match, the role is resolved here instead, and a caller
404+
* with no read access is handed back `null` so the surface's own role failure —
405+
* the concealed one — is what it answers with. That second lookup is free:
406+
* `getUserEntityPermissions` for a workspace goes through the request-scoped
407+
* memo the role check itself uses.
408+
*/
409+
async function resolvePersonalKeyGroupRefusal(
410+
rateLimit: RateLimitResult,
411+
workspaceId: string,
412+
roleVerifiedFor: string | null
413+
): Promise<WorkspaceAccessError | null> {
414+
const governedUserId = capabilityGovernedUserId(rateLimit)
415+
if (!governedUserId) return null
416+
417+
if (roleVerifiedFor !== governedUserId) {
418+
const permission = await getUserEntityPermissions(governedUserId, 'workspace', workspaceId)
419+
if (!permissionSatisfies(permission, 'read')) return null
420+
}
421+
422+
// permission-group-enforced: personal_api_key.use — v1 authorizes in this middleware, not through the funnel
423+
if (!(await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'personal_api_key.use'))) {
424+
return null
425+
}
426+
427+
return {
428+
status: 403,
429+
code: 'FORBIDDEN',
430+
message: PERSONAL_KEY_DENIED,
431+
}
432+
}
433+
434+
/**
435+
* Core workspace-access check: key scope and the workspace's own columns, then
436+
* the user's workspace permission level, then the two permission-group
437+
* decisions — the personal-key refusal, then the capability the route declares.
438+
* Returns a structured failure or null on success.
439+
*
440+
* Both group keys come after the role, matching `authorizeWorkspaceOperation` —
441+
* see {@link resolveCapabilityRefusal} for why the ordering is load-bearing.
442+
* The personal-key refusal sits first of the two for the reason the funnel
443+
* gives: the remedies differ, and the narrower one is worth naming first.
407444
*/
408445
export async function resolveWorkspaceAccess(
409446
rateLimit: RateLimitResult,
@@ -420,22 +457,33 @@ export async function resolveWorkspaceAccess(
420457
return { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
421458
}
422459

460+
const personalKeyRefusal = await resolvePersonalKeyGroupRefusal(rateLimit, workspaceId, userId)
461+
if (personalKeyRefusal) return personalKeyRefusal
462+
423463
return resolveCapabilityRefusal(rateLimit, workspaceId, capability)
424464
}
425465

426466
/**
427-
* v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body.
467+
* v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body,
468+
* plus the personal-key group refusal that belongs with it.
469+
*
470+
* It deliberately gates no MODULE capability: it runs before the route's role
471+
* check, and a route using it authorizes its resource through a domain helper
472+
* afterwards (the table routes call `checkAccess`, which applies `tables.use`
473+
* itself), so the capability is declared there.
428474
*
429-
* Scope only — it deliberately gates no module capability, because it runs
430-
* before the route's role check. A route using it authorizes its resource
431-
* through a domain helper afterwards (the table routes call `checkAccess`,
432-
* which applies `tables.use` itself), so the capability is declared there.
475+
* `personal_api_key.use` cannot wait for that helper — `checkAccess` gates the
476+
* module, not the key kind — so it is asked here, and
477+
* {@link resolvePersonalKeyGroupRefusal} resolves the caller's role itself
478+
* before answering rather than relying on a role check this wrapper never runs.
433479
*/
434480
export async function checkWorkspaceScope(
435481
rateLimit: RateLimitResult,
436482
requestedWorkspaceId: string
437483
): Promise<NextResponse | null> {
438-
const failure = await resolveWorkspaceScope(rateLimit, requestedWorkspaceId)
484+
const failure =
485+
(await resolveWorkspaceScope(rateLimit, requestedWorkspaceId)) ??
486+
(await resolvePersonalKeyGroupRefusal(rateLimit, requestedWorkspaceId, null))
439487
return failure ? workspaceAccessErrorResponse(failure) : null
440488
}
441489

‎apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,21 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
119119
* enterprise organization. The server combines them the same way, so offering
120120
* a key type here that it would refuse is the only failure worth avoiding —
121121
* which is why the policy fails closed while its query is pending or errored,
122-
* rather than treating an unanswered question as an unrestricted answer. In
123-
* personal scope the hook is disabled (no `workspaceId`) and no group
124-
* applies, so `isSuccess` is only required when the query actually runs.
122+
* rather than treating an unanswered question as an unrestricted answer.
123+
*
124+
* Failing closed on `isSuccess` needs the query to be able to recover, or one
125+
* transient failure disables the create button for the session with nothing
126+
* to say why: the client's defaults retry once, never on remount, and do not
127+
* refetch on focus outside the desktop app. `useUserPermissionConfig` raises
128+
* both, which is what makes this gate self-healing rather than sticky.
129+
*
130+
* The `!workspaceId` arm is defense, not a live case: this component ships
131+
* only from the workspace settings panel, always as `scope='combined'`, and
132+
* `workspaceId` is that route's own param, so the query always runs. It
133+
* covers the `|| ''` fallback above — a render outside the route would
134+
* disable the hook, and `isSuccess` on a query that never runs is false
135+
* forever, which would present as a dead button rather than a refusal. The
136+
* server is the enforcement either way; this gate is the affordance.
125137
*/
126138
const permissionPolicyReady = !workspaceId || permissionConfigQuery.isSuccess
127139

0 commit comments

Comments
 (0)