Skip to content

Commit 3e30fe3

Browse files
committed
Merge branch 'pgx/t2' into feat/permission-groups-coverage
2 parents 2f8d064 + bf9cdd7 commit 3e30fe3

39 files changed

Lines changed: 986 additions & 345 deletions

.agents/skills/validate-permission-group-item/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ Confirm the assertions at the bottom of `fields.ts` still name a field of this k
4848
## Step 3: Admin UI (`ee/access-control/components/group-detail.tsx`)
4949

5050
- **Boolean:** appears automatically via `PLATFORM_FEATURES`. Confirm its `category` is in `PLATFORM_CATEGORY_ORDER`; an unlisted one renders after every ordered section.
51-
- **Allowlist / denylist:** renders **nothing** unless it is in the `featureExtras` map — keyed by the *parent boolean's feature id*, not the config key. No picker and no bespoke section means no admin can ever set it. Report it.
52-
- For a picker, check both behaviors: refuses an empty selection (`if (values.length === 0) return`) and collapses a full one back to `null` (otherwise the allowlist freezes at today's members).
51+
- **Nested allowlist / denylist** (one that qualifies a platform-feature boolean): renders **nothing** unless it is in the `featureExtras` map — keyed by the *parent boolean's feature id*, not the config key. No picker there means no admin can ever set it. Report it. Top-level lists — `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, `deniedTools` — are not in `featureExtras` and must not be reported for it; they render from the dedicated Providers and Blocks sections, so check them there.
52+
- For an **allowlist** picker, check both behaviors: refuses an empty selection (`if (values.length === 0) return`) and collapses a full one back to `null` (otherwise the allowlist freezes at today's members). A **denylist** picker must do neither: clearing every entry is how an admin denies nothing, and a full selection is a real state that denies everything.
5353
- Check the parent is the right one (`allowedKnowledgeConnectors` under `hide-knowledge-base`, not `disable-knowledge-base-creation`).
5454

5555
## Step 4: Capability rule

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

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@ const {
2929
mockGetRateLimit,
3030
mockGetUserEntityPermissions,
3131
mockGetWorkspaceBillingSettings,
32+
mockGetWorkspaceBilledAccountUserId,
3233
} = vi.hoisted(() => ({
3334
mockAuthenticateV1Request: vi.fn(),
3435
mockGetSubscription: vi.fn(),
3536
mockCheckRateLimit: vi.fn(),
3637
mockGetRateLimit: vi.fn(),
3738
mockGetUserEntityPermissions: vi.fn(),
3839
mockGetWorkspaceBillingSettings: vi.fn(),
40+
mockGetWorkspaceBilledAccountUserId: vi.fn(),
3941
}))
4042

4143
vi.mock('@/app/api/v1/auth', () => ({
@@ -61,7 +63,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
6163

6264
vi.mock('@/lib/workspaces/utils', () => ({
6365
getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings,
64-
getWorkspaceBilledAccountUserId: vi.fn(async () => 'billed-user'),
66+
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
6567
}))
6668

6769
import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields'
@@ -70,6 +72,7 @@ import {
7072
checkRateLimit,
7173
checkWorkspaceScope,
7274
createRateLimitResponse,
75+
requireWorkspaceRequestActor,
7376
v1ValidationErrorResponse,
7477
} from '@/app/api/v1/middleware'
7578

@@ -421,3 +424,47 @@ describe('checkWorkspaceScope', () => {
421424
expect(response?.status).toBe(403)
422425
})
423426
})
427+
428+
describe('requireWorkspaceRequestActor', () => {
429+
beforeEach(() => {
430+
vi.clearAllMocks()
431+
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-user')
432+
})
433+
434+
it('substitutes the billed account as the system actor for a workspace key', async () => {
435+
const actor = await requireWorkspaceRequestActor(
436+
{ allowed: true, keyType: 'workspace', userId: 'key-creator' } as never,
437+
'workspace-1'
438+
)
439+
440+
expect(actor).toEqual({ ok: true, actorUserId: 'billed-user' })
441+
})
442+
443+
it('keeps the owner for a personal key', async () => {
444+
const actor = await requireWorkspaceRequestActor(
445+
{ allowed: true, keyType: 'personal', userId: 'user-1' } as never,
446+
'workspace-1'
447+
)
448+
449+
expect(actor).toEqual({ ok: true, actorUserId: 'user-1' })
450+
})
451+
452+
/**
453+
* An archived or deleted workspace has no billed account to stand in. That is
454+
* a reachable request about an unreachable workspace, not a server fault: the
455+
* call sites used to throw, and the routes' catch-all reported it as a 500.
456+
*/
457+
it('projects an unresolvable actor onto a 400 rather than throwing', async () => {
458+
mockGetWorkspaceBilledAccountUserId.mockResolvedValue(null)
459+
460+
const actor = await requireWorkspaceRequestActor(
461+
{ allowed: true, keyType: 'workspace', userId: 'key-creator' } as never,
462+
'workspace-gone'
463+
)
464+
465+
expect(actor.ok).toBe(false)
466+
if (actor.ok) throw new Error('expected a refusal')
467+
expect(actor.response.status).toBe(400)
468+
await expect(actor.response.json()).resolves.toEqual({ error: 'Invalid workspace ID' })
469+
})
470+
})

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,30 @@ export async function resolveWorkspaceRequestActor(
532532
return rateLimit.userId ?? null
533533
}
534534

535+
/**
536+
* {@link resolveWorkspaceRequestActor} as a route-ready result.
537+
*
538+
* The resolver answers `null` for a real, reachable request: an authenticated
539+
* workspace key whose workspace has since been archived or deleted has no
540+
* billed account to stand in as its system actor. Every call site used to
541+
* `throw` on that, which the route's catch-all turned into a generic 500 — an
542+
* unreachable workspace reported as a server fault. It is the same condition
543+
* the routes already report as a 400 `Invalid workspace ID` when the addressed
544+
* table belongs to another workspace, so it is reported the same way, from one
545+
* place, rather than five copies of a throw.
546+
*/
547+
export async function requireWorkspaceRequestActor(
548+
rateLimit: RateLimitResult,
549+
workspaceId: string
550+
): Promise<{ ok: true; actorUserId: string } | { ok: false; response: NextResponse }> {
551+
const actorUserId = await resolveWorkspaceRequestActor(rateLimit, workspaceId)
552+
if (actorUserId) return { ok: true, actorUserId }
553+
return {
554+
ok: false,
555+
response: NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }),
556+
}
557+
}
558+
535559
/**
536560
* v1 wrapper: renders {@link resolveWorkspaceAccess} as the v1 `{ error }` body.
537561
* Returns null on success, NextResponse on failure.

apps/sim/app/api/v1/tables/[tableId]/route.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,20 @@ vi.mock('@/app/api/v1/middleware', () => ({
4646
/**
4747
* Mirrors the real resolver: a workspace key names no human, so the billed
4848
* account stands in as the explicit system actor; anything else keeps its
49-
* owner.
49+
* owner. The route reads it through `requireWorkspaceRequestActor`, which
50+
* projects an unresolvable actor onto a 400 instead of throwing, so the mock
51+
* reproduces that projection rather than only the raw resolver.
5052
*/
5153
resolveWorkspaceRequestActor: mockResolveWorkspaceRequestActor,
54+
requireWorkspaceRequestActor: async (rateLimit: unknown, workspaceId: string) => {
55+
const actorUserId = await mockResolveWorkspaceRequestActor(rateLimit, workspaceId)
56+
return actorUserId
57+
? { ok: true, actorUserId }
58+
: {
59+
ok: false,
60+
response: NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }),
61+
}
62+
},
5263
}))
5364

5465
vi.mock('@/lib/table', () => ({
@@ -109,6 +120,23 @@ describe('DELETE /api/v1/tables/[tableId] — orchestration failure projection',
109120
mockGetUserEntityPermissions.mockResolvedValue('admin')
110121
})
111122

123+
/**
124+
* A workspace key whose workspace has since been archived resolves no billed
125+
* account, so there is no system actor to attribute the deletion to. That is
126+
* a reachable request about an unreachable workspace, not a server fault: it
127+
* used to `throw`, and the catch-all reported it as a 500.
128+
*/
129+
it('reports an unresolvable workspace actor as a 400, not a 500', async () => {
130+
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1', keyType: 'workspace' })
131+
mockResolveWorkspaceRequestActor.mockResolvedValue(null)
132+
133+
const response = await DELETE(makeRequest(), makeContext())
134+
135+
expect(response.status).toBe(400)
136+
expect(await response.json()).toEqual({ error: 'Invalid workspace ID' })
137+
expect(mockPerformDeleteTable).not.toHaveBeenCalled()
138+
})
139+
112140
it('renders an unclassified internal failure as a fixed generic message', async () => {
113141
mockPerformDeleteTable.mockResolvedValue({
114142
success: false,

apps/sim/app/api/v1/tables/[tableId]/route.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
checkRateLimit,
1818
checkWorkspaceScope,
1919
createRateLimitResponse,
20-
resolveWorkspaceRequestActor,
20+
requireWorkspaceRequestActor,
2121
tableAccessPrincipal,
2222
} from '@/app/api/v1/middleware'
2323

@@ -137,12 +137,14 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab
137137
* A workspace key names no human, so its creator must not be attributed the
138138
* deletion in audit and analytics. The shared resolver substitutes the
139139
* explicit system actor for a workspace key and keeps the owner for a
140-
* personal one, exactly as the row routes on this table already do.
140+
* personal one, exactly as the row routes on this table already do. An
141+
* archived or deleted workspace has no billed account to stand in, which is
142+
* a controlled 400 rather than an uncaught throw the catch-all would report
143+
* as a 500.
141144
*/
142-
const actorUserId = await resolveWorkspaceRequestActor(rateLimit, workspaceId)
143-
if (!actorUserId) {
144-
throw new Error(`Unable to resolve system actor for workspace ${workspaceId}`)
145-
}
145+
const actor = await requireWorkspaceRequestActor(rateLimit, workspaceId)
146+
if (!actor.ok) return actor.response
147+
const actorUserId = actor.actorUserId
146148

147149
const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write')
148150
if (!result.ok) return accessError(result, requestId, tableId)

apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
checkRateLimit,
3131
checkWorkspaceScope,
3232
createRateLimitResponse,
33-
resolveWorkspaceRequestActor,
33+
requireWorkspaceRequestActor,
3434
tableAccessPrincipal,
3535
v1ValidationErrorResponse,
3636
v1ValidationErrorResponseFromError,
@@ -135,10 +135,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
135135

136136
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write')
137137
if (scopeError) return scopeError
138-
const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId)
139-
if (!actorUserId) {
140-
throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`)
141-
}
138+
const actor = await requireWorkspaceRequestActor(rateLimit, validated.workspaceId)
139+
if (!actor.ok) return actor.response
140+
const actorUserId = actor.actorUserId
142141

143142
const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write')
144143
if (!result.ok) return accessError(result, requestId, tableId)

apps/sim/app/api/v1/tables/[tableId]/rows/route.ts

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import {
4444
checkRateLimit,
4545
checkWorkspaceScope,
4646
createRateLimitResponse,
47-
resolveWorkspaceRequestActor,
47+
requireWorkspaceRequestActor,
4848
tableAccessPrincipal,
4949
v1ValidationErrorResponse,
5050
v1ValidationErrorResponseFromError,
@@ -243,15 +243,9 @@ export const POST = withRouteHandler(
243243
const batchValidated = parsed.data.body
244244
const scopeError = await checkWorkspaceScope(rateLimit, batchValidated.workspaceId, 'write')
245245
if (scopeError) return scopeError
246-
const actorUserId = await resolveWorkspaceRequestActor(
247-
rateLimit,
248-
batchValidated.workspaceId
249-
)
250-
if (!actorUserId) {
251-
throw new Error(
252-
`Unable to resolve system actor for workspace ${batchValidated.workspaceId}`
253-
)
254-
}
246+
const batchActor = await requireWorkspaceRequestActor(rateLimit, batchValidated.workspaceId)
247+
if (!batchActor.ok) return batchActor.response
248+
const actorUserId = batchActor.actorUserId
255249
return handleBatchInsert(
256250
requestId,
257251
tableId,
@@ -266,10 +260,9 @@ export const POST = withRouteHandler(
266260

267261
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write')
268262
if (scopeError) return scopeError
269-
const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId)
270-
if (!actorUserId) {
271-
throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`)
272-
}
263+
const actor = await requireWorkspaceRequestActor(rateLimit, validated.workspaceId)
264+
if (!actor.ok) return actor.response
265+
const actorUserId = actor.actorUserId
273266

274267
const accessResult = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write')
275268
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
@@ -350,10 +343,9 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR
350343

351344
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write')
352345
if (scopeError) return scopeError
353-
const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId)
354-
if (!actorUserId) {
355-
throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`)
356-
}
346+
const actor = await requireWorkspaceRequestActor(rateLimit, validated.workspaceId)
347+
if (!actor.ok) return actor.response
348+
const actorUserId = actor.actorUserId
357349

358350
const accessResult = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write')
359351
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)

apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
checkRateLimit,
2222
checkWorkspaceScope,
2323
createRateLimitResponse,
24-
resolveWorkspaceRequestActor,
24+
requireWorkspaceRequestActor,
2525
tableAccessPrincipal,
2626
v1ValidationErrorResponse,
2727
v1ValidationErrorResponseFromError,
@@ -55,10 +55,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser
5555

5656
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write')
5757
if (scopeError) return scopeError
58-
const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId)
59-
if (!actorUserId) {
60-
throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`)
61-
}
58+
const actor = await requireWorkspaceRequestActor(rateLimit, validated.workspaceId)
59+
if (!actor.ok) return actor.response
60+
const actorUserId = actor.actorUserId
6261

6362
const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write')
6463
if (!result.ok) return accessError(result, requestId, tableId)

0 commit comments

Comments
 (0)