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
This commit is contained in:
Waleed
2026-06-29 22:30:59 -07:00
committed by GitHub
parent 98d84f23c9
commit 7bf8dbaf8d
3 changed files with 50 additions and 7 deletions
@@ -10,6 +10,7 @@ const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({
envRef: {
APPCONFIG_APPLICATION: 'sim-staging' as string | undefined,
APPCONFIG_ENVIRONMENT: 'staging' as string | undefined,
FORKING_ENABLED: undefined as boolean | undefined,
},
flagRef: { isAppConfigEnabled: false },
}))
@@ -106,6 +107,26 @@ describe('isFeatureEnabled', () => {
beforeEach(() => {
vi.clearAllMocks()
flagRef.isAppConfigEnabled = false
envRef.FORKING_ENABLED = undefined
})
describe('workspace-forking flag', () => {
it('falls back to FORKING_ENABLED when AppConfig is disabled', async () => {
envRef.FORKING_ENABLED = undefined
expect(await isFeatureEnabled('workspace-forking', { userId: 'u1', orgId: 'o1' })).toBe(false)
envRef.FORKING_ENABLED = true
expect(await isFeatureEnabled('workspace-forking', { userId: 'u1', orgId: 'o1' })).toBe(true)
})
it('targets specific orgs/users via AppConfig, ignoring the fallback secret', async () => {
envRef.FORKING_ENABLED = undefined
withAppConfig({ 'workspace-forking': { orgIds: ['o1'], userIds: ['u9'] } })
expect(await isFeatureEnabled('workspace-forking', { orgId: 'o1' })).toBe(true)
expect(await isFeatureEnabled('workspace-forking', { userId: 'u9' })).toBe(true)
expect(await isFeatureEnabled('workspace-forking', { orgId: 'o2', userId: 'u1' })).toBe(false)
})
})
it('returns false for an unknown flag', async () => {
@@ -105,6 +105,15 @@ const FEATURE_FLAGS = {
'logging session (no user/org context) so an execution never mixes write paths.',
fallback: 'REDIS_PROGRESS_MARKERS',
},
'workspace-forking': {
description:
'Runtime rollout gate for workspace forking (fork/promote/rollback), layered on top of ' +
'the existing FORKING_ENABLED / Enterprise-plan gate at the shared assertForkingEnabled ' +
'choke point. Enforced ONLY where AppConfig is the source of truth (Sim Cloud), so ' +
'operators can dark-launch forking to specific orgs/users/admins without touching ' +
'self-hosted/local behaviour. Fallback mirrors FORKING_ENABLED for off-AppConfig reads.',
fallback: 'FORKING_ENABLED',
},
} satisfies Record<string, FeatureFlagDefinition>
/**
+20 -7
View File
@@ -1,5 +1,6 @@
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isBillingEnabled, isForkingEnabled } from '@/lib/core/config/env-flags'
import { isAppConfigEnabled, isBillingEnabled, isForkingEnabled } from '@/lib/core/config/env-flags'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { HttpError } from '@/lib/core/utils/http-error'
import { type ForkEdge, resolveForkEdge } from '@/lib/workspaces/fork/lineage/lineage'
import { checkWorkspaceAccess, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
@@ -9,12 +10,18 @@ import { getWorkspaceCreationPolicy, type WorkspaceCreationPolicy } from '@/lib/
export type PromoteDirection = 'push' | 'pull'
/**
* Enterprise-only gate shared by every fork/promote route. On Sim Cloud the gate
* is the Enterprise plan; on self-hosted it's `FORKING_ENABLED`, which 404s when
* unset so a newer image doesn't silently expose forking. Mirrors the data-drains
* gate - this repo gates EE features by plan + env flag, not by directory.
* Gate shared by every fork/promote route. The deployment/entitlement gate is
* unchanged: on Sim Cloud the gate is the Enterprise plan; on self-hosted it's
* `FORKING_ENABLED`, which 404s when unset so a newer image doesn't silently expose
* forking. Mirrors the data-drains gate - this repo gates EE features by plan + env
* flag, not by directory.
*
* Layered on top is the runtime `workspace-forking` flag, a rollout switch enforced
* ONLY where AppConfig is the source of truth (Sim Cloud). It lets us dark-launch
* forking to specific orgs/users/admins without a redeploy; self-hosted/local
* deployments have no AppConfig, so their behaviour is untouched by the flag.
*/
async function assertForkingEnabled(organizationId: string | null): Promise<void> {
async function assertForkingEnabled(organizationId: string | null, userId: string): Promise<void> {
if (!isBillingEnabled && !isForkingEnabled) {
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
}
@@ -26,6 +33,12 @@ async function assertForkingEnabled(organizationId: string | null): Promise<void
throw new ForkError('Workspace forking is available on Enterprise plans only', 403)
}
}
if (
isAppConfigEnabled &&
!(await isFeatureEnabled('workspace-forking', { userId, orgId: organizationId }))
) {
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
}
}
/**
@@ -51,7 +64,7 @@ async function requireWorkspace(
if (!access.exists || !access.workspace) {
throw new ForkError('Workspace not found', 404)
}
await assertForkingEnabled(access.workspace.organizationId)
await assertForkingEnabled(access.workspace.organizationId, userId)
return { workspace: access.workspace, canAdmin: access.canAdmin }
}