mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user