From 89cf16f9152f0df2ebe3f1936a21eda0a4e5b087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Fri, 23 Jun 2023 10:57:08 +0200 Subject: [PATCH] Feat/more granular permissions check in create apitoken (#4072) ## About the changes This PR enables or disables create API token button based on the permissions. **Note:** the button is only displayed if you have READ permissions on some API token. This is a minor limitation as having CREATE permissions should also grant READ permissions, but right now this is up to the user to set up the custom role with the correct permissions **Note 2:** Project-specific API tokens are also ruled by the project-specific permission to create API tokens in a project (just having the root permissions to create a client token or frontend token does not grant access to create a project-specific API token). The permissions to access the creation of a project-specific API token then rely on the root permissions to allow the user to create either a client token or a frontend token. --------- Co-authored-by: David Leek --- .../TokenTypeSelector/TokenTypeSelector.tsx | 99 ++++++++----------- .../apiToken/ApiTokenForm/useApiTokenForm.ts | 48 ++++++++- .../apiToken/ApiTokenPage/ApiTokenPage.tsx | 8 +- .../CreateApiToken/CreateApiToken.tsx | 24 ++++- .../CreateApiTokenButton.tsx | 2 +- .../ResponsiveButton/ResponsiveButton.tsx | 2 +- .../CreateProjectApiTokenForm.tsx | 7 +- src/lib/routes/admin-api/api-token.ts | 6 +- src/lib/routes/admin-api/project/api-token.ts | 15 +++ 9 files changed, 140 insertions(+), 71 deletions(-) diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/TokenTypeSelector/TokenTypeSelector.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/TokenTypeSelector/TokenTypeSelector.tsx index ce4ca76e68..27553d19aa 100644 --- a/frontend/src/component/admin/apiToken/ApiTokenForm/TokenTypeSelector/TokenTypeSelector.tsx +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/TokenTypeSelector/TokenTypeSelector.tsx @@ -7,45 +7,25 @@ import { RadioGroup, Typography, } from '@mui/material'; -import React from 'react'; -import { TokenType } from '../../../../../interfaces/token'; -import useUiConfig from '../../../../../hooks/api/getters/useUiConfig/useUiConfig'; -import { useOptionalPathParam } from '../../../../../hooks/useOptionalPathParam'; +import { TokenType } from 'interfaces/token'; + +export type SelectOption = { + key: string; + label: string; + title: string; + enabled: boolean; +}; interface ITokenTypeSelectorProps { type: string; - setType: (value: string) => void; + setType: (value: TokenType) => void; + apiTokenTypes: SelectOption[]; } export const TokenTypeSelector = ({ type, setType, + apiTokenTypes, }: ITokenTypeSelectorProps) => { - const projectId = useOptionalPathParam('projectId'); - const { uiConfig } = useUiConfig(); - - const selectableTypes = [ - { - key: TokenType.CLIENT, - label: `Server-side SDK (${TokenType.CLIENT})`, - title: 'Connect server-side SDK or Unleash Proxy', - }, - ]; - - if (!projectId) { - selectableTypes.push({ - key: TokenType.ADMIN, - label: TokenType.ADMIN, - title: 'Full access for managing Unleash', - }); - } - - if (uiConfig.flags.embedProxyFrontend) { - selectableTypes.splice(1, 0, { - key: TokenType.FRONTEND, - label: `Client-side SDK (${TokenType.FRONTEND})`, - title: 'Connect web and mobile SDK directly to Unleash', - }); - } return ( @@ -57,36 +37,39 @@ export const TokenTypeSelector = ({ defaultValue="CLIENT" name="radio-buttons-group" value={type} - onChange={(event, value) => setType(value)} + onChange={(_, value) => setType(value as TokenType)} > - {selectableTypes.map(({ key, label, title }) => ( - - } - label={ - + {apiTokenTypes.map( + ({ key, label, title, enabled: hasAccess }) => ( + + } + label={ - {label} - - {title} - + + {label} + + {title} + + - - } - /> - ))} + } + /> + ) + )} diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts b/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts index 04e5cd84d9..b2c16b941c 100644 --- a/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts @@ -1,14 +1,55 @@ import { useEffect, useState } from 'react'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; import { IApiTokenCreate } from 'hooks/api/actions/useApiTokensApi/useApiTokensApi'; +import { TokenType } from 'interfaces/token'; +import { + ADMIN, + CREATE_FRONTEND_API_TOKEN, + CREATE_CLIENT_API_TOKEN, +} from '@server/types/permissions'; +import { useHasRootAccess } from 'hooks/useHasAccess'; +import { SelectOption } from './TokenTypeSelector/TokenTypeSelector'; export type ApiTokenFormErrorType = 'username' | 'projects'; export const useApiTokenForm = (project?: string) => { const { environments } = useEnvironments(); + const { uiConfig } = useUiConfig(); const initialEnvironment = environments?.find(e => e.enabled)?.name; + const apiTokenTypes: SelectOption[] = [ + { + key: TokenType.CLIENT, + label: `Server-side SDK (${TokenType.CLIENT})`, + title: 'Connect server-side SDK or Unleash Proxy', + enabled: useHasRootAccess(CREATE_CLIENT_API_TOKEN), + }, + ]; + + const hasAdminAccess = useHasRootAccess(ADMIN); + const hasCreateFrontendAccess = useHasRootAccess(CREATE_FRONTEND_API_TOKEN); + if (!project) { + apiTokenTypes.push({ + key: TokenType.ADMIN, + label: TokenType.ADMIN, + title: 'Full access for managing Unleash', + enabled: hasAdminAccess, + }); + } + + if (uiConfig.flags.embedProxyFrontend) { + apiTokenTypes.splice(1, 0, { + key: TokenType.FRONTEND, + label: `Client-side SDK (${TokenType.FRONTEND})`, + title: 'Connect web and mobile SDK directly to Unleash', + enabled: hasCreateFrontendAccess, + }); + } + + const firstAccessibleType = apiTokenTypes.find(t => t.enabled)?.key; + const [username, setUsername] = useState(''); - const [type, setType] = useState('CLIENT'); + const [type, setType] = useState(firstAccessibleType || TokenType.CLIENT); const [projects, setProjects] = useState([ project ? project : '*', ]); @@ -23,9 +64,9 @@ export const useApiTokenForm = (project?: string) => { setEnvironment(type === 'ADMIN' ? '*' : initialEnvironment); }, [type, initialEnvironment]); - const setTokenType = (value: string) => { + const setTokenType = (value: TokenType) => { if (value === 'ADMIN') { - setType(value); + setType(TokenType.ADMIN); setMemorizedProjects(projects); setProjects(['*']); setEnvironment('*'); @@ -69,6 +110,7 @@ export const useApiTokenForm = (project?: string) => { return { username, type, + apiTokenTypes, projects, environment, setUsername, diff --git a/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx b/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx index 84ccb0c4f5..fa0007fb9f 100644 --- a/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx +++ b/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx @@ -19,6 +19,8 @@ import { DELETE_FRONTEND_API_TOKEN, READ_CLIENT_API_TOKEN, READ_FRONTEND_API_TOKEN, + CREATE_CLIENT_API_TOKEN, + CREATE_FRONTEND_API_TOKEN, } from '@server/types/permissions'; export const ApiTokenPage = () => { @@ -88,7 +90,11 @@ export const ApiTokenPage = () => { /> diff --git a/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx b/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx index e2be813b02..51b0baf7bd 100644 --- a/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx +++ b/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx @@ -17,7 +17,11 @@ import { TokenInfo } from '../ApiTokenForm/TokenInfo/TokenInfo'; import { TokenTypeSelector } from '../ApiTokenForm/TokenTypeSelector/TokenTypeSelector'; import { ProjectSelector } from '../ApiTokenForm/ProjectSelector/ProjectSelector'; import { EnvironmentSelector } from '../ApiTokenForm/EnvironmentSelector/EnvironmentSelector'; -import { ADMIN } from '@server/types/permissions'; +import { + ADMIN, + CREATE_CLIENT_API_TOKEN, + CREATE_FRONTEND_API_TOKEN, +} from '@server/types/permissions'; const pageTitle = 'Create API token'; interface ICreateApiTokenProps { @@ -43,6 +47,7 @@ export const CreateApiToken = ({ modal = false }: ICreateApiTokenProps) => { isValid, errors, clearErrors, + apiTokenTypes, } = useApiTokenForm(); const { createToken, loading } = useApiTokensApi(); @@ -105,7 +110,16 @@ export const CreateApiToken = ({ modal = false }: ICreateApiTokenProps) => { handleSubmit={handleSubmit} handleCancel={handleCancel} mode="Create" - actions={} + actions={ + + } > { errors={errors} clearErrors={clearErrors} /> - + ; onClick: () => void; disabled?: boolean; - permission: string; + permission: string | string[]; projectId?: string; environmentId?: string; maxWidth: string; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectApiAccess/CreateProjectApiTokenForm.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectApiAccess/CreateProjectApiTokenForm.tsx index 92594f236a..1bea96da97 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectApiAccess/CreateProjectApiTokenForm.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectApiAccess/CreateProjectApiTokenForm.tsx @@ -36,6 +36,7 @@ export const CreateProjectApiTokenForm = () => { getApiTokenPayload, username, type, + apiTokenTypes, environment, setUsername, setTokenType, @@ -126,7 +127,11 @@ export const CreateProjectApiTokenForm = () => { errors={errors} clearErrors={clearErrors} /> - + string = ( - tokenType, -) => { +export const tokenTypeToCreatePermission: ( + tokenType: ApiTokenType, +) => string = (tokenType) => { switch (tokenType) { case ApiTokenType.ADMIN: return ADMIN; diff --git a/src/lib/routes/admin-api/project/api-token.ts b/src/lib/routes/admin-api/project/api-token.ts index 1b2277ce5c..fbd14e0f0e 100644 --- a/src/lib/routes/admin-api/project/api-token.ts +++ b/src/lib/routes/admin-api/project/api-token.ts @@ -33,6 +33,8 @@ import { Logger } from '../../../logger'; import { Response } from 'express'; import { timingSafeEqual } from 'crypto'; import { createApiToken } from '../../../schema/api-token-schema'; +import { OperationDeniedError } from '../../../error'; +import { tokenTypeToCreatePermission } from '../api-token'; interface ProjectTokenParam { token: string; @@ -157,6 +159,19 @@ export class ProjectApiTokenController extends Controller { ): Promise { const createToken = await createApiToken.validateAsync(req.body); const { projectId } = req.params; + const permissionRequired = tokenTypeToCreatePermission( + createToken.type, + ); + const hasPermission = await this.accessService.hasPermission( + req.user, + permissionRequired, + projectId, + ); + if (!hasPermission) { + throw new OperationDeniedError( + `You don't have the necessary access [${permissionRequired}] to perform this operation]`, + ); + } if (!createToken.project) { createToken.project = projectId; }