Skip to content

Commit 7bf8dba

Browse files
authored
feat(workspaces): gate workspace forking behind runtime workspace-forking feature flag (#5280)
* feat(workspaces): gate workspace forking behind runtime workspace-forking feature flag * fix(workspaces): scope workspace-forking flag to AppConfig deployments to preserve self-hosted gate
1 parent 98d84f2 commit 7bf8dba

3 files changed

Lines changed: 50 additions & 7 deletions

File tree

apps/sim/lib/core/config/feature-flags.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({
1010
envRef: {
1111
APPCONFIG_APPLICATION: 'sim-staging' as string | undefined,
1212
APPCONFIG_ENVIRONMENT: 'staging' as string | undefined,
13+
FORKING_ENABLED: undefined as boolean | undefined,
1314
},
1415
flagRef: { isAppConfigEnabled: false },
1516
}))
@@ -106,6 +107,26 @@ describe('isFeatureEnabled', () => {
106107
beforeEach(() => {
107108
vi.clearAllMocks()
108109
flagRef.isAppConfigEnabled = false
110+
envRef.FORKING_ENABLED = undefined
111+
})
112+
113+
describe('workspace-forking flag', () => {
114+
it('falls back to FORKING_ENABLED when AppConfig is disabled', async () => {
115+
envRef.FORKING_ENABLED = undefined
116+
expect(await isFeatureEnabled('workspace-forking', { userId: 'u1', orgId: 'o1' })).toBe(false)
117+
118+
envRef.FORKING_ENABLED = true
119+
expect(await isFeatureEnabled('workspace-forking', { userId: 'u1', orgId: 'o1' })).toBe(true)
120+
})
121+
122+
it('targets specific orgs/users via AppConfig, ignoring the fallback secret', async () => {
123+
envRef.FORKING_ENABLED = undefined
124+
withAppConfig({ 'workspace-forking': { orgIds: ['o1'], userIds: ['u9'] } })
125+
126+
expect(await isFeatureEnabled('workspace-forking', { orgId: 'o1' })).toBe(true)
127+
expect(await isFeatureEnabled('workspace-forking', { userId: 'u9' })).toBe(true)
128+
expect(await isFeatureEnabled('workspace-forking', { orgId: 'o2', userId: 'u1' })).toBe(false)
129+
})
109130
})
110131

111132
it('returns false for an unknown flag', async () => {

apps/sim/lib/core/config/feature-flags.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ const FEATURE_FLAGS = {
105105
'logging session (no user/org context) so an execution never mixes write paths.',
106106
fallback: 'REDIS_PROGRESS_MARKERS',
107107
},
108+
'workspace-forking': {
109+
description:
110+
'Runtime rollout gate for workspace forking (fork/promote/rollback), layered on top of ' +
111+
'the existing FORKING_ENABLED / Enterprise-plan gate at the shared assertForkingEnabled ' +
112+
'choke point. Enforced ONLY where AppConfig is the source of truth (Sim Cloud), so ' +
113+
'operators can dark-launch forking to specific orgs/users/admins without touching ' +
114+
'self-hosted/local behaviour. Fallback mirrors FORKING_ENABLED for off-AppConfig reads.',
115+
fallback: 'FORKING_ENABLED',
116+
},
108117
} satisfies Record<string, FeatureFlagDefinition>
109118

110119
/**

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

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
2-
import { isBillingEnabled, isForkingEnabled } from '@/lib/core/config/env-flags'
2+
import { isAppConfigEnabled, isBillingEnabled, isForkingEnabled } from '@/lib/core/config/env-flags'
3+
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
34
import { HttpError } from '@/lib/core/utils/http-error'
45
import { type ForkEdge, resolveForkEdge } from '@/lib/workspaces/fork/lineage/lineage'
56
import { checkWorkspaceAccess, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
@@ -9,12 +10,18 @@ import { getWorkspaceCreationPolicy, type WorkspaceCreationPolicy } from '@/lib/
910
export type PromoteDirection = 'push' | 'pull'
1011

1112
/**
12-
* Enterprise-only gate shared by every fork/promote route. On Sim Cloud the gate
13-
* is the Enterprise plan; on self-hosted it's `FORKING_ENABLED`, which 404s when
14-
* unset so a newer image doesn't silently expose forking. Mirrors the data-drains
15-
* gate - this repo gates EE features by plan + env flag, not by directory.
13+
* Gate shared by every fork/promote route. The deployment/entitlement gate is
14+
* unchanged: on Sim Cloud the gate is the Enterprise plan; on self-hosted it's
15+
* `FORKING_ENABLED`, which 404s when unset so a newer image doesn't silently expose
16+
* forking. Mirrors the data-drains gate - this repo gates EE features by plan + env
17+
* flag, not by directory.
18+
*
19+
* Layered on top is the runtime `workspace-forking` flag, a rollout switch enforced
20+
* ONLY where AppConfig is the source of truth (Sim Cloud). It lets us dark-launch
21+
* forking to specific orgs/users/admins without a redeploy; self-hosted/local
22+
* deployments have no AppConfig, so their behaviour is untouched by the flag.
1623
*/
17-
async function assertForkingEnabled(organizationId: string | null): Promise<void> {
24+
async function assertForkingEnabled(organizationId: string | null, userId: string): Promise<void> {
1825
if (!isBillingEnabled && !isForkingEnabled) {
1926
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
2027
}
@@ -26,6 +33,12 @@ async function assertForkingEnabled(organizationId: string | null): Promise<void
2633
throw new ForkError('Workspace forking is available on Enterprise plans only', 403)
2734
}
2835
}
36+
if (
37+
isAppConfigEnabled &&
38+
!(await isFeatureEnabled('workspace-forking', { userId, orgId: organizationId }))
39+
) {
40+
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
41+
}
2942
}
3043

3144
/**
@@ -51,7 +64,7 @@ async function requireWorkspace(
5164
if (!access.exists || !access.workspace) {
5265
throw new ForkError('Workspace not found', 404)
5366
}
54-
await assertForkingEnabled(access.workspace.organizationId)
67+
await assertForkingEnabled(access.workspace.organizationId, userId)
5568
return { workspace: access.workspace, canAdmin: access.canAdmin }
5669
}
5770

0 commit comments

Comments
 (0)