Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/sim/lib/core/config/feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}))
Expand Down Expand Up @@ -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 () => {
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/lib/core/config/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>

/**
Expand Down
27 changes: 20 additions & 7 deletions apps/sim/lib/workspaces/fork/lineage/authz.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
}
Expand All @@ -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)
}
}

/**
Expand All @@ -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 }
}

Expand Down
Loading