diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts b/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts index 7295120c1d..e6314d7a68 100644 --- a/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts @@ -11,11 +11,13 @@ import { } from '@server/types/permissions'; import { useHasRootAccess } from 'hooks/useHasAccess'; import { SelectOption } from './TokenTypeSelector/TokenTypeSelector'; +import { useUiFlag } from '../../../../hooks/useUiFlag'; export type ApiTokenFormErrorType = 'username' | 'projects'; export const useApiTokenForm = (project?: string) => { const { environments } = useEnvironments(); const { uiConfig } = useUiConfig(); + const adminTokenKillSwitch = useUiFlag('adminTokenKillSwitch'); const initialEnvironment = environments?.find((e) => e.enabled)?.name; const hasCreateTokenPermission = useHasRootAccess(CREATE_CLIENT_API_TOKEN); @@ -40,7 +42,7 @@ export const useApiTokenForm = (project?: string) => { CREATE_PROJECT_API_TOKEN, project, ); - if (!project) { + if (!project && !adminTokenKillSwitch) { apiTokenTypes.push({ key: TokenType.ADMIN, label: TokenType.ADMIN, diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 8d17e61916..2f615b02aa 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -74,6 +74,7 @@ export type UiFlags = { enableLicense?: boolean; newStrategyConfigurationFeedback?: boolean; extendedUsageMetricsUI?: boolean; + adminTokenKillSwitch?: boolean; }; export interface IVersionInfo { diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 676e392d45..75b376a9c3 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -73,6 +73,7 @@ exports[`should create default config 1`] = ` "feedbackUriPath": undefined, "flagResolver": FlagResolver { "experiments": { + "adminTokenKillSwitch": false, "anonymiseEventLog": false, "automatedActions": false, "caseInsensitiveInOperators": false, diff --git a/src/lib/routes/admin-api/api-token.test.ts b/src/lib/routes/admin-api/api-token.test.ts new file mode 100644 index 0000000000..e6edc76ef2 --- /dev/null +++ b/src/lib/routes/admin-api/api-token.test.ts @@ -0,0 +1,104 @@ +import permissions from '../../../test/fixtures/permissions'; +import { createTestConfig } from '../../../test/config/test-config'; +import createStores from '../../../test/fixtures/store'; +import { createServices } from '../../services'; +import getApp from '../../app'; +import supertest from 'supertest'; +import { addDays } from 'date-fns'; + +async function getSetup(adminTokenKillSwitchEnabled: boolean) { + const base = `/random${Math.round(Math.random() * 1000)}`; + const perms = permissions(); + const config = createTestConfig({ + preHook: perms.hook, + server: { baseUriPath: base }, + experimental: { + flags: { + adminTokenKillSwitch: adminTokenKillSwitchEnabled, + }, + }, + //@ts-ignore - Just testing, so only need the isEnabled call here + }); + const stores = createStores(); + await stores.environmentStore.create({ + name: 'development', + type: 'development', + enabled: true, + }); + const services = createServices(stores, config); + const app = await getApp(config, stores, services); + + return { + base, + request: supertest(app), + }; +} + +describe('Admin token killswitch', () => { + test('If killswitch is off we can still create admin tokens', async () => { + const setup = await getSetup(false); + return setup.request + .post(`${setup.base}/api/admin/api-tokens`) + .set('Content-Type', 'application/json') + .send({ + expiresAt: addDays(new Date(), 60), + type: 'ADMIN', + tokenName: 'Non killswitched', + }) + .expect(201) + .expect((res) => { + expect(res.body.secret).toBeTruthy(); + }); + }); + test('If killswitch is on we will get an operation denied if we try to create an admin token', async () => { + const setup = await getSetup(true); + return setup.request + .post(`${setup.base}/api/admin/api-tokens`) + .set('Content-Type', 'application/json') + .send({ + expiresAt: addDays(new Date(), 60), + type: 'ADMIN', + tokenName: 'Killswitched', + }) + .expect(403) + .expect((res) => { + expect(res.body.message).toBe( + 'Admin tokens are disabled in this instance. Use a Service account or a PAT to access admin operations instead', + ); + }); + }); + test('If killswitch is on we can still create a client token', async () => { + const setup = await getSetup(true); + return setup.request + .post(`${setup.base}/api/admin/api-tokens`) + .set('Content-Type', 'application/json') + .send({ + expiresAt: addDays(new Date(), 60), + type: 'CLIENT', + environment: 'development', + projects: ['*'], + tokenName: 'Client', + }) + .expect(201) + .expect((res) => { + expect(res.body.secret).toBeTruthy(); + }); + }); + test('If killswitch is on we can still create a frontend token', async () => { + const setup = await getSetup(true); + return setup.request + .post(`${setup.base}/api/admin/api-tokens`) + .set('Content-Type', 'application/json') + .send({ + expiresAt: addDays(new Date(), 60), + type: 'FRONTEND', + environment: 'development', + projects: ['*'], + tokenName: 'Frontend', + }) + .expect(201) + .expect((res) => { + expect(res.body.secret).toBeTruthy(); + }); + }); +}); diff --git a/src/lib/routes/admin-api/api-token.ts b/src/lib/routes/admin-api/api-token.ts index dabd6ad2a1..0fcfbc3484 100644 --- a/src/lib/routes/admin-api/api-token.ts +++ b/src/lib/routes/admin-api/api-token.ts @@ -21,7 +21,7 @@ import { IUnleashConfig } from '../../types/option'; import { ApiTokenType, IApiToken } from '../../types/models/api-token'; import { createApiToken } from '../../schema/api-token-schema'; import { OpenApiService } from '../../services/openapi-service'; -import { IUnleashServices } from '../../types'; +import { IFlagResolver, IUnleashServices } from '../../types'; import { createRequestSchema } from '../../openapi/util/create-request-schema'; import { createResponseSchema, @@ -127,6 +127,8 @@ export class ApiTokenController extends Controller { private logger: Logger; + private flagResolver: IFlagResolver; + constructor( config: IUnleashConfig, { @@ -147,6 +149,7 @@ export class ApiTokenController extends Controller { this.accessService = accessService; this.proxyService = proxyService; this.openApiService = openApiService; + this.flagResolver = config.flagResolver; this.logger = config.getLogger('api-token-controller.js'); this.route({ @@ -304,6 +307,14 @@ export class ApiTokenController extends Controller { const permissionRequired = tokenTypeToCreatePermission( createToken.type, ); + if ( + createToken.type.toUpperCase() === 'ADMIN' && + this.flagResolver.isEnabled('adminTokenKillSwitch') + ) { + throw new OperationDeniedError( + `Admin tokens are disabled in this instance. Use a Service account or a PAT to access admin operations instead`, + ); + } const hasPermission = await this.accessService.hasPermission( req.user, permissionRequired, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index b4ad36a4e0..1b6068ae3d 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -42,7 +42,8 @@ export type IFlagKey = | 'newStrategyConfigurationFeedback' | 'edgeBulkMetricsKillSwitch' | 'extendedUsageMetrics' - | 'extendedUsageMetricsUI'; + | 'extendedUsageMetricsUI' + | 'adminTokenKillSwitch'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -193,6 +194,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_EXTENDED_USAGE_METRICS_UI, false, ), + adminTokenKillSwitch: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_ADMIN_TOKEN_KILL_SWITCH, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = {