Skip to content

Commit 0f36c2e

Browse files
committed
feat(workspaces): gate workspace forking behind runtime workspace-forking feature flag
1 parent 98d84f2 commit 0f36c2e

3 files changed

Lines changed: 46 additions & 8 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). Resolved at the ' +
111+
'shared assertForkingEnabled choke point with org/user context, so operators can dark-' +
112+
'launch forking to specific orgs/users/admins via AppConfig before broad availability. ' +
113+
'Falls back to FORKING_ENABLED off-AppConfig, leaving self-hosted behaviour unchanged. ' +
114+
'On Sim Cloud the Enterprise-plan entitlement still applies on top of this gate.',
115+
fallback: 'FORKING_ENABLED',
116+
},
108117
} satisfies Record<string, FeatureFlagDefinition>
109118

110119
/**

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

Lines changed: 16 additions & 8 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 { isBillingEnabled } 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,13 +10,20 @@ 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. A runtime `workspace-forking` flag is the
14+
* master rollout switch: on Sim Cloud it's resolved from AppConfig with org/user
15+
* context (dark-launchable to specific orgs/users/admins), and off-AppConfig it falls
16+
* back to `FORKING_ENABLED`, leaving self-hosted behaviour unchanged. An off/absent
17+
* flag 404s so a newer image never silently exposes forking. On Sim Cloud the
18+
* Enterprise-plan entitlement still applies on top of the flag. Mirrors the
19+
* data-drains gate - this repo gates EE features by plan + flag, not by directory.
1620
*/
17-
async function assertForkingEnabled(organizationId: string | null): Promise<void> {
18-
if (!isBillingEnabled && !isForkingEnabled) {
21+
async function assertForkingEnabled(organizationId: string | null, userId: string): Promise<void> {
22+
const flagEnabled = await isFeatureEnabled('workspace-forking', {
23+
userId,
24+
orgId: organizationId,
25+
})
26+
if (!flagEnabled) {
1927
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
2028
}
2129
if (isBillingEnabled) {
@@ -51,7 +59,7 @@ async function requireWorkspace(
5159
if (!access.exists || !access.workspace) {
5260
throw new ForkError('Workspace not found', 404)
5361
}
54-
await assertForkingEnabled(access.workspace.organizationId)
62+
await assertForkingEnabled(access.workspace.organizationId, userId)
5563
return { workspace: access.workspace, canAdmin: access.canAdmin }
5664
}
5765

0 commit comments

Comments
 (0)