Skip to content

Commit 5d0452f

Browse files
committed
Merge branch 'pgx/s2' into feat/permission-groups-coverage
2 parents 1a387ed + 0b7fc4b commit 5d0452f

24 files changed

Lines changed: 21163 additions & 40 deletions

File tree

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,7 @@ For an allowlist the three states must be tested separately — `null` permits e
106106
bun run check:permission-group-enforcement
107107
bun run check:application-graph
108108
bun run check:capability-subject
109-
cd apps/sim && bun run type-check
110-
cd apps/sim && bunx vitest run lib/permission-groups
109+
cd apps/sim && bun run type-check && bunx vitest run lib/permission-groups
111110
```
112111

113112
All three are inside `check:audits`, which derives its list from the `check:*` scripts in `package.json` — a new audit is opted *out* deliberately. Read the output, not the exit codes. Reference success lines (counts grow):

apps/sim/app/api/logs/stats/route.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ describe('GET /api/logs/stats', () => {
8080
expect(mocks.readLogStatsBounds).toHaveBeenCalled()
8181
})
8282

83+
/**
84+
* The refusal needs both conditions, so an unfiltered read can never be
85+
* refused and the config lookup — which re-reads workspace and
86+
* organization/group state — is pure cost on the dashboard's common path.
87+
*/
88+
it('does not consult the group for an unfiltered read', async () => {
89+
resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true })
90+
91+
const response = await GET(makeRequest())
92+
93+
expect(response.status).toBe(200)
94+
expect(resolveGroupConfigMock).not.toHaveBeenCalled()
95+
})
96+
8397
it('answers the same cost-filtered read when no group withholds spend', async () => {
8498
const response = await GET(makeRequest('&costOperator=%3E&costValue=0.5'))
8599

apps/sim/app/api/logs/stats/route.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
7171
* workspace access check above has already passed, so the caller is a
7272
* member learning about their own group.
7373
*/
74-
const hideCostInfo = await isWorkspaceCapabilityWithheld(
75-
userId,
76-
params.workspaceId,
77-
'logs.cost'
78-
)
79-
if (hideCostInfo && logQuerySelectsCost(params)) {
74+
if (
75+
logQuerySelectsCost(params) &&
76+
(await isWorkspaceCapabilityWithheld(userId, params.workspaceId, 'logs.cost'))
77+
) {
8078
return capabilityRefusalResponse('logs.cost')
8179
}
8280

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ const {
1919
mockGetTableById,
2020
mockGetUserEntityPermissions,
2121
mockPerformDeleteTable,
22+
mockResolveWorkspaceRequestActor,
2223
} = vi.hoisted(() => ({
2324
mockCheckRateLimit: vi.fn(),
2425
mockCheckWorkspaceScope: vi.fn(),
2526
mockGetTableById: vi.fn(),
2627
mockGetUserEntityPermissions: vi.fn(),
2728
mockPerformDeleteTable: vi.fn(),
29+
mockResolveWorkspaceRequestActor: vi.fn(),
2830
}))
2931

3032
vi.mock('@/app/api/v1/middleware', () => ({
@@ -41,6 +43,12 @@ vi.mock('@/app/api/v1/middleware', () => ({
4143
rateLimit.keyType === 'personal'
4244
? { kind: 'user', userId: rateLimit.userId }
4345
: { kind: 'workspace_api_key', keyCreatorUserId: rateLimit.userId },
46+
/**
47+
* Mirrors the real resolver: a workspace key names no human, so the billed
48+
* account stands in as the explicit system actor; anything else keeps its
49+
* owner.
50+
*/
51+
resolveWorkspaceRequestActor: mockResolveWorkspaceRequestActor,
4452
}))
4553

4654
vi.mock('@/lib/table', () => ({
@@ -92,6 +100,7 @@ describe('DELETE /api/v1/tables/[tableId] — orchestration failure projection',
92100
vi.clearAllMocks()
93101
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1', keyType: 'personal' })
94102
mockCheckWorkspaceScope.mockResolvedValue(null)
103+
mockResolveWorkspaceRequestActor.mockResolvedValue('user-1')
95104
mockGetTableById.mockResolvedValue({
96105
id: TABLE_ID,
97106
name: 'Table',

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
checkRateLimit,
1818
checkWorkspaceScope,
1919
createRateLimitResponse,
20+
resolveWorkspaceRequestActor,
2021
tableAccessPrincipal,
2122
} from '@/app/api/v1/middleware'
2223

@@ -111,7 +112,6 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab
111112
return createRateLimitResponse(rateLimit)
112113
}
113114

114-
const userId = rateLimit.userId!
115115
const parsed = await parseRequest(v1DeleteTableContract, request, context, {
116116
validationErrorResponse: (error) => {
117117
const hasInvalidTableId = error.issues.some((issue) => issue.path.includes('tableId'))
@@ -133,14 +133,30 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab
133133
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId, 'write')
134134
if (scopeError) return scopeError
135135

136+
/**
137+
* A workspace key names no human, so its creator must not be attributed the
138+
* deletion in audit and analytics. The shared resolver substitutes the
139+
* 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.
141+
*/
142+
const actorUserId = await resolveWorkspaceRequestActor(rateLimit, workspaceId)
143+
if (!actorUserId) {
144+
throw new Error(`Unable to resolve system actor for workspace ${workspaceId}`)
145+
}
146+
136147
const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write')
137148
if (!result.ok) return accessError(result, requestId, tableId)
138149

139150
if (result.table.workspaceId !== workspaceId) {
140151
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
141152
}
142153

143-
const outcome = await performDeleteTable({ table: result.table, userId, requestId, request })
154+
const outcome = await performDeleteTable({
155+
table: result.table,
156+
userId: actorUserId,
157+
requestId,
158+
request,
159+
})
144160
if (!outcome.success) {
145161
return orchestrationOutcomeErrorResponse(outcome, 'Failed to delete table')
146162
}

apps/sim/app/api/workspaces/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { parseRequest } from '@/lib/api/server'
1010
import { getSession } from '@/lib/auth'
1111
import { getActiveOrganizationId } from '@/lib/auth/session-response'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
13+
import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response'
1314
import { captureServerEvent } from '@/lib/posthog/server'
1415
import { createWorkspace } from '@/lib/workspaces/create'
1516
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
@@ -183,7 +184,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
183184
return NextResponse.json({ workspace: newWorkspace })
184185
} catch (error) {
185186
if (error instanceof WorkspaceCreationCapabilityWithheldError) {
186-
return NextResponse.json({ error: error.message }, { status: 403 })
187+
return capabilityRefusalResponse('workspace.create')
187188
}
188189
if (error instanceof WorkspaceCreationContextChangedError) {
189190
return NextResponse.json(

apps/sim/background/workflow-column-execution.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -627,16 +627,18 @@ async function runWorkflowAndWriteTerminal(
627627
rowId,
628628
workspaceId,
629629
/**
630-
* The person who asked, not who pays. For a system-triggered cell
631-
* the billing attribution names the workspace's billing owner, and
632-
* running a member's tool denylist against a bystander is wrong in
633-
* both directions: it fails cells nobody meant to govern, and it
634-
* skips the denylist for the person who actually triggered one.
635-
* `null` means no per-tool gate applies, which is the documented
636-
* behavior for an actorless run — stated, because the field is
637-
* required precisely so it cannot be skipped by omission.
630+
* The person who asked, not who pays. `triggeredByUserId` is an
631+
* attribution: for a workspace-API-key run it names the workspace's
632+
* billing owner, and running that bystander's tool denylist against
633+
* an actorless request is wrong in both directions — it fails cells
634+
* nobody meant to govern, and it skips the denylist for the person
635+
* who actually triggered one. The governed subject is carried
636+
* separately from the dispatch. `null` means no per-tool gate
637+
* applies, which is the documented behavior for an actorless run —
638+
* stated, because the field is required precisely so it cannot be
639+
* skipped by omission.
638640
*/
639-
userId: payload.triggeredByUserId ?? null,
641+
userId: payload.capabilityGovernedUserId ?? null,
640642
signal: attemptSignal,
641643
resolvedSecretTraceRegistry: enrichmentRegistry,
642644
})

apps/sim/ee/access-control/utils/permission-check.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,26 @@ describe('validateBlockType', () => {
445445
await validateBlockType('user-123', 'workspace-1', 'slack')
446446
})
447447

448+
/**
449+
* Registry keys are lowercase, so a mixed-case block type must be folded
450+
* *before* the successor lookup. Resolving first makes `getBlock('Slack')`
451+
* miss, the successor answer `Slack`, and the comparison fall back to
452+
* `slack` — refusing a block the allowlist permits as `slack_v2`.
453+
*/
454+
it('resolves a superseded block supplied with different casing', async () => {
455+
setEnterpriseOrgWorkspace()
456+
mockGetBlock.mockImplementation((type: string) =>
457+
type === 'slack'
458+
? { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } }
459+
: type === 'slack_v2'
460+
? {}
461+
: undefined
462+
)
463+
queueGroupResolution([{ config: { allowedIntegrations: ['slack_v2'] } }])
464+
465+
await validateBlockType('user-123', 'workspace-1', 'Slack')
466+
})
467+
448468
it('still rejects a block absent from a mixed-case stored allowlist', async () => {
449469
setEnterpriseOrgWorkspace()
450470
queueGroupResolution([{ config: { allowedIntegrations: ['Slack'] } }])

apps/sim/ee/access-control/utils/permission-check.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,16 @@ function assertBlockTypeAllowed(
270270
* current block covers every retired version of the same integration. The
271271
* editor only offers current ids, so without this an admin could not deny a
272272
* legacy block even knowing it existed.
273+
*
274+
* Lowercased *before* resolving, not after: registry keys are lowercase, so
275+
* `getBlock('Slack')` misses and the successor lookup answers `Slack` — which
276+
* then compares as `slack` against an allowlist holding `slack_v2` and
277+
* refuses a block both policies allow. `blockType` reaches here from
278+
* persisted workflow state and from an agent block's `tool.type`, neither of
279+
* which is case-normalized upstream. `toAccessControlAllowlist` normalizes
280+
* the policy side the same way.
273281
*/
274-
const allowlistType = resolveAccessControlBlockType(blockType).toLowerCase()
282+
const allowlistType = resolveAccessControlBlockType(blockType.toLowerCase())
275283

276284
if (!toAccessControlAllowlist(config.allowedIntegrations)?.has(allowlistType)) {
277285
const envAllowlist = toAccessControlAllowlist(getAllowedIntegrationsFromEnv())

apps/sim/hooks/use-permission-config.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import {
1515
resolveIntegrationAvailabilityStateForVisibility,
1616
} from '@/lib/integrations/availability'
1717
import {
18+
intersectAccessControlAllowlists,
1819
isBlockTypeAccessControlExempt,
1920
resolveAccessControlBlockType,
20-
toAccessControlAllowlist,
2121
} from '@/lib/permission-groups/block-access'
2222
import {
2323
DEFAULT_PERMISSION_GROUP_CONFIG,
@@ -96,10 +96,20 @@ export function usePermissionConfig(): PermissionConfigResult {
9696
* Both sides of the membership test are judged as the current block, so a
9797
* policy naming a retired id — `ALLOWED_INTEGRATIONS=slack` — still permits
9898
* the successor the editor offers.
99+
*
100+
* Each policy is canonicalized *before* the two are intersected, not after.
101+
* `intersectIntegrationAllowlists` case-folds but does not successor-resolve,
102+
* so a group naming `slack` and an env allowlist naming `slack_v2` intersect
103+
* to nothing textually — hiding an integration both policies allow. Resolving
104+
* first puts them in one vocabulary, and the intersection is then exact.
99105
*/
100106
const allowedAccessControlTypes = useMemo(
101-
() => toAccessControlAllowlist(mergedAllowedIntegrations),
102-
[mergedAllowedIntegrations]
107+
() =>
108+
intersectAccessControlAllowlists(
109+
config.allowedIntegrations,
110+
envAllowlistData?.allowedIntegrations ?? null
111+
),
112+
[config.allowedIntegrations, envAllowlistData]
103113
)
104114

105115
const integrationAvailability = useMemo(() => {

0 commit comments

Comments
 (0)