Skip to content

Commit a94fce5

Browse files
authored
fix(workspace): hydrate access policy before rendering chat (#7875)
* fix(workspace): hydrate access policy before rendering chat * fix(workspace): update layout test setup for access prefetch
1 parent c568401 commit a94fce5

14 files changed

Lines changed: 650 additions & 140 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/** @vitest-environment node */
2+
import { createMockRequest } from '@sim/testing'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
const mocks = vi.hoisted(() => ({
6+
session: vi.fn(),
7+
context: vi.fn(),
8+
role: vi.fn(),
9+
admin: vi.fn(),
10+
enterprise: vi.fn(),
11+
group: vi.fn(),
12+
}))
13+
vi.mock('@/lib/auth', () => ({ getSession: mocks.session }))
14+
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
15+
resolveActiveWorkspaceApplicationContext: mocks.context,
16+
}))
17+
vi.mock('@sim/platform-authz/workspace', async (importOriginal) => ({
18+
...(await importOriginal<typeof import('@sim/platform-authz/workspace')>()),
19+
resolveEffectiveWorkspacePermission: mocks.role,
20+
}))
21+
vi.mock('@/lib/workspaces/permissions/utils', () => ({ isOrganizationAdminOrOwner: mocks.admin }))
22+
vi.mock('@/lib/billing/core/subscription', () => ({
23+
isOrganizationOnEnterprisePlan: mocks.enterprise,
24+
}))
25+
vi.mock('@/lib/permission-groups/resolve.server', () => ({ resolveWorkspaceGroup: mocks.group }))
26+
27+
import { userPermissionConfigSchema } from '@/lib/api/contracts/permission-groups'
28+
import { OrchestrationError } from '@/lib/core/orchestration/types'
29+
import { readUserPermissionConfig } from '@/lib/permission-groups/application/read-user-config'
30+
import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields'
31+
import { GET } from '@/app/api/permission-groups/user/route'
32+
33+
const principal = { kind: 'session', userId: 'viewer', sessionId: 'session' } as const
34+
const context = {
35+
workspaceId: 'workspace',
36+
workspaceOrganizationId: 'owning-org',
37+
allowPersonalApiKeys: true,
38+
billedAccountUserId: 'owner',
39+
}
40+
const unrestricted = {
41+
permissionGroupId: null,
42+
groupName: null,
43+
config: null,
44+
entitled: false,
45+
organizationId: 'owning-org',
46+
isOrgAdmin: false,
47+
}
48+
function get(query = '?workspaceId=workspace') {
49+
return GET(
50+
createMockRequest('GET', undefined, {}, `http://localhost/api/permission-groups/user${query}`)
51+
)
52+
}
53+
54+
beforeEach(() => {
55+
vi.clearAllMocks()
56+
mocks.session.mockResolvedValue({
57+
user: { id: 'viewer' },
58+
session: { id: 'session', activeOrganizationId: 'unrelated-org' },
59+
})
60+
mocks.context.mockResolvedValue(context)
61+
mocks.role.mockResolvedValue('read')
62+
mocks.admin.mockResolvedValue(false)
63+
mocks.enterprise.mockResolvedValue(true)
64+
mocks.group.mockResolvedValue(null)
65+
})
66+
67+
describe('user permission policy shared read', () => {
68+
it('authenticates before parsing or protected lookups', async () => {
69+
mocks.session.mockResolvedValue(null)
70+
expect((await get('')).status).toBe(401)
71+
expect(mocks.context).not.toHaveBeenCalled()
72+
})
73+
it.each(['', '?workspaceId='])('preserves missing workspace validation for %s', async (query) => {
74+
const response = await get(query)
75+
expect(response.status).toBe(400)
76+
expect(await response.json()).toMatchObject({ error: 'workspaceId is required' })
77+
expect(mocks.context).not.toHaveBeenCalled()
78+
})
79+
it('preserves missing or archived workspace responses', async () => {
80+
mocks.context.mockRejectedValue(new OrchestrationError('not_found', 'Workspace not found'))
81+
const response = await get()
82+
expect(response.status).toBe(404)
83+
expect(await response.json()).toMatchObject({ error: 'Workspace not found' })
84+
expect(mocks.group).not.toHaveBeenCalled()
85+
})
86+
it('refuses current nonmembers before loading their policy', async () => {
87+
mocks.role.mockResolvedValue(null)
88+
const response = await get()
89+
expect(response.status).toBe(403)
90+
expect(await response.json()).toMatchObject({ error: 'Not a member of this workspace' })
91+
expect(mocks.admin).not.toHaveBeenCalled()
92+
expect(mocks.enterprise).not.toHaveBeenCalled()
93+
expect(mocks.group).not.toHaveBeenCalled()
94+
})
95+
it('leaves personal workspaces unrestricted without organization reads', async () => {
96+
mocks.context.mockResolvedValue({ ...context, workspaceOrganizationId: null })
97+
const response = await get()
98+
expect(response.status).toBe(200)
99+
expect(await response.json()).toEqual({ ...unrestricted, organizationId: null })
100+
expect(mocks.admin).not.toHaveBeenCalled()
101+
expect(mocks.enterprise).not.toHaveBeenCalled()
102+
expect(mocks.group).not.toHaveBeenCalled()
103+
})
104+
it('retains organization admin status without enterprise entitlement', async () => {
105+
mocks.enterprise.mockResolvedValue(false)
106+
mocks.admin.mockResolvedValue(true)
107+
expect(await (await get()).json()).toEqual({ ...unrestricted, isOrgAdmin: true })
108+
expect(mocks.group).not.toHaveBeenCalled()
109+
})
110+
it('reads the acting member in the workspace owning organization and matches the server result', async () => {
111+
const group = {
112+
permissionGroupId: 'group',
113+
groupName: 'Restricted',
114+
config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCopilot: true },
115+
}
116+
mocks.group.mockResolvedValue(group)
117+
const response = await get()
118+
expect(response.status).toBe(200)
119+
const body = await response.json()
120+
expect(body).toEqual({ ...unrestricted, ...group, entitled: true })
121+
expect(mocks.group).toHaveBeenCalledWith('viewer', 'owning-org', 'workspace')
122+
expect(mocks.admin).toHaveBeenCalledWith('viewer', 'owning-org')
123+
const serverResult = await readUserPermissionConfig.execute({
124+
principal,
125+
input: { workspaceId: 'workspace' },
126+
})
127+
expect(userPermissionConfigSchema.parse(serverResult)).toEqual(body)
128+
})
129+
it('retains enterprise entitlement when no group applies', async () => {
130+
expect(await (await get()).json()).toEqual({ ...unrestricted, entitled: true })
131+
})
132+
it('does not turn policy infrastructure failures into unrestricted access', async () => {
133+
mocks.enterprise.mockImplementation(async (_organizationId, onError) => {
134+
if (onError === 'throw') throw new Error('unavailable')
135+
return false
136+
})
137+
expect((await get()).status).toBe(500)
138+
await expect(
139+
readUserPermissionConfig.execute({ principal, input: { workspaceId: 'workspace' } })
140+
).rejects.toThrow('unavailable')
141+
})
142+
it('rejects API keys before canonical lookup on the shared server entry point', async () => {
143+
await expect(
144+
readUserPermissionConfig.execute({
145+
principal: { kind: 'personal_api_key', userId: 'viewer', keyId: 'key' },
146+
input: { workspaceId: 'workspace' },
147+
})
148+
).rejects.toThrow('cannot perform operation')
149+
expect(mocks.context).not.toHaveBeenCalled()
150+
})
151+
})
Lines changed: 32 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,35 @@
11
import { NextResponse } from 'next/server'
2-
import { userPermissionConfigQuerySchema } from '@/lib/api/contracts/permission-groups'
3-
import { getSession } from '@/lib/auth'
4-
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
5-
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2+
import { getUserPermissionConfigContract } from '@/lib/api/contracts/permission-groups'
63
import {
7-
checkWorkspaceAccess,
8-
isOrganizationAdminOrOwner,
9-
} from '@/lib/workspaces/permissions/utils'
10-
import { resolveWorkspaceGroup } from '@/ee/access-control/utils/permission-check'
11-
12-
export const GET = withRouteHandler(async (req: Request) => {
13-
const session = await getSession()
14-
if (!session?.user?.id) {
15-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
16-
}
17-
18-
const queryResult = userPermissionConfigQuerySchema.safeParse(
19-
Object.fromEntries(new URL(req.url).searchParams.entries())
20-
)
21-
if (!queryResult.success) {
22-
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
23-
}
24-
const { workspaceId } = queryResult.data
25-
26-
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
27-
if (!access.exists) {
28-
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
29-
}
30-
if (!access.hasAccess) {
31-
return NextResponse.json({ error: 'Not a member of this workspace' }, { status: 403 })
32-
}
33-
34-
const organizationId = access.workspace?.organizationId ?? null
35-
36-
// Workspaces without an organization have no permission groups, and the caller
37-
// can never be an org admin in that case.
38-
if (!organizationId) {
39-
return NextResponse.json({
40-
permissionGroupId: null,
41-
groupName: null,
42-
config: null,
43-
entitled: false,
44-
organizationId: null,
45-
isOrgAdmin: false,
46-
})
47-
}
48-
49-
// Resolve role + entitlement against the WORKSPACE's owning organization (not
50-
// the caller's active org) so management gating is scoped to the org that
51-
// actually governs this workspace. External members are not org admins here.
52-
const isOrgAdmin = await isOrganizationAdminOrOwner(session.user.id, organizationId)
53-
54-
if (!(await isOrganizationOnEnterprisePlan(organizationId))) {
55-
return NextResponse.json({
56-
permissionGroupId: null,
57-
groupName: null,
58-
config: null,
59-
entitled: false,
60-
organizationId,
61-
isOrgAdmin,
62-
})
63-
}
64-
65-
// Single source of truth: specific-scope group covering this workspace ->
66-
// the user's all-workspaces group -> org default -> none.
67-
const resolved = await resolveWorkspaceGroup(session.user.id, organizationId, workspaceId)
68-
69-
return NextResponse.json({
70-
permissionGroupId: resolved?.permissionGroupId ?? null,
71-
groupName: resolved?.groupName ?? null,
72-
config: resolved?.config ?? null,
73-
entitled: true,
74-
organizationId,
75-
isOrgAdmin,
76-
})
4+
defineInternalJsonRoute,
5+
extendInternalErrorPolicy,
6+
internalErrorResponse,
7+
internalOrchestrationErrorPolicy,
8+
internalRateLimits,
9+
internalSessionAuth,
10+
} from '@/lib/api/server/routes'
11+
import { NoWorkspaceAccessError } from '@/lib/core/application/workspace-authorization'
12+
import {
13+
readUserPermissionConfig,
14+
readUserPermissionConfigOperation,
15+
} from '@/lib/permission-groups/application/read-user-config'
16+
17+
export const GET = defineInternalJsonRoute({
18+
contract: getUserPermissionConfigContract,
19+
auth: internalSessionAuth,
20+
operation: readUserPermissionConfigOperation,
21+
rateLimit: internalRateLimits.none({
22+
reason: 'Preserve the existing internal policy read rate.',
23+
}),
24+
parseOptions: {
25+
validationErrorResponse: () =>
26+
NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }),
27+
},
28+
errorPolicy: extendInternalErrorPolicy(internalOrchestrationErrorPolicy, (error) =>
29+
error instanceof NoWorkspaceAccessError
30+
? internalErrorResponse(403, { error: 'Not a member of this workspace' })
31+
: null
32+
),
33+
mapInput: ({ query }) => query,
34+
useCase: readUserPermissionConfig,
7735
})

‎apps/sim/app/workspace/[workspaceId]/layout.test.tsx‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ const {
1212
mockGetOrgWhitelabelSettings,
1313
mockPrefetchWorkspaceHostContext,
1414
mockPrefetchWorkspaceSidebar,
15+
mockPrefetchWorkspaceAccess,
1516
} = vi.hoisted(() => ({
1617
mockBrandingProvider: vi.fn(({ children }: { children: ReactNode }) => children),
1718
mockGetOrgWhitelabelSettings: vi.fn(),
1819
mockPrefetchWorkspaceHostContext: vi.fn(),
1920
mockPrefetchWorkspaceSidebar: vi.fn(),
21+
mockPrefetchWorkspaceAccess: vi.fn(),
2022
}))
2123

2224
vi.mock('@sim/emcn', () => ({
@@ -45,6 +47,10 @@ vi.mock('@/app/workspace/[workspaceId]/prefetch', () => ({
4547
prefetchWorkspaceSidebar: mockPrefetchWorkspaceSidebar,
4648
}))
4749

50+
vi.mock('@/app/workspace/[workspaceId]/prefetch-access', () => ({
51+
prefetchWorkspaceAccess: mockPrefetchWorkspaceAccess,
52+
}))
53+
4854
vi.mock('@/ee/whitelabeling/org-branding', () => ({
4955
getOrgWhitelabelSettings: mockGetOrgWhitelabelSettings,
5056
}))
@@ -146,10 +152,11 @@ describe('WorkspaceLayout host context', () => {
146152
vi.clearAllMocks()
147153
mockGetSession.mockResolvedValue({
148154
user: { id: 'viewer-1' },
149-
session: { activeOrganizationId: 'org-a' },
155+
session: { id: 'session-1', activeOrganizationId: 'org-a' },
150156
})
151157
mockPrefetchWorkspaceHostContext.mockResolvedValue(HOST_CONTEXT)
152158
mockPrefetchWorkspaceSidebar.mockResolvedValue(undefined)
159+
mockPrefetchWorkspaceAccess.mockResolvedValue(undefined)
153160
mockGetOrgWhitelabelSettings.mockResolvedValue({ brandName: 'Host B' })
154161
})
155162

@@ -169,6 +176,11 @@ describe('WorkspaceLayout host context', () => {
169176
HOST_CONTEXT,
170177
'org-a'
171178
)
179+
expect(mockPrefetchWorkspaceAccess).toHaveBeenCalledWith(expect.anything(), 'workspace-b', {
180+
kind: 'session',
181+
userId: 'viewer-1',
182+
sessionId: 'session-1',
183+
})
172184
expect(mockBrandingProvider).toHaveBeenCalledWith(
173185
expect.objectContaining({
174186
hostOrganizationId: 'org-b',
@@ -191,6 +203,7 @@ describe('WorkspaceLayout host context', () => {
191203
expect(html).toContain('Workspace access denied')
192204
expect(html).not.toContain('Secret workspace child')
193205
expect(mockPrefetchWorkspaceSidebar).not.toHaveBeenCalled()
206+
expect(mockPrefetchWorkspaceAccess).not.toHaveBeenCalled()
194207
expect(mockGetOrgWhitelabelSettings).not.toHaveBeenCalled()
195208
})
196209
})

‎apps/sim/app/workspace/[workspaceId]/layout.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
prefetchWorkspaceHostContext,
1414
prefetchWorkspaceSidebar,
1515
} from '@/app/workspace/[workspaceId]/prefetch'
16+
import { prefetchWorkspaceAccess } from '@/app/workspace/[workspaceId]/prefetch-access'
1617
import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader'
1718
import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader'
1819
import { DesktopOAuthConnectListener } from '@/app/workspace/[workspaceId]/providers/desktop-oauth-connect-listener'
@@ -60,6 +61,11 @@ export default async function WorkspaceLayout({
6061
activeOrganizationId
6162
),
6263
isTableRowTtlEnabled(),
64+
prefetchWorkspaceAccess(queryClient, workspaceId, {
65+
kind: 'session',
66+
userId: session.user.id,
67+
sessionId: session.session.id,
68+
}),
6369
])
6470
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
6571

0 commit comments

Comments
 (0)