Skip to content

Commit d939f0c

Browse files
feat(files): gate public sharing behind an access-control permission
1 parent 30f2c7d commit d939f0c

8 files changed

Lines changed: 108 additions & 11 deletions

File tree

apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { auditMock, authMockFns, permissionsMock, permissionsMockFns } from '@si
55
import { NextRequest } from 'next/server'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const { mockGetWorkspaceFile, mockGetShareForResource, mockUpsertFileShare } = vi.hoisted(() => ({
9-
mockGetWorkspaceFile: vi.fn(),
10-
mockGetShareForResource: vi.fn(),
11-
mockUpsertFileShare: vi.fn(),
12-
}))
8+
const { mockGetWorkspaceFile, mockGetShareForResource, mockUpsertFileShare, mockValidateSharing } =
9+
vi.hoisted(() => ({
10+
mockGetWorkspaceFile: vi.fn(),
11+
mockGetShareForResource: vi.fn(),
12+
mockUpsertFileShare: vi.fn(),
13+
mockValidateSharing: vi.fn(),
14+
}))
1315

1416
vi.mock('@/lib/uploads/contexts/workspace', () => ({
1517
getWorkspaceFile: mockGetWorkspaceFile,
@@ -20,6 +22,16 @@ vi.mock('@/lib/public-shares/share-manager', () => ({
2022
upsertFileShare: mockUpsertFileShare,
2123
}))
2224

25+
vi.mock('@/ee/access-control/utils/permission-check', () => {
26+
class PublicFileSharingNotAllowedError extends Error {
27+
constructor() {
28+
super('Public file sharing is not allowed based on your permission group settings')
29+
this.name = 'PublicFileSharingNotAllowedError'
30+
}
31+
}
32+
return { validatePublicFileSharing: mockValidateSharing, PublicFileSharingNotAllowedError }
33+
})
34+
2335
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
2436
vi.mock('@sim/audit', () => auditMock)
2537

@@ -59,6 +71,7 @@ describe('share route', () => {
5971
mockGetWorkspaceFile.mockResolvedValue({ id: FILE_ID, name: 'report.pdf' })
6072
mockGetShareForResource.mockResolvedValue(SHARE)
6173
mockUpsertFileShare.mockResolvedValue(SHARE)
74+
mockValidateSharing.mockResolvedValue(undefined) // policy allows by default
6275
})
6376

6477
describe('GET', () => {
@@ -108,6 +121,29 @@ describe('share route', () => {
108121
expect(await res.json()).toEqual({ share: SHARE })
109122
})
110123

124+
it('returns 403 when org access-control disables public sharing (enable)', async () => {
125+
const { PublicFileSharingNotAllowedError } = await import(
126+
'@/ee/access-control/utils/permission-check'
127+
)
128+
mockValidateSharing.mockRejectedValueOnce(new PublicFileSharingNotAllowedError())
129+
const res = await PUT(putRequest({ isActive: true }), params())
130+
expect(res.status).toBe(403)
131+
expect(mockUpsertFileShare).not.toHaveBeenCalled()
132+
})
133+
134+
it('allows disabling a share even when policy disallows enabling', async () => {
135+
mockValidateSharing.mockRejectedValue(new Error('should not be called for disable'))
136+
const res = await PUT(putRequest({ isActive: false }), params())
137+
expect(res.status).toBe(200)
138+
expect(mockValidateSharing).not.toHaveBeenCalled()
139+
expect(mockUpsertFileShare).toHaveBeenCalledWith({
140+
workspaceId: WS,
141+
fileId: FILE_ID,
142+
userId: 'user-1',
143+
isActive: false,
144+
})
145+
})
146+
111147
it('rejects a missing isActive body', async () => {
112148
const res = await PUT(putRequest({}), params())
113149
expect(res.status).toBe(400)

apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1010
import { getShareForResource, upsertFileShare } from '@/lib/public-shares/share-manager'
1111
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
1212
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
13+
import {
14+
PublicFileSharingNotAllowedError,
15+
validatePublicFileSharing,
16+
} from '@/ee/access-control/utils/permission-check'
1317

1418
export const dynamic = 'force-dynamic'
1519

@@ -92,6 +96,20 @@ export const PUT = withRouteHandler(
9296
return NextResponse.json({ error: 'File not found' }, { status: 404 })
9397
}
9498

99+
// Enabling a public link is gated by the org's access-control policy; disabling
100+
// is always allowed so users can still un-share after the policy is turned on.
101+
if (isActive) {
102+
try {
103+
await validatePublicFileSharing(session.user.id, workspaceId)
104+
} catch (error) {
105+
if (error instanceof PublicFileSharingNotAllowedError) {
106+
logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`)
107+
return NextResponse.json({ error: error.message }, { status: 403 })
108+
}
109+
throw error
110+
}
111+
}
112+
95113
const share = await upsertFileShare({
96114
workspaceId,
97115
fileId,

apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { Link } from '@/components/emcn/icons'
1313
import type { ShareRecord } from '@/lib/api/contracts/public-shares'
1414
import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares'
15+
import { usePermissionConfig } from '@/hooks/use-permission-config'
1516

1617
interface ShareModalProps {
1718
open: boolean
@@ -37,11 +38,16 @@ export function ShareModal({
3738
initialShare,
3839
}: ShareModalProps) {
3940
const { data: share } = useFileShare(workspaceId, fileId, { enabled: open })
41+
const { config: permissionConfig } = usePermissionConfig()
4042
const upsertShare = useUpsertFileShare()
4143

4244
const saved = share ?? initialShare ?? null
4345
const savedActive = saved?.isActive ?? false
4446

47+
// Org access-control policy can disable enabling new public links (the route is the
48+
// source of truth; this just reflects it). Disabling an existing share stays allowed.
49+
const enableBlockedByPolicy = permissionConfig.disablePublicFileSharing && !savedActive
50+
4551
// `null` until the user toggles, so the switch always reflects the authoritative
4652
// saved state (which may resolve after mount via useFileShare) instead of a stale
4753
// initial snapshot — otherwise a Save could silently flip sharing the wrong way.
@@ -66,11 +72,13 @@ export function ShareModal({
6672
type='custom'
6773
title='Access'
6874
hint={
69-
effectiveActive
70-
? isDirty
71-
? 'Save to make this file accessible to anyone with the link.'
72-
: 'Anyone with the link can view and download this file.'
73-
: 'Only workspace members can access this file.'
75+
enableBlockedByPolicy
76+
? 'Public sharing is disabled for this workspace by an administrator.'
77+
: effectiveActive
78+
? isDirty
79+
? 'Save to make this file accessible to anyone with the link.'
80+
: 'Anyone with the link can view and download this file.'
81+
: 'Only workspace members can access this file.'
7482
}
7583
>
7684
<ChipSwitch
@@ -89,7 +97,7 @@ export function ShareModal({
8997
primaryAction={{
9098
label: upsertShare.isPending ? 'Saving...' : 'Save',
9199
onClick: handleSave,
92-
disabled: !isDirty || upsertShare.isPending,
100+
disabled: !isDirty || upsertShare.isPending || (effectiveActive && enableBlockedByPolicy),
93101
}}
94102
/>
95103
</ChipModal>

apps/sim/ee/access-control/components/access-control.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,12 @@ export function AccessControl() {
644644
category: 'Features',
645645
configKey: 'disablePublicApi' as const,
646646
},
647+
{
648+
id: 'disable-public-file-sharing',
649+
label: 'Public Sharing',
650+
category: 'Files',
651+
configKey: 'disablePublicFileSharing' as const,
652+
},
647653
],
648654
[]
649655
)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const {
3232
disableSkills: false,
3333
disableInvitations: false,
3434
disablePublicApi: false,
35+
disablePublicFileSharing: false,
3536
hideDeployApi: false,
3637
hideDeployMcp: false,
3738
hideDeployA2a: false,

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ export class PublicApiNotAllowedError extends Error {
8484
}
8585
}
8686

87+
export class PublicFileSharingNotAllowedError extends Error {
88+
constructor() {
89+
super('Public file sharing is not allowed based on your permission group settings')
90+
this.name = 'PublicFileSharingNotAllowedError'
91+
}
92+
}
93+
8794
/**
8895
* Merges the env allowlist into a permission config.
8996
* If `config` is null and no env allowlist is set, returns null.
@@ -284,6 +291,21 @@ export async function getUserPermissionConfig(
284291
return mergeEnvAllowlist(resolved?.config ?? null)
285292
}
286293

294+
/**
295+
* Throws {@link PublicFileSharingNotAllowedError} if the user's effective permission
296+
* group for the workspace disables public file sharing. No-op when access control
297+
* doesn't apply (non-enterprise / disabled), so non-governed orgs are unaffected.
298+
*/
299+
export async function validatePublicFileSharing(
300+
userId: string,
301+
workspaceId: string
302+
): Promise<void> {
303+
const config = await getUserPermissionConfig(userId, workspaceId)
304+
if (config?.disablePublicFileSharing) {
305+
throw new PublicFileSharingNotAllowedError()
306+
}
307+
}
308+
287309
/**
288310
* Org-addressed variant of {@link getUserPermissionConfig}. Use when only the
289311
* organization is known (e.g. organization-level invitations); resolves the

apps/sim/lib/api/contracts/permission-groups.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const permissionGroupFullConfigSchema = z.object({
2121
disableSkills: z.boolean(),
2222
disableInvitations: z.boolean(),
2323
disablePublicApi: z.boolean(),
24+
disablePublicFileSharing: z.boolean(),
2425
hideDeployApi: z.boolean(),
2526
hideDeployMcp: z.boolean(),
2627
hideDeployA2a: z.boolean(),

apps/sim/lib/permission-groups/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const permissionGroupConfigSchema = z.object({
3131
disableSkills: z.boolean().optional(),
3232
disableInvitations: z.boolean().optional(),
3333
disablePublicApi: z.boolean().optional(),
34+
disablePublicFileSharing: z.boolean().optional(),
3435
hideDeployApi: z.boolean().optional(),
3536
hideDeployMcp: z.boolean().optional(),
3637
hideDeployA2a: z.boolean().optional(),
@@ -60,6 +61,7 @@ export interface PermissionGroupConfig {
6061
disableSkills: boolean
6162
disableInvitations: boolean
6263
disablePublicApi: boolean
64+
disablePublicFileSharing: boolean
6365
hideDeployApi: boolean
6466
hideDeployMcp: boolean
6567
hideDeployA2a: boolean
@@ -85,6 +87,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
8587
disableSkills: false,
8688
disableInvitations: false,
8789
disablePublicApi: false,
90+
disablePublicFileSharing: false,
8891
hideDeployApi: false,
8992
hideDeployMcp: false,
9093
hideDeployA2a: false,
@@ -120,6 +123,8 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf
120123
disableSkills: typeof c.disableSkills === 'boolean' ? c.disableSkills : false,
121124
disableInvitations: typeof c.disableInvitations === 'boolean' ? c.disableInvitations : false,
122125
disablePublicApi: typeof c.disablePublicApi === 'boolean' ? c.disablePublicApi : false,
126+
disablePublicFileSharing:
127+
typeof c.disablePublicFileSharing === 'boolean' ? c.disablePublicFileSharing : false,
123128
hideDeployApi: typeof c.hideDeployApi === 'boolean' ? c.hideDeployApi : false,
124129
hideDeployMcp: typeof c.hideDeployMcp === 'boolean' ? c.hideDeployMcp : false,
125130
hideDeployA2a: typeof c.hideDeployA2a === 'boolean' ? c.hideDeployA2a : false,

0 commit comments

Comments
 (0)