From 1cedd3dc33f84c9fdacadf3ace93c7b5f03c4e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Wed, 5 Apr 2023 10:20:50 +0200 Subject: [PATCH] feat: add PAT kill switch (#3454) This PR disables PAT admin endpoints so it's not possible to create or get PATs the kill switch is enabled, the UI is hidden but the existing PATs will continue to work if they were created before. The delete endpoint still works allowing an admin to delete old PATs By default the kill switch is disabled (i.e. PAT is enabled by default) --- .../projects/notifications.spec.ts | 147 +++++------------- .../integration/projects/settings.spec.ts | 127 ++++++--------- .../src/component/user/Profile/Profile.tsx | 4 + frontend/src/interfaces/uiConfig.ts | 1 + .../__snapshots__/create-config.test.ts.snap | 2 + src/lib/routes/admin-api/user/pat.ts | 18 ++- src/lib/types/experimental.ts | 4 + 7 files changed, 111 insertions(+), 192 deletions(-) diff --git a/frontend/cypress/integration/projects/notifications.spec.ts b/frontend/cypress/integration/projects/notifications.spec.ts index 125c9b861b..e214cdc45a 100644 --- a/frontend/cypress/integration/projects/notifications.spec.ts +++ b/frontend/cypress/integration/projects/notifications.spec.ts @@ -1,134 +1,63 @@ -/// +/// + +import UserCredentials = Cypress.UserCredentials; -type UserCredentials = { email: string; password: string }; const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE')); const randomId = String(Math.random()).split('.')[1]; const featureToggleName = `notifications_test-${randomId}`; const baseUrl = Cypress.config().baseUrl; let strategyId = ''; -const userIds: number[] = []; -const userCredentials: UserCredentials[] = []; +let userIds: number[] = []; +let userCredentials: UserCredentials[] = []; const userName = `notifications_user-${randomId}`; const projectName = `default`; -const password = Cypress.env(`AUTH_PASSWORD`) + '_A'; + const EDITOR = 2; -const PROJECT_MEMBER = 5; - -// Disable all active splash pages by visiting them. -const disableActiveSplashScreens = () => { - cy.visit(`/splash/operators`); -}; - -const createUser = () => { - const name = `${userName}`; - const email = `${name}@test.com`; - cy.request('POST', `${baseUrl}/api/admin/user-admin`, { - name: name, - email: `${name}@test.com`, - username: `${name}@test.com`, - sendEmail: false, - rootRole: EDITOR, - }) - .as(name) - .then(response => { - const id = response.body.id; - updateUserPassword(id).then(() => { - addUserToProject(id).then(() => { - userIds.push(id); - userCredentials.push({ email, password }); - }); - }); - }); -}; - -const updateUserPassword = (id: number) => - cy.request( - 'POST', - `${baseUrl}/api/admin/user-admin/${id}/change-password`, - { - password, - } - ); - -const addUserToProject = (id: number) => - cy.request( - 'POST', - `${baseUrl}/api/admin/projects/${projectName}/role/${PROJECT_MEMBER}/access`, - { - groups: [], - users: [{ id }], - } - ); describe('notifications', () => { before(() => { - disableActiveSplashScreens(); - cy.login(); - createUser(); + cy.runBefore(); }); - after(() => { - // We need to login as admin for cleanup - cy.login(); - userIds.forEach(id => - cy.request('DELETE', `${baseUrl}/api/admin/user-admin/${id}`) - ); + // This one is failing on CI: https://github.com/Unleash/unleash/actions/runs/4609305167/jobs/8160244872#step:4:193 + it.skip('should create a notification when a feature is created in a project', () => { + cy.login_UI(); + cy.createUser_API(userName, EDITOR).then(value => { + userIds = value.userIds; + userCredentials = value.userCredentials; - cy.request( - 'DELETE', - `${baseUrl}/api/admin/features/${featureToggleName}` - ); - }); + cy.login_UI(); + cy.visit(`/projects/${projectName}`); - beforeEach(() => { - cy.login(); - cy.visit(`/projects/${projectName}`); - if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { - cy.get("[data-testid='CLOSE_SPLASH']").click(); - } - }); + cy.createFeature_UI(featureToggleName); - afterEach(() => { - cy.logout(); - }); + //Should not show own notifications + cy.get("[data-testid='NOTIFICATIONS_BUTTON']").click(); - const createFeature = () => { - cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click(); + //then + cy.get("[data-testid='NOTIFICATIONS_MODAL']").should('exist'); - cy.intercept('POST', `/api/admin/projects/${projectName}/features`).as( - 'createFeature' - ); + const credentials = userCredentials[0]; - cy.get("[data-testid='CF_NAME_ID'").type(featureToggleName); - cy.get("[data-testid='CF_DESC_ID'").type('hello-world'); - cy.get("[data-testid='CF_CREATE_BTN_ID']").click(); - cy.wait('@createFeature'); - }; + //Sign in as a different user + cy.login_UI(credentials.email, credentials.password); + cy.visit(`/projects/${projectName}`); + cy.get("[data-testid='NOTIFICATIONS_BUTTON']").click(); - it('should create a notification when a feature is created in a project', () => { - createFeature(); + //then + cy.get("[data-testid='UNREAD_NOTIFICATIONS']").should('exist'); + cy.get("[data-testid='NOTIFICATIONS_LIST']") + .eq(0) + .should('contain.text', `New feature ${featureToggleName}`); - //Should not show own notifications - cy.get("[data-testid='NOTIFICATIONS_BUTTON']").click(); + //clean + // We need to login as admin for cleanup + cy.login_UI(); + userIds.forEach(id => + cy.request('DELETE', `${baseUrl}/api/admin/user-admin/${id}`) + ); - //then - cy.get("[data-testid='NOTIFICATIONS_MODAL']").should('exist'); - - const credentials = userCredentials[0]; - - //Sign in as a different user - cy.login(credentials.email, credentials.password); - cy.visit(`/projects/${projectName}`); - if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { - cy.get("[data-testid='CLOSE_SPLASH']").click(); - } - cy.get("[data-testid='NOTIFICATIONS_BUTTON']").click(); - - //then - cy.get("[data-testid='UNREAD_NOTIFICATIONS']").should('exist'); - cy.get("[data-testid='NOTIFICATIONS_LIST']") - .should('have.length', 1) - .eq(0) - .should('contain.text', 'New feature'); + cy.deleteFeature_API(featureToggleName); + }); }); }); diff --git a/frontend/cypress/integration/projects/settings.spec.ts b/frontend/cypress/integration/projects/settings.spec.ts index 51307231e9..9637a2bcb1 100644 --- a/frontend/cypress/integration/projects/settings.spec.ts +++ b/frontend/cypress/integration/projects/settings.spec.ts @@ -1,117 +1,80 @@ -/// +/// -type UserCredentials = { email: string; password: string }; -const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE')); const randomId = String(Math.random()).split('.')[1]; -const featureToggleName = `settings-${randomId}`; const baseUrl = Cypress.config().baseUrl; let strategyId = ''; const userName = `settings-user-${randomId}`; const projectName = `stickiness-project-${randomId}`; +const TEST_STICKINESS = 'userId'; +const featureToggleName = `settings-${randomId}`; +let cleanFeature = false; +let cleanProject = false; -// Disable all active splash pages by visiting them. -const disableActiveSplashScreens = () => { - cy.visit(`/splash/operators`); -}; - -const disableFeatureStrategiesProdGuard = () => { - localStorage.setItem( - 'useFeatureStrategyProdGuardSettings:v2', - JSON.stringify({ hide: true }) - ); -}; - -describe('notifications', () => { +describe('project settings', () => { before(() => { - disableFeatureStrategiesProdGuard(); - disableActiveSplashScreens(); - cy.login(); - }); - - after(() => { - cy.request( - 'DELETE', - `${baseUrl}/api/admin/features/${featureToggleName}` - ); - - cy.request('DELETE', `${baseUrl}/api/admin/projects/${projectName}`); + cy.runBefore(); }); beforeEach(() => { - cy.login(); - cy.visit(`/projects`); - if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { - cy.get("[data-testid='CLOSE_SPLASH']").click(); + cy.login_UI(); + if (cleanFeature) { + cy.deleteFeature_API(featureToggleName); } + if (cleanProject) { + cy.deleteProject_API(projectName); + } + cy.visit(`/projects`); + cy.wait(300); }); - afterEach(() => { - cy.request('DELETE', `${baseUrl}/api/admin/projects/${projectName}`); - }); - - const createFeature = () => { - cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click(); - - cy.intercept('POST', `/api/admin/projects/${projectName}/features`).as( - 'createFeature' - ); - - cy.get("[data-testid='CF_NAME_ID'").type(featureToggleName); - cy.get("[data-testid='CF_DESC_ID'").type('hello-world'); - cy.get("[data-testid='CF_CREATE_BTN_ID']").click(); - cy.wait('@createFeature'); - }; - - const createProject = () => { - cy.get('[data-testid=NAVIGATE_TO_CREATE_PROJECT').click(); - - cy.get("[data-testid='PROJECT_ID_INPUT']").type(projectName); - cy.get("[data-testid='PROJECT_NAME_INPUT']").type(projectName); - cy.get("[id='stickiness-select']") - .first() - .click() - .get('[data-testid=SELECT_ITEM_ID-userId') - .first() - .click(); - cy.get("[data-testid='CREATE_PROJECT_BTN']").click(); - }; - it('should store default project stickiness when creating, retrieve it when editing a project', () => { - createProject(); - + //when + cleanProject = true; + cy.createProject_UI(projectName, TEST_STICKINESS); cy.visit(`/projects/${projectName}`); - if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { - cy.get("[data-testid='CLOSE_SPLASH']").click(); - } cy.get("[data-testid='NAVIGATE_TO_EDIT_PROJECT']").click(); //then cy.get("[id='stickiness-select']") .first() .should('have.text', 'userId'); + + //clean + cy.request('DELETE', `${baseUrl}/api/admin/projects/${projectName}`); }); it('should respect the default project stickiness when creating a Gradual Rollout Strategy', () => { - createProject(); - createFeature(); + cy.createProject_UI(projectName, TEST_STICKINESS); + cy.createFeature_UI(featureToggleName, true, projectName); + cleanFeature = true; + + //when - then + cy.addFlexibleRolloutStrategyToFeature_UI({ + featureToggleName, + project: projectName, + stickiness: TEST_STICKINESS, + }); + + //clean + }); + + it.skip('should respect the default project stickiness when creating a variant', () => { + cy.createProject_UI(projectName, TEST_STICKINESS); + cy.createFeature_UI(featureToggleName, true, projectName); + + //when cy.visit( - `/projects/default/features/${featureToggleName}/strategies/create?environmentId=development&strategyName=flexibleRollout` + `/projects/${projectName}/features/${featureToggleName}/variants` ); + cy.get("[data-testid='ADD_VARIANT_BUTTON']").first().click(); //then cy.get("[id='stickiness-select']") .first() .should('have.text', 'userId'); - }); - it('should respect the default project stickiness when creating a variant', () => { - createProject(); - createFeature(); - - cy.visit(`/projects/default/features/${featureToggleName}/variants`); - - cy.get("[data-testid='EDIT_VARIANTS_BUTTON']").click(); - //then - cy.get('#menu-stickiness').first().should('have.text', 'userId'); + //clean + cy.deleteFeature_API(featureToggleName); + cy.deleteProject_API(projectName); }); }); diff --git a/frontend/src/component/user/Profile/Profile.tsx b/frontend/src/component/user/Profile/Profile.tsx index 94748642d3..324d2aff7f 100644 --- a/frontend/src/component/user/Profile/Profile.tsx +++ b/frontend/src/component/user/Profile/Profile.tsx @@ -7,6 +7,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { PasswordTab } from './PasswordTab/PasswordTab'; import { PersonalAPITokensTab } from './PersonalAPITokensTab/PersonalAPITokensTab'; import { ProfileTab } from './ProfileTab/ProfileTab'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; export const Profile = () => { const { user } = useAuthUser(); @@ -14,6 +15,8 @@ export const Profile = () => { const navigate = useNavigate(); const { config: simpleAuthConfig } = useAuthSettings('simple'); + const { uiConfig } = useUiConfig(); + const tabs = [ { id: 'profile', label: 'Profile' }, { @@ -26,6 +29,7 @@ export const Profile = () => { id: 'pat', label: 'Personal API tokens', path: 'personal-api-tokens', + hidden: uiConfig.flags.personalAccessTokensKillSwitch, }, ]; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 2b6ac934c7..4773226136 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -51,6 +51,7 @@ export interface IFlags { projectScopedSegments?: boolean; projectScopedStickiness?: boolean; projectMode?: boolean; + personalAccessTokensKillSwitch?: 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 df802fb748..0e901a2942 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -83,6 +83,7 @@ exports[`should create default config 1`] = ` "newProjectOverview": false, "optimal304": false, "optimal304Differ": false, + "personalAccessTokensKillSwitch": false, "proPlanAutoCharge": false, "projectMode": false, "projectScopedSegments": false, @@ -110,6 +111,7 @@ exports[`should create default config 1`] = ` "newProjectOverview": false, "optimal304": false, "optimal304Differ": false, + "personalAccessTokensKillSwitch": false, "proPlanAutoCharge": false, "projectMode": false, "projectScopedSegments": false, diff --git a/src/lib/routes/admin-api/user/pat.ts b/src/lib/routes/admin-api/user/pat.ts index 077a189a42..330de59c35 100644 --- a/src/lib/routes/admin-api/user/pat.ts +++ b/src/lib/routes/admin-api/user/pat.ts @@ -1,7 +1,11 @@ import { Response } from 'express'; import Controller from '../../controller'; import { Logger } from '../../../logger'; -import { IUnleashConfig, IUnleashServices } from '../../../types'; +import { + IFlagResolver, + IUnleashConfig, + IUnleashServices, +} from '../../../types'; import { createRequestSchema } from '../../../openapi/util/create-request-schema'; import { createResponseSchema } from '../../../openapi/util/create-response-schema'; import { OpenApiService } from '../../../services/openapi-service'; @@ -21,6 +25,8 @@ export default class PatController extends Controller { private logger: Logger; + private flagResolver: IFlagResolver; + constructor( config: IUnleashConfig, { @@ -30,6 +36,7 @@ export default class PatController extends Controller { ) { super(config); this.logger = config.getLogger('lib/routes/auth/pat-controller.ts'); + this.flagResolver = config.flagResolver; this.openApiService = openApiService; this.patService = patService; this.route({ @@ -77,6 +84,11 @@ export default class PatController extends Controller { } async createPat(req: IAuthRequest, res: Response): Promise { + if (this.flagResolver.isEnabled('personalAccessTokensKillSwitch')) { + res.status(404).send({ message: 'PAT is disabled' }); + return; + } + const pat = req.body; const createdPat = await this.patService.createPat( pat, @@ -92,6 +104,10 @@ export default class PatController extends Controller { } async getPats(req: IAuthRequest, res: Response): Promise { + if (this.flagResolver.isEnabled('personalAccessTokensKillSwitch')) { + res.status(404).send({ message: 'PAT is disabled' }); + return; + } const pats = await this.patService.getAll(req.user.id); this.openApiService.respondWithValidation(200, res, patsSchema.$id, { pats: serializeDates(pats), diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 6012013081..e2017d0463 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -68,6 +68,10 @@ const flags = { false, ), projectMode: parseEnvVarBoolean(process.env.PROJECT_MODE, false), + personalAccessTokensKillSwitch: parseEnvVarBoolean( + process.env.UNLEASH_PAT_KILL_SWITCH, + false, + ), cleanClientApi: parseEnvVarBoolean(process.env.CLEAN_CLIENT_API, false), optimal304: parseEnvVarBoolean( process.env.UNLEASH_EXPERIMENTAL_OPTIMAL_304,