mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 05:45:22 +02:00
refactor(app): move access-control components [EE-3441] (#7559)
This commit is contained in:
parent
77c3f9131b
commit
d9cc7eda51
62 changed files with 57 additions and 62 deletions
|
@ -4,7 +4,7 @@ import { useRouter } from '@uirouter/react';
|
|||
import { ContainerInstanceFormValues } from '@/react/azure/types';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { AccessControlForm } from '@/portainer/access-control/AccessControlForm';
|
||||
import { AccessControlForm } from '@/react/portainer/access-control/AccessControlForm';
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { object, string, number, boolean } from 'yup';
|
||||
|
||||
import { validationSchema as accessControlSchema } from '@/portainer/access-control/AccessControlForm/AccessControlForm.validation';
|
||||
import { validationSchema as accessControlSchema } from '@/react/portainer/access-control/AccessControlForm/AccessControlForm.validation';
|
||||
|
||||
import { validationSchema as portsSchema } from './PortsMappingField.validation';
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
ContainerInstanceFormValues,
|
||||
ResourceGroup,
|
||||
} from '@/react/azure/types';
|
||||
import { applyResourceControl } from '@/portainer/access-control/access-control.service';
|
||||
import { applyResourceControl } from '@/react/portainer/access-control/access-control.service';
|
||||
|
||||
import { getSubscriptionResourceGroups } from './utils';
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
ResourceGroup,
|
||||
Subscription,
|
||||
} from '@/react/azure/types';
|
||||
import { parseAccessControlFormData } from '@/portainer/access-control/utils';
|
||||
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { useProvider } from '@/react/azure/queries/useProvider';
|
||||
import { useResourceGroups } from '@/react/azure/queries/useResourceGroups';
|
||||
|
|
|
@ -2,9 +2,9 @@ import { useCurrentStateAndParams } from '@uirouter/react';
|
|||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
import { AccessControlPanel } from '@/portainer/access-control/AccessControlPanel/AccessControlPanel';
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||
import { AccessControlPanel } from '@/react/portainer/access-control/AccessControlPanel/AccessControlPanel';
|
||||
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { ResourceControlType } from '@/react/portainer/access-control/types';
|
||||
import {
|
||||
ContainerGroup,
|
||||
ResourceGroup,
|
||||
|
|
|
@ -2,9 +2,9 @@ import { Column } from 'react-table';
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { ownershipIcon } from '@/portainer/filters/filters';
|
||||
import { ResourceControlOwnership } from '@/portainer/access-control/types';
|
||||
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
|
||||
import { ContainerGroup } from '@/react/azure/types';
|
||||
import { determineOwnership } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { determineOwnership } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
export const ownership: Column<ContainerGroup> = {
|
||||
Header: 'Ownership',
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AccessControlFormData } from '@/portainer/access-control/types';
|
||||
import { AccessControlFormData } from '@/react/portainer/access-control/types';
|
||||
import { PortainerMetadata } from '@/react/docker/types';
|
||||
|
||||
import { PortMapping } from './container-instances/CreateView/PortsMappingField';
|
||||
|
|
|
@ -3,7 +3,7 @@ import clsx from 'clsx';
|
|||
|
||||
import { ownershipIcon } from '@/portainer/filters/filters';
|
||||
import type { DockerContainer } from '@/react/docker/containers/types';
|
||||
import { ResourceControlOwnership } from '@/portainer/access-control/types';
|
||||
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
|
||||
|
||||
export const ownership: Column<DockerContainer> = {
|
||||
Header: 'Ownership',
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
import { DockerContainerResponse } from './types/response';
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import _ from 'lodash';
|
|||
import { useInfo } from 'Docker/services/system.service';
|
||||
import { EnvironmentId } from 'Portainer/environments/types';
|
||||
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
import { DockerContainer, ContainerStatus } from './types';
|
||||
import { DockerContainerResponse } from './types/response';
|
||||
|
|
|
@ -5,10 +5,10 @@ import _ from 'lodash';
|
|||
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
||||
import { AccessControlPanel } from '@/portainer/access-control/AccessControlPanel/AccessControlPanel';
|
||||
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||
import { AccessControlPanel } from '@/react/portainer/access-control/AccessControlPanel/AccessControlPanel';
|
||||
import { ResourceControlType } from '@/react/portainer/access-control/types';
|
||||
import { DockerContainer } from '@/react/docker/containers/types';
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { useContainers } from '@/react/docker/containers/queries/containers';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceControlResponse } from '@/portainer/access-control/types';
|
||||
import { ResourceControlResponse } from '@/react/portainer/access-control/types';
|
||||
|
||||
interface AgentMetadata {
|
||||
NodeName: string;
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import { Meta, Story } from '@storybook/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
import { parseAccessControlFormData } from '../utils';
|
||||
|
||||
import { AccessControlForm } from './AccessControlForm';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/AccessControlForm',
|
||||
component: AccessControlForm,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
enum Role {
|
||||
Admin = 1,
|
||||
User,
|
||||
}
|
||||
|
||||
const testQueryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
interface Args {
|
||||
userRole: Role;
|
||||
}
|
||||
|
||||
function Template({ userRole }: Args) {
|
||||
const isAdmin = userRole === Role.Admin;
|
||||
const defaults = parseAccessControlFormData(isAdmin);
|
||||
|
||||
const [value, setValue] = useState(defaults);
|
||||
|
||||
const userProviderState = useMemo(
|
||||
() => ({ user: new UserViewModel({ Role: userRole }) }),
|
||||
[userRole]
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
<UserContext.Provider value={userProviderState}>
|
||||
<AccessControlForm values={value} onChange={setValue} errors={{}} />
|
||||
</UserContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const AdminAccessControl: Story<Args> = Template.bind({});
|
||||
AdminAccessControl.args = {
|
||||
userRole: Role.Admin,
|
||||
};
|
||||
|
||||
export const NonAdminAccessControl: Story<Args> = Template.bind({});
|
||||
NonAdminAccessControl.args = {
|
||||
userRole: Role.User,
|
||||
};
|
|
@ -0,0 +1,338 @@
|
|||
import { server, rest } from '@/setup-tests/server';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { renderWithQueryClient, within } from '@/react-tools/test-utils';
|
||||
import { Team, TeamId } from '@/react/portainer/users/teams/types';
|
||||
import { createMockTeams } from '@/react-tools/test-mocks';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
import { ResourceControlOwnership, AccessControlFormData } from '../types';
|
||||
import { ResourceControlViewModel } from '../models/ResourceControlViewModel';
|
||||
|
||||
import { AccessControlForm } from './AccessControlForm';
|
||||
|
||||
test('renders correctly', async () => {
|
||||
const values = buildFormData();
|
||||
|
||||
const { findByText } = await renderComponent(values);
|
||||
|
||||
expect(await findByText('Access control')).toBeVisible();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[ResourceControlOwnership.ADMINISTRATORS],
|
||||
[ResourceControlOwnership.PRIVATE],
|
||||
[ResourceControlOwnership.RESTRICTED],
|
||||
])(
|
||||
`when ownership is %s, ownership selector should be visible`,
|
||||
async (ownership) => {
|
||||
const values = buildFormData(ownership);
|
||||
|
||||
const { findByRole, getByLabelText } = await renderComponent(values);
|
||||
const accessSwitch = getByLabelText(/Enable access control/);
|
||||
|
||||
expect(accessSwitch).toBeEnabled();
|
||||
|
||||
await expect(findByRole('radiogroup')).resolves.toBeVisible();
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
[ResourceControlOwnership.ADMINISTRATORS],
|
||||
[ResourceControlOwnership.PRIVATE],
|
||||
[ResourceControlOwnership.RESTRICTED],
|
||||
])(
|
||||
'when isAdmin and ownership is %s, ownership selector should show admin and restricted options',
|
||||
async (ownership) => {
|
||||
const values = buildFormData(ownership);
|
||||
|
||||
const { findByRole } = await renderComponent(values, jest.fn(), {
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
expect(
|
||||
await selectorQueries.findByLabelText(/Administrator/)
|
||||
).toBeVisible();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeVisible();
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
[ResourceControlOwnership.ADMINISTRATORS],
|
||||
[ResourceControlOwnership.PRIVATE],
|
||||
[ResourceControlOwnership.RESTRICTED],
|
||||
])(
|
||||
`when user is not an admin and %s and no teams, should have only private option`,
|
||||
async (ownership) => {
|
||||
const values = buildFormData(ownership);
|
||||
|
||||
const { findByRole } = await renderComponent(values, jest.fn(), {
|
||||
teams: [],
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(selectorQueries.queryByLabelText(/Private/)).toBeVisible();
|
||||
expect(selectorQueries.queryByLabelText(/Restricted/)).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
[ResourceControlOwnership.ADMINISTRATORS],
|
||||
[ResourceControlOwnership.PRIVATE],
|
||||
[ResourceControlOwnership.RESTRICTED],
|
||||
])(
|
||||
`when user is not an admin and %s and there is 1 team, should have private and restricted options`,
|
||||
async (ownership) => {
|
||||
const values = buildFormData(ownership);
|
||||
|
||||
const { findByRole } = await renderComponent(values, jest.fn(), {
|
||||
teams: createMockTeams(1),
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(await selectorQueries.findByLabelText(/Private/)).toBeVisible();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeVisible();
|
||||
}
|
||||
);
|
||||
|
||||
test('when ownership is public, ownership selector should be hidden', async () => {
|
||||
const values = buildFormData(ResourceControlOwnership.PUBLIC);
|
||||
|
||||
const { queryByRole } = await renderComponent(values);
|
||||
|
||||
expect(queryByRole('radiogroup')).toBeNull();
|
||||
});
|
||||
|
||||
test('when hideTitle is true, title should be hidden', async () => {
|
||||
const values = buildFormData();
|
||||
|
||||
const { queryByRole } = await renderComponent(values, jest.fn(), {
|
||||
hideTitle: true,
|
||||
});
|
||||
|
||||
expect(queryByRole('Access control')).toBeNull();
|
||||
});
|
||||
|
||||
test('when isAdmin and admin ownership is selected, no extra options are visible', async () => {
|
||||
const values = buildFormData(ResourceControlOwnership.ADMINISTRATORS);
|
||||
|
||||
const { findByRole, queryByLabelText } = await renderComponent(
|
||||
values,
|
||||
jest.fn(),
|
||||
{
|
||||
isAdmin: true,
|
||||
}
|
||||
);
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(await selectorQueries.findByLabelText(/Administrator/)).toBeChecked();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).not.toBeChecked();
|
||||
|
||||
expect(queryByLabelText('extra-options')).toBeNull();
|
||||
});
|
||||
|
||||
test('when isAdmin and restricted ownership is selected, show team and users selectors', async () => {
|
||||
const values = buildFormData(ResourceControlOwnership.RESTRICTED);
|
||||
|
||||
const { findByRole, findByLabelText } = await renderComponent(
|
||||
values,
|
||||
jest.fn(),
|
||||
{
|
||||
isAdmin: true,
|
||||
}
|
||||
);
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(
|
||||
await selectorQueries.findByLabelText(/Administrator/)
|
||||
).not.toBeChecked();
|
||||
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeChecked();
|
||||
|
||||
const extraOptions = await findByLabelText('extra-options');
|
||||
expect(extraOptions).toBeVisible();
|
||||
|
||||
if (!extraOptions) {
|
||||
throw new Error('extra options section is missing');
|
||||
}
|
||||
|
||||
const extraQueries = within(extraOptions);
|
||||
expect(await extraQueries.findByText(/Authorized users/)).toBeVisible();
|
||||
expect(await extraQueries.findByText(/Authorized teams/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('when user is not an admin, there are more then 1 team and ownership is restricted, team selector should be visible', async () => {
|
||||
const values = buildFormData(ResourceControlOwnership.RESTRICTED);
|
||||
|
||||
const { findByRole, findByLabelText } = await renderComponent(
|
||||
values,
|
||||
jest.fn()
|
||||
);
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(await selectorQueries.findByLabelText(/Private/)).toBeVisible();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeVisible();
|
||||
|
||||
const extraOptions = await findByLabelText('extra-options');
|
||||
expect(extraOptions).toBeVisible();
|
||||
|
||||
if (!extraOptions) {
|
||||
throw new Error('extra options section is missing');
|
||||
}
|
||||
|
||||
const extraQueries = within(extraOptions);
|
||||
expect(extraQueries.queryByLabelText(/Authorized teams/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('when user is not an admin, there is 1 team and ownership is restricted, team selector not should be visible', async () => {
|
||||
const values = buildFormData(ResourceControlOwnership.RESTRICTED);
|
||||
|
||||
const { findByRole, findByLabelText } = await renderComponent(
|
||||
values,
|
||||
jest.fn(),
|
||||
{
|
||||
teams: createMockTeams(1),
|
||||
isAdmin: false,
|
||||
}
|
||||
);
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(await selectorQueries.findByLabelText(/Private/)).toBeVisible();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeVisible();
|
||||
|
||||
const extraOptions = await findByLabelText('extra-options');
|
||||
expect(extraOptions).toBeVisible();
|
||||
|
||||
if (!extraOptions) {
|
||||
throw new Error('extra options section is missing');
|
||||
}
|
||||
|
||||
const extraQueries = within(extraOptions);
|
||||
expect(extraQueries.queryByText(/Authorized teams/)).toBeNull();
|
||||
});
|
||||
|
||||
test('when user is not an admin, and ownership is restricted, user selector not should be visible', async () => {
|
||||
const values = buildFormData(ResourceControlOwnership.RESTRICTED);
|
||||
|
||||
const { findByRole, findByLabelText } = await renderComponent(
|
||||
values,
|
||||
jest.fn(),
|
||||
{
|
||||
isAdmin: false,
|
||||
}
|
||||
);
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const extraOptions = await findByLabelText('extra-options');
|
||||
expect(extraOptions).toBeVisible();
|
||||
|
||||
if (!extraOptions) {
|
||||
throw new Error('extra options section is missing');
|
||||
}
|
||||
const extraQueries = within(extraOptions);
|
||||
|
||||
expect(extraQueries.queryByText(/Authorized users/)).toBeNull();
|
||||
});
|
||||
|
||||
interface AdditionalProps {
|
||||
teams?: Team[];
|
||||
users?: UserViewModel[];
|
||||
isAdmin?: boolean;
|
||||
hideTitle?: boolean;
|
||||
resourceControl?: ResourceControlViewModel;
|
||||
}
|
||||
|
||||
async function renderComponent(
|
||||
values: AccessControlFormData,
|
||||
onChange = jest.fn(),
|
||||
{ isAdmin = false, hideTitle = false, teams, users }: AdditionalProps = {}
|
||||
) {
|
||||
const user = new UserViewModel({ Username: 'user', Role: isAdmin ? 1 : 2 });
|
||||
const state = { user };
|
||||
|
||||
if (teams) {
|
||||
server.use(rest.get('/api/teams', (req, res, ctx) => res(ctx.json(teams))));
|
||||
}
|
||||
|
||||
if (users) {
|
||||
server.use(rest.get('/api/users', (req, res, ctx) => res(ctx.json(users))));
|
||||
}
|
||||
|
||||
const renderResult = renderWithQueryClient(
|
||||
<UserContext.Provider value={state}>
|
||||
<AccessControlForm
|
||||
errors={{}}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
hideTitle={hideTitle}
|
||||
/>
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
await expect(
|
||||
renderResult.findByLabelText(/Enable access control/)
|
||||
).resolves.toBeVisible();
|
||||
return renderResult;
|
||||
}
|
||||
|
||||
function buildFormData(
|
||||
ownership = ResourceControlOwnership.PRIVATE,
|
||||
authorizedTeams: TeamId[] = [],
|
||||
authorizedUsers: UserId[] = []
|
||||
): AccessControlFormData {
|
||||
return { ownership, authorizedTeams, authorizedUsers };
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
|
||||
import { EditDetails } from '../EditDetails';
|
||||
import { ResourceControlOwnership, AccessControlFormData } from '../types';
|
||||
|
||||
export interface Props {
|
||||
values: AccessControlFormData;
|
||||
onChange(values: AccessControlFormData): void;
|
||||
hideTitle?: boolean;
|
||||
formNamespace?: string;
|
||||
errors?: FormikErrors<AccessControlFormData>;
|
||||
}
|
||||
|
||||
export function AccessControlForm({
|
||||
values,
|
||||
onChange,
|
||||
hideTitle,
|
||||
formNamespace,
|
||||
errors,
|
||||
}: Props) {
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
const accessControlEnabled =
|
||||
values.ownership !== ResourceControlOwnership.PUBLIC;
|
||||
return (
|
||||
<>
|
||||
{!hideTitle && <FormSectionTitle>Access control</FormSectionTitle>}
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
checked={accessControlEnabled}
|
||||
name={withNamespace('accessControlEnabled')}
|
||||
label="Enable access control"
|
||||
tooltip="When enabled, you can restrict the access and management of this resource."
|
||||
onChange={handleToggleEnable}
|
||||
dataCy="portainer-accessMgmtToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accessControlEnabled && (
|
||||
<EditDetails
|
||||
onChange={onChange}
|
||||
values={values}
|
||||
errors={errors}
|
||||
formNamespace={formNamespace}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function withNamespace(name: string) {
|
||||
return formNamespace ? `${formNamespace}.${name}` : name;
|
||||
}
|
||||
|
||||
function handleToggleEnable(accessControlEnabled: boolean) {
|
||||
let ownership = ResourceControlOwnership.PUBLIC;
|
||||
if (accessControlEnabled) {
|
||||
ownership = isAdmin
|
||||
? ResourceControlOwnership.ADMINISTRATORS
|
||||
: ResourceControlOwnership.PRIVATE;
|
||||
}
|
||||
onChange({ ...values, ownership });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
import { ResourceControlOwnership } from '../types';
|
||||
|
||||
import { validationSchema } from './AccessControlForm.validation';
|
||||
|
||||
test('when ownership not restricted, should be valid', async () => {
|
||||
const schema = validationSchema(true);
|
||||
[
|
||||
ResourceControlOwnership.ADMINISTRATORS,
|
||||
ResourceControlOwnership.PRIVATE,
|
||||
ResourceControlOwnership.PUBLIC,
|
||||
].forEach(async (ownership) => {
|
||||
const object = { ownership };
|
||||
|
||||
await expect(
|
||||
schema.validate(object, { strict: true })
|
||||
).resolves.toStrictEqual(object);
|
||||
});
|
||||
});
|
||||
|
||||
test('when ownership is restricted and no teams or users, should be invalid', async () => {
|
||||
[true, false].forEach(async (isAdmin) => {
|
||||
const schema = validationSchema(isAdmin);
|
||||
|
||||
await expect(
|
||||
schema.validate(
|
||||
{
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [],
|
||||
authorizedUsers: [],
|
||||
},
|
||||
{ strict: true }
|
||||
)
|
||||
).rejects.toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
test('when ownership is restricted, and the user is admin should have either teams or users', async () => {
|
||||
const schema = validationSchema(true);
|
||||
const teams = {
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
authorizedUsers: [],
|
||||
};
|
||||
|
||||
await expect(schema.validate(teams, { strict: true })).resolves.toStrictEqual(
|
||||
teams
|
||||
);
|
||||
|
||||
const users = {
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [],
|
||||
authorizedUsers: [1],
|
||||
};
|
||||
|
||||
await expect(schema.validate(users, { strict: true })).resolves.toStrictEqual(
|
||||
users
|
||||
);
|
||||
|
||||
const both = {
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
authorizedUsers: [2],
|
||||
};
|
||||
|
||||
await expect(schema.validate(both, { strict: true })).resolves.toStrictEqual(
|
||||
both
|
||||
);
|
||||
});
|
||||
|
||||
test('when ownership is restricted, user is not admin with teams, should be valid', async () => {
|
||||
const schema = validationSchema(false);
|
||||
|
||||
const object = {
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
authorizedUsers: [],
|
||||
};
|
||||
|
||||
await expect(
|
||||
schema.validate(object, { strict: true })
|
||||
).resolves.toStrictEqual(object);
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
import { object, string, array, number } from 'yup';
|
||||
|
||||
import { ResourceControlOwnership } from '../types';
|
||||
|
||||
export function validationSchema(isAdmin: boolean) {
|
||||
return object()
|
||||
.shape({
|
||||
ownership: string()
|
||||
.oneOf(Object.values(ResourceControlOwnership))
|
||||
.required(),
|
||||
authorizedUsers: array(number()),
|
||||
authorizedTeams: array(number()),
|
||||
})
|
||||
.test(
|
||||
'user-and-team',
|
||||
isAdmin
|
||||
? 'You must specify at least one team or user.'
|
||||
: 'You must specify at least one team.',
|
||||
({ ownership, authorizedTeams, authorizedUsers }) => {
|
||||
if (ownership !== ResourceControlOwnership.RESTRICTED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return !!authorizedTeams && authorizedTeams.length > 0;
|
||||
}
|
||||
|
||||
return (
|
||||
!!authorizedTeams &&
|
||||
!!authorizedUsers &&
|
||||
(authorizedTeams.length > 0 || authorizedUsers.length > 0)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`when ownership is restricted and no teams or users, should be invalid 1`] = `"You must specify at least one team or user."`;
|
||||
|
||||
exports[`when ownership is restricted and no teams or users, should be invalid 2`] = `"You must specify at least one team."`;
|
|
@ -0,0 +1 @@
|
|||
export { AccessControlForm } from './AccessControlForm';
|
|
@ -0,0 +1,173 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { createMockTeams, createMockUsers } from '@/react-tools/test-mocks';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { rest, server } from '@/setup-tests/server';
|
||||
import { Role } from '@/portainer/users/types';
|
||||
|
||||
import {
|
||||
ResourceControlOwnership,
|
||||
ResourceControlType,
|
||||
TeamResourceAccess,
|
||||
UserResourceAccess,
|
||||
} from '../types';
|
||||
import { ResourceControlViewModel } from '../models/ResourceControlViewModel';
|
||||
|
||||
import { AccessControlPanelDetails } from './AccessControlPanelDetails';
|
||||
|
||||
test.each([
|
||||
[ResourceControlOwnership.ADMINISTRATORS],
|
||||
[ResourceControlOwnership.PRIVATE],
|
||||
[ResourceControlOwnership.PUBLIC],
|
||||
[ResourceControlOwnership.RESTRICTED],
|
||||
])(
|
||||
'when resource control with ownership %s is supplied, show its ownership',
|
||||
async (ownership) => {
|
||||
const resourceControl = buildViewModel(ownership);
|
||||
const { queryByLabelText } = await renderComponent(
|
||||
ResourceControlType.Container,
|
||||
resourceControl
|
||||
);
|
||||
|
||||
expect(queryByLabelText('ownership')).toHaveTextContent(ownership);
|
||||
}
|
||||
);
|
||||
|
||||
test('when resource control is not supplied, show administrators', async () => {
|
||||
const { queryByLabelText } = await renderComponent(
|
||||
ResourceControlType.Container
|
||||
);
|
||||
|
||||
expect(queryByLabelText('ownership')).toHaveTextContent(
|
||||
ResourceControlOwnership.ADMINISTRATORS
|
||||
);
|
||||
});
|
||||
|
||||
const inheritanceTests = [
|
||||
{
|
||||
resourceType: ResourceControlType.Container,
|
||||
parentType: ResourceControlType.Service,
|
||||
},
|
||||
{
|
||||
resourceType: ResourceControlType.Volume,
|
||||
parentType: ResourceControlType.Container,
|
||||
},
|
||||
...[
|
||||
ResourceControlType.Config,
|
||||
ResourceControlType.Container,
|
||||
ResourceControlType.Network,
|
||||
ResourceControlType.Secret,
|
||||
ResourceControlType.Service,
|
||||
ResourceControlType.Volume,
|
||||
].map((resourceType) => ({
|
||||
resourceType,
|
||||
parentType: ResourceControlType.Stack,
|
||||
})),
|
||||
];
|
||||
|
||||
for (let i = 0; i < inheritanceTests.length; i += 1) {
|
||||
const { resourceType, parentType } = inheritanceTests[i];
|
||||
test(`when resource is ${ResourceControlType[resourceType]} and resource control is ${ResourceControlType[parentType]}, show message`, async () => {
|
||||
const resourceControl = buildViewModel(
|
||||
ResourceControlOwnership.ADMINISTRATORS,
|
||||
parentType
|
||||
);
|
||||
|
||||
const { queryByLabelText } = await renderComponent(
|
||||
resourceType,
|
||||
resourceControl
|
||||
);
|
||||
const inheritanceMessage = queryByLabelText('inheritance-message');
|
||||
expect(inheritanceMessage).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('when resource is limited to specific users, show comma separated list of their names', async () => {
|
||||
const users = createMockUsers(10, Role.Standard);
|
||||
|
||||
server.use(rest.get('/api/users', (req, res, ctx) => res(ctx.json(users))));
|
||||
|
||||
const restrictedToUsers = _.sampleSize(users, 3);
|
||||
|
||||
const resourceControl = buildViewModel(
|
||||
ResourceControlOwnership.RESTRICTED,
|
||||
ResourceControlType.Service,
|
||||
restrictedToUsers.map((user) => ({
|
||||
UserId: user.Id,
|
||||
AccessLevel: 1,
|
||||
}))
|
||||
);
|
||||
|
||||
const { queryByText, findByLabelText } = await renderComponent(
|
||||
undefined,
|
||||
resourceControl
|
||||
);
|
||||
|
||||
expect(queryByText(/Authorized users/)).toBeVisible();
|
||||
|
||||
await expect(findByLabelText('authorized-users')).resolves.toHaveTextContent(
|
||||
restrictedToUsers.map((user) => user.Username).join(', ')
|
||||
);
|
||||
});
|
||||
|
||||
test('when resource is limited to specific teams, show comma separated list of their names', async () => {
|
||||
const teams = createMockTeams(10);
|
||||
|
||||
server.use(rest.get('/api/teams', (req, res, ctx) => res(ctx.json(teams))));
|
||||
|
||||
const restrictedToTeams = _.sampleSize(teams, 3);
|
||||
|
||||
const resourceControl = buildViewModel(
|
||||
ResourceControlOwnership.RESTRICTED,
|
||||
ResourceControlType.Config,
|
||||
[],
|
||||
restrictedToTeams.map((team) => ({
|
||||
TeamId: team.Id,
|
||||
AccessLevel: 1,
|
||||
}))
|
||||
);
|
||||
|
||||
const { queryByText, findByLabelText } = await renderComponent(
|
||||
undefined,
|
||||
resourceControl
|
||||
);
|
||||
|
||||
expect(queryByText(/Authorized teams/)).toBeVisible();
|
||||
|
||||
await expect(findByLabelText('authorized-teams')).resolves.toHaveTextContent(
|
||||
restrictedToTeams.map((team) => team.Name).join(', ')
|
||||
);
|
||||
});
|
||||
|
||||
async function renderComponent(
|
||||
resourceType: ResourceControlType = ResourceControlType.Container,
|
||||
resourceControl?: ResourceControlViewModel
|
||||
) {
|
||||
const queries = renderWithQueryClient(
|
||||
<AccessControlPanelDetails
|
||||
resourceControl={resourceControl}
|
||||
resourceType={resourceType}
|
||||
/>
|
||||
);
|
||||
await expect(queries.findByText('Ownership')).resolves.toBeVisible();
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
function buildViewModel(
|
||||
ownership: ResourceControlOwnership,
|
||||
type: ResourceControlType = ResourceControlType.Config,
|
||||
users: UserResourceAccess[] = [],
|
||||
teams: TeamResourceAccess[] = []
|
||||
): ResourceControlViewModel {
|
||||
return {
|
||||
Id: 0,
|
||||
Public: false,
|
||||
ResourceId: 0,
|
||||
System: false,
|
||||
TeamAccesses: teams,
|
||||
Ownership: ownership,
|
||||
Type: type,
|
||||
UserAccesses: users,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,143 @@
|
|||
import { useReducer } from 'react';
|
||||
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { Icon } from '@/react/components/Icon';
|
||||
import { TeamMembership, TeamRole } from '@/react/portainer/users/teams/types';
|
||||
import { useUserMembership } from '@/portainer/users/queries';
|
||||
|
||||
import { TableContainer, TableTitle } from '@@/datatables';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { ResourceControlType, ResourceId } from '../types';
|
||||
import { ResourceControlViewModel } from '../models/ResourceControlViewModel';
|
||||
|
||||
import { AccessControlPanelDetails } from './AccessControlPanelDetails';
|
||||
import { AccessControlPanelForm } from './AccessControlPanelForm';
|
||||
|
||||
interface Props {
|
||||
resourceControl?: ResourceControlViewModel;
|
||||
resourceType: ResourceControlType;
|
||||
resourceId: ResourceId;
|
||||
disableOwnershipChange?: boolean;
|
||||
onUpdateSuccess(): Promise<void>;
|
||||
}
|
||||
|
||||
export function AccessControlPanel({
|
||||
resourceControl,
|
||||
resourceType,
|
||||
disableOwnershipChange,
|
||||
resourceId,
|
||||
onUpdateSuccess,
|
||||
}: Props) {
|
||||
const [isEditMode, toggleEditMode] = useReducer((state) => !state, false);
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
const isInherited = checkIfInherited();
|
||||
|
||||
const { isPartOfRestrictedUsers, isLeaderOfAnyRestrictedTeams } =
|
||||
useRestrictions(resourceControl);
|
||||
|
||||
const isEditDisabled =
|
||||
disableOwnershipChange ||
|
||||
isInherited ||
|
||||
(!isAdmin && !isPartOfRestrictedUsers && !isLeaderOfAnyRestrictedTeams);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<TableContainer>
|
||||
<TableTitle label="Access control" icon="eye" featherIcon />
|
||||
<AccessControlPanelDetails
|
||||
resourceType={resourceType}
|
||||
resourceControl={resourceControl}
|
||||
/>
|
||||
|
||||
{!isEditDisabled && !isEditMode && (
|
||||
<div className="row">
|
||||
<div>
|
||||
<Button color="link" onClick={toggleEditMode}>
|
||||
<Icon icon="edit" className="space-right" feather />
|
||||
Change ownership
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<AccessControlPanelForm
|
||||
resourceControl={resourceControl}
|
||||
onCancelClick={() => toggleEditMode()}
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
onUpdateSuccess={handleUpdateSuccess}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
async function handleUpdateSuccess() {
|
||||
await onUpdateSuccess();
|
||||
toggleEditMode();
|
||||
}
|
||||
|
||||
function checkIfInherited() {
|
||||
if (!resourceControl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const inheritedVolume =
|
||||
resourceControl.Type === ResourceControlType.Container &&
|
||||
resourceType === ResourceControlType.Volume;
|
||||
const inheritedContainer =
|
||||
resourceControl.Type === ResourceControlType.Service &&
|
||||
resourceType === ResourceControlType.Container;
|
||||
const inheritedFromStack =
|
||||
resourceControl.Type === ResourceControlType.Stack &&
|
||||
resourceType !== ResourceControlType.Stack;
|
||||
|
||||
return inheritedVolume || inheritedContainer || inheritedFromStack;
|
||||
}
|
||||
}
|
||||
|
||||
function useRestrictions(resourceControl?: ResourceControlViewModel) {
|
||||
const { user, isAdmin } = useUser();
|
||||
|
||||
const memberships = useUserMembership(user.Id);
|
||||
|
||||
if (!resourceControl || isAdmin) {
|
||||
return {
|
||||
isPartOfRestrictedUsers: false,
|
||||
isLeaderOfAnyRestrictedTeams: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (resourceControl.UserAccesses.some((ua) => ua.UserId === user.Id)) {
|
||||
return {
|
||||
isPartOfRestrictedUsers: true,
|
||||
isLeaderOfAnyRestrictedTeams: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isTeamLeader =
|
||||
memberships.isSuccess &&
|
||||
isLeaderOfAnyRestrictedTeams(memberships.data, resourceControl);
|
||||
|
||||
return {
|
||||
isPartOfRestrictedUsers: false,
|
||||
isLeaderOfAnyRestrictedTeams: isTeamLeader,
|
||||
};
|
||||
}
|
||||
|
||||
// returns true if user is a team leader and resource is limited to this team
|
||||
function isLeaderOfAnyRestrictedTeams(
|
||||
userMemberships: TeamMembership[],
|
||||
resourceControl: ResourceControlViewModel
|
||||
) {
|
||||
return userMemberships.some(
|
||||
(membership) =>
|
||||
membership.Role === TeamRole.Leader &&
|
||||
resourceControl.TeamAccesses.some((ta) => ta.TeamId === membership.TeamID)
|
||||
);
|
||||
}
|
|
@ -0,0 +1,211 @@
|
|||
import clsx from 'clsx';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { ownershipIcon, truncate } from '@/portainer/filters/filters';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||
import { useTeams } from '@/react/portainer/users/teams/queries';
|
||||
import { useUsers } from '@/portainer/users/queries';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
|
||||
import {
|
||||
ResourceControlOwnership,
|
||||
ResourceControlType,
|
||||
ResourceId,
|
||||
} from '../types';
|
||||
import { ResourceControlViewModel } from '../models/ResourceControlViewModel';
|
||||
|
||||
interface Props {
|
||||
resourceControl?: ResourceControlViewModel;
|
||||
resourceType: ResourceControlType;
|
||||
}
|
||||
|
||||
export function AccessControlPanelDetails({
|
||||
resourceControl,
|
||||
resourceType,
|
||||
}: Props) {
|
||||
const inheritanceMessage = getInheritanceMessage(
|
||||
resourceType,
|
||||
resourceControl
|
||||
);
|
||||
|
||||
const {
|
||||
Ownership: ownership = ResourceControlOwnership.ADMINISTRATORS,
|
||||
UserAccesses: restrictedToUsers = [],
|
||||
TeamAccesses: restrictedToTeams = [],
|
||||
} = resourceControl || {};
|
||||
|
||||
const users = useAuthorizedUsers(restrictedToUsers.map((ra) => ra.UserId));
|
||||
const teams = useAuthorizedTeams(restrictedToTeams.map((ra) => ra.TeamId));
|
||||
|
||||
return (
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr data-cy="access-ownership">
|
||||
<td>Ownership</td>
|
||||
<td>
|
||||
<i
|
||||
className={clsx(ownershipIcon(ownership), 'space-right')}
|
||||
aria-hidden="true"
|
||||
aria-label="ownership-icon"
|
||||
/>
|
||||
<span aria-label="ownership">{ownership}</span>
|
||||
<Tooltip message={getOwnershipTooltip(ownership)} />
|
||||
</td>
|
||||
</tr>
|
||||
{inheritanceMessage}
|
||||
{restrictedToUsers.length > 0 && (
|
||||
<tr data-cy="access-authorisedUsers">
|
||||
<td>Authorized users</td>
|
||||
<td aria-label="authorized-users">
|
||||
{users.data && users.data.join(', ')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{restrictedToTeams.length > 0 && (
|
||||
<tr data-cy="access-authorisedTeams">
|
||||
<td>Authorized teams</td>
|
||||
<td aria-label="authorized-teams">
|
||||
{teams.data && teams.data.join(', ')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function getOwnershipTooltip(ownership: ResourceControlOwnership) {
|
||||
switch (ownership) {
|
||||
case ResourceControlOwnership.PRIVATE:
|
||||
return 'Management of this resource is restricted to a single user.';
|
||||
case ResourceControlOwnership.RESTRICTED:
|
||||
return 'This resource can be managed by a restricted set of users and/or teams.';
|
||||
case ResourceControlOwnership.PUBLIC:
|
||||
return 'This resource can be managed by any user with access to this environment.';
|
||||
case ResourceControlOwnership.ADMINISTRATORS:
|
||||
default:
|
||||
return 'This resource can only be managed by administrators.';
|
||||
}
|
||||
}
|
||||
|
||||
function getInheritanceMessage(
|
||||
resourceType: ResourceControlType,
|
||||
resourceControl?: ResourceControlViewModel
|
||||
) {
|
||||
if (!resourceControl || resourceControl.Type === resourceType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentType = resourceControl.Type;
|
||||
const resourceId = resourceControl.ResourceId;
|
||||
|
||||
if (
|
||||
resourceType === ResourceControlType.Container &&
|
||||
parentType === ResourceControlType.Service
|
||||
) {
|
||||
return (
|
||||
<InheritanceMessage tooltip="Access control applied on a service is also applied on each container of that service.">
|
||||
Access control on this resource is inherited from the following service:
|
||||
<Link to="docker.services.service" params={{ id: resourceId }}>
|
||||
{truncate(resourceId)}
|
||||
</Link>
|
||||
</InheritanceMessage>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
resourceType === ResourceControlType.Volume &&
|
||||
parentType === ResourceControlType.Container
|
||||
) {
|
||||
return (
|
||||
<InheritanceMessage tooltip="Access control applied on a container created using a template is also applied on each volume associated to the container.">
|
||||
Access control on this resource is inherited from the following
|
||||
container:
|
||||
<Link to="docker.containers.container" params={{ id: resourceId }}>
|
||||
{truncate(resourceId)}
|
||||
</Link>
|
||||
</InheritanceMessage>
|
||||
);
|
||||
}
|
||||
|
||||
if (parentType === ResourceControlType.Stack) {
|
||||
return (
|
||||
<InheritanceMessage tooltip="Access control applied on a stack is also applied on each resource in the stack.">
|
||||
<span className="space-right">
|
||||
Access control on this resource is inherited from the following stack:
|
||||
</span>
|
||||
{removeEndpointIdFromStackResourceId(resourceId)}
|
||||
</InheritanceMessage>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function removeEndpointIdFromStackResourceId(stackName: ResourceId) {
|
||||
if (!stackName || typeof stackName !== 'string') {
|
||||
return stackName;
|
||||
}
|
||||
|
||||
const firstUnderlineIndex = stackName.indexOf('_');
|
||||
if (firstUnderlineIndex < 0) {
|
||||
return stackName;
|
||||
}
|
||||
return stackName.substring(firstUnderlineIndex + 1);
|
||||
}
|
||||
|
||||
interface InheritanceMessageProps {
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
function InheritanceMessage({
|
||||
children,
|
||||
tooltip,
|
||||
}: PropsWithChildren<InheritanceMessageProps>) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={2} aria-label="inheritance-message">
|
||||
<i className="fa fa-info-circle space-right" aria-hidden="true" />
|
||||
{children}
|
||||
<Tooltip message={tooltip} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function useAuthorizedTeams(authorizedTeamIds: TeamId[]) {
|
||||
return useTeams(false, {
|
||||
enabled: authorizedTeamIds.length > 0,
|
||||
select: (teams) => {
|
||||
if (authorizedTeamIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.compact(
|
||||
authorizedTeamIds.map((id) => {
|
||||
const team = teams.find((u) => u.Id === id);
|
||||
return team?.Name;
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useAuthorizedUsers(authorizedUserIds: UserId[]) {
|
||||
return useUsers(false, authorizedUserIds.length > 0, (users) => {
|
||||
if (authorizedUserIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.compact(
|
||||
authorizedUserIds.map((id) => {
|
||||
const user = users.find((u) => u.Id === id);
|
||||
return user?.Username;
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.form {
|
||||
padding: 0 20px;
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
import { Form, Formik } from 'formik';
|
||||
import clsx from 'clsx';
|
||||
import { useMutation } from 'react-query';
|
||||
import { object } from 'yup';
|
||||
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||
|
||||
import { EditDetails } from '../EditDetails';
|
||||
import { parseAccessControlFormData } from '../utils';
|
||||
import { validationSchema } from '../AccessControlForm/AccessControlForm.validation';
|
||||
import { applyResourceControlChange } from '../access-control.service';
|
||||
import {
|
||||
ResourceControlType,
|
||||
ResourceId,
|
||||
AccessControlFormData,
|
||||
} from '../types';
|
||||
import { ResourceControlViewModel } from '../models/ResourceControlViewModel';
|
||||
|
||||
import styles from './AccessControlPanelForm.module.css';
|
||||
|
||||
interface Props {
|
||||
resourceType: ResourceControlType;
|
||||
resourceId: ResourceId;
|
||||
resourceControl?: ResourceControlViewModel;
|
||||
onCancelClick(): void;
|
||||
onUpdateSuccess(): Promise<void>;
|
||||
}
|
||||
|
||||
export function AccessControlPanelForm({
|
||||
resourceId,
|
||||
resourceType,
|
||||
resourceControl,
|
||||
onCancelClick,
|
||||
onUpdateSuccess,
|
||||
}: Props) {
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
const updateAccess = useMutation(
|
||||
(variables: AccessControlFormData) =>
|
||||
applyResourceControlChange(
|
||||
resourceType,
|
||||
resourceId,
|
||||
variables,
|
||||
resourceControl
|
||||
),
|
||||
{
|
||||
meta: {
|
||||
error: { title: 'Failure', message: 'Unable to update access control' },
|
||||
},
|
||||
onSuccess() {
|
||||
return onUpdateSuccess();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const initialValues = {
|
||||
accessControl: parseAccessControlFormData(isAdmin, resourceControl),
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validateOnMount
|
||||
validateOnChange
|
||||
validationSchema={() =>
|
||||
object({ accessControl: validationSchema(isAdmin) })
|
||||
}
|
||||
>
|
||||
{({ setFieldValue, values, isSubmitting, isValid, errors }) => (
|
||||
<Form className={clsx('form-horizontal', styles.form)}>
|
||||
<EditDetails
|
||||
onChange={(accessControl) =>
|
||||
setFieldValue('accessControl', accessControl)
|
||||
}
|
||||
values={values.accessControl}
|
||||
isPublicVisible
|
||||
errors={errors.accessControl}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<Button size="small" color="default" onClick={onCancelClick}>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="small"
|
||||
color="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
loadingText="Updating Ownership"
|
||||
>
|
||||
Update Ownership
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
async function handleSubmit({
|
||||
accessControl,
|
||||
}: {
|
||||
accessControl: AccessControlFormData;
|
||||
}) {
|
||||
const confirmed = await confirmAccessControlUpdate();
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateAccess.mutate(accessControl, {
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Access control successfully updated');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function confirmAccessControlUpdate() {
|
||||
return confirmAsync({
|
||||
title: 'Are you sure?',
|
||||
message:
|
||||
'Changing the ownership of this resource will potentially restrict its management to some users.',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Change ownership',
|
||||
className: 'btn-primary',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { AccessControlPanel } from './AccessControlPanel';
|
113
app/react/portainer/access-control/EditDetails/EditDetails.tsx
Normal file
113
app/react/portainer/access-control/EditDetails/EditDetails.tsx
Normal file
|
@ -0,0 +1,113 @@
|
|||
import { useCallback } from 'react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
|
||||
import { BoxSelector } from '@@/BoxSelector';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { ResourceControlOwnership, AccessControlFormData } from '../types';
|
||||
|
||||
import { UsersField } from './UsersField';
|
||||
import { TeamsField } from './TeamsField';
|
||||
import { useLoadState } from './useLoadState';
|
||||
import { useOptions } from './useOptions';
|
||||
|
||||
interface Props {
|
||||
values: AccessControlFormData;
|
||||
onChange(values: AccessControlFormData): void;
|
||||
isPublicVisible?: boolean;
|
||||
errors?: FormikErrors<AccessControlFormData>;
|
||||
formNamespace?: string;
|
||||
}
|
||||
|
||||
export function EditDetails({
|
||||
values,
|
||||
onChange,
|
||||
isPublicVisible = false,
|
||||
errors,
|
||||
formNamespace,
|
||||
}: Props) {
|
||||
const { user, isAdmin } = useUser();
|
||||
|
||||
const { users, teams, isLoading } = useLoadState();
|
||||
const options = useOptions(isAdmin, teams, isPublicVisible);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(partialValues: Partial<typeof values>) => {
|
||||
onChange({ ...values, ...partialValues });
|
||||
},
|
||||
|
||||
[values, onChange]
|
||||
);
|
||||
|
||||
if (isLoading || !teams || !users) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BoxSelector
|
||||
radioName={withNamespace('ownership')}
|
||||
value={values.ownership}
|
||||
options={options}
|
||||
onChange={(ownership) => handleChangeOwnership(ownership)}
|
||||
/>
|
||||
|
||||
{values.ownership === ResourceControlOwnership.RESTRICTED && (
|
||||
<div aria-label="extra-options">
|
||||
{isAdmin && (
|
||||
<UsersField
|
||||
name={withNamespace('authorizedUsers')}
|
||||
users={users}
|
||||
onChange={(authorizedUsers) => handleChange({ authorizedUsers })}
|
||||
value={values.authorizedUsers}
|
||||
errors={errors?.authorizedUsers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isAdmin || teams.length > 1) && (
|
||||
<TeamsField
|
||||
name={withNamespace('authorizedTeams')}
|
||||
teams={teams}
|
||||
overrideTooltip={
|
||||
!isAdmin && teams.length > 1
|
||||
? 'As you are a member of multiple teams, you can select which teams(s) will be able to manage this resource.'
|
||||
: undefined
|
||||
}
|
||||
onChange={(authorizedTeams) => handleChange({ authorizedTeams })}
|
||||
value={values.authorizedTeams}
|
||||
errors={errors?.authorizedTeams}
|
||||
/>
|
||||
)}
|
||||
|
||||
{typeof errors === 'string' && (
|
||||
<div className="form-group col-md-12">
|
||||
<FormError>{errors}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function withNamespace(name: string) {
|
||||
return formNamespace ? `${formNamespace}.${name}` : name;
|
||||
}
|
||||
|
||||
function handleChangeOwnership(ownership: ResourceControlOwnership) {
|
||||
let { authorizedTeams, authorizedUsers } = values;
|
||||
|
||||
if (ownership === ResourceControlOwnership.PRIVATE && user) {
|
||||
authorizedUsers = [user.Id];
|
||||
authorizedTeams = [];
|
||||
}
|
||||
|
||||
if (ownership === ResourceControlOwnership.RESTRICTED) {
|
||||
authorizedUsers = [];
|
||||
authorizedTeams = [];
|
||||
}
|
||||
|
||||
handleChange({ ownership, authorizedTeams, authorizedUsers });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
import { Team } from '@/react/portainer/users/teams/types';
|
||||
|
||||
import { TeamsSelector } from '@@/TeamsSelector';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
teams: Team[];
|
||||
value: number[];
|
||||
overrideTooltip?: string;
|
||||
onChange(value: number[]): void;
|
||||
errors?: string | string[];
|
||||
}
|
||||
|
||||
export function TeamsField({
|
||||
name,
|
||||
teams,
|
||||
value,
|
||||
overrideTooltip,
|
||||
onChange,
|
||||
errors,
|
||||
}: Props) {
|
||||
return (
|
||||
<FormControl
|
||||
label="Authorized teams"
|
||||
tooltip={
|
||||
teams.length > 0
|
||||
? overrideTooltip ||
|
||||
'You can select which team(s) will be able to manage this resource.'
|
||||
: undefined
|
||||
}
|
||||
inputId="teams-selector"
|
||||
errors={errors}
|
||||
>
|
||||
{teams.length > 0 ? (
|
||||
<TeamsSelector
|
||||
name={name}
|
||||
teams={teams}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
inputId="teams-selector"
|
||||
/>
|
||||
) : (
|
||||
<span className="small text-muted">
|
||||
You have not yet created any teams. Head over to the
|
||||
<Link to="portainer.teams">Teams view</Link> to manage teams.
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { User } from '@/portainer/users/types';
|
||||
|
||||
import { UsersSelector } from '@@/UsersSelector';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
users: User[];
|
||||
value: number[];
|
||||
onChange(value: number[]): void;
|
||||
errors?: string | string[];
|
||||
}
|
||||
|
||||
export function UsersField({ name, users, value, onChange, errors }: Props) {
|
||||
return (
|
||||
<FormControl
|
||||
label="Authorized users"
|
||||
tooltip={
|
||||
users.length > 0
|
||||
? 'You can select which user(s) will be able to manage this resource.'
|
||||
: undefined
|
||||
}
|
||||
inputId="users-selector"
|
||||
errors={errors}
|
||||
>
|
||||
{users.length > 0 ? (
|
||||
<UsersSelector
|
||||
name={name}
|
||||
users={users}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
inputId="users-selector"
|
||||
/>
|
||||
) : (
|
||||
<span className="small text-muted">
|
||||
You have not yet created any users. Head over to the
|
||||
<Link to="portainer.users">Users view</Link> to manage users.
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
1
app/react/portainer/access-control/EditDetails/index.ts
Normal file
1
app/react/portainer/access-control/EditDetails/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { EditDetails } from './EditDetails';
|
|
@ -0,0 +1,14 @@
|
|||
import { useTeams } from '@/react/portainer/users/teams/queries';
|
||||
import { useUsers } from '@/portainer/users/queries';
|
||||
|
||||
export function useLoadState() {
|
||||
const teams = useTeams();
|
||||
|
||||
const users = useUsers(false);
|
||||
|
||||
return {
|
||||
teams: teams.data,
|
||||
users: users.data,
|
||||
isLoading: teams.isLoading || users.isLoading,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
import _ from 'lodash';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { buildOption } from '@/portainer/components/BoxSelector';
|
||||
import { ownershipIcon } from '@/portainer/filters/filters';
|
||||
import { Team } from '@/react/portainer/users/teams/types';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector/types';
|
||||
import { BadgeIcon } from '@@/BoxSelector/BadgeIcon';
|
||||
|
||||
import { ResourceControlOwnership } from '../types';
|
||||
|
||||
const publicOption: BoxSelectorOption<ResourceControlOwnership> = {
|
||||
value: ResourceControlOwnership.PUBLIC,
|
||||
label: 'Public',
|
||||
id: 'access_public',
|
||||
description:
|
||||
'I want any user with access to this environment to be able to manage this resource',
|
||||
icon: <BadgeIcon icon={ownershipIcon('public')} />,
|
||||
};
|
||||
|
||||
export function useOptions(
|
||||
isAdmin: boolean,
|
||||
teams?: Team[],
|
||||
isPublicVisible = false
|
||||
) {
|
||||
const [options, setOptions] = useState<
|
||||
Array<BoxSelectorOption<ResourceControlOwnership>>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const options = isAdmin ? adminOptions() : nonAdminOptions(teams);
|
||||
|
||||
setOptions(isPublicVisible ? [...options, publicOption] : options);
|
||||
}, [isAdmin, teams, isPublicVisible]);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function adminOptions() {
|
||||
return [
|
||||
buildOption(
|
||||
'access_administrators',
|
||||
<BadgeIcon icon={ownershipIcon('administrators')} />,
|
||||
'Administrators',
|
||||
'I want to restrict the management of this resource to administrators only',
|
||||
ResourceControlOwnership.ADMINISTRATORS
|
||||
),
|
||||
buildOption(
|
||||
'access_restricted',
|
||||
<BadgeIcon icon={ownershipIcon('restricted')} />,
|
||||
'Restricted',
|
||||
'I want to restrict the management of this resource to a set of users and/or teams',
|
||||
ResourceControlOwnership.RESTRICTED
|
||||
),
|
||||
];
|
||||
}
|
||||
function nonAdminOptions(teams?: Team[]) {
|
||||
return _.compact([
|
||||
buildOption(
|
||||
'access_private',
|
||||
<BadgeIcon icon={ownershipIcon('private')} />,
|
||||
'Private',
|
||||
'I want to this resource to be manageable by myself only',
|
||||
ResourceControlOwnership.PRIVATE
|
||||
),
|
||||
teams &&
|
||||
teams.length > 0 &&
|
||||
buildOption(
|
||||
'access_restricted',
|
||||
<BadgeIcon icon={ownershipIcon('restricted')} />,
|
||||
'Restricted',
|
||||
teams.length === 1
|
||||
? `I want any member of my team (${teams[0].Name}) to be able to manage this resource`
|
||||
: 'I want to restrict the management of this resource to one or more of my teams',
|
||||
ResourceControlOwnership.RESTRICTED
|
||||
),
|
||||
]);
|
||||
}
|
98
app/react/portainer/access-control/access-control.service.ts
Normal file
98
app/react/portainer/access-control/access-control.service.ts
Normal file
|
@ -0,0 +1,98 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import {
|
||||
AccessControlFormData,
|
||||
OwnershipParameters,
|
||||
ResourceControlId,
|
||||
ResourceControlResponse,
|
||||
ResourceControlType,
|
||||
ResourceId,
|
||||
} from './types';
|
||||
import { ResourceControlViewModel } from './models/ResourceControlViewModel';
|
||||
import { parseOwnershipParameters } from './utils';
|
||||
|
||||
/**
|
||||
* Update an existing ResourceControl or create a new one on existing resource without RC
|
||||
* @param resourceType Type of ResourceControl
|
||||
* @param resourceId ID of involved Resource
|
||||
* @param resourceControl Previous ResourceControl (can be undefined)
|
||||
* @param formValues View data generated by AccessControlPanel
|
||||
*/
|
||||
export function applyResourceControlChange(
|
||||
resourceType: ResourceControlType,
|
||||
resourceId: ResourceId,
|
||||
formValues: AccessControlFormData,
|
||||
resourceControl?: ResourceControlViewModel
|
||||
) {
|
||||
const ownershipParameters = parseOwnershipParameters(formValues);
|
||||
if (resourceControl) {
|
||||
return updateResourceControl(resourceControl.Id, ownershipParameters);
|
||||
}
|
||||
return createResourceControl(resourceType, resourceId, ownershipParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a ResourceControl after Resource creation
|
||||
* @param accessControlData ResourceControl to apply
|
||||
* @param resourceControl ResourceControl to update
|
||||
* @param subResourcesIds SubResources managed by the ResourceControl
|
||||
*/
|
||||
export function applyResourceControl(
|
||||
accessControlData: AccessControlFormData,
|
||||
resourceControl: ResourceControlResponse,
|
||||
subResourcesIds: (number | string)[] = []
|
||||
) {
|
||||
const ownershipParameters = parseOwnershipParameters(
|
||||
accessControlData,
|
||||
subResourcesIds
|
||||
);
|
||||
return updateResourceControl(resourceControl.Id, ownershipParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a ResourceControl
|
||||
* @param resourceControlId ID of involved resource
|
||||
* @param ownershipParameters
|
||||
*/
|
||||
async function updateResourceControl(
|
||||
resourceControlId: ResourceControlId,
|
||||
ownershipParameters: OwnershipParameters
|
||||
) {
|
||||
try {
|
||||
await axios.put(buildUrl(resourceControlId), ownershipParameters);
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ResourceControl
|
||||
* @param resourceType Type of ResourceControl
|
||||
* @param resourceId ID of involved resource
|
||||
* @param ownershipParameters Transient type from view data to payload
|
||||
*/
|
||||
async function createResourceControl(
|
||||
resourceType: ResourceControlType,
|
||||
resourceId: ResourceId,
|
||||
ownershipParameters: OwnershipParameters
|
||||
) {
|
||||
try {
|
||||
await axios.post(buildUrl(), {
|
||||
...ownershipParameters,
|
||||
type: resourceType,
|
||||
resourceId,
|
||||
});
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(id?: ResourceControlId) {
|
||||
let url = '/resource_controls';
|
||||
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
2
app/react/portainer/access-control/index.ts
Normal file
2
app/react/portainer/access-control/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { AccessControlPanel } from './AccessControlPanel';
|
||||
export { AccessControlForm } from './AccessControlForm';
|
|
@ -0,0 +1,60 @@
|
|||
import {
|
||||
ResourceControlId,
|
||||
ResourceControlOwnership,
|
||||
ResourceControlResponse,
|
||||
ResourceControlType,
|
||||
ResourceId,
|
||||
TeamResourceAccess,
|
||||
UserResourceAccess,
|
||||
} from '../types';
|
||||
|
||||
export class ResourceControlViewModel {
|
||||
Id: ResourceControlId;
|
||||
|
||||
Type: ResourceControlType;
|
||||
|
||||
ResourceId: ResourceId;
|
||||
|
||||
UserAccesses: UserResourceAccess[];
|
||||
|
||||
TeamAccesses: TeamResourceAccess[];
|
||||
|
||||
Public: boolean;
|
||||
|
||||
System: boolean;
|
||||
|
||||
Ownership: ResourceControlOwnership;
|
||||
|
||||
constructor(data: ResourceControlResponse) {
|
||||
this.Id = data.Id;
|
||||
this.Type = data.Type;
|
||||
this.ResourceId = data.ResourceId;
|
||||
this.UserAccesses = data.UserAccesses;
|
||||
this.TeamAccesses = data.TeamAccesses;
|
||||
this.Public = data.Public;
|
||||
this.System = data.System;
|
||||
this.Ownership = determineOwnership(data);
|
||||
}
|
||||
}
|
||||
|
||||
export function determineOwnership(resourceControl: ResourceControlResponse) {
|
||||
if (resourceControl.Public) {
|
||||
return ResourceControlOwnership.PUBLIC;
|
||||
}
|
||||
|
||||
if (
|
||||
resourceControl.UserAccesses.length === 1 &&
|
||||
resourceControl.TeamAccesses.length === 0
|
||||
) {
|
||||
return ResourceControlOwnership.PRIVATE;
|
||||
}
|
||||
|
||||
if (
|
||||
resourceControl.UserAccesses.length > 1 ||
|
||||
resourceControl.TeamAccesses.length > 0
|
||||
) {
|
||||
return ResourceControlOwnership.RESTRICTED;
|
||||
}
|
||||
|
||||
return ResourceControlOwnership.ADMINISTRATORS;
|
||||
}
|
76
app/react/portainer/access-control/types.ts
Normal file
76
app/react/portainer/access-control/types.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
export type ResourceControlId = number;
|
||||
|
||||
export type ResourceId = number | string;
|
||||
|
||||
export enum ResourceControlOwnership {
|
||||
PUBLIC = 'public',
|
||||
PRIVATE = 'private',
|
||||
RESTRICTED = 'restricted',
|
||||
ADMINISTRATORS = 'administrators',
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient type from view data to payload
|
||||
*/
|
||||
export interface OwnershipParameters {
|
||||
administratorsOnly: boolean;
|
||||
public: boolean;
|
||||
users: UserId[];
|
||||
teams: TeamId[];
|
||||
subResourcesIds: ResourceId[];
|
||||
}
|
||||
|
||||
export enum ResourceControlType {
|
||||
// Container represents a resource control associated to a Docker container
|
||||
Container = 1,
|
||||
// Service represents a resource control associated to a Docker service
|
||||
Service,
|
||||
// Volume represents a resource control associated to a Docker volume
|
||||
Volume,
|
||||
// Network represents a resource control associated to a Docker network
|
||||
Network,
|
||||
// Secret represents a resource control associated to a Docker secret
|
||||
Secret,
|
||||
// Stack represents a resource control associated to a stack composed of Docker services
|
||||
Stack,
|
||||
// Config represents a resource control associated to a Docker config
|
||||
Config,
|
||||
// CustomTemplate represents a resource control associated to a custom template
|
||||
CustomTemplate,
|
||||
// ContainerGroup represents a resource control associated to an Azure container group
|
||||
ContainerGroup,
|
||||
}
|
||||
|
||||
enum ResourceAccessLevel {
|
||||
ReadWriteAccessLevel = 1,
|
||||
}
|
||||
|
||||
export interface UserResourceAccess {
|
||||
UserId: UserId;
|
||||
AccessLevel: ResourceAccessLevel;
|
||||
}
|
||||
|
||||
export interface TeamResourceAccess {
|
||||
TeamId: TeamId;
|
||||
AccessLevel: ResourceAccessLevel;
|
||||
}
|
||||
|
||||
export interface ResourceControlResponse {
|
||||
Id: number;
|
||||
Type: ResourceControlType;
|
||||
ResourceId: ResourceId;
|
||||
UserAccesses: UserResourceAccess[];
|
||||
TeamAccesses: TeamResourceAccess[];
|
||||
Public: boolean;
|
||||
AdministratorsOnly: boolean;
|
||||
System: boolean;
|
||||
}
|
||||
|
||||
export interface AccessControlFormData {
|
||||
ownership: ResourceControlOwnership;
|
||||
authorizedUsers: UserId[];
|
||||
authorizedTeams: TeamId[];
|
||||
}
|
62
app/react/portainer/access-control/utils.test.ts
Normal file
62
app/react/portainer/access-control/utils.test.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { ResourceControlViewModel } from './models/ResourceControlViewModel';
|
||||
import { ResourceControlOwnership, ResourceControlType } from './types';
|
||||
import { parseAccessControlFormData } from './utils';
|
||||
|
||||
describe('parseAccessControlFormData', () => {
|
||||
[
|
||||
ResourceControlOwnership.ADMINISTRATORS,
|
||||
ResourceControlOwnership.RESTRICTED,
|
||||
ResourceControlOwnership.PUBLIC,
|
||||
ResourceControlOwnership.PRIVATE,
|
||||
].forEach((ownership) => {
|
||||
test(`when resource control supplied, if user is not admin, will change ownership to rc ownership (${ownership})`, () => {
|
||||
const resourceControl = buildResourceControl(ownership);
|
||||
|
||||
const actual = parseAccessControlFormData(false, resourceControl);
|
||||
expect(actual.ownership).toBe(resourceControl.Ownership);
|
||||
});
|
||||
});
|
||||
|
||||
[
|
||||
ResourceControlOwnership.ADMINISTRATORS,
|
||||
ResourceControlOwnership.RESTRICTED,
|
||||
ResourceControlOwnership.PUBLIC,
|
||||
].forEach((ownership) => {
|
||||
test(`when resource control supplied and user is admin, if resource ownership is ${ownership} , will change ownership to rc ownership`, () => {
|
||||
const resourceControl = buildResourceControl(ownership);
|
||||
|
||||
const actual = parseAccessControlFormData(true, resourceControl);
|
||||
expect(actual.ownership).toBe(resourceControl.Ownership);
|
||||
});
|
||||
});
|
||||
|
||||
test('when isAdmin and resource control not supplied, ownership should be set to Administrator', () => {
|
||||
const actual = parseAccessControlFormData(true);
|
||||
|
||||
expect(actual.ownership).toBe(ResourceControlOwnership.ADMINISTRATORS);
|
||||
});
|
||||
|
||||
test('when resource control supplied, if user is admin and resource ownership is private, will change ownership to restricted', () => {
|
||||
const resourceControl = buildResourceControl(
|
||||
ResourceControlOwnership.PRIVATE
|
||||
);
|
||||
|
||||
const actual = parseAccessControlFormData(true, resourceControl);
|
||||
expect(actual.ownership).toBe(ResourceControlOwnership.RESTRICTED);
|
||||
});
|
||||
|
||||
function buildResourceControl(
|
||||
ownership: ResourceControlOwnership
|
||||
): ResourceControlViewModel {
|
||||
return {
|
||||
UserAccesses: [],
|
||||
TeamAccesses: [],
|
||||
Ownership: ownership,
|
||||
Id: 1,
|
||||
Public: false,
|
||||
ResourceId: 1,
|
||||
System: false,
|
||||
Type: ResourceControlType.Config,
|
||||
};
|
||||
}
|
||||
});
|
76
app/react/portainer/access-control/utils.ts
Normal file
76
app/react/portainer/access-control/utils.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
import {
|
||||
AccessControlFormData,
|
||||
OwnershipParameters,
|
||||
ResourceControlOwnership,
|
||||
ResourceId,
|
||||
} from './types';
|
||||
import { ResourceControlViewModel } from './models/ResourceControlViewModel';
|
||||
|
||||
/**
|
||||
* Transform AccessControlFormData to ResourceControlOwnershipParameters
|
||||
* @param {int} userId ID of user performing the operation
|
||||
* @param {AccessControlFormData} formValues Form data (generated by AccessControlForm)
|
||||
* @param {int[]} subResources Sub Resources restricted by the ResourceControl
|
||||
*/
|
||||
export function parseOwnershipParameters(
|
||||
formValues: AccessControlFormData,
|
||||
subResourcesIds: ResourceId[] = []
|
||||
): OwnershipParameters {
|
||||
const { ownership, authorizedTeams, authorizedUsers } = formValues;
|
||||
|
||||
const adminOnly = ownership === ResourceControlOwnership.ADMINISTRATORS;
|
||||
const publicOnly = ownership === ResourceControlOwnership.PUBLIC;
|
||||
|
||||
let users = authorizedUsers;
|
||||
let teams = authorizedTeams;
|
||||
if (
|
||||
[
|
||||
ResourceControlOwnership.ADMINISTRATORS,
|
||||
ResourceControlOwnership.PUBLIC,
|
||||
].includes(ownership)
|
||||
) {
|
||||
users = [];
|
||||
teams = [];
|
||||
}
|
||||
|
||||
return {
|
||||
administratorsOnly: adminOnly,
|
||||
public: publicOnly,
|
||||
users,
|
||||
teams,
|
||||
subResourcesIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccessControlFormData(
|
||||
isAdmin: boolean,
|
||||
resourceControl?: ResourceControlViewModel
|
||||
): AccessControlFormData {
|
||||
let ownership =
|
||||
resourceControl?.Ownership || ResourceControlOwnership.PRIVATE;
|
||||
if (isAdmin) {
|
||||
if (!resourceControl) {
|
||||
ownership = ResourceControlOwnership.ADMINISTRATORS;
|
||||
} else if (ownership === ResourceControlOwnership.PRIVATE) {
|
||||
ownership = ResourceControlOwnership.RESTRICTED;
|
||||
}
|
||||
}
|
||||
|
||||
let authorizedTeams: TeamId[] = [];
|
||||
let authorizedUsers: UserId[] = [];
|
||||
if (
|
||||
resourceControl &&
|
||||
[
|
||||
ResourceControlOwnership.PRIVATE,
|
||||
ResourceControlOwnership.RESTRICTED,
|
||||
].includes(ownership)
|
||||
) {
|
||||
authorizedTeams = resourceControl.TeamAccesses.map((ra) => ra.TeamId);
|
||||
authorizedUsers = resourceControl.UserAccesses.map((ra) => ra.UserId);
|
||||
}
|
||||
|
||||
return { ownership, authorizedUsers, authorizedTeams };
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue