From bf19b6207958136e39947772b51f70f0f1bcaffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 3 Nov 2025 09:27:15 +0100 Subject: [PATCH] feat: ability to disable custom strategies (#10885) ## About the changes This adds the ability to disable custom strategy creation and editing by using two env variables: `UNLEASH_DISABLE_CUSTOM_STRATEGY_CREATION` and `UNLEASH_DISABLE_CUSTOM_STRATEGY_EDITING`. Fixes: #9593 This could be useful if you want to remove the ability to create new custom strategies and rely on the built-in ones --- .../__snapshots__/create-config.test.ts.snap | 4 ++ src/lib/create-config.ts | 13 +++++++ src/lib/routes/admin-api/strategy.test.ts | 37 ++++++++++++++++++- src/lib/services/strategy-service.ts | 28 +++++++++++++- src/lib/types/option.ts | 7 ++++ 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index b407729b53..24f52627ed 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -29,6 +29,10 @@ exports[`should create default config 1`] = ` "enabled": true, "maxAge": 3600000, }, + "customStrategySettings": { + "disableCreation": false, + "disableEditing": false, + }, "dailyMetricsStorageDays": 91, "db": { "acquireConnectionTimeout": 30000, diff --git a/src/lib/create-config.ts b/src/lib/create-config.ts index 426e86787c..795186cc73 100644 --- a/src/lib/create-config.ts +++ b/src/lib/create-config.ts @@ -585,6 +585,18 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig { ).values(), ]; + const customStrategySettings = options.customStrategySettings ?? { + disableCreation: parseEnvVarBoolean( + process.env.UNLEASH_DISABLE_CUSTOM_STRATEGY_CREATION, + false, + ), + + disableEditing: parseEnvVarBoolean( + process.env.UNLEASH_DISABLE_CUSTOM_STRATEGY_EDITING, + false, + ), + }; + const environmentEnableOverrides = loadEnvironmentEnableOverrides(); const importSetting: IImportOption = mergeAll([ @@ -834,6 +846,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig { userInactivityThresholdInDays, buildDate: process.env.BUILD_DATE, unleashFrontendToken, + customStrategySettings, checkDbOnReady, }; } diff --git a/src/lib/routes/admin-api/strategy.test.ts b/src/lib/routes/admin-api/strategy.test.ts index 0c671db80a..d159e1bc0f 100644 --- a/src/lib/routes/admin-api/strategy.test.ts +++ b/src/lib/routes/admin-api/strategy.test.ts @@ -4,7 +4,7 @@ import createStores from '../../../test/fixtures/store.js'; import permissions from '../../../test/fixtures/permissions.js'; import getApp from '../../app.js'; import { createServices } from '../../services/index.js'; -import { vi } from 'vitest'; +import { afterEach, vi } from 'vitest'; async function getSetup() { const randomBase = `/random${Math.round(Math.random() * 1000)}`; @@ -25,6 +25,11 @@ async function getSetup() { }; } +afterEach(() => { + delete process.env.UNLEASH_DISABLE_CUSTOM_STRATEGY_CREATION; + delete process.env.UNLEASH_DISABLE_CUSTOM_STRATEGY_EDITING; +}); + test('add version numbers for /strategies', async () => { const { request, base } = await getSetup(); return request @@ -71,6 +76,20 @@ test('create a new strategy with empty parameters', async () => { .expect(201); }); +test('creating strategies is forbidden when disabled by configuration', async () => { + process.env.UNLEASH_DISABLE_CUSTOM_STRATEGY_CREATION = 'true'; + const { request, base } = await getSetup(); + + const response = await request + .post(`${base}/api/admin/strategies`) + .send({ name: 'LockedStrategy', parameters: [] }) + .expect(403); + + expect(response.body.message).toContain( + 'Custom strategy creation is disabled', + ); +}); + test('not be possible to override name', async () => { const { request, base, strategyStore } = await getSetup(); strategyStore.createStrategy({ name: 'Testing', parameters: [] }); @@ -92,6 +111,22 @@ test('update strategy', async () => { .expect(200); }); +test('updating strategies is forbidden when disabled by configuration', async () => { + process.env.UNLEASH_DISABLE_CUSTOM_STRATEGY_EDITING = 'true'; + const { request, base, strategyStore } = await getSetup(); + const name = 'LockedStrategy'; + await strategyStore.createStrategy({ name, parameters: [] }); + + const response = await request + .put(`${base}/api/admin/strategies/${name}`) + .send({ name, parameters: [] }) + .expect(403); + + expect(response.body.message).toContain( + 'Custom strategy modification is disabled', + ); +}); + test('not update unknown strategy', async () => { const { request, base } = await getSetup(); const name = 'UnknownStrat'; diff --git a/src/lib/services/strategy-service.ts b/src/lib/services/strategy-service.ts index 00e78f76e4..d1bd44a1e1 100644 --- a/src/lib/services/strategy-service.ts +++ b/src/lib/services/strategy-service.ts @@ -17,7 +17,7 @@ import { StrategyUpdatedEvent, } from '../types/index.js'; import strategySchema from './strategy-schema.js'; -import { NameExistsError } from '../error/index.js'; +import { NameExistsError, OperationDeniedError } from '../error/index.js'; class StrategyService { private logger: Logger; @@ -26,14 +26,20 @@ class StrategyService { private eventService: EventService; + private customStrategySettings: IUnleashConfig['customStrategySettings']; + constructor( { strategyStore }: Pick, - { getLogger }: Pick, + { + getLogger, + customStrategySettings, + }: Pick, eventService: EventService, ) { this.strategyStore = strategyStore; this.eventService = eventService; this.logger = getLogger('services/strategy-service.js'); + this.customStrategySettings = customStrategySettings; } async getStrategies(): Promise { @@ -103,6 +109,7 @@ class StrategyService { value: IMinimalStrategy, auditUser: IAuditUser, ): Promise { + this.assertCreationAllowed(); const strategy = await strategySchema.validateAsync(value); strategy.deprecated = false; await this._validateStrategyName(strategy); @@ -120,6 +127,7 @@ class StrategyService { input: IMinimalStrategy, auditUser: IAuditUser, ): Promise { + this.assertEditingAllowed(); const value = await strategySchema.validateAsync(input); const strategy = await this.strategyStore.get(input.name); await this._validateEditable(strategy); @@ -155,5 +163,21 @@ class StrategyService { throw new Error(`Cannot edit strategy ${strategy?.name}`); } } + + private assertCreationAllowed(): void { + if (this.customStrategySettings?.disableCreation) { + throw new OperationDeniedError( + 'Custom strategy creation is disabled by configuration.', + ); + } + } + + private assertEditingAllowed(): void { + if (this.customStrategySettings?.disableEditing) { + throw new OperationDeniedError( + 'Custom strategy modification is disabled by configuration.', + ); + } + } } export default StrategyService; diff --git a/src/lib/types/option.ts b/src/lib/types/option.ts index ef0df45051..4e2694526e 100644 --- a/src/lib/types/option.ts +++ b/src/lib/types/option.ts @@ -118,6 +118,11 @@ export interface IClientCachingOption { maxAge: number; } +export interface ICustomStrategySettings { + disableCreation: boolean; + disableEditing: boolean; +} + export interface ResourceLimits { apiTokens: number; constraints: number; @@ -175,6 +180,7 @@ export interface IUnleashOptions { resourceLimits?: Partial; userInactivityThresholdInDays?: number; unleashFrontendToken?: string; + customStrategySettings?: ICustomStrategySettings; checkDbOnReady?: boolean; } @@ -302,5 +308,6 @@ export interface IUnleashConfig { userInactivityThresholdInDays: number; buildDate?: string; unleashFrontendToken?: string; + customStrategySettings?: ICustomStrategySettings; checkDbOnReady?: boolean; }