1
0
Fork 0
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:
Chaim Lev-Ari 2022-09-07 07:25:00 +03:00 committed by GitHub
parent 77c3f9131b
commit d9cc7eda51
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 57 additions and 62 deletions

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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,

View file

@ -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',

View file

@ -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';

View file

@ -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',

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -1,4 +1,4 @@
import { ResourceControlResponse } from '@/portainer/access-control/types';
import { ResourceControlResponse } from '@/react/portainer/access-control/types';
interface AgentMetadata {
NodeName: string;

View file

@ -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,
};

View file

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

View file

@ -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 });
}
}

View file

@ -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);
});

View file

@ -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)
);
}
);
}

View file

@ -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."`;

View file

@ -0,0 +1 @@
export { AccessControlForm } from './AccessControlForm';

View file

@ -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,
};
}

View file

@ -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)
);
}

View file

@ -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;
})
);
});
}

View file

@ -0,0 +1,3 @@
.form {
padding: 0 20px;
}

View file

@ -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',
},
},
});
}

View file

@ -0,0 +1 @@
export { AccessControlPanel } from './AccessControlPanel';

View 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 });
}
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -0,0 +1 @@
export { EditDetails } from './EditDetails';

View file

@ -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,
};
}

View file

@ -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
),
]);
}

View 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;
}

View file

@ -0,0 +1,2 @@
export { AccessControlPanel } from './AccessControlPanel';
export { AccessControlForm } from './AccessControlForm';

View file

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

View 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[];
}

View 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,
};
}
});

View 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 };
}