Skip to content

Commit ec5de63

Browse files
feat(access-control): org admins can restrict allowed file-share auth types
1 parent 32afa71 commit ec5de63

7 files changed

Lines changed: 167 additions & 12 deletions

File tree

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,12 @@ export const PUT = withRouteHandler(
100100
return NextResponse.json({ error: 'File not found' }, { status: 404 })
101101
}
102102

103-
// Enabling a public link is gated by the org's access-control policy; disabling
104-
// is always allowed so users can still un-share after the policy is turned on.
103+
// Enabling a share is gated by the org's access-control policy (both the
104+
// master on/off and the per-auth-type allow-list); disabling is always
105+
// allowed so users can still un-share after the policy is turned on.
105106
if (isActive) {
106107
try {
107-
await validatePublicFileSharing(session.user.id, workspaceId)
108+
await validatePublicFileSharing(session.user.id, workspaceId, authType ?? 'public')
108109
} catch (error) {
109110
if (error instanceof PublicFileSharingNotAllowedError) {
110111
logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`)

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,18 +85,30 @@ export function ShareModal({
8585
const effectiveActive = effectiveMode !== 'private'
8686
const effectiveEmails = draftEmails ?? saved?.allowedEmails ?? []
8787

88+
// Org access-control may restrict which auth modes are allowed (`null` = all).
89+
// The route is the source of truth; this just hides disallowed options.
90+
const allowedAuthTypes = permissionConfig.allowedFileShareAuthTypes
91+
const isAuthTypeAllowed = (mode: ShareAuthType) =>
92+
allowedAuthTypes === null || allowedAuthTypes.includes(mode)
93+
8894
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) || savedAccessMode === 'sso'
89-
const accessModes: AccessMode[] = [
90-
'private',
95+
const candidateAuthTypes: ShareAuthType[] = [
9196
'public',
9297
'password',
9398
'email',
9499
...(ssoEnabled ? (['sso'] as const) : []),
95100
]
101+
// Keep the saved mode visible even if newly disallowed, so the current state shows.
102+
const accessModes: AccessMode[] = [
103+
'private',
104+
...candidateAuthTypes.filter((mode) => isAuthTypeAllowed(mode) || mode === savedAccessMode),
105+
]
96106

97-
// Org access-control policy can disable enabling new public links (the route is the
98-
// source of truth; this just reflects it). Disabling an existing share stays allowed.
99-
const enableBlockedByPolicy = permissionConfig.disablePublicFileSharing && !saved?.isActive
107+
// The selected mode is blocked when org policy disables public sharing entirely
108+
// (enabling a new share) or when the chosen auth mode isn't allowed.
109+
const modeDisallowed = effectiveMode !== 'private' && !isAuthTypeAllowed(effectiveMode)
110+
const enableBlockedByPolicy =
111+
(permissionConfig.disablePublicFileSharing && !saved?.isActive) || modeDisallowed
100112

101113
// A password share needs a secret: either one already stored or a freshly typed one.
102114
const passwordMissing =
@@ -169,6 +181,7 @@ export function ShareModal({
169181
}
170182

171183
const accessHint = (() => {
184+
if (modeDisallowed) return 'This sharing method is disabled by an administrator.'
172185
if (enableBlockedByPolicy)
173186
return 'Public sharing is disabled for this workspace by an administrator.'
174187
if (effectiveMode === 'private') return 'Only workspace members can access this file.'

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
toast,
3636
} from '@/components/emcn'
3737
import { ArrowLeft } from '@/components/emcn/icons'
38+
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
3839
import { getEnv, isTruthy } from '@/lib/core/config/env'
3940
import { cn } from '@/lib/core/utils/cn'
4041
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
@@ -69,6 +70,15 @@ import type { ProviderName } from '@/stores/providers'
6970

7071
const logger = createLogger('AccessControl')
7172

73+
/** Public-file-share auth modes an admin can allow/disallow. `null` config = all allowed. */
74+
const FILE_SHARE_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = [
75+
{ value: 'public', label: 'Anyone with link' },
76+
{ value: 'password', label: 'Password' },
77+
{ value: 'email', label: 'Email' },
78+
{ value: 'sso', label: 'SSO' },
79+
]
80+
const ALL_FILE_SHARE_AUTH_TYPES: ShareAuthType[] = FILE_SHARE_AUTH_TYPE_OPTIONS.map((o) => o.value)
81+
7282
interface OrganizationMemberOption {
7383
userId: string
7484
user: {
@@ -1070,6 +1080,36 @@ export function AccessControl() {
10701080
[editingConfig, allProviderIds]
10711081
)
10721082

1083+
const isFileShareAuthAllowed = useCallback(
1084+
(authType: ShareAuthType) => {
1085+
if (!editingConfig) return true
1086+
return (
1087+
editingConfig.allowedFileShareAuthTypes === null ||
1088+
editingConfig.allowedFileShareAuthTypes.includes(authType)
1089+
)
1090+
},
1091+
[editingConfig]
1092+
)
1093+
1094+
const toggleFileShareAuthType = useCallback(
1095+
(authType: ShareAuthType) => {
1096+
if (!editingConfig) return
1097+
const current = editingConfig.allowedFileShareAuthTypes
1098+
const next =
1099+
current === null
1100+
? ALL_FILE_SHARE_AUTH_TYPES.filter((t) => t !== authType)
1101+
: current.includes(authType)
1102+
? current.filter((t) => t !== authType)
1103+
: [...current, authType]
1104+
// A full list collapses back to `null` ("all allowed").
1105+
setEditingConfig({
1106+
...editingConfig,
1107+
allowedFileShareAuthTypes: next.length === ALL_FILE_SHARE_AUTH_TYPES.length ? null : next,
1108+
})
1109+
},
1110+
[editingConfig]
1111+
)
1112+
10731113
const isIntegrationAllowed = useCallback(
10741114
(blockType: string) => {
10751115
if (!editingConfig) return true
@@ -1603,6 +1643,30 @@ export function AccessControl() {
16031643
</div>
16041644
))}
16051645
</div>
1646+
<div className='mt-8 flex flex-col gap-1.5'>
1647+
<span className='font-medium text-[var(--text-tertiary)] text-xs uppercase tracking-wide'>
1648+
File Sharing Methods
1649+
</span>
1650+
<p className='text-[var(--text-secondary)] text-xs'>
1651+
Auth modes that public file-share links may use.
1652+
</p>
1653+
<div className='flex max-w-md flex-col gap-0.5 pt-1'>
1654+
{FILE_SHARE_AUTH_TYPE_OPTIONS.map(({ value, label }) => (
1655+
<label
1656+
key={value}
1657+
htmlFor={`fsauth-${value}`}
1658+
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-active)]'
1659+
>
1660+
<Checkbox
1661+
id={`fsauth-${value}`}
1662+
checked={isFileShareAuthAllowed(value)}
1663+
onCheckedChange={() => toggleFileShareAuthType(value)}
1664+
/>
1665+
<span className='font-normal text-sm'>{label}</span>
1666+
</label>
1667+
))}
1668+
</div>
1669+
</div>
16061670
</div>
16071671
)}
16081672
</ChipModalBody>

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const {
3333
disableInvitations: false,
3434
disablePublicApi: false,
3535
disablePublicFileSharing: false,
36+
allowedFileShareAuthTypes: null,
3637
hideDeployApi: false,
3738
hideDeployMcp: false,
3839
hideDeployA2a: false,
@@ -149,10 +150,12 @@ import {
149150
McpToolsNotAllowedError,
150151
ModelNotAllowedError,
151152
ProviderNotAllowedError,
153+
PublicFileSharingNotAllowedError,
152154
SkillsNotAllowedError,
153155
validateBlockType,
154156
validateMcpToolsAllowed,
155157
validateModelProvider,
158+
validatePublicFileSharing,
156159
} from './permission-check'
157160

158161
/** Default an org-backed, enterprise-entitled workspace so resolution reaches the group queries. */
@@ -532,6 +535,46 @@ describe('validateMcpToolsAllowed', () => {
532535
})
533536
})
534537

538+
describe('validatePublicFileSharing', () => {
539+
beforeEach(() => {
540+
vi.clearAllMocks()
541+
mockExplicitGroup.value = []
542+
mockAllWorkspacesGroup.value = []
543+
mockDefaultGroup.value = []
544+
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
545+
setEnterpriseOrgWorkspace()
546+
})
547+
548+
it('throws when public file sharing is fully disabled', async () => {
549+
mockExplicitGroup.value = [{ config: { disablePublicFileSharing: true } }]
550+
await expect(
551+
validatePublicFileSharing('user-123', 'workspace-1', 'password')
552+
).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError)
553+
})
554+
555+
it('throws when the auth type is not in the allow-list', async () => {
556+
mockExplicitGroup.value = [{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }]
557+
await expect(
558+
validatePublicFileSharing('user-123', 'workspace-1', 'public')
559+
).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError)
560+
})
561+
562+
it('allows an auth type that is in the allow-list', async () => {
563+
mockExplicitGroup.value = [{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }]
564+
await validatePublicFileSharing('user-123', 'workspace-1', 'password')
565+
})
566+
567+
it('allows any auth type when the allow-list is null', async () => {
568+
mockExplicitGroup.value = [{ config: { allowedFileShareAuthTypes: null } }]
569+
await validatePublicFileSharing('user-123', 'workspace-1', 'email')
570+
})
571+
572+
it('no-ops when no auth type is provided (master switch only)', async () => {
573+
mockExplicitGroup.value = [{ config: { allowedFileShareAuthTypes: ['password'] } }]
574+
await validatePublicFileSharing('user-123', 'workspace-1')
575+
})
576+
})
577+
535578
describe('assertPermissionsAllowed', () => {
536579
beforeEach(() => {
537580
vi.clearAllMocks()

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, asc, eq } from 'drizzle-orm'
5+
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
56
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
67
import {
78
getAllowedIntegrationsFromEnv,
@@ -293,15 +294,33 @@ export async function getUserPermissionConfig(
293294

294295
/**
295296
* 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.
297+
* group for the workspace disables public file sharing, or — when `authType` is
298+
* given — if that auth mode isn't in the group's `allowedFileShareAuthTypes`
299+
* allow-list (`null` allows all). No-op when access control doesn't apply
300+
* (non-enterprise / disabled), so non-governed orgs are unaffected.
298301
*/
299302
export async function validatePublicFileSharing(
300303
userId: string,
301-
workspaceId: string
304+
workspaceId: string,
305+
authType?: ShareAuthType
302306
): Promise<void> {
303307
const config = await getUserPermissionConfig(userId, workspaceId)
304-
if (config?.disablePublicFileSharing) {
308+
if (!config) {
309+
return
310+
}
311+
if (config.disablePublicFileSharing) {
312+
throw new PublicFileSharingNotAllowedError()
313+
}
314+
if (
315+
authType &&
316+
config.allowedFileShareAuthTypes !== null &&
317+
!config.allowedFileShareAuthTypes.includes(authType)
318+
) {
319+
logger.warn('File share auth type blocked by permission group', {
320+
userId,
321+
workspaceId,
322+
authType,
323+
})
305324
throw new PublicFileSharingNotAllowedError()
306325
}
307326
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from 'zod'
22
import { organizationIdSchema } from '@/lib/api/contracts/primitives'
3+
import { shareAuthTypeSchema } from '@/lib/api/contracts/public-shares'
34
import { defineRouteContract } from '@/lib/api/contracts/types'
45
import { permissionGroupConfigSchema } from '@/lib/permission-groups/types'
56

@@ -22,6 +23,7 @@ export const permissionGroupFullConfigSchema = z.object({
2223
disableInvitations: z.boolean(),
2324
disablePublicApi: z.boolean(),
2425
disablePublicFileSharing: z.boolean(),
26+
allowedFileShareAuthTypes: z.array(shareAuthTypeSchema).nullable(),
2527
hideDeployApi: z.boolean(),
2628
hideDeployMcp: z.boolean(),
2729
hideDeployA2a: z.boolean(),

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { z } from 'zod'
2+
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
3+
4+
/** Auth modes a public file share can use; admins may restrict the allowed subset. */
5+
export const FILE_SHARE_AUTH_TYPES = ['public', 'password', 'email', 'sso'] as const
26

37
export const PERMISSION_GROUP_CONSTRAINTS = {
48
organizationName: 'permission_group_organization_name_unique',
@@ -32,6 +36,7 @@ export const permissionGroupConfigSchema = z.object({
3236
disableInvitations: z.boolean().optional(),
3337
disablePublicApi: z.boolean().optional(),
3438
disablePublicFileSharing: z.boolean().optional(),
39+
allowedFileShareAuthTypes: z.array(z.enum(FILE_SHARE_AUTH_TYPES)).nullable().optional(),
3540
hideDeployApi: z.boolean().optional(),
3641
hideDeployMcp: z.boolean().optional(),
3742
hideDeployA2a: z.boolean().optional(),
@@ -62,6 +67,8 @@ export interface PermissionGroupConfig {
6267
disableInvitations: boolean
6368
disablePublicApi: boolean
6469
disablePublicFileSharing: boolean
70+
/** Allowed public-file-share auth modes; `null` means all are allowed. */
71+
allowedFileShareAuthTypes: ShareAuthType[] | null
6572
hideDeployApi: boolean
6673
hideDeployMcp: boolean
6774
hideDeployA2a: boolean
@@ -88,6 +95,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
8895
disableInvitations: false,
8996
disablePublicApi: false,
9097
disablePublicFileSharing: false,
98+
allowedFileShareAuthTypes: null,
9199
hideDeployApi: false,
92100
hideDeployMcp: false,
93101
hideDeployA2a: false,
@@ -125,6 +133,11 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf
125133
disablePublicApi: typeof c.disablePublicApi === 'boolean' ? c.disablePublicApi : false,
126134
disablePublicFileSharing:
127135
typeof c.disablePublicFileSharing === 'boolean' ? c.disablePublicFileSharing : false,
136+
allowedFileShareAuthTypes: Array.isArray(c.allowedFileShareAuthTypes)
137+
? c.allowedFileShareAuthTypes.filter((t): t is ShareAuthType =>
138+
(FILE_SHARE_AUTH_TYPES as readonly string[]).includes(t as string)
139+
)
140+
: null,
128141
hideDeployApi: typeof c.hideDeployApi === 'boolean' ? c.hideDeployApi : false,
129142
hideDeployMcp: typeof c.hideDeployMcp === 'boolean' ? c.hideDeployMcp : false,
130143
hideDeployA2a: typeof c.hideDeployA2a === 'boolean' ? c.hideDeployA2a : false,

0 commit comments

Comments
 (0)