Skip to content

Commit 3c84193

Browse files
committed
make it workspace admin level
1 parent 6589121 commit 3c84193

5 files changed

Lines changed: 38 additions & 20 deletions

File tree

apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { getForkLineageContract } from '@/lib/api/contracts/workspace-fork'
77
import { parseRequest } from '@/lib/api/server'
88
import { getSession } from '@/lib/auth'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10-
import { assertWorkspaceReadAccess } from '@/lib/workspaces/fork/lineage/authz'
10+
import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
1111
import { getForkLineage } from '@/lib/workspaces/fork/lineage/lineage'
1212
import { getUndoableRunForTarget } from '@/lib/workspaces/fork/promote/promote-run-store'
1313

@@ -22,7 +22,7 @@ export const GET = withRouteHandler(
2222
if (!parsed.success) return parsed.response
2323
const { id: workspaceId } = parsed.data.params
2424

25-
await assertWorkspaceReadAccess(workspaceId, session.user.id)
25+
await assertWorkspaceAdminAccess(workspaceId, session.user.id)
2626

2727
const [{ parent, children }, run] = await Promise.all([
2828
getForkLineage(workspaceId),

apps/sim/app/api/workspaces/[id]/fork/resources/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getForkResourcesContract } from '@/lib/api/contracts/workspace-fork'
44
import { parseRequest } from '@/lib/api/server'
55
import { getSession } from '@/lib/auth'
66
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7-
import { assertWorkspaceReadAccess } from '@/lib/workspaces/fork/lineage/authz'
7+
import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
88
import { listForkCopyableResources } from '@/lib/workspaces/fork/mapping/resources'
99

1010
export const GET = withRouteHandler(
@@ -18,7 +18,7 @@ export const GET = withRouteHandler(
1818
if (!parsed.success) return parsed.response
1919
const { id } = parsed.data.params
2020

21-
await assertWorkspaceReadAccess(id, session.user.id)
21+
await assertWorkspaceAdminAccess(id, session.user.id)
2222

2323
const resources = await listForkCopyableResources(db, id)
2424
return NextResponse.json(resources)
Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
1-
import { isEnterprise } from '@/lib/billing/plan-helpers'
1+
import { getSubscriptionAccessState } from '@/lib/billing/client'
22
import { getEnv, isTruthy } from '@/lib/core/config/env'
3-
import { useSubscriptionData } from '@/hooks/queries/subscription'
3+
import { useWorkspaceOwnerBilling } from '@/hooks/queries/workspace'
44

55
const isBillingEnabledClient = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
66
const isForkingEnabledClient = isTruthy(getEnv('NEXT_PUBLIC_FORKING_ENABLED'))
77

88
/**
9-
* Client mirror of the server fork EE gate (`assertForkingEnabled`): the
10-
* Enterprise plan on Sim Cloud, or the `NEXT_PUBLIC_FORKING_ENABLED` override on
11-
* self-hosted. Used to hide the fork UI (and skip the lineage query) for
12-
* workspaces that cannot fork. The server gate remains the security boundary.
9+
* Client mirror of the server fork EE gate (`assertForkingEnabled`): on Sim Cloud
10+
* the active workspace's billed account (its owner's rolled-up plan) must be
11+
* Enterprise; on self-hosted it's the `NEXT_PUBLIC_FORKING_ENABLED` override. Used
12+
* to hide the fork UI (and skip the lineage query) for workspaces that cannot fork.
13+
*
14+
* Gating on the WORKSPACE's plan (not the viewer's) is what matches the server,
15+
* which checks the workspace org's plan: a viewer who belongs to a different
16+
* Enterprise org no longer sees fork UI on a non-Enterprise workspace, and a
17+
* member of an Enterprise workspace isn't denied it just because their own
18+
* highest plan is lower. The server gate remains the security boundary.
19+
*
20+
* Self-hosted relies on `NEXT_PUBLIC_FORKING_ENABLED` / `NEXT_PUBLIC_BILLING_ENABLED`
21+
* mirroring the server's `FORKING_ENABLED` / `BILLING_ENABLED`; set each pair
22+
* together or the UI and API will disagree.
1323
*/
14-
export function useForkingAvailable(): boolean {
15-
const { data } = useSubscriptionData()
24+
export function useForkingAvailable(workspaceId?: string): boolean {
25+
const { data } = useWorkspaceOwnerBilling(isBillingEnabledClient ? workspaceId : undefined)
1626
if (!isBillingEnabledClient) return isForkingEnabledClient
17-
return isEnterprise(data?.data?.plan)
27+
return getSubscriptionAccessState(data).hasUsableEnterpriseAccess
1828
}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '@/components/emcn'
2020
import { ManageWorkspace, PanelLeft, Rocket, Shuffle } from '@/components/emcn/icons'
2121
import { cn } from '@/lib/core/utils/cn'
22+
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2223
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
2324
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
2425
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
@@ -128,8 +129,14 @@ function WorkspaceHeaderImpl({
128129
}, [])
129130

130131
const { navigateToSettings } = useSettingsNavigation()
131-
const forkingAvailable = useForkingAvailable()
132-
const { data: forkLineage } = useForkLineage(workspaceId, forkingAvailable)
132+
const forkingAvailable = useForkingAvailable(workspaceId)
133+
const { canAdmin } = useUserPermissionsContext()
134+
// Forking and sync rewrite workflow state and deployments en masse, so they are
135+
// workspace-admin only (org owners/admins derive workspace admin server-side via
136+
// the resolved viewer permission). Every fork route re-checks this; gating the
137+
// entry points here just keeps the UI honest. The server remains the boundary.
138+
const canUseForking = forkingAvailable && canAdmin
139+
const { data: forkLineage } = useForkLineage(workspaceId, canUseForking)
133140

134141
const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null
135142
const isWorkspaceReady = !isWorkspacesLoading && activeWorkspaceFull !== null
@@ -573,7 +580,7 @@ function WorkspaceHeaderImpl({
573580
>
574581
New workspace
575582
</Chip>
576-
{forkingAvailable ? (
583+
{canUseForking ? (
577584
<>
578585
<Chip
579586
leftIcon={Shuffle}

apps/sim/lib/workspaces/fork/lineage/authz.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,16 @@ export interface ForkAuthorization {
9595
}
9696

9797
/**
98-
* Authorize forking `sourceWorkspaceId`: the caller needs read access to the
99-
* source and must pass the workspace-creation policy for the parent's org (the
100-
* child inherits the parent's org/mode; plan caps apply).
98+
* Authorize forking `sourceWorkspaceId`: the caller needs admin access to the
99+
* source (a fork copies its deployed workflows and resources en masse) and must
100+
* pass the workspace-creation policy for the parent's org (the child inherits the
101+
* parent's org/mode; plan caps apply). Org owners/admins derive workspace admin.
101102
*/
102103
export async function assertCanFork(
103104
sourceWorkspaceId: string,
104105
userId: string
105106
): Promise<ForkAuthorization> {
106-
const source = await assertWorkspaceReadAccess(sourceWorkspaceId, userId)
107+
const source = await assertWorkspaceAdminAccess(sourceWorkspaceId, userId)
107108
const policy = await getWorkspaceCreationPolicy({
108109
userId,
109110
activeOrganizationId: source.organizationId,

0 commit comments

Comments
 (0)