feat(gatekeeper): add per workspace feature flags (#5303)

* feat(gatekeeper): add per workspace feature flags

* feat(workspaces): add admin api for granting and removing access to
workspace features

* fix(workspaces): use the correct constant name

* fix(workspaces): more test type fixes

* fix(shared): fix tests and types

* fix(workspaces): properly use exhaustive switch statement

* fix(workspaces): add new workspace plan feature to switch

* fix(workspaces): use regular integer, its fine for now...

* fix(workspaces): feature flag retention post checkout

* fix(gatekeeper): fix upsert plan tests
This commit is contained in:
Gergő Jedlicska
2025-08-26 10:23:02 +01:00
committed by GitHub
parent ae3086f681
commit 6982023dca
31 changed files with 616 additions and 386 deletions
@@ -2,31 +2,98 @@ import { describe, expect, it } from 'vitest'
import {
workspacePlanHasAccessToFeature,
WorkspacePlanFeatures,
WorkspacePlanConfigs
WorkspacePlanConfigs,
isWorkspaceFeatureFlagOn,
WorkspaceFeatureFlags
} from './features.js'
import { WorkspacePlans } from './plans.js'
describe('workspacePlanHasAccessToFeature', () => {
describe('Comprehensive feature coverage', () => {
const allPlans = Object.values(WorkspacePlans) as WorkspacePlans[]
const allFeatures = Object.values(WorkspacePlanFeatures) as WorkspacePlanFeatures[]
describe('workspace features', () => {
describe('workspacePlanHasAccessToFeature', () => {
describe('Comprehensive feature coverage', () => {
const allPlans = Object.values(WorkspacePlans) as WorkspacePlans[]
const allFeatures = Object.values(
WorkspacePlanFeatures
) as WorkspacePlanFeatures[]
describe.each(allPlans)('should work for %s plan', (plan) => {
it.each(allFeatures)('%s feature combination', (feature) => {
const expectedResult = WorkspacePlanConfigs({ featureFlags: undefined })[
plan
].features.includes(feature)
const actualResult = workspacePlanHasAccessToFeature({
plan,
feature,
featureFlags: undefined
describe.each(allPlans)('should work for %s plan', (plan) => {
it.each(allFeatures)('%s feature combination', (feature) => {
const expectedResult = WorkspacePlanConfigs({ featureFlags: undefined })[
plan
].features.includes(feature)
const actualResult = workspacePlanHasAccessToFeature({
plan,
feature,
featureFlags: undefined
})
expect(
actualResult,
`Plan ${plan} feature ${feature} access should be ${expectedResult}`
).toBe(expectedResult)
})
expect(
actualResult,
`Plan ${plan} feature ${feature} access should be ${expectedResult}`
).toBe(expectedResult)
})
})
})
describe('isWorkspaceFeatureFlagOn', () => {
it('returns false if no flags are on', () => {
const workspaceFeatureFlags = WorkspaceFeatureFlags.none
const feature = WorkspaceFeatureFlags.dashboards
const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature })
expect(result).toBe(false)
})
it('returns false if the currently tested flag is off', () => {
const workspaceFeatureFlags = WorkspaceFeatureFlags.accIntegration
const feature = WorkspaceFeatureFlags.dashboards
const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature })
expect(result).toBe(false)
})
it('returns true if the currently tested flag is on', () => {
const workspaceFeatureFlags = WorkspaceFeatureFlags.dashboards
const feature = WorkspaceFeatureFlags.dashboards
const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature })
expect(result).toBe(true)
})
it('returns true if the currently tested flag is on in a combo flag', () => {
const workspaceFeatureFlags =
WorkspaceFeatureFlags.dashboards | WorkspaceFeatureFlags.accIntegration
let result = isWorkspaceFeatureFlagOn({
workspaceFeatureFlags,
feature: WorkspaceFeatureFlags.dashboards
})
expect(result).toBe(true)
result = isWorkspaceFeatureFlagOn({
workspaceFeatureFlags,
feature: WorkspaceFeatureFlags.accIntegration
})
expect(result).toBe(true)
})
it('feature flag can be turned on and off', () => {
let workspaceFeatureFlags = WorkspaceFeatureFlags.none
let result = isWorkspaceFeatureFlagOn({
workspaceFeatureFlags,
feature: WorkspaceFeatureFlags.dashboards
})
expect(result).toBe(false)
workspaceFeatureFlags |= WorkspaceFeatureFlags.dashboards
result = isWorkspaceFeatureFlagOn({
workspaceFeatureFlags,
feature: WorkspaceFeatureFlags.dashboards
})
expect(result).toBe(true)
workspaceFeatureFlags ^= WorkspaceFeatureFlags.dashboards
result = isWorkspaceFeatureFlagOn({
workspaceFeatureFlags,
feature: WorkspaceFeatureFlags.dashboards
})
expect(result).toBe(false)
})
})
})
@@ -24,13 +24,37 @@ export const WorkspacePlanFeatures = <const>{
HideSpeckleBranding: 'hideSpeckleBranding',
ExclusiveMembership: 'exclusiveMembership',
EmbedPrivateProjects: 'embedPrivateProjects',
AccIntegration: 'accIntegration',
AccIntegration: 'accIntegration', // TODO: this should be moved to a workspace addon
SavedViews: 'savedViews'
}
export type WorkspacePlanFeatures =
(typeof WorkspacePlanFeatures)[keyof typeof WorkspacePlanFeatures]
// this const will be used as a bitwise flag for a per workspace feature access controller
// IMPORTANT: always use powers of 2 as the value of the object
// read more https://www.hendrik-erz.de/post/bitwise-flags-are-beautiful-and-heres-why
// this will make its way to the pricing plan and info setup at some point
// but for now its an internal only control
export const WorkspaceFeatureFlags = <const>{
none: 0,
dashboards: 1,
accIntegration: 2
}
export type WorkspaceFeatureFlags =
(typeof WorkspaceFeatureFlags)[keyof typeof WorkspaceFeatureFlags]
export const isWorkspaceFeatureFlagOn = ({
workspaceFeatureFlags,
feature
}: {
workspaceFeatureFlags: number
feature: WorkspaceFeatureFlags
}): boolean => (workspaceFeatureFlags & feature) === feature
export type WorkspaceFeatures = WorkspacePlanFeatures | WorkspaceFeatureFlags
export const WorkspacePlanFeaturesMetadata = (<const>{
[WorkspacePlanFeatures.AutomateBeta]: {
displayName: 'Automate beta access',
@@ -314,3 +338,12 @@ export const workspacePlanHasAccessToFeature = ({
const hasAccess = planConfig.features.includes(feature)
return hasAccess
}
export const isPlanFeature = (
feature: WorkspaceFeatures
): feature is WorkspacePlanFeatures => {
if (typeof feature === 'number') {
return false
}
return Object.values(WorkspacePlanFeatures).includes(feature)
}
@@ -136,6 +136,7 @@ type BaseWorkspacePlan = {
workspaceId: string
createdAt: Date
updatedAt: Date
featureFlags: number // this will be a bitwise flag number
}
export type PaidWorkspacePlan = BaseWorkspacePlan & {