1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

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 <david@getunleash.io>
This commit is contained in:
Gastón Fournier 2023-06-23 10:57:08 +02:00 committed by GitHub
parent c81de4a5bc
commit 89cf16f915
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 140 additions and 71 deletions

View File

@ -7,45 +7,25 @@ import {
RadioGroup, RadioGroup,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import React from 'react'; import { TokenType } from 'interfaces/token';
import { TokenType } from '../../../../../interfaces/token';
import useUiConfig from '../../../../../hooks/api/getters/useUiConfig/useUiConfig'; export type SelectOption = {
import { useOptionalPathParam } from '../../../../../hooks/useOptionalPathParam'; key: string;
label: string;
title: string;
enabled: boolean;
};
interface ITokenTypeSelectorProps { interface ITokenTypeSelectorProps {
type: string; type: string;
setType: (value: string) => void; setType: (value: TokenType) => void;
apiTokenTypes: SelectOption[];
} }
export const TokenTypeSelector = ({ export const TokenTypeSelector = ({
type, type,
setType, setType,
apiTokenTypes,
}: ITokenTypeSelectorProps) => { }: 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 ( return (
<StyledContainer> <StyledContainer>
<FormControl sx={{ mb: 2, width: '100%' }}> <FormControl sx={{ mb: 2, width: '100%' }}>
@ -57,13 +37,15 @@ export const TokenTypeSelector = ({
defaultValue="CLIENT" defaultValue="CLIENT"
name="radio-buttons-group" name="radio-buttons-group"
value={type} value={type}
onChange={(event, value) => setType(value)} onChange={(_, value) => setType(value as TokenType)}
> >
{selectableTypes.map(({ key, label, title }) => ( {apiTokenTypes.map(
({ key, label, title, enabled: hasAccess }) => (
<FormControlLabel <FormControlLabel
key={key} key={key}
value={key} value={key}
sx={{ mb: 1 }} sx={{ mb: 1 }}
disabled={!hasAccess}
control={ control={
<Radio <Radio
sx={{ sx={{
@ -86,7 +68,8 @@ export const TokenTypeSelector = ({
</Box> </Box>
} }
/> />
))} )
)}
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
</StyledContainer> </StyledContainer>

View File

@ -1,14 +1,55 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import { IApiTokenCreate } from 'hooks/api/actions/useApiTokensApi/useApiTokensApi'; 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 type ApiTokenFormErrorType = 'username' | 'projects';
export const useApiTokenForm = (project?: string) => { export const useApiTokenForm = (project?: string) => {
const { environments } = useEnvironments(); const { environments } = useEnvironments();
const { uiConfig } = useUiConfig();
const initialEnvironment = environments?.find(e => e.enabled)?.name; 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 [username, setUsername] = useState('');
const [type, setType] = useState('CLIENT'); const [type, setType] = useState(firstAccessibleType || TokenType.CLIENT);
const [projects, setProjects] = useState<string[]>([ const [projects, setProjects] = useState<string[]>([
project ? project : '*', project ? project : '*',
]); ]);
@ -23,9 +64,9 @@ export const useApiTokenForm = (project?: string) => {
setEnvironment(type === 'ADMIN' ? '*' : initialEnvironment); setEnvironment(type === 'ADMIN' ? '*' : initialEnvironment);
}, [type, initialEnvironment]); }, [type, initialEnvironment]);
const setTokenType = (value: string) => { const setTokenType = (value: TokenType) => {
if (value === 'ADMIN') { if (value === 'ADMIN') {
setType(value); setType(TokenType.ADMIN);
setMemorizedProjects(projects); setMemorizedProjects(projects);
setProjects(['*']); setProjects(['*']);
setEnvironment('*'); setEnvironment('*');
@ -69,6 +110,7 @@ export const useApiTokenForm = (project?: string) => {
return { return {
username, username,
type, type,
apiTokenTypes,
projects, projects,
environment, environment,
setUsername, setUsername,

View File

@ -19,6 +19,8 @@ import {
DELETE_FRONTEND_API_TOKEN, DELETE_FRONTEND_API_TOKEN,
READ_CLIENT_API_TOKEN, READ_CLIENT_API_TOKEN,
READ_FRONTEND_API_TOKEN, READ_FRONTEND_API_TOKEN,
CREATE_CLIENT_API_TOKEN,
CREATE_FRONTEND_API_TOKEN,
} from '@server/types/permissions'; } from '@server/types/permissions';
export const ApiTokenPage = () => { export const ApiTokenPage = () => {
@ -88,7 +90,11 @@ export const ApiTokenPage = () => {
/> />
<PageHeader.Divider /> <PageHeader.Divider />
<CreateApiTokenButton <CreateApiTokenButton
permission={ADMIN} permission={[
CREATE_FRONTEND_API_TOKEN,
CREATE_CLIENT_API_TOKEN,
ADMIN,
]}
path="/admin/api/create-token" path="/admin/api/create-token"
/> />
</> </>

View File

@ -17,7 +17,11 @@ import { TokenInfo } from '../ApiTokenForm/TokenInfo/TokenInfo';
import { TokenTypeSelector } from '../ApiTokenForm/TokenTypeSelector/TokenTypeSelector'; import { TokenTypeSelector } from '../ApiTokenForm/TokenTypeSelector/TokenTypeSelector';
import { ProjectSelector } from '../ApiTokenForm/ProjectSelector/ProjectSelector'; import { ProjectSelector } from '../ApiTokenForm/ProjectSelector/ProjectSelector';
import { EnvironmentSelector } from '../ApiTokenForm/EnvironmentSelector/EnvironmentSelector'; 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'; const pageTitle = 'Create API token';
interface ICreateApiTokenProps { interface ICreateApiTokenProps {
@ -43,6 +47,7 @@ export const CreateApiToken = ({ modal = false }: ICreateApiTokenProps) => {
isValid, isValid,
errors, errors,
clearErrors, clearErrors,
apiTokenTypes,
} = useApiTokenForm(); } = useApiTokenForm();
const { createToken, loading } = useApiTokensApi(); const { createToken, loading } = useApiTokensApi();
@ -105,7 +110,16 @@ export const CreateApiToken = ({ modal = false }: ICreateApiTokenProps) => {
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
handleCancel={handleCancel} handleCancel={handleCancel}
mode="Create" mode="Create"
actions={<CreateButton name="token" permission={ADMIN} />} actions={
<CreateButton
name="token"
permission={[
ADMIN,
CREATE_CLIENT_API_TOKEN,
CREATE_FRONTEND_API_TOKEN,
]}
/>
}
> >
<TokenInfo <TokenInfo
username={username} username={username}
@ -113,7 +127,11 @@ export const CreateApiToken = ({ modal = false }: ICreateApiTokenProps) => {
errors={errors} errors={errors}
clearErrors={clearErrors} clearErrors={clearErrors}
/> />
<TokenTypeSelector type={type} setType={setTokenType} /> <TokenTypeSelector
type={type}
setType={setTokenType}
apiTokenTypes={apiTokenTypes}
/>
<ProjectSelector <ProjectSelector
type={type} type={type}
projects={projects} projects={projects}

View File

@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { Add } from '@mui/icons-material'; import { Add } from '@mui/icons-material';
interface ICreateApiTokenButton { interface ICreateApiTokenButton {
path: string; path: string;
permission: string; permission: string | string[];
project?: string; project?: string;
} }

View File

@ -11,7 +11,7 @@ interface IResponsiveButtonProps {
tooltipProps?: Omit<ITooltipResolverProps, 'children'>; tooltipProps?: Omit<ITooltipResolverProps, 'children'>;
onClick: () => void; onClick: () => void;
disabled?: boolean; disabled?: boolean;
permission: string; permission: string | string[];
projectId?: string; projectId?: string;
environmentId?: string; environmentId?: string;
maxWidth: string; maxWidth: string;

View File

@ -36,6 +36,7 @@ export const CreateProjectApiTokenForm = () => {
getApiTokenPayload, getApiTokenPayload,
username, username,
type, type,
apiTokenTypes,
environment, environment,
setUsername, setUsername,
setTokenType, setTokenType,
@ -126,7 +127,11 @@ export const CreateProjectApiTokenForm = () => {
errors={errors} errors={errors}
clearErrors={clearErrors} clearErrors={clearErrors}
/> />
<TokenTypeSelector type={type} setType={setTokenType} /> <TokenTypeSelector
type={type}
setType={setTokenType}
apiTokenTypes={apiTokenTypes}
/>
<EnvironmentSelector <EnvironmentSelector
type={type} type={type}
environment={environment} environment={environment}

View File

@ -45,9 +45,9 @@ import { OperationDeniedError } from '../../error';
interface TokenParam { interface TokenParam {
token: string; token: string;
} }
const tokenTypeToCreatePermission: (tokenType: ApiTokenType) => string = ( export const tokenTypeToCreatePermission: (
tokenType, tokenType: ApiTokenType,
) => { ) => string = (tokenType) => {
switch (tokenType) { switch (tokenType) {
case ApiTokenType.ADMIN: case ApiTokenType.ADMIN:
return ADMIN; return ADMIN;

View File

@ -33,6 +33,8 @@ import { Logger } from '../../../logger';
import { Response } from 'express'; import { Response } from 'express';
import { timingSafeEqual } from 'crypto'; import { timingSafeEqual } from 'crypto';
import { createApiToken } from '../../../schema/api-token-schema'; import { createApiToken } from '../../../schema/api-token-schema';
import { OperationDeniedError } from '../../../error';
import { tokenTypeToCreatePermission } from '../api-token';
interface ProjectTokenParam { interface ProjectTokenParam {
token: string; token: string;
@ -157,6 +159,19 @@ export class ProjectApiTokenController extends Controller {
): Promise<any> { ): Promise<any> {
const createToken = await createApiToken.validateAsync(req.body); const createToken = await createApiToken.validateAsync(req.body);
const { projectId } = req.params; 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) { if (!createToken.project) {
createToken.project = projectId; createToken.project = projectId;
} }