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:
parent
c81de4a5bc
commit
89cf16f915
@ -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>
|
||||||
|
@ -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,
|
||||||
|
@ -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"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -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}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user