diff --git a/apps/sim/app/api/permission-groups/user/route.test.ts b/apps/sim/app/api/permission-groups/user/route.test.ts new file mode 100644 index 00000000000..688b2c47a21 --- /dev/null +++ b/apps/sim/app/api/permission-groups/user/route.test.ts @@ -0,0 +1,151 @@ +/** @vitest-environment node */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + session: vi.fn(), + context: vi.fn(), + role: vi.fn(), + admin: vi.fn(), + enterprise: vi.fn(), + group: vi.fn(), +})) +vi.mock('@/lib/auth', () => ({ getSession: mocks.session })) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.context, +})) +vi.mock('@sim/platform-authz/workspace', async (importOriginal) => ({ + ...(await importOriginal()), + resolveEffectiveWorkspacePermission: mocks.role, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ isOrganizationAdminOrOwner: mocks.admin })) +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mocks.enterprise, +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ resolveWorkspaceGroup: mocks.group })) + +import { userPermissionConfigSchema } from '@/lib/api/contracts/permission-groups' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { readUserPermissionConfig } from '@/lib/permission-groups/application/read-user-config' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GET } from '@/app/api/permission-groups/user/route' + +const principal = { kind: 'session', userId: 'viewer', sessionId: 'session' } as const +const context = { + workspaceId: 'workspace', + workspaceOrganizationId: 'owning-org', + allowPersonalApiKeys: true, + billedAccountUserId: 'owner', +} +const unrestricted = { + permissionGroupId: null, + groupName: null, + config: null, + entitled: false, + organizationId: 'owning-org', + isOrgAdmin: false, +} +function get(query = '?workspaceId=workspace') { + return GET( + createMockRequest('GET', undefined, {}, `http://localhost/api/permission-groups/user${query}`) + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.session.mockResolvedValue({ + user: { id: 'viewer' }, + session: { id: 'session', activeOrganizationId: 'unrelated-org' }, + }) + mocks.context.mockResolvedValue(context) + mocks.role.mockResolvedValue('read') + mocks.admin.mockResolvedValue(false) + mocks.enterprise.mockResolvedValue(true) + mocks.group.mockResolvedValue(null) +}) + +describe('user permission policy shared read', () => { + it('authenticates before parsing or protected lookups', async () => { + mocks.session.mockResolvedValue(null) + expect((await get('')).status).toBe(401) + expect(mocks.context).not.toHaveBeenCalled() + }) + it.each(['', '?workspaceId='])('preserves missing workspace validation for %s', async (query) => { + const response = await get(query) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ error: 'workspaceId is required' }) + expect(mocks.context).not.toHaveBeenCalled() + }) + it('preserves missing or archived workspace responses', async () => { + mocks.context.mockRejectedValue(new OrchestrationError('not_found', 'Workspace not found')) + const response = await get() + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ error: 'Workspace not found' }) + expect(mocks.group).not.toHaveBeenCalled() + }) + it('refuses current nonmembers before loading their policy', async () => { + mocks.role.mockResolvedValue(null) + const response = await get() + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ error: 'Not a member of this workspace' }) + expect(mocks.admin).not.toHaveBeenCalled() + expect(mocks.enterprise).not.toHaveBeenCalled() + expect(mocks.group).not.toHaveBeenCalled() + }) + it('leaves personal workspaces unrestricted without organization reads', async () => { + mocks.context.mockResolvedValue({ ...context, workspaceOrganizationId: null }) + const response = await get() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ...unrestricted, organizationId: null }) + expect(mocks.admin).not.toHaveBeenCalled() + expect(mocks.enterprise).not.toHaveBeenCalled() + expect(mocks.group).not.toHaveBeenCalled() + }) + it('retains organization admin status without enterprise entitlement', async () => { + mocks.enterprise.mockResolvedValue(false) + mocks.admin.mockResolvedValue(true) + expect(await (await get()).json()).toEqual({ ...unrestricted, isOrgAdmin: true }) + expect(mocks.group).not.toHaveBeenCalled() + }) + it('reads the acting member in the workspace owning organization and matches the server result', async () => { + const group = { + permissionGroupId: 'group', + groupName: 'Restricted', + config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCopilot: true }, + } + mocks.group.mockResolvedValue(group) + const response = await get() + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ ...unrestricted, ...group, entitled: true }) + expect(mocks.group).toHaveBeenCalledWith('viewer', 'owning-org', 'workspace') + expect(mocks.admin).toHaveBeenCalledWith('viewer', 'owning-org') + const serverResult = await readUserPermissionConfig.execute({ + principal, + input: { workspaceId: 'workspace' }, + }) + expect(userPermissionConfigSchema.parse(serverResult)).toEqual(body) + }) + it('retains enterprise entitlement when no group applies', async () => { + expect(await (await get()).json()).toEqual({ ...unrestricted, entitled: true }) + }) + it('does not turn policy infrastructure failures into unrestricted access', async () => { + mocks.enterprise.mockImplementation(async (_organizationId, onError) => { + if (onError === 'throw') throw new Error('unavailable') + return false + }) + expect((await get()).status).toBe(500) + await expect( + readUserPermissionConfig.execute({ principal, input: { workspaceId: 'workspace' } }) + ).rejects.toThrow('unavailable') + }) + it('rejects API keys before canonical lookup on the shared server entry point', async () => { + await expect( + readUserPermissionConfig.execute({ + principal: { kind: 'personal_api_key', userId: 'viewer', keyId: 'key' }, + input: { workspaceId: 'workspace' }, + }) + ).rejects.toThrow('cannot perform operation') + expect(mocks.context).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/permission-groups/user/route.ts b/apps/sim/app/api/permission-groups/user/route.ts index fcf78e58df9..4fb90f82005 100644 --- a/apps/sim/app/api/permission-groups/user/route.ts +++ b/apps/sim/app/api/permission-groups/user/route.ts @@ -1,77 +1,35 @@ import { NextResponse } from 'next/server' -import { userPermissionConfigQuerySchema } from '@/lib/api/contracts/permission-groups' -import { getSession } from '@/lib/auth' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserPermissionConfigContract } from '@/lib/api/contracts/permission-groups' import { - checkWorkspaceAccess, - isOrganizationAdminOrOwner, -} from '@/lib/workspaces/permissions/utils' -import { resolveWorkspaceGroup } from '@/ee/access-control/utils/permission-check' - -export const GET = withRouteHandler(async (req: Request) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const queryResult = userPermissionConfigQuerySchema.safeParse( - Object.fromEntries(new URL(req.url).searchParams.entries()) - ) - if (!queryResult.success) { - return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) - } - const { workspaceId } = queryResult.data - - const access = await checkWorkspaceAccess(workspaceId, session.user.id) - if (!access.exists) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - if (!access.hasAccess) { - return NextResponse.json({ error: 'Not a member of this workspace' }, { status: 403 }) - } - - const organizationId = access.workspace?.organizationId ?? null - - // Workspaces without an organization have no permission groups, and the caller - // can never be an org admin in that case. - if (!organizationId) { - return NextResponse.json({ - permissionGroupId: null, - groupName: null, - config: null, - entitled: false, - organizationId: null, - isOrgAdmin: false, - }) - } - - // Resolve role + entitlement against the WORKSPACE's owning organization (not - // the caller's active org) so management gating is scoped to the org that - // actually governs this workspace. External members are not org admins here. - const isOrgAdmin = await isOrganizationAdminOrOwner(session.user.id, organizationId) - - if (!(await isOrganizationOnEnterprisePlan(organizationId))) { - return NextResponse.json({ - permissionGroupId: null, - groupName: null, - config: null, - entitled: false, - organizationId, - isOrgAdmin, - }) - } - - // Single source of truth: specific-scope group covering this workspace -> - // the user's all-workspaces group -> org default -> none. - const resolved = await resolveWorkspaceGroup(session.user.id, organizationId, workspaceId) - - return NextResponse.json({ - permissionGroupId: resolved?.permissionGroupId ?? null, - groupName: resolved?.groupName ?? null, - config: resolved?.config ?? null, - entitled: true, - organizationId, - isOrgAdmin, - }) + defineInternalJsonRoute, + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { NoWorkspaceAccessError } from '@/lib/core/application/workspace-authorization' +import { + readUserPermissionConfig, + readUserPermissionConfigOperation, +} from '@/lib/permission-groups/application/read-user-config' + +export const GET = defineInternalJsonRoute({ + contract: getUserPermissionConfigContract, + auth: internalSessionAuth, + operation: readUserPermissionConfigOperation, + rateLimit: internalRateLimits.none({ + reason: 'Preserve the existing internal policy read rate.', + }), + parseOptions: { + validationErrorResponse: () => + NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }), + }, + errorPolicy: extendInternalErrorPolicy(internalOrchestrationErrorPolicy, (error) => + error instanceof NoWorkspaceAccessError + ? internalErrorResponse(403, { error: 'Not a member of this workspace' }) + : null + ), + mapInput: ({ query }) => query, + useCase: readUserPermissionConfig, }) diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index 79f619de2cd..1254f58c678 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -12,11 +12,13 @@ const { mockGetOrgWhitelabelSettings, mockPrefetchWorkspaceHostContext, mockPrefetchWorkspaceSidebar, + mockPrefetchWorkspaceAccess, } = vi.hoisted(() => ({ mockBrandingProvider: vi.fn(({ children }: { children: ReactNode }) => children), mockGetOrgWhitelabelSettings: vi.fn(), mockPrefetchWorkspaceHostContext: vi.fn(), mockPrefetchWorkspaceSidebar: vi.fn(), + mockPrefetchWorkspaceAccess: vi.fn(), })) vi.mock('@sim/emcn', () => ({ @@ -45,6 +47,10 @@ vi.mock('@/app/workspace/[workspaceId]/prefetch', () => ({ prefetchWorkspaceSidebar: mockPrefetchWorkspaceSidebar, })) +vi.mock('@/app/workspace/[workspaceId]/prefetch-access', () => ({ + prefetchWorkspaceAccess: mockPrefetchWorkspaceAccess, +})) + vi.mock('@/ee/whitelabeling/org-branding', () => ({ getOrgWhitelabelSettings: mockGetOrgWhitelabelSettings, })) @@ -146,10 +152,11 @@ describe('WorkspaceLayout host context', () => { vi.clearAllMocks() mockGetSession.mockResolvedValue({ user: { id: 'viewer-1' }, - session: { activeOrganizationId: 'org-a' }, + session: { id: 'session-1', activeOrganizationId: 'org-a' }, }) mockPrefetchWorkspaceHostContext.mockResolvedValue(HOST_CONTEXT) mockPrefetchWorkspaceSidebar.mockResolvedValue(undefined) + mockPrefetchWorkspaceAccess.mockResolvedValue(undefined) mockGetOrgWhitelabelSettings.mockResolvedValue({ brandName: 'Host B' }) }) @@ -169,6 +176,11 @@ describe('WorkspaceLayout host context', () => { HOST_CONTEXT, 'org-a' ) + expect(mockPrefetchWorkspaceAccess).toHaveBeenCalledWith(expect.anything(), 'workspace-b', { + kind: 'session', + userId: 'viewer-1', + sessionId: 'session-1', + }) expect(mockBrandingProvider).toHaveBeenCalledWith( expect.objectContaining({ hostOrganizationId: 'org-b', @@ -191,6 +203,7 @@ describe('WorkspaceLayout host context', () => { expect(html).toContain('Workspace access denied') expect(html).not.toContain('Secret workspace child') expect(mockPrefetchWorkspaceSidebar).not.toHaveBeenCalled() + expect(mockPrefetchWorkspaceAccess).not.toHaveBeenCalled() expect(mockGetOrgWhitelabelSettings).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 1e93ff58add..5db58722fe9 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -13,6 +13,7 @@ import { prefetchWorkspaceHostContext, prefetchWorkspaceSidebar, } from '@/app/workspace/[workspaceId]/prefetch' +import { prefetchWorkspaceAccess } from '@/app/workspace/[workspaceId]/prefetch-access' import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader' import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader' import { DesktopOAuthConnectListener } from '@/app/workspace/[workspaceId]/providers/desktop-oauth-connect-listener' @@ -60,6 +61,11 @@ export default async function WorkspaceLayout({ activeOrganizationId ), isTableRowTtlEnabled(), + prefetchWorkspaceAccess(queryClient, workspaceId, { + kind: 'session', + userId: session.user.id, + sessionId: session.session.id, + }), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx new file mode 100644 index 00000000000..f74b6c91084 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx @@ -0,0 +1,262 @@ +/** @vitest-environment jsdom */ +import { act, type ReactNode } from 'react' +import { dehydrate, hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + policy: vi.fn(), + discovery: vi.fn(), + requestJson: vi.fn(), + workspaceId: 'workspace', +})) +vi.mock('@/lib/permission-groups/application/read-user-config', () => ({ + readUserPermissionConfig: { execute: mocks.policy }, +})) +vi.mock('@/lib/permission-access-requests/application/requests', () => ({ + discoverAccessRequests: { execute: mocks.discovery }, +})) +vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson })) +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: mocks.workspaceId }), + useRouter: () => ({ refresh: vi.fn() }), +})) +vi.mock('@sim/emcn', () => ({ + cn: (...values: string[]) => values.join(' '), + Chip: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), + ChipLink: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})) +vi.mock('@sim/emcn/icons', () => ({ + Lock: () => null, + Plus: () => null, + Upload: () => null, + BookOpen: () => null, +})) +vi.mock('@/components/access-requests/request-access-action', () => ({ + RequestAccessAction: ({ pendingRequestId }: { pendingRequestId: string | null }) => ( + + ), +})) + +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' +import { ApiClientError } from '@/lib/api/client/errors' +import { getUserPermissionConfigContract } from '@/lib/api/contracts/permission-groups' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { prefetchWorkspaceAccess } from '@/app/workspace/[workspaceId]/prefetch-access' +import { + accessRequestKeys, + workspaceFeatureDiscoveryQuery, +} from '@/hooks/queries/utils/access-request-keys' +import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' + +const principal = { kind: 'session', userId: 'viewer', sessionId: 'session' } as const +const policy = { + permissionGroupId: 'group', + groupName: 'Group', + config: DEFAULT_PERMISSION_GROUP_CONFIG, + entitled: true, + organizationId: 'org', + isOrgAdmin: false, +} +const discovery = { + enabled: true, + organizationId: 'org', + entries: [ + { + target: { kind: 'feature', configKey: 'hideCopilot' }, + label: 'Chat', + state: 'requestable', + reason: null, + pendingRequestId: null, + }, + ], + total: 1, + hasMore: false, +} + +describe('workspace access hydration', () => { + let server: QueryClient + let client: QueryClient + let root: Root | undefined + let container: HTMLDivElement + + beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + mocks.workspaceId = 'workspace' + mocks.policy.mockResolvedValue(policy) + mocks.discovery.mockResolvedValue(discovery) + mocks.requestJson.mockImplementation(() => new Promise(() => {})) + server = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + container = document.createElement('div') + document.body.appendChild(container) + }) + afterEach(() => { + if (root) act(() => root?.unmount()) + root = undefined + server.clear() + client.clear() + container.remove() + }) + async function prefetch() { + await prefetchWorkspaceAccess(server, 'workspace', principal) + hydrate(client, dehydrate(server)) + } + function tree() { + return ( + + +
Workspace chat
+
+
+ ) + } + function render() { + root = createRoot(container) + act(() => root?.render(tree())) + } + function restrict() { + mocks.policy.mockResolvedValue({ ...policy, config: { ...policy.config, hideCopilot: true } }) + } + + it('renders allowed chat immediately from the server seed without a second policy request', async () => { + await prefetch() + const html = renderToString(tree()) + expect(html).toContain('Workspace chat') + expect(html).not.toContain('Checking access') + render() + expect(container.textContent).toBe('Workspace chat') + expect(mocks.discovery).not.toHaveBeenCalled() + expect( + mocks.requestJson.mock.calls.some( + ([contract]) => contract === getUserPermissionConfigContract + ) + ).toBe(false) + expect(mocks.policy).toHaveBeenCalledWith({ principal, input: { workspaceId: 'workspace' } }) + }) + it.each([null, 'request'])( + 'renders restricted chat and request state %s on the first render', + async (pendingRequestId) => { + restrict() + mocks.discovery.mockResolvedValue({ + ...discovery, + entries: [{ ...discovery.entries[0], pendingRequestId }], + }) + await prefetch() + expect(renderToString(tree())).toContain('Access required') + render() + expect(container.textContent).not.toContain('Checking access') + expect(container.textContent).not.toContain('Workspace chat') + expect(container.textContent).toContain( + pendingRequestId ? 'Your request is pending.' : 'Request access' + ) + expect(mocks.requestJson).not.toHaveBeenCalled() + } + ) + it('awaits restricted discovery before dehydrating', async () => { + restrict() + const deferred = Promise.withResolvers() + mocks.discovery.mockReturnValue(deferred.promise) + const done = vi.fn() + const work = prefetch().then(done) + await vi.waitFor(() => expect(mocks.discovery).toHaveBeenCalledOnce()) + expect(done).not.toHaveBeenCalled() + deferred.resolve(discovery) + await work + expect( + client.getQueryData(accessRequestKeys.discovery(workspaceFeatureDiscoveryQuery('workspace'))) + ).toEqual(discovery) + }) + it('preserves legacy behavior when access requests are disabled', async () => { + restrict() + mocks.discovery.mockResolvedValue({ ...discovery, enabled: false, entries: [], total: 0 }) + await prefetch() + render() + expect(container.textContent).toBe('Workspace chat') + expect(mocks.requestJson).not.toHaveBeenCalled() + }) + it('does not load discovery for personal or unrestricted workspaces', async () => { + mocks.policy.mockResolvedValue({ + ...policy, + config: null, + organizationId: null, + entitled: false, + permissionGroupId: null, + groupName: null, + }) + await prefetch() + expect(renderToString(tree())).toContain('Workspace chat') + expect(mocks.discovery).not.toHaveBeenCalled() + }) + it.each(['rejected', 'invalid'])( + 'never hydrates a permissive policy after a %s server read', + async (failure) => { + if (failure === 'rejected') mocks.policy.mockRejectedValue(new Error('unavailable')) + else mocks.policy.mockResolvedValue({ config: null }) + await prefetch() + expect(dehydrate(server).queries).toHaveLength(0) + expect(mocks.discovery).not.toHaveBeenCalled() + expect(renderToString(tree())).toContain('Checking access') + expect(renderToString(tree())).not.toContain('Workspace chat') + } + ) + it('keeps restricted content closed when discovery fails', async () => { + restrict() + mocks.discovery.mockRejectedValue(new Error('unavailable')) + await prefetch() + expect(dehydrate(server).queries).toHaveLength(1) + expect(renderToString(tree())).toContain('Checking access') + expect(renderToString(tree())).not.toContain('Workspace chat') + }) + it('shows a retryable error after a failed seed and recovers through the existing query', async () => { + mocks.policy.mockRejectedValue(new Error('unavailable')) + await prefetch() + mocks.requestJson.mockRejectedValue( + new ApiClientError({ status: 403, message: 'Access unavailable', body: {} }) + ) + render() + await vi.waitFor(() => expect(container.textContent).toContain('Unable to check access')) + expect(container.textContent).not.toContain('Workspace chat') + mocks.requestJson.mockResolvedValue(policy) + await act(async () => container.querySelector('button')?.click()) + await vi.waitFor(() => expect(container.textContent).toBe('Workspace chat')) + }) + it('isolates workspace keys during navigation', async () => { + await prefetch() + mocks.workspaceId = 'different-workspace' + expect(renderToString(tree())).toContain('Checking access') + expect(renderToString(tree())).not.toContain('Workspace chat') + }) + it('keeps background policy invalidation effective after hydration', async () => { + await prefetch() + render() + const mountedContent = container.firstChild + const deferred = Promise.withResolvers() + mocks.requestJson.mockImplementation((contract) => + contract === getUserPermissionConfigContract ? deferred.promise : new Promise(() => {}) + ) + let refresh: Promise + act(() => { + refresh = client.invalidateQueries({ queryKey: permissionGroupKeys.userConfig('workspace') }) + }) + expect(container.firstChild).toBe(mountedContent) + await act(async () => { + client.setQueryData( + accessRequestKeys.discovery(workspaceFeatureDiscoveryQuery('workspace')), + discovery + ) + deferred.resolve({ ...policy, config: { ...policy.config, hideCopilot: true } }) + await refresh + }) + await vi.waitFor(() => expect(container.textContent).toContain('Access required')) + expect(container.textContent).not.toContain('Workspace chat') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts new file mode 100644 index 00000000000..182426e174a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts @@ -0,0 +1,57 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import type { QueryClient } from '@tanstack/react-query' +import { discoverAccessRequestsContract } from '@/lib/api/contracts/access-requests' +import { + type UserPermissionConfig, + userPermissionConfigSchema, +} from '@/lib/api/contracts/permission-groups' +import { readUserPermissionConfig } from '@/lib/permission-groups/application/read-user-config' +import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' +import { + ACCESS_REQUESTS_STALE_TIME, + accessRequestKeys, + workspaceFeatureDiscoveryQuery, +} from '@/hooks/queries/utils/access-request-keys' +import { + PERMISSION_GROUPS_STALE_TIME, + permissionGroupKeys, +} from '@/hooks/queries/utils/permission-group-keys' + +/** Seeds the boundary's existing queries; failed reads remain unhydrated and recover in the client. */ +export async function prefetchWorkspaceAccess( + queryClient: QueryClient, + workspaceId: string, + principal: SessionPrincipal +): Promise { + const queryKey = permissionGroupKeys.userConfig(workspaceId) + await queryClient.prefetchQuery({ + queryKey, + queryFn: async () => + userPermissionConfigSchema.parse( + await readUserPermissionConfig.execute({ principal, input: { workspaceId } }) + ), + staleTime: PERMISSION_GROUPS_STALE_TIME, + }) + + const policy = queryClient.getQueryData(queryKey) + if ( + !PLATFORM_FEATURES.some( + (feature) => feature.scope !== 'organization' && policy?.config?.[feature.configKey] + ) + ) + return + + const query = workspaceFeatureDiscoveryQuery(workspaceId) + await queryClient.prefetchQuery({ + queryKey: accessRequestKeys.discovery(query), + queryFn: async () => { + const { discoverAccessRequests } = await import( + '@/lib/permission-access-requests/application/requests' + ) + return discoverAccessRequestsContract.response.schema.parse( + await discoverAccessRequests.execute({ principal, input: query }) + ) + }, + staleTime: ACCESS_REQUESTS_STALE_TIME, + }) +} diff --git a/apps/sim/components/access-requests/permission-access-boundary.tsx b/apps/sim/components/access-requests/permission-access-boundary.tsx index 063d6c7ed7a..2b11bc50a1d 100644 --- a/apps/sim/components/access-requests/permission-access-boundary.tsx +++ b/apps/sim/components/access-requests/permission-access-boundary.tsx @@ -11,13 +11,14 @@ import { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/re import { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state' import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' +import { workspaceFeatureDiscoveryQuery } from '@/hooks/queries/utils/access-request-keys' /** Safe feature metadata shared by navigation and access-required pages. */ export function useWorkspaceAccessRequestFeatures() { const params = useParams() const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : '' return useDiscoverAccessRequests( - { kind: 'workspace', workspaceId, targetKind: 'feature', limit: 100, offset: 0 }, + workspaceFeatureDiscoveryQuery(workspaceId), Boolean(workspaceId) ) } diff --git a/apps/sim/ee/access-control/hooks/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts index cef9d86278e..ba0bbd699d2 100644 --- a/apps/sim/ee/access-control/hooks/permission-groups.ts +++ b/apps/sim/ee/access-control/hooks/permission-groups.ts @@ -21,10 +21,12 @@ import { type UpdatePermissionGroupBody, type UserPermissionConfig, updatePermissionGroupContract, -} from '@/lib/api/contracts' - -export const PERMISSION_GROUP_MEMBERS_STALE_TIME = 30 * 1000 -export const PERMISSION_GROUPS_STALE_TIME = 60 * 1000 +} from '@/lib/api/contracts/permission-groups' +import { + PERMISSION_GROUP_MEMBERS_STALE_TIME, + PERMISSION_GROUPS_STALE_TIME, + permissionGroupKeys, +} from '@/hooks/queries/utils/permission-group-keys' export type { PermissionGroup, @@ -33,22 +35,6 @@ export type { UserPermissionConfig, } -export const permissionGroupKeys = { - all: ['permissionGroups'] as const, - lists: () => [...permissionGroupKeys.all, 'list'] as const, - list: (organizationId?: string) => - [...permissionGroupKeys.lists(), organizationId ?? ''] as const, - details: () => [...permissionGroupKeys.all, 'detail'] as const, - detail: (organizationId?: string, id?: string) => - [...permissionGroupKeys.details(), organizationId ?? '', id ?? ''] as const, - members: (organizationId?: string, id?: string) => - [...permissionGroupKeys.detail(organizationId, id), 'members'] as const, - userConfig: (workspaceId?: string) => - [...permissionGroupKeys.all, 'userConfig', workspaceId ?? ''] as const, - orgWorkspaces: (organizationId?: string) => - [...permissionGroupKeys.all, 'orgWorkspaces', organizationId ?? ''] as const, -} - export function usePermissionGroups(organizationId?: string, enabled = true) { return useQuery({ queryKey: permissionGroupKeys.list(organizationId), diff --git a/apps/sim/hooks/queries/access-requests.test.tsx b/apps/sim/hooks/queries/access-requests.test.tsx index 6480ebf15ba..12c27aca857 100644 --- a/apps/sim/hooks/queries/access-requests.test.tsx +++ b/apps/sim/hooks/queries/access-requests.test.tsx @@ -8,12 +8,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { requestJson } = vi.hoisted(() => ({ requestJson: vi.fn() })) vi.mock('@/lib/api/client/request', () => ({ requestJson })) -vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ - permissionGroupKeys: { - all: ['permissionGroups'], - userConfig: (workspaceId: string) => ['permissionGroups', 'userConfig', workspaceId], - }, -})) import { discoverAccessRequestsContract, @@ -21,11 +15,11 @@ import { resolveAccessRequestContract, } from '@/lib/api/contracts/access-requests' import { - accessRequestKeys, useDiscoverAccessRequests, useMyAccessRequests, useResolveAccessRequest, } from '@/hooks/queries/access-requests' +import { accessRequestKeys } from '@/hooks/queries/utils/access-request-keys' import { workspaceUsageKeys } from '@/hooks/queries/utils/workspace-usage-keys' describe('access request query lifecycle', () => { diff --git a/apps/sim/hooks/queries/access-requests.ts b/apps/sim/hooks/queries/access-requests.ts index bf3121f6e79..4f9339037ad 100644 --- a/apps/sim/hooks/queries/access-requests.ts +++ b/apps/sim/hooks/queries/access-requests.ts @@ -23,33 +23,17 @@ import type { WorkspaceUsageGate, } from '@/lib/api/contracts/workspaces' import { ACCESS_REQUEST_LIST_PAGE_SIZE } from '@/lib/permission-access-requests/constants' -import { permissionGroupKeys } from '@/ee/access-control/hooks/permission-groups' +import { + ACCESS_REQUESTS_STALE_TIME, + accessRequestKeys, +} from '@/hooks/queries/utils/access-request-keys' import { invalidateWorkspaceUsage } from '@/hooks/queries/utils/invalidate-usage' +import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' import { workspaceUsageKeys } from '@/hooks/queries/utils/workspace-usage-keys' -export const ACCESS_REQUESTS_STALE_TIME = 15_000 export const ACCESS_REQUESTS_POLL_INTERVAL = 30_000 export const ACCESS_REQUEST_PAGE_SIZE = ACCESS_REQUEST_LIST_PAGE_SIZE -export const accessRequestKeys = { - all: ['accessRequests'] as const, - lists: () => [...accessRequestKeys.all, 'list'] as const, - mine: (scope: AccessRequestScope, offset: number, requestId?: string) => - [...accessRequestKeys.lists(), 'mine', scope, offset, requestId ?? ''] as const, - organization: (organizationId: string, offset: number, status: AccessRequestStatus | 'all') => - [...accessRequestKeys.lists(), 'organization', organizationId, offset, status] as const, - discoveries: () => [...accessRequestKeys.all, 'discovery'] as const, - discovery: (query: DiscoverAccessRequestsQuery) => - [...accessRequestKeys.discoveries(), query] as const, - details: () => [...accessRequestKeys.all, 'detail'] as const, - organizationDetails: (organizationId: string) => - [...accessRequestKeys.details(), organizationId] as const, - preview: (organizationId: string, requestId: string) => - [...accessRequestKeys.organizationDetails(organizationId), requestId] as const, - settings: (organizationId: string) => - [...accessRequestKeys.all, 'settings', organizationId] as const, -} - export function useDiscoverAccessRequests(query: DiscoverAccessRequestsQuery, enabled = true) { const queryClient = useQueryClient() return useQuery({ diff --git a/apps/sim/hooks/queries/utils/access-request-keys.ts b/apps/sim/hooks/queries/utils/access-request-keys.ts new file mode 100644 index 00000000000..3f53588e4d4 --- /dev/null +++ b/apps/sim/hooks/queries/utils/access-request-keys.ts @@ -0,0 +1,36 @@ +import type { + AccessRequestScope, + AccessRequestStatus, + DiscoverAccessRequestsQuery, +} from '@/lib/api/contracts/access-requests' + +export const ACCESS_REQUESTS_STALE_TIME = 15_000 + +export const accessRequestKeys = { + all: ['accessRequests'] as const, + lists: () => [...accessRequestKeys.all, 'list'] as const, + mine: (scope: AccessRequestScope, offset: number, requestId?: string) => + [...accessRequestKeys.lists(), 'mine', scope, offset, requestId ?? ''] as const, + organization: (organizationId: string, offset: number, status: AccessRequestStatus | 'all') => + [...accessRequestKeys.lists(), 'organization', organizationId, offset, status] as const, + discoveries: () => [...accessRequestKeys.all, 'discovery'] as const, + discovery: (query: DiscoverAccessRequestsQuery) => + [...accessRequestKeys.discoveries(), query] as const, + details: () => [...accessRequestKeys.all, 'detail'] as const, + organizationDetails: (organizationId: string) => + [...accessRequestKeys.details(), organizationId] as const, + preview: (organizationId: string, requestId: string) => + [...accessRequestKeys.organizationDetails(organizationId), requestId] as const, + settings: (organizationId: string) => + [...accessRequestKeys.all, 'settings', organizationId] as const, +} + +export function workspaceFeatureDiscoveryQuery(workspaceId: string) { + return { + kind: 'workspace', + workspaceId, + targetKind: 'feature', + limit: 100, + offset: 0, + } as const satisfies DiscoverAccessRequestsQuery +} diff --git a/apps/sim/hooks/queries/utils/permission-group-keys.ts b/apps/sim/hooks/queries/utils/permission-group-keys.ts new file mode 100644 index 00000000000..97c0e07946b --- /dev/null +++ b/apps/sim/hooks/queries/utils/permission-group-keys.ts @@ -0,0 +1,18 @@ +export const PERMISSION_GROUP_MEMBERS_STALE_TIME = 30 * 1000 +export const PERMISSION_GROUPS_STALE_TIME = 60 * 1000 + +export const permissionGroupKeys = { + all: ['permissionGroups'] as const, + lists: () => [...permissionGroupKeys.all, 'list'] as const, + list: (organizationId?: string) => + [...permissionGroupKeys.lists(), organizationId ?? ''] as const, + details: () => [...permissionGroupKeys.all, 'detail'] as const, + detail: (organizationId?: string, id?: string) => + [...permissionGroupKeys.details(), organizationId ?? '', id ?? ''] as const, + members: (organizationId?: string, id?: string) => + [...permissionGroupKeys.detail(organizationId, id), 'members'] as const, + userConfig: (workspaceId?: string) => + [...permissionGroupKeys.all, 'userConfig', workspaceId ?? ''] as const, + orgWorkspaces: (organizationId?: string) => + [...permissionGroupKeys.all, 'orgWorkspaces', organizationId ?? ''] as const, +} diff --git a/apps/sim/lib/permission-groups/application/read-user-config.ts b/apps/sim/lib/permission-groups/application/read-user-config.ts new file mode 100644 index 00000000000..4ceca105cfd --- /dev/null +++ b/apps/sim/lib/permission-groups/application/read-user-config.ts @@ -0,0 +1,46 @@ +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case' +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' +import { resolveWorkspaceGroup } from '@/lib/permission-groups/resolve.server' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils' + +/** + * permission-group-exempt: Members must be able to read their own restrictions. + */ +export const readUserPermissionConfigOperation = defineWorkspaceOperation({ + id: 'permission_groups.read_user_config', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + capability: 'none', +}) + +export const readUserPermissionConfig = defineAuthorizedWorkspaceUseCase({ + operation: readUserPermissionConfigOperation, + resolveContext: ({ input }: { input: { workspaceId: string } }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ principal, context }) => { + const organizationId = context.workspaceOrganizationId + const [isOrgAdmin, entitled] = organizationId + ? await Promise.all([ + isOrganizationAdminOrOwner(principal.userId, organizationId), + isOrganizationOnEnterprisePlan(organizationId, 'throw'), + ]) + : [false, false] + const resolved = + organizationId && entitled + ? await resolveWorkspaceGroup(principal.userId, organizationId, context.workspaceId) + : null + + return { + permissionGroupId: resolved?.permissionGroupId ?? null, + groupName: resolved?.groupName ?? null, + config: resolved?.config ?? null, + entitled, + organizationId, + isOrgAdmin, + } + }, +}) diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 93b812d2985..a8acbca2503 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -75,11 +75,9 @@ "gateways": {} }, "app/workspace/[workspaceId]/access-requests/page.tsx": { - "modules": 113, + "modules": 44, "gateways": { - "apps/sim/components/access-requests/my-access-requests.tsx": 111, - "apps/sim/hooks/queries/access-requests.ts": 95, - "apps/sim/ee/access-control/hooks/permission-groups.ts": 74 + "apps/sim/components/access-requests/my-access-requests.tsx": 42 } }, "app/workspace/[workspaceId]/chat/[chatId]/error.tsx": { @@ -323,16 +321,16 @@ } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 2205, + "modules": 2302, "gateways": { "apps/sim/triggers/registry.ts": 485, - "apps/sim/lib/auth/index.ts": 413, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 384, - "apps/sim/blocks/registry.ts": 353, + "apps/sim/lib/auth/index.ts": 402, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 387, + "apps/sim/blocks/registry.ts": 352, "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 256, "apps/sim/lib/webhooks/providers/index.ts": 117, "apps/sim/lib/webhooks/providers/registry.ts": 115, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/index.ts": 92 + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/index.ts": 91 } }, "app/workspace/[workspaceId]/logs/error.tsx": { @@ -574,16 +572,16 @@ } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 1836, + "modules": 1874, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1678, + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1696, "apps/sim/triggers/registry.ts": 522, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 362, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 366, "apps/sim/blocks/registry.ts": 331, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 316, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 312, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 281, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 271 + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 320, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 316, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 265, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 259 } }, "app/workspace/[workspaceId]/tables/error.tsx": {