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,
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 (
<StyledContainer>
<FormControl sx={{ mb: 2, width: '100%' }}>
@ -57,13 +37,15 @@ 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 }) => (
{apiTokenTypes.map(
({ key, label, title, enabled: hasAccess }) => (
<FormControlLabel
key={key}
value={key}
sx={{ mb: 1 }}
disabled={!hasAccess}
control={
<Radio
sx={{
@ -86,7 +68,8 @@ export const TokenTypeSelector = ({
</Box>
}
/>
))}
)
)}
</RadioGroup>
</FormControl>
</StyledContainer>

View File

@ -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<string[]>([
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,

View File

@ -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 = () => {
/>
<PageHeader.Divider />
<CreateApiTokenButton
permission={ADMIN}
permission={[
CREATE_FRONTEND_API_TOKEN,
CREATE_CLIENT_API_TOKEN,
ADMIN,
]}
path="/admin/api/create-token"
/>
</>

View File

@ -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={<CreateButton name="token" permission={ADMIN} />}
actions={
<CreateButton
name="token"
permission={[
ADMIN,
CREATE_CLIENT_API_TOKEN,
CREATE_FRONTEND_API_TOKEN,
]}
/>
}
>
<TokenInfo
username={username}
@ -113,7 +127,11 @@ export const CreateApiToken = ({ modal = false }: ICreateApiTokenProps) => {
errors={errors}
clearErrors={clearErrors}
/>
<TokenTypeSelector type={type} setType={setTokenType} />
<TokenTypeSelector
type={type}
setType={setTokenType}
apiTokenTypes={apiTokenTypes}
/>
<ProjectSelector
type={type}
projects={projects}

View File

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

View File

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

View File

@ -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}
/>
<TokenTypeSelector type={type} setType={setTokenType} />
<TokenTypeSelector
type={type}
setType={setTokenType}
apiTokenTypes={apiTokenTypes}
/>
<EnvironmentSelector
type={type}
environment={environment}

View File

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

View File

@ -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<any> {
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;
}