mirror of https://github.com/portainer/portainer
refactor(access-control): create access-control-panel component [EE-2345] (#6486)
parent
07294c19bb
commit
f63b07bbb9
|
@ -14,9 +14,9 @@ import (
|
||||||
type resourceControlCreatePayload struct {
|
type resourceControlCreatePayload struct {
|
||||||
//
|
//
|
||||||
ResourceID string `example:"617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" validate:"required"`
|
ResourceID string `example:"617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" validate:"required"`
|
||||||
// Type of Docker resource. Valid values are: container, volume\
|
// Type of Resource. Valid values are: 1 - container, 2 - service
|
||||||
// service, secret, config or stack
|
// 3 - volume, 4 - network, 5 - secret, 6 - stack, 7 - config, 8 - custom template, 9 - azure-container-group
|
||||||
Type string `example:"container" validate:"required"`
|
Type portainer.ResourceControlType `example:"1" validate:"required" enums:"1,2,3,4,5,6,7,8,9"`
|
||||||
// Permit access to the associated resource to any user
|
// Permit access to the associated resource to any user
|
||||||
Public bool `example:"true"`
|
Public bool `example:"true"`
|
||||||
// Permit access to resource only to admins
|
// Permit access to resource only to admins
|
||||||
|
@ -39,8 +39,8 @@ func (payload *resourceControlCreatePayload) Validate(r *http.Request) error {
|
||||||
return errors.New("invalid payload: invalid resource identifier")
|
return errors.New("invalid payload: invalid resource identifier")
|
||||||
}
|
}
|
||||||
|
|
||||||
if govalidator.IsNull(payload.Type) {
|
if payload.Type <= 0 || payload.Type >= 10 {
|
||||||
return errors.New("invalid payload: invalid type")
|
return errors.New("invalid payload: Invalid type value. Value must be one of: 1 - container, 2 - service, 3 - volume, 4 - network, 5 - secret, 6 - stack, 7 - config, 8 - custom template, 9 - azure-container-group")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.Public && !payload.AdministratorsOnly {
|
if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.Public && !payload.AdministratorsOnly {
|
||||||
|
@ -75,29 +75,7 @@ func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Req
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
var resourceControlType portainer.ResourceControlType
|
rc, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(payload.ResourceID, payload.Type)
|
||||||
switch payload.Type {
|
|
||||||
case "container":
|
|
||||||
resourceControlType = portainer.ContainerResourceControl
|
|
||||||
case "container-group":
|
|
||||||
resourceControlType = portainer.ContainerGroupResourceControl
|
|
||||||
case "service":
|
|
||||||
resourceControlType = portainer.ServiceResourceControl
|
|
||||||
case "volume":
|
|
||||||
resourceControlType = portainer.VolumeResourceControl
|
|
||||||
case "network":
|
|
||||||
resourceControlType = portainer.NetworkResourceControl
|
|
||||||
case "secret":
|
|
||||||
resourceControlType = portainer.SecretResourceControl
|
|
||||||
case "stack":
|
|
||||||
resourceControlType = portainer.StackResourceControl
|
|
||||||
case "config":
|
|
||||||
resourceControlType = portainer.ConfigResourceControl
|
|
||||||
default:
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid type value. Value must be one of: container, service, volume, network, secret, stack or config", errInvalidResourceControlType}
|
|
||||||
}
|
|
||||||
|
|
||||||
rc, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(payload.ResourceID, resourceControlType)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err}
|
||||||
}
|
}
|
||||||
|
@ -126,7 +104,7 @@ func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Req
|
||||||
resourceControl := portainer.ResourceControl{
|
resourceControl := portainer.ResourceControl{
|
||||||
ResourceID: payload.ResourceID,
|
ResourceID: payload.ResourceID,
|
||||||
SubResourceIDs: payload.SubResourceIDs,
|
SubResourceIDs: payload.SubResourceIDs,
|
||||||
Type: resourceControlType,
|
Type: payload.Type,
|
||||||
Public: payload.Public,
|
Public: payload.Public,
|
||||||
AdministratorsOnly: payload.AdministratorsOnly,
|
AdministratorsOnly: payload.AdministratorsOnly,
|
||||||
UserAccesses: userAccesses,
|
UserAccesses: userAccesses,
|
||||||
|
|
|
@ -6,10 +6,10 @@ import { Input, Select } from '@/portainer/components/form-components/Input';
|
||||||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||||
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||||
import { InputListError } from '@/portainer/components/form-components/InputList/InputList';
|
import { InputListError } from '@/portainer/components/form-components/InputList/InputList';
|
||||||
import { AccessControlForm } from '@/portainer/components/accessControlForm';
|
|
||||||
import { ContainerInstanceFormValues } from '@/azure/types';
|
import { ContainerInstanceFormValues } from '@/azure/types';
|
||||||
import * as notifications from '@/portainer/services/notifications';
|
import * as notifications from '@/portainer/services/notifications';
|
||||||
import { isAdmin, useUser } from '@/portainer/hooks/useUser';
|
import { useUser } from '@/portainer/hooks/useUser';
|
||||||
|
import { AccessControlForm } from '@/portainer/access-control/AccessControlForm';
|
||||||
|
|
||||||
import { validationSchema } from './CreateContainerInstanceForm.validation';
|
import { validationSchema } from './CreateContainerInstanceForm.validation';
|
||||||
import { PortMapping, PortsMappingField } from './PortsMappingField';
|
import { PortMapping, PortsMappingField } from './PortsMappingField';
|
||||||
|
@ -29,19 +29,14 @@ export function CreateContainerInstanceForm() {
|
||||||
throw new Error('endpointId url param is required');
|
throw new Error('endpointId url param is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user } = useUser();
|
const { isAdmin } = useUser();
|
||||||
const isUserAdmin = isAdmin(user);
|
|
||||||
|
|
||||||
const { initialValues, isLoading, providers, subscriptions, resourceGroups } =
|
const { initialValues, isLoading, providers, subscriptions, resourceGroups } =
|
||||||
useLoadFormState(environmentId, isUserAdmin);
|
useLoadFormState(environmentId, isAdmin);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync } = useCreateInstance(
|
const { mutateAsync } = useCreateInstance(resourceGroups, environmentId);
|
||||||
resourceGroups,
|
|
||||||
environmentId,
|
|
||||||
user?.Id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -50,7 +45,7 @@ export function CreateContainerInstanceForm() {
|
||||||
return (
|
return (
|
||||||
<Formik<ContainerInstanceFormValues>
|
<Formik<ContainerInstanceFormValues>
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
validationSchema={() => validationSchema(isUserAdmin)}
|
validationSchema={() => validationSchema(isAdmin)}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
validateOnMount
|
validateOnMount
|
||||||
validateOnChange
|
validateOnChange
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { object, string, number, boolean } from 'yup';
|
import { object, string, number, boolean } from 'yup';
|
||||||
|
|
||||||
import { validationSchema as accessControlSchema } from '@/portainer/components/accessControlForm/AccessControlForm.validation';
|
import { validationSchema as accessControlSchema } from '@/portainer/access-control/AccessControlForm/AccessControlForm.validation';
|
||||||
|
|
||||||
import { validationSchema as portsSchema } from './PortsMappingField.validation';
|
import { validationSchema as portsSchema } from './PortsMappingField.validation';
|
||||||
|
|
||||||
|
|
|
@ -8,8 +8,7 @@ import {
|
||||||
ContainerInstanceFormValues,
|
ContainerInstanceFormValues,
|
||||||
ResourceGroup,
|
ResourceGroup,
|
||||||
} from '@/azure/types';
|
} from '@/azure/types';
|
||||||
import { UserId } from '@/portainer/users/types';
|
import { applyResourceControl } from '@/portainer/access-control/access-control.service';
|
||||||
import { applyResourceControl } from '@/portainer/resource-control/resource-control.service';
|
|
||||||
|
|
||||||
import { getSubscriptionResourceGroups } from './utils';
|
import { getSubscriptionResourceGroups } from './utils';
|
||||||
|
|
||||||
|
@ -17,8 +16,7 @@ export function useCreateInstance(
|
||||||
resourceGroups: {
|
resourceGroups: {
|
||||||
[k: string]: ResourceGroup[];
|
[k: string]: ResourceGroup[];
|
||||||
},
|
},
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId
|
||||||
userId?: UserId
|
|
||||||
) {
|
) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation<ContainerGroup, unknown, ContainerInstanceFormValues>(
|
return useMutation<ContainerGroup, unknown, ContainerInstanceFormValues>(
|
||||||
|
@ -47,13 +45,13 @@ export function useCreateInstance(
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
async onSuccess(containerGroup, values) {
|
async onSuccess(containerGroup, values) {
|
||||||
if (!userId) {
|
const resourceControl = containerGroup.Portainer?.ResourceControl;
|
||||||
throw new Error('missing user id');
|
if (!resourceControl) {
|
||||||
|
throw new PortainerError('resource control expected after creation');
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceControl = containerGroup.Portainer.ResourceControl;
|
|
||||||
const accessControlData = values.accessControl;
|
const accessControlData = values.accessControl;
|
||||||
await applyResourceControl(userId, accessControlData, resourceControl);
|
await applyResourceControl(accessControlData, resourceControl);
|
||||||
queryClient.invalidateQueries(['azure', 'container-instances']);
|
queryClient.invalidateQueries(['azure', 'container-instances']);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { getResourceGroups } from '@/azure/services/resource-groups.service';
|
||||||
import { getSubscriptions } from '@/azure/services/subscription.service';
|
import { getSubscriptions } from '@/azure/services/subscription.service';
|
||||||
import { getContainerInstanceProvider } from '@/azure/services/provider.service';
|
import { getContainerInstanceProvider } from '@/azure/services/provider.service';
|
||||||
import { ContainerInstanceFormValues, Subscription } from '@/azure/types';
|
import { ContainerInstanceFormValues, Subscription } from '@/azure/types';
|
||||||
import { parseFromResourceControl } from '@/portainer/components/accessControlForm/model';
|
import { parseAccessControlFormData } from '@/portainer/access-control/utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSubscriptionLocations,
|
getSubscriptionLocations,
|
||||||
|
@ -58,7 +58,7 @@ export function useLoadFormState(
|
||||||
cpu: 1,
|
cpu: 1,
|
||||||
ports: [{ container: '80', host: '80', protocol: 'TCP' }],
|
ports: [{ container: '80', host: '80', protocol: 'TCP' }],
|
||||||
allocatePublicIP: true,
|
allocatePublicIP: true,
|
||||||
accessControl: parseFromResourceControl(isUserAdmin),
|
accessControl: parseAccessControlFormData(isUserAdmin),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||||
|
|
||||||
export function ContainerGroupDefaultModel() {
|
export function ContainerGroupDefaultModel() {
|
||||||
this.Location = '';
|
this.Location = '';
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/model';
|
import {
|
||||||
import { ResourceControlResponse } from '@/portainer/models/resourceControl/resourceControl';
|
AccessControlFormData,
|
||||||
|
ResourceControlResponse,
|
||||||
|
} from '@/portainer/access-control/types';
|
||||||
|
|
||||||
import { PortMapping } from './ContainerInstances/CreateContainerInstanceForm/PortsMappingField';
|
import { PortMapping } from './ContainerInstances/CreateContainerInstanceForm/PortsMappingField';
|
||||||
|
|
||||||
|
|
|
@ -123,8 +123,12 @@
|
||||||
</rd-widget-body>
|
</rd-widget-body>
|
||||||
</rd-widget>
|
</rd-widget>
|
||||||
</div>
|
</div>
|
||||||
<!-- access-control-panel -->
|
|
||||||
<por-access-control-panel ng-if="$ctrl.container" resource-id="$ctrl.container.Id" resource-control="$ctrl.container.ResourceControl" resource-type="'container-group'">
|
<access-control-panel
|
||||||
</por-access-control-panel>
|
ng-if="$ctrl.container"
|
||||||
<!-- !access-control-panel -->
|
resource-id="$ctrl.container.Id"
|
||||||
|
resource-control="$ctrl.container.ResourceControl"
|
||||||
|
resource-type="$ctrl.resourceType"
|
||||||
|
on-update-success="($ctrl.onUpdateSuccess)"
|
||||||
|
></access-control-panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||||
|
|
||||||
class ContainerInstanceDetailsController {
|
class ContainerInstanceDetailsController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
constructor($state, AzureService, ContainerGroupService, Notifications, ResourceGroupService, SubscriptionService) {
|
constructor($state, AzureService, ContainerGroupService, Notifications, ResourceGroupService, SubscriptionService) {
|
||||||
|
@ -7,9 +9,16 @@ class ContainerInstanceDetailsController {
|
||||||
loading: false,
|
loading: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.resourceType = ResourceControlType.ContainerGroup;
|
||||||
|
|
||||||
this.container = null;
|
this.container = null;
|
||||||
this.subscription = null;
|
this.subscription = null;
|
||||||
this.resourceGroup = null;
|
this.resourceGroup = null;
|
||||||
|
this.onUpdateSuccess = this.onUpdateSuccess.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdateSuccess() {
|
||||||
|
this.$state.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
async $onInit() {
|
async $onInit() {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||||
|
|
||||||
angular.module('portainer.docker').directive('networkRowContent', [
|
angular.module('portainer.docker').directive('networkRowContent', [
|
||||||
function networkRowContent() {
|
function networkRowContent() {
|
||||||
|
|
|
@ -2,8 +2,8 @@ import { Column } from 'react-table';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { ownershipIcon } from '@/portainer/filters/filters';
|
import { ownershipIcon } from '@/portainer/filters/filters';
|
||||||
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
|
||||||
import type { DockerContainer } from '@/docker/containers/types';
|
import type { DockerContainer } from '@/docker/containers/types';
|
||||||
|
import { ResourceControlOwnership } from '@/portainer/access-control/types';
|
||||||
|
|
||||||
export const ownership: Column<DockerContainer> = {
|
export const ownership: Column<DockerContainer> = {
|
||||||
Header: 'Ownership',
|
Header: 'Ownership',
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ResourceControlViewModel } from '@/portainer/models/resourceControl/resourceControl';
|
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||||
|
|
||||||
export type DockerContainerStatus =
|
export type DockerContainerStatus =
|
||||||
| 'paused'
|
| 'paused'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||||
|
|
||||||
function b64DecodeUnicode(str) {
|
function b64DecodeUnicode(str) {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||||
|
|
||||||
export function createStatus(statusText) {
|
export function createStatus(statusText) {
|
||||||
var status = _.toLower(statusText);
|
var status = _.toLower(statusText);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||||
|
|
||||||
export function NetworkViewModel(data) {
|
export function NetworkViewModel(data) {
|
||||||
this.Id = data.Id;
|
this.Id = data.Id;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||||
|
|
||||||
export function SecretViewModel(data) {
|
export function SecretViewModel(data) {
|
||||||
this.Id = data.ID;
|
this.Id = data.ID;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||||
|
|
||||||
export function ServiceViewModel(data, runningTasks, allTasks) {
|
export function ServiceViewModel(data, runningTasks, allTasks) {
|
||||||
this.Model = data;
|
this.Model = data;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||||
|
|
||||||
export function VolumeViewModel(data) {
|
export function VolumeViewModel(data) {
|
||||||
this.Id = data.Name;
|
this.Id = data.Name;
|
||||||
|
|
|
@ -59,7 +59,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- access-control-panel -->
|
<!-- access-control-panel -->
|
||||||
<por-access-control-panel ng-if="config" resource-id="config.Id" resource-control="config.ResourceControl" resource-type="'config'"> </por-access-control-panel>
|
<access-control-panel
|
||||||
|
ng-if="config"
|
||||||
|
resource-id="config.Id"
|
||||||
|
resource-control="config.ResourceControl"
|
||||||
|
resource-type="resourceType"
|
||||||
|
on-update-success="(onUpdateResourceControlSuccess)"
|
||||||
|
>
|
||||||
|
</access-control-panel>
|
||||||
<!-- !access-control-panel -->
|
<!-- !access-control-panel -->
|
||||||
|
|
||||||
<div class="row" ng-if="config">
|
<div class="row" ng-if="config">
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||||
|
|
||||||
angular.module('portainer.docker').controller('ConfigController', [
|
angular.module('portainer.docker').controller('ConfigController', [
|
||||||
'$scope',
|
'$scope',
|
||||||
'$transition$',
|
'$transition$',
|
||||||
|
@ -5,6 +7,12 @@ angular.module('portainer.docker').controller('ConfigController', [
|
||||||
'ConfigService',
|
'ConfigService',
|
||||||
'Notifications',
|
'Notifications',
|
||||||
function ($scope, $transition$, $state, ConfigService, Notifications) {
|
function ($scope, $transition$, $state, ConfigService, Notifications) {
|
||||||
|
$scope.resourceType = ResourceControlType.Config;
|
||||||
|
|
||||||
|
$scope.onUpdateResourceControlSuccess = function () {
|
||||||
|
$state.reload();
|
||||||
|
};
|
||||||
|
|
||||||
$scope.removeConfig = function removeConfig(configId) {
|
$scope.removeConfig = function removeConfig(configId) {
|
||||||
ConfigService.remove(configId)
|
ConfigService.remove(configId)
|
||||||
.then(function success() {
|
.then(function success() {
|
||||||
|
|
|
@ -152,7 +152,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- access-control-panel -->
|
<!-- access-control-panel -->
|
||||||
<por-access-control-panel ng-if="container" resource-id="container.Id" resource-control="container.ResourceControl" resource-type="'container'"> </por-access-control-panel>
|
<access-control-panel
|
||||||
|
ng-if="container"
|
||||||
|
resource-id="container.Id"
|
||||||
|
resource-control="container.ResourceControl"
|
||||||
|
resource-type="resourceType"
|
||||||
|
on-update-success="(onUpdateResourceControlSuccess)"
|
||||||
|
>
|
||||||
|
</access-control-panel>
|
||||||
<!-- !access-control-panel -->
|
<!-- !access-control-panel -->
|
||||||
|
|
||||||
<div ng-if="container.State.Health" class="row">
|
<div ng-if="container.State.Health" class="row">
|
||||||
|
|
|
@ -3,6 +3,7 @@ import _ from 'lodash-es';
|
||||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||||
import { confirmContainerDeletion } from '@/portainer/services/modal.service/prompt';
|
import { confirmContainerDeletion } from '@/portainer/services/modal.service/prompt';
|
||||||
import { FeatureId } from 'Portainer/feature-flags/enums';
|
import { FeatureId } from 'Portainer/feature-flags/enums';
|
||||||
|
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||||
|
|
||||||
angular.module('portainer.docker').controller('ContainerController', [
|
angular.module('portainer.docker').controller('ContainerController', [
|
||||||
'$q',
|
'$q',
|
||||||
|
@ -45,6 +46,7 @@ angular.module('portainer.docker').controller('ContainerController', [
|
||||||
Authentication,
|
Authentication,
|
||||||
endpoint
|
endpoint
|
||||||
) {
|
) {
|
||||||
|
$scope.resourceType = ResourceControlType.Container;
|
||||||
$scope.endpoint = endpoint;
|
$scope.endpoint = endpoint;
|
||||||
$scope.isAdmin = Authentication.isAdmin();
|
$scope.isAdmin = Authentication.isAdmin();
|
||||||
$scope.activityTime = 0;
|
$scope.activityTime = 0;
|
||||||
|
@ -71,6 +73,10 @@ angular.module('portainer.docker').controller('ContainerController', [
|
||||||
|
|
||||||
$scope.updateRestartPolicy = updateRestartPolicy;
|
$scope.updateRestartPolicy = updateRestartPolicy;
|
||||||
|
|
||||||
|
$scope.onUpdateResourceControlSuccess = function () {
|
||||||
|
$state.reload();
|
||||||
|
};
|
||||||
|
|
||||||
var update = function () {
|
var update = function () {
|
||||||
var nodeName = $transition$.params().nodeName;
|
var nodeName = $transition$.params().nodeName;
|
||||||
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
|
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
|
||||||
|
|
|
@ -69,14 +69,15 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- access-control-panel -->
|
<!-- access-control-panel -->
|
||||||
<por-access-control-panel
|
<access-control-panel
|
||||||
ng-if="network"
|
ng-if="network"
|
||||||
resource-id="network.Id"
|
resource-id="network.Id"
|
||||||
resource-control="network.ResourceControl"
|
resource-control="network.ResourceControl"
|
||||||
resource-type="'network'"
|
resource-type="resourceType"
|
||||||
disable-ownership-change="isSystemNetwork()"
|
disable-ownership-change="isSystemNetwork()"
|
||||||
|
on-update-success="(onUpdateResourceControlSuccess)"
|
||||||
>
|
>
|
||||||
</por-access-control-panel>
|
</access-control-panel>
|
||||||
<!-- !access-control-panel -->
|
<!-- !access-control-panel -->
|
||||||
|
|
||||||
<div class="row" ng-if="!(network.Options | emptyobject)">
|
<div class="row" ng-if="!(network.Options | emptyobject)">
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||||
import DockerNetworkHelper from 'Docker/helpers/networkHelper';
|
import DockerNetworkHelper from 'Docker/helpers/networkHelper';
|
||||||
|
|
||||||
angular.module('portainer.docker').controller('NetworkController', [
|
angular.module('portainer.docker').controller('NetworkController', [
|
||||||
|
@ -11,6 +12,12 @@ angular.module('portainer.docker').controller('NetworkController', [
|
||||||
'HttpRequestHelper',
|
'HttpRequestHelper',
|
||||||
'NetworkHelper',
|
'NetworkHelper',
|
||||||
function ($scope, $state, $transition$, $filter, NetworkService, Container, Notifications, HttpRequestHelper, NetworkHelper) {
|
function ($scope, $state, $transition$, $filter, NetworkService, Container, Notifications, HttpRequestHelper, NetworkHelper) {
|
||||||
|
$scope.resourceType = ResourceControlType.Network;
|
||||||
|
|
||||||
|
$scope.onUpdateResourceControlSuccess = function () {
|
||||||
|
$state.reload();
|
||||||
|
};
|
||||||
|
|
||||||
$scope.removeNetwork = function removeNetwork() {
|
$scope.removeNetwork = function removeNetwork() {
|
||||||
NetworkService.remove($transition$.params().id, $transition$.params().id)
|
NetworkService.remove($transition$.params().id, $transition$.params().id)
|
||||||
.then(function success() {
|
.then(function success() {
|
||||||
|
|
|
@ -56,5 +56,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- access-control-panel -->
|
<!-- access-control-panel -->
|
||||||
<por-access-control-panel ng-if="secret" resource-id="secret.Id" resource-control="secret.ResourceControl" resource-type="'secret'"> </por-access-control-panel>
|
<access-control-panel
|
||||||
|
ng-if="secret"
|
||||||
|
resource-id="secret.Id"
|
||||||
|
resource-control="secret.ResourceControl"
|
||||||
|
resource-type="resourceType"
|
||||||
|
on-update-success="(onUpdateResourceControlSuccess)"
|
||||||
|
>
|
||||||
|
</access-control-panel>
|
||||||
<!-- !access-control-panel -->
|
<!-- !access-control-panel -->
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||||
|
|
||||||
angular.module('portainer.docker').controller('SecretController', [
|
angular.module('portainer.docker').controller('SecretController', [
|
||||||
'$scope',
|
'$scope',
|
||||||
'$transition$',
|
'$transition$',
|
||||||
|
@ -5,6 +7,12 @@ angular.module('portainer.docker').controller('SecretController', [
|
||||||
'SecretService',
|
'SecretService',
|
||||||
'Notifications',
|
'Notifications',
|
||||||
function ($scope, $transition$, $state, SecretService, Notifications) {
|
function ($scope, $transition$, $state, SecretService, Notifications) {
|
||||||
|
$scope.resourceType = ResourceControlType.Secret;
|
||||||
|
|
||||||
|
$scope.onUpdateResourceControlSuccess = function () {
|
||||||
|
$state.reload();
|
||||||
|
};
|
||||||
|
|
||||||
$scope.removeSecret = function removeSecret(secretId) {
|
$scope.removeSecret = function removeSecret(secretId) {
|
||||||
SecretService.remove(secretId)
|
SecretService.remove(secretId)
|
||||||
.then(function success() {
|
.then(function success() {
|
||||||
|
|
|
@ -198,7 +198,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- access-control-panel -->
|
<!-- access-control-panel -->
|
||||||
<por-access-control-panel ng-if="service" resource-id="service.Id" resource-control="service.ResourceControl" resource-type="'service'"> </por-access-control-panel>
|
<access-control-panel
|
||||||
|
ng-if="service"
|
||||||
|
resource-id="service.Id"
|
||||||
|
resource-control="service.ResourceControl"
|
||||||
|
resource-type="resourceType"
|
||||||
|
on-update-success="(onUpdateResourceControlSuccess)"
|
||||||
|
>
|
||||||
|
</access-control-panel>
|
||||||
<!-- !access-control-panel -->
|
<!-- !access-control-panel -->
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
|
@ -21,6 +21,7 @@ import _ from 'lodash-es';
|
||||||
|
|
||||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||||
import * as envVarsUtils from '@/portainer/helpers/env-vars';
|
import * as envVarsUtils from '@/portainer/helpers/env-vars';
|
||||||
|
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||||
|
|
||||||
angular.module('portainer.docker').controller('ServiceController', [
|
angular.module('portainer.docker').controller('ServiceController', [
|
||||||
'$q',
|
'$q',
|
||||||
|
@ -87,6 +88,12 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||||
RegistryService,
|
RegistryService,
|
||||||
endpoint
|
endpoint
|
||||||
) {
|
) {
|
||||||
|
$scope.resourceType = ResourceControlType.Service;
|
||||||
|
|
||||||
|
$scope.onUpdateResourceControlSuccess = function () {
|
||||||
|
$state.reload();
|
||||||
|
};
|
||||||
|
|
||||||
$scope.endpoint = endpoint;
|
$scope.endpoint = endpoint;
|
||||||
|
|
||||||
$scope.state = {
|
$scope.state = {
|
||||||
|
|
|
@ -52,7 +52,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- access-control-panel -->
|
<!-- access-control-panel -->
|
||||||
<por-access-control-panel ng-if="volume" resource-id="volume.ResourceId" resource-control="volume.ResourceControl" resource-type="'volume'"> </por-access-control-panel>
|
<access-control-panel
|
||||||
|
ng-if="volume"
|
||||||
|
resource-id="volume.ResourceId"
|
||||||
|
resource-control="volume.ResourceControl"
|
||||||
|
resource-type="resourceType"
|
||||||
|
on-update-success="(onUpdateResourceControlSuccess)"
|
||||||
|
>
|
||||||
|
</access-control-panel>
|
||||||
<!-- !access-control-panel -->
|
<!-- !access-control-panel -->
|
||||||
|
|
||||||
<div class="row" ng-if="!(volume.Options | emptyobject)">
|
<div class="row" ng-if="!(volume.Options | emptyobject)">
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||||
|
|
||||||
angular.module('portainer.docker').controller('VolumeController', [
|
angular.module('portainer.docker').controller('VolumeController', [
|
||||||
'$scope',
|
'$scope',
|
||||||
'$state',
|
'$state',
|
||||||
|
@ -9,6 +11,12 @@ angular.module('portainer.docker').controller('VolumeController', [
|
||||||
'Notifications',
|
'Notifications',
|
||||||
'HttpRequestHelper',
|
'HttpRequestHelper',
|
||||||
function ($scope, $state, $transition$, $q, ModalService, VolumeService, ContainerService, Notifications, HttpRequestHelper) {
|
function ($scope, $state, $transition$, $q, ModalService, VolumeService, ContainerService, Notifications, HttpRequestHelper) {
|
||||||
|
$scope.resourceType = ResourceControlType.Volume;
|
||||||
|
|
||||||
|
$scope.onUpdateResourceControlSuccess = function () {
|
||||||
|
$state.reload();
|
||||||
|
};
|
||||||
|
|
||||||
$scope.removeVolume = function removeVolume() {
|
$scope.removeVolume = function removeVolume() {
|
||||||
ModalService.confirmDeletion('Do you want to remove this volume?', (confirmed) => {
|
ModalService.confirmDeletion('Do you want to remove this volume?', (confirmed) => {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
||||||
import { ResourceControlViewModel } from '@/portainer/models/resourceControl/resourceControl';
|
|
||||||
|
|
||||||
class KubeEditCustomTemplateViewController {
|
class KubeEditCustomTemplateViewController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
|
|
|
@ -8,6 +8,7 @@ import userActivityModule from './user-activity';
|
||||||
import servicesModule from './services';
|
import servicesModule from './services';
|
||||||
import teamsModule from './teams';
|
import teamsModule from './teams';
|
||||||
import homeModule from './home';
|
import homeModule from './home';
|
||||||
|
import { accessControlModule } from './access-control';
|
||||||
|
|
||||||
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
|
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
|
||||||
authManager.checkAuthOnRefresh();
|
authManager.checkAuthOnRefresh();
|
||||||
|
@ -36,6 +37,7 @@ angular
|
||||||
'portainer.shared.datatable',
|
'portainer.shared.datatable',
|
||||||
servicesModule,
|
servicesModule,
|
||||||
teamsModule,
|
teamsModule,
|
||||||
|
accessControlModule,
|
||||||
])
|
])
|
||||||
.config([
|
.config([
|
||||||
'$stateRegistryProvider',
|
'$stateRegistryProvider',
|
||||||
|
|
|
@ -3,11 +3,11 @@ import { useMemo, useState } from 'react';
|
||||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||||
|
|
||||||
import { UserContext } from '@/portainer/hooks/useUser';
|
import { UserContext } from '@/portainer/hooks/useUser';
|
||||||
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
|
||||||
import { UserViewModel } from '@/portainer/models/user';
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
|
|
||||||
|
import { parseAccessControlFormData } from '../utils';
|
||||||
|
|
||||||
import { AccessControlForm } from './AccessControlForm';
|
import { AccessControlForm } from './AccessControlForm';
|
||||||
import { AccessControlFormData } from './model';
|
|
||||||
|
|
||||||
const meta: Meta = {
|
const meta: Meta = {
|
||||||
title: 'Components/AccessControlForm',
|
title: 'Components/AccessControlForm',
|
||||||
|
@ -30,11 +30,8 @@ interface Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Template({ userRole }: Args) {
|
function Template({ userRole }: Args) {
|
||||||
const defaults = new AccessControlFormData();
|
const isAdmin = userRole === Role.Admin;
|
||||||
defaults.ownership =
|
const defaults = parseAccessControlFormData(isAdmin);
|
||||||
userRole === Role.Admin
|
|
||||||
? ResourceControlOwnership.ADMINISTRATORS
|
|
||||||
: ResourceControlOwnership.PRIVATE;
|
|
||||||
|
|
||||||
const [value, setValue] = useState(defaults);
|
const [value, setValue] = useState(defaults);
|
||||||
|
|
||||||
|
@ -46,7 +43,7 @@ function Template({ userRole }: Args) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={testQueryClient}>
|
<QueryClientProvider client={testQueryClient}>
|
||||||
<UserContext.Provider value={userProviderState}>
|
<UserContext.Provider value={userProviderState}>
|
||||||
<AccessControlForm values={value} onChange={setValue} />
|
<AccessControlForm values={value} onChange={setValue} errors={{}} />
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
|
@ -1,54 +1,50 @@
|
||||||
import { server, rest } from '@/setup-tests/server';
|
import { server, rest } from '@/setup-tests/server';
|
||||||
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
|
||||||
import { UserContext } from '@/portainer/hooks/useUser';
|
import { UserContext } from '@/portainer/hooks/useUser';
|
||||||
import { UserViewModel } from '@/portainer/models/user';
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
import { renderWithQueryClient, within } from '@/react-tools/test-utils';
|
import { renderWithQueryClient, within } from '@/react-tools/test-utils';
|
||||||
import { Team } from '@/portainer/teams/types';
|
import { Team, TeamId } from '@/portainer/teams/types';
|
||||||
import { ResourceControlViewModel } from '@/portainer/models/resourceControl/resourceControl';
|
|
||||||
import { createMockTeams } from '@/react-tools/test-mocks';
|
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';
|
import { AccessControlForm } from './AccessControlForm';
|
||||||
import { AccessControlFormData } from './model';
|
|
||||||
|
|
||||||
test('renders correctly', async () => {
|
test('renders correctly', async () => {
|
||||||
const values: AccessControlFormData = new AccessControlFormData();
|
const values = buildFormData();
|
||||||
|
|
||||||
const { findByText } = await renderComponent(values);
|
const { findByText } = await renderComponent(values);
|
||||||
|
|
||||||
expect(await findByText('Access control')).toBeVisible();
|
expect(await findByText('Access control')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('when AccessControlEnabled is true, ownership selector should be visible', async () => {
|
test.each([
|
||||||
const values = new AccessControlFormData();
|
[ResourceControlOwnership.ADMINISTRATORS],
|
||||||
|
[ResourceControlOwnership.PRIVATE],
|
||||||
|
[ResourceControlOwnership.RESTRICTED],
|
||||||
|
])(
|
||||||
|
`when ownership is %s, ownership selector should be visible`,
|
||||||
|
async (ownership) => {
|
||||||
|
const values = buildFormData(ownership);
|
||||||
|
|
||||||
const { queryByRole } = await renderComponent(values);
|
const { findByRole, getByLabelText } = await renderComponent(values);
|
||||||
|
const accessSwitch = getByLabelText(/Enable access control/);
|
||||||
|
|
||||||
expect(queryByRole('radiogroup')).toBeVisible();
|
expect(accessSwitch).toBeEnabled();
|
||||||
});
|
|
||||||
|
|
||||||
test('when AccessControlEnabled is false, ownership selector should be hidden', async () => {
|
await expect(findByRole('radiogroup')).resolves.toBeVisible();
|
||||||
const values: AccessControlFormData = {
|
}
|
||||||
...new AccessControlFormData(),
|
);
|
||||||
accessControlEnabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { queryByRole } = await renderComponent(values);
|
test.each([
|
||||||
|
[ResourceControlOwnership.ADMINISTRATORS],
|
||||||
expect(queryByRole('radiogroup')).toBeNull();
|
[ResourceControlOwnership.PRIVATE],
|
||||||
});
|
[ResourceControlOwnership.RESTRICTED],
|
||||||
|
])(
|
||||||
test('when hideTitle is true, title should be hidden', async () => {
|
'when isAdmin and ownership is %s, ownership selector should show admin and restricted options',
|
||||||
const values = new AccessControlFormData();
|
async (ownership) => {
|
||||||
|
const values = buildFormData(ownership);
|
||||||
const { queryByRole } = await renderComponent(values, jest.fn(), {
|
|
||||||
hideTitle: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(queryByRole('Access control')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when isAdmin and AccessControlEnabled, ownership selector should admin and restricted options', async () => {
|
|
||||||
const values = new AccessControlFormData();
|
|
||||||
|
|
||||||
const { findByRole } = await renderComponent(values, jest.fn(), {
|
const { findByRole } = await renderComponent(values, jest.fn(), {
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
|
@ -62,15 +58,79 @@ test('when isAdmin and AccessControlEnabled, ownership selector should admin and
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectorQueries = within(ownershipSelector);
|
const selectorQueries = within(ownershipSelector);
|
||||||
expect(await selectorQueries.findByLabelText(/Administrator/)).toBeVisible();
|
expect(
|
||||||
|
await selectorQueries.findByLabelText(/Administrator/)
|
||||||
|
).toBeVisible();
|
||||||
expect(await selectorQueries.findByLabelText(/Restricted/)).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 isAdmin, AccessControlEnabled and admin ownership is selected, no extra options are visible', async () => {
|
test('when hideTitle is true, title should be hidden', async () => {
|
||||||
const values: AccessControlFormData = {
|
const values = buildFormData();
|
||||||
...new AccessControlFormData(),
|
|
||||||
ownership: RCO.ADMINISTRATORS,
|
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(
|
const { findByRole, queryByLabelText } = await renderComponent(
|
||||||
values,
|
values,
|
||||||
|
@ -95,11 +155,8 @@ test('when isAdmin, AccessControlEnabled and admin ownership is selected, no ext
|
||||||
expect(queryByLabelText('extra-options')).toBeNull();
|
expect(queryByLabelText('extra-options')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('when isAdmin, AccessControlEnabled and restricted ownership is selected, show team and users selectors', async () => {
|
test('when isAdmin and restricted ownership is selected, show team and users selectors', async () => {
|
||||||
const values: AccessControlFormData = {
|
const values = buildFormData(ResourceControlOwnership.RESTRICTED);
|
||||||
...new AccessControlFormData(),
|
|
||||||
ownership: RCO.RESTRICTED,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { findByRole, findByLabelText } = await renderComponent(
|
const { findByRole, findByLabelText } = await renderComponent(
|
||||||
values,
|
values,
|
||||||
|
@ -136,43 +193,8 @@ test('when isAdmin, AccessControlEnabled and restricted ownership is selected, s
|
||||||
expect(await extraQueries.findByText(/Authorized teams/)).toBeVisible();
|
expect(await extraQueries.findByText(/Authorized teams/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('when user is not an admin and access control is enabled and no teams, should have only private option', async () => {
|
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 = new AccessControlFormData();
|
const values = buildFormData(ResourceControlOwnership.RESTRICTED);
|
||||||
|
|
||||||
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('when user is not an admin and access control is enabled and there is 1 team, should have private and restricted options', async () => {
|
|
||||||
const values = new AccessControlFormData();
|
|
||||||
|
|
||||||
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 user is not an admin, access control is enabled, there are more then 1 team and ownership is restricted, team selector should be visible', async () => {
|
|
||||||
const values: AccessControlFormData = {
|
|
||||||
...new AccessControlFormData(),
|
|
||||||
ownership: RCO.RESTRICTED,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { findByRole, findByLabelText } = await renderComponent(
|
const { findByRole, findByLabelText } = await renderComponent(
|
||||||
values,
|
values,
|
||||||
|
@ -202,11 +224,8 @@ test('when user is not an admin, access control is enabled, there are more then
|
||||||
expect(extraQueries.queryByLabelText(/Authorized teams/)).toBeVisible();
|
expect(extraQueries.queryByLabelText(/Authorized teams/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('when user is not an admin, access control is enabled, there is 1 team and ownership is restricted, team selector not should be visible', async () => {
|
test('when user is not an admin, there is 1 team and ownership is restricted, team selector not should be visible', async () => {
|
||||||
const values: AccessControlFormData = {
|
const values = buildFormData(ResourceControlOwnership.RESTRICTED);
|
||||||
...new AccessControlFormData(),
|
|
||||||
ownership: RCO.RESTRICTED,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { findByRole, findByLabelText } = await renderComponent(
|
const { findByRole, findByLabelText } = await renderComponent(
|
||||||
values,
|
values,
|
||||||
|
@ -240,11 +259,8 @@ test('when user is not an admin, access control is enabled, there is 1 team and
|
||||||
expect(extraQueries.queryByText(/Authorized teams/)).toBeNull();
|
expect(extraQueries.queryByText(/Authorized teams/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('when user is not an admin, access control is enabled, and ownership is restricted, user selector not should be visible', async () => {
|
test('when user is not an admin, and ownership is restricted, user selector not should be visible', async () => {
|
||||||
const values: AccessControlFormData = {
|
const values = buildFormData(ResourceControlOwnership.RESTRICTED);
|
||||||
...new AccessControlFormData(),
|
|
||||||
ownership: RCO.RESTRICTED,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { findByRole, findByLabelText } = await renderComponent(
|
const { findByRole, findByLabelText } = await renderComponent(
|
||||||
values,
|
values,
|
||||||
|
@ -299,6 +315,7 @@ async function renderComponent(
|
||||||
const renderResult = renderWithQueryClient(
|
const renderResult = renderWithQueryClient(
|
||||||
<UserContext.Provider value={state}>
|
<UserContext.Provider value={state}>
|
||||||
<AccessControlForm
|
<AccessControlForm
|
||||||
|
errors={{}}
|
||||||
values={values}
|
values={values}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hideTitle={hideTitle}
|
hideTitle={hideTitle}
|
||||||
|
@ -309,6 +326,13 @@ async function renderComponent(
|
||||||
await expect(
|
await expect(
|
||||||
renderResult.findByLabelText(/Enable access control/)
|
renderResult.findByLabelText(/Enable access control/)
|
||||||
).resolves.toBeVisible();
|
).resolves.toBeVisible();
|
||||||
|
|
||||||
return renderResult;
|
return renderResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildFormData(
|
||||||
|
ownership = ResourceControlOwnership.PRIVATE,
|
||||||
|
authorizedTeams: TeamId[] = [],
|
||||||
|
authorizedUsers: UserId[] = []
|
||||||
|
): AccessControlFormData {
|
||||||
|
return { ownership, authorizedTeams, authorizedUsers };
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
|
import { useUser } from '@/portainer/hooks/useUser';
|
||||||
|
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||||
|
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
||||||
|
import { EditDetails } from '@/portainer/access-control/EditDetails/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);
|
||||||
|
});
|
|
@ -1,17 +1,13 @@
|
||||||
import { object, string, array, number, bool } from 'yup';
|
import { object, string, array, number } from 'yup';
|
||||||
|
|
||||||
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
import { ResourceControlOwnership } from '../types';
|
||||||
|
|
||||||
export function validationSchema(isAdmin: boolean) {
|
export function validationSchema(isAdmin: boolean) {
|
||||||
return object()
|
return object()
|
||||||
.shape({
|
.shape({
|
||||||
accessControlEnabled: bool(),
|
|
||||||
ownership: string()
|
ownership: string()
|
||||||
.oneOf(Object.values(ResourceControlOwnership))
|
.oneOf(Object.values(ResourceControlOwnership))
|
||||||
.when('accessControlEnabled', {
|
.required(),
|
||||||
is: true,
|
|
||||||
then: (schema) => schema.required(),
|
|
||||||
}),
|
|
||||||
authorizedUsers: array(number()),
|
authorizedUsers: array(number()),
|
||||||
authorizedTeams: array(number()),
|
authorizedTeams: array(number()),
|
||||||
})
|
})
|
||||||
|
@ -20,16 +16,8 @@ export function validationSchema(isAdmin: boolean) {
|
||||||
isAdmin
|
isAdmin
|
||||||
? 'You must specify at least one team or user.'
|
? 'You must specify at least one team or user.'
|
||||||
: 'You must specify at least one team.',
|
: 'You must specify at least one team.',
|
||||||
({
|
({ ownership, authorizedTeams, authorizedUsers }) => {
|
||||||
accessControlEnabled,
|
if (ownership !== ResourceControlOwnership.RESTRICTED) {
|
||||||
ownership,
|
|
||||||
authorizedTeams,
|
|
||||||
authorizedUsers,
|
|
||||||
}) => {
|
|
||||||
if (
|
|
||||||
!accessControlEnabled ||
|
|
||||||
ownership !== ResourceControlOwnership.RESTRICTED
|
|
||||||
) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,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,152 @@
|
||||||
|
import { useReducer } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/portainer/components/Button';
|
||||||
|
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
|
||||||
|
import { useUser } from '@/portainer/hooks/useUser';
|
||||||
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
|
import { TeamMembership, Role } from '@/portainer/teams/types';
|
||||||
|
import { useUserMembership } from '@/portainer/users/queries';
|
||||||
|
|
||||||
|
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(): 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">
|
||||||
|
<Widget>
|
||||||
|
<WidgetTitle title="Access control" icon="fa-eye" />
|
||||||
|
<WidgetBody className="no-padding">
|
||||||
|
<AccessControlPanelDetails
|
||||||
|
resourceType={resourceType}
|
||||||
|
resourceControl={resourceControl}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isEditDisabled && !isEditMode && (
|
||||||
|
<div className="row">
|
||||||
|
<div>
|
||||||
|
<Button color="link" onClick={toggleEditMode}>
|
||||||
|
<i className="fa fa-edit space-right" aria-hidden="true" />
|
||||||
|
Change ownership
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditMode && (
|
||||||
|
<AccessControlPanelForm
|
||||||
|
resourceControl={resourceControl}
|
||||||
|
onCancelClick={() => toggleEditMode()}
|
||||||
|
resourceId={resourceId}
|
||||||
|
resourceType={resourceType}
|
||||||
|
onUpdateSuccess={handleUpdateSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleUpdateSuccess() {
|
||||||
|
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 || !user) {
|
||||||
|
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 === Role.TeamLeader &&
|
||||||
|
resourceControl.TeamAccesses.some((ta) => ta.TeamId === membership.TeamID)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccessControlPanelAngular = r2a(AccessControlPanel, [
|
||||||
|
'resourceControl',
|
||||||
|
'resourceType',
|
||||||
|
'disableOwnershipChange',
|
||||||
|
'resourceId',
|
||||||
|
'onUpdateSuccess',
|
||||||
|
]);
|
|
@ -0,0 +1,207 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { ownershipIcon, truncate } from '@/portainer/filters/filters';
|
||||||
|
import { Tooltip } from '@/portainer/components/Tip/Tooltip';
|
||||||
|
import { Link } from '@/portainer/components/Link';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
import { TeamId } from '@/portainer/teams/types';
|
||||||
|
import { useTeams } from '@/portainer/teams/queries';
|
||||||
|
import { useUsers } from '@/portainer/users/queries';
|
||||||
|
|
||||||
|
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} position="bottom" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAuthorizedTeams(authorizedTeamIds: TeamId[]) {
|
||||||
|
return useTeams(authorizedTeamIds.length > 0, (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,135 @@
|
||||||
|
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 { Button } from '@/portainer/components/Button';
|
||||||
|
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||||
|
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
|
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(): 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' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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('Access control successfully updated');
|
||||||
|
onUpdateSuccess();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,116 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
|
import { BoxSelector } from '@/portainer/components/BoxSelector';
|
||||||
|
import { useUser } from '@/portainer/hooks/useUser';
|
||||||
|
import { FormError } from '@/portainer/components/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 (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<BoxSelector
|
||||||
|
radioName={withNamespace('ownership')}
|
||||||
|
value={values.ownership}
|
||||||
|
options={options}
|
||||||
|
onChange={(ownership) => handleChangeOwnership(ownership)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,11 @@
|
||||||
import { UsersSelector } from '@/portainer/components/UsersSelector';
|
import { UsersSelector } from '@/portainer/components/UsersSelector';
|
||||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||||
import { UserViewModel } from '@/portainer/models/user';
|
|
||||||
import { Link } from '@/portainer/components/Link';
|
import { Link } from '@/portainer/components/Link';
|
||||||
|
import { User } from '@/portainer/users/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
users: UserViewModel[];
|
users: User[];
|
||||||
value: number[];
|
value: number[];
|
||||||
onChange(value: number[]): void;
|
onChange(value: number[]): void;
|
||||||
errors?: string | string[];
|
errors?: string | string[];
|
|
@ -0,0 +1 @@
|
||||||
|
export { EditDetails } from './EditDetails';
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { useTeams } from '@/portainer/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,77 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { buildOption } from '@/portainer/components/BoxSelector';
|
||||||
|
import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types';
|
||||||
|
import { ownershipIcon } from '@/portainer/filters/filters';
|
||||||
|
import { Team } from '@/portainer/teams/types';
|
||||||
|
|
||||||
|
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: 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',
|
||||||
|
ownershipIcon('administrators'),
|
||||||
|
'Administrators',
|
||||||
|
'I want to restrict the management of this resource to administrators only',
|
||||||
|
ResourceControlOwnership.ADMINISTRATORS
|
||||||
|
),
|
||||||
|
buildOption(
|
||||||
|
'access_restricted',
|
||||||
|
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',
|
||||||
|
ownershipIcon('private'),
|
||||||
|
'Private',
|
||||||
|
'I want to this resource to be manageable by myself only',
|
||||||
|
ResourceControlOwnership.PRIVATE
|
||||||
|
),
|
||||||
|
teams &&
|
||||||
|
teams.length > 0 &&
|
||||||
|
buildOption(
|
||||||
|
'access_restricted',
|
||||||
|
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
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
|
@ -0,0 +1,98 @@
|
||||||
|
import axios, { parseAxiosError } from '../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;
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
|
||||||
|
import { AccessControlPanelAngular } from './AccessControlPanel/AccessControlPanel';
|
||||||
|
|
||||||
|
export const accessControlModule = angular
|
||||||
|
.module('portainer.access-control', [])
|
||||||
|
.component('accessControlPanel', AccessControlPanelAngular).name;
|
|
@ -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(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function determineOwnership(resourceControl: ResourceControlViewModel) {
|
||||||
|
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;
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { TeamId } from '@/portainer/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[];
|
||||||
|
}
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { TeamId } from '../teams/types';
|
||||||
|
import { UserId } from '../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 };
|
||||||
|
}
|
|
@ -85,10 +85,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-selector-item-description {
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.box-selector-item.limited.business {
|
.box-selector-item.limited.business {
|
||||||
--selected-item-color: var(--BE-only);
|
--selected-item-color: var(--BE-only);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import Select from 'react-select';
|
import { Select } from '@/portainer/components/form-components/ReactSelect';
|
||||||
|
|
||||||
import { Team, TeamId } from '@/portainer/teams/types';
|
import { Team, TeamId } from '@/portainer/teams/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
.selector__control {
|
|
||||||
border: 1px solid var(--border-multiselect) !important;
|
|
||||||
background-color: var(--bg-multiselect-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector__multi-value {
|
|
||||||
background-color: var(--bg-multiselect-checkboxcontainer) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector__multi-value__label {
|
|
||||||
color: var(--text-multiselect-item-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector__menu {
|
|
||||||
background-color: var(--bg-multiselect-color) !important;
|
|
||||||
border: 1px solid var(--border-multiselect) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector__option {
|
|
||||||
background-color: var(--bg-multiselect-color) !important;
|
|
||||||
border: 1px solid var(--border-multiselect) !important;
|
|
||||||
border: 1px solid red;
|
|
||||||
}
|
|
|
@ -1,14 +1,11 @@
|
||||||
import Select from 'react-select';
|
import { User, UserId } from '@/portainer/users/types';
|
||||||
|
import { Select } from '@/portainer/components/form-components/ReactSelect';
|
||||||
import { UserViewModel } from '@/portainer/models/user';
|
|
||||||
import { UserId } from '@/portainer/users/types';
|
|
||||||
import './UsersSelector.css';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name?: string;
|
name?: string;
|
||||||
value: UserId[];
|
value: UserId[];
|
||||||
onChange(value: UserId[]): void;
|
onChange(value: UserId[]): void;
|
||||||
users: UserViewModel[];
|
users: User[];
|
||||||
dataCy?: string;
|
dataCy?: string;
|
||||||
inputId?: string;
|
inputId?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
@ -28,9 +25,8 @@ export function UsersSelector({
|
||||||
isMulti
|
isMulti
|
||||||
name={name}
|
name={name}
|
||||||
getOptionLabel={(user) => user.Username}
|
getOptionLabel={(user) => user.Username}
|
||||||
getOptionValue={(user) => user.Id}
|
getOptionValue={(user) => `${user.Id}`}
|
||||||
options={users}
|
options={users}
|
||||||
classNamePrefix="selector"
|
|
||||||
value={users.filter((user) => value.includes(user.Id))}
|
value={users.filter((user) => value.includes(user.Id))}
|
||||||
closeMenuOnSelect={false}
|
closeMenuOnSelect={false}
|
||||||
onChange={(selectedUsers) =>
|
onChange={(selectedUsers) =>
|
||||||
|
|
|
@ -1,181 +0,0 @@
|
||||||
import _ from 'lodash';
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { FormikErrors } from 'formik';
|
|
||||||
|
|
||||||
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
|
||||||
import { BoxSelector, buildOption } from '@/portainer/components/BoxSelector';
|
|
||||||
import { ownershipIcon } from '@/portainer/filters/filters';
|
|
||||||
import { useUser } from '@/portainer/hooks/useUser';
|
|
||||||
import { Team } from '@/portainer/teams/types';
|
|
||||||
import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types';
|
|
||||||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
|
||||||
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
|
||||||
|
|
||||||
import { FormError } from '../form-components/FormError';
|
|
||||||
|
|
||||||
import { AccessControlFormData } from './model';
|
|
||||||
import { UsersField } from './UsersField';
|
|
||||||
import { TeamsField } from './TeamsField';
|
|
||||||
import { useLoadState } from './useLoadState';
|
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
values: AccessControlFormData;
|
|
||||||
onChange(values: AccessControlFormData): void;
|
|
||||||
hideTitle?: boolean;
|
|
||||||
errors?: FormikErrors<AccessControlFormData>;
|
|
||||||
formNamespace?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AccessControlForm({
|
|
||||||
values,
|
|
||||||
onChange,
|
|
||||||
hideTitle,
|
|
||||||
errors,
|
|
||||||
formNamespace,
|
|
||||||
}: Props) {
|
|
||||||
const { users, teams, isLoading } = useLoadState();
|
|
||||||
|
|
||||||
const { user } = useUser();
|
|
||||||
const isAdmin = user?.Role === 1;
|
|
||||||
|
|
||||||
const options = useOptions(isAdmin, teams);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(partialValues: Partial<typeof values>) => {
|
|
||||||
onChange({ ...values, ...partialValues });
|
|
||||||
},
|
|
||||||
|
|
||||||
[values, onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading || !teams || !users) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!hideTitle && <FormSectionTitle>Access control</FormSectionTitle>}
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<div className="col-sm-12">
|
|
||||||
<SwitchField
|
|
||||||
dataCy="portainer-accessMgmtToggle"
|
|
||||||
checked={values.accessControlEnabled}
|
|
||||||
name={withNamespace('accessControlEnabled')}
|
|
||||||
label="Enable access control"
|
|
||||||
tooltip="When enabled, you can restrict the access and management of this resource."
|
|
||||||
onChange={(accessControlEnabled) =>
|
|
||||||
handleChange({ accessControlEnabled })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{values.accessControlEnabled && (
|
|
||||||
<>
|
|
||||||
<div className="form-group">
|
|
||||||
<BoxSelector
|
|
||||||
radioName={withNamespace('ownership')}
|
|
||||||
value={values.ownership}
|
|
||||||
options={options}
|
|
||||||
onChange={(ownership) => handleChange({ ownership })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{values.ownership === RCO.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 useOptions(isAdmin: boolean, teams?: Team[]) {
|
|
||||||
const [options, setOptions] = useState<Array<BoxSelectorOption<RCO>>>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOptions(isAdmin ? adminOptions() : nonAdminOptions(teams));
|
|
||||||
}, [isAdmin, teams]);
|
|
||||||
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminOptions() {
|
|
||||||
return [
|
|
||||||
buildOption(
|
|
||||||
'access_administrators',
|
|
||||||
ownershipIcon('administrators'),
|
|
||||||
'Administrators',
|
|
||||||
'I want to restrict the management of this resource to administrators only',
|
|
||||||
RCO.ADMINISTRATORS
|
|
||||||
),
|
|
||||||
buildOption(
|
|
||||||
'access_restricted',
|
|
||||||
ownershipIcon('restricted'),
|
|
||||||
'Restricted',
|
|
||||||
'I want to restrict the management of this resource to a set of users and/or teams',
|
|
||||||
RCO.RESTRICTED
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
function nonAdminOptions(teams?: Team[]) {
|
|
||||||
return _.compact([
|
|
||||||
buildOption(
|
|
||||||
'access_private',
|
|
||||||
ownershipIcon('private'),
|
|
||||||
'Private',
|
|
||||||
'I want to this resource to be manageable by myself only',
|
|
||||||
RCO.PRIVATE
|
|
||||||
),
|
|
||||||
teams &&
|
|
||||||
teams.length > 0 &&
|
|
||||||
buildOption(
|
|
||||||
'access_restricted',
|
|
||||||
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',
|
|
||||||
RCO.RESTRICTED
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}
|
|
|
@ -1,118 +0,0 @@
|
||||||
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
|
||||||
|
|
||||||
import { validationSchema } from './AccessControlForm.validation';
|
|
||||||
|
|
||||||
test('when access control is disabled, should be valid', async () => {
|
|
||||||
const schema = validationSchema(true);
|
|
||||||
const object = { accessControlEnabled: false };
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
schema.validate(object, { strict: true })
|
|
||||||
).resolves.toStrictEqual(object);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when only access control is enabled, should be invalid', async () => {
|
|
||||||
const schema = validationSchema(true);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
schema.validate({ accessControlEnabled: true }, { strict: true })
|
|
||||||
).rejects.toThrowErrorMatchingSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when access control is enabled and ownership not restricted, should be valid', async () => {
|
|
||||||
const schema = validationSchema(true);
|
|
||||||
[
|
|
||||||
ResourceControlOwnership.ADMINISTRATORS,
|
|
||||||
ResourceControlOwnership.PRIVATE,
|
|
||||||
ResourceControlOwnership.PUBLIC,
|
|
||||||
].forEach(async (ownership) => {
|
|
||||||
const object = { accessControlEnabled: false, ownership };
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
schema.validate(object, { strict: true })
|
|
||||||
).resolves.toStrictEqual(object);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when access control is enabled, 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(
|
|
||||||
{
|
|
||||||
accessControlEnabled: true,
|
|
||||||
ownership: ResourceControlOwnership.RESTRICTED,
|
|
||||||
authorizedTeams: [],
|
|
||||||
authorizedUsers: [],
|
|
||||||
},
|
|
||||||
{ strict: true }
|
|
||||||
)
|
|
||||||
).rejects.toThrowErrorMatchingSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when access control is enabled, ownership is restricted, user is admin should have either teams or users', async () => {
|
|
||||||
const schema = validationSchema(true);
|
|
||||||
const teams = {
|
|
||||||
accessControlEnabled: true,
|
|
||||||
ownership: ResourceControlOwnership.RESTRICTED,
|
|
||||||
authorizedTeams: [1],
|
|
||||||
authorizedUsers: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(schema.validate(teams, { strict: true })).resolves.toStrictEqual(
|
|
||||||
teams
|
|
||||||
);
|
|
||||||
|
|
||||||
const users = {
|
|
||||||
accessControlEnabled: true,
|
|
||||||
ownership: ResourceControlOwnership.RESTRICTED,
|
|
||||||
authorizedTeams: [],
|
|
||||||
authorizedUsers: [1],
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(schema.validate(users, { strict: true })).resolves.toStrictEqual(
|
|
||||||
users
|
|
||||||
);
|
|
||||||
|
|
||||||
const both = {
|
|
||||||
accessControlEnabled: true,
|
|
||||||
ownership: ResourceControlOwnership.RESTRICTED,
|
|
||||||
authorizedTeams: [1],
|
|
||||||
authorizedUsers: [2],
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(schema.validate(both, { strict: true })).resolves.toStrictEqual(
|
|
||||||
both
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when access control is enabled, ownership is restricted, user is admin with teams and users, should be valid', async () => {
|
|
||||||
const schema = validationSchema(false);
|
|
||||||
|
|
||||||
const object = {
|
|
||||||
accessControlEnabled: true,
|
|
||||||
ownership: ResourceControlOwnership.RESTRICTED,
|
|
||||||
authorizedTeams: [1],
|
|
||||||
authorizedUsers: [1],
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
schema.validate(object, { strict: true })
|
|
||||||
).resolves.toStrictEqual(object);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when access control is enabled, ownership is restricted, user is not admin with teams, should be valid', async () => {
|
|
||||||
const schema = validationSchema(false);
|
|
||||||
|
|
||||||
const object = {
|
|
||||||
accessControlEnabled: true,
|
|
||||||
ownership: ResourceControlOwnership.RESTRICTED,
|
|
||||||
authorizedTeams: [1],
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
schema.validate(object, { strict: true })
|
|
||||||
).resolves.toStrictEqual(object);
|
|
||||||
});
|
|
|
@ -1,7 +0,0 @@
|
||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 1`] = `"You must specify at least one team or user."`;
|
|
||||||
|
|
||||||
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 2`] = `"You must specify at least one team."`;
|
|
||||||
|
|
||||||
exports[`when only access control is enabled, should be invalid 1`] = `"ownership is a required field"`;
|
|
|
@ -1,61 +0,0 @@
|
||||||
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
|
||||||
import {
|
|
||||||
ResourceControlType,
|
|
||||||
ResourceControlViewModel,
|
|
||||||
} from '@/portainer/models/resourceControl/resourceControl';
|
|
||||||
|
|
||||||
import { parseFromResourceControl } from './model';
|
|
||||||
|
|
||||||
test('when resource control supplied, if user is not admin, will change ownership to rc ownership', () => {
|
|
||||||
[RCO.ADMINISTRATORS, RCO.RESTRICTED, RCO.PUBLIC, RCO.PRIVATE].forEach(
|
|
||||||
(ownership) => {
|
|
||||||
const resourceControl = buildResourceControl(ownership);
|
|
||||||
|
|
||||||
const actual = parseFromResourceControl(false, resourceControl.Ownership);
|
|
||||||
expect(actual.ownership).toBe(resourceControl.Ownership);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when resource control supplied and user is admin, if resource ownership is not private , will change ownership to rc ownership', () => {
|
|
||||||
[RCO.ADMINISTRATORS, RCO.RESTRICTED, RCO.PUBLIC].forEach((ownership) => {
|
|
||||||
const resourceControl = buildResourceControl(ownership);
|
|
||||||
|
|
||||||
const actual = parseFromResourceControl(true, resourceControl.Ownership);
|
|
||||||
expect(actual.ownership).toBe(resourceControl.Ownership);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when resource control supplied, if ownership is public, will disabled access control', () => {
|
|
||||||
const resourceControl = buildResourceControl(RCO.PUBLIC);
|
|
||||||
|
|
||||||
const actual = parseFromResourceControl(false, resourceControl.Ownership);
|
|
||||||
|
|
||||||
expect(actual.accessControlEnabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when isAdmin and resource control not supplied, ownership should be set to Administrator', () => {
|
|
||||||
const actual = parseFromResourceControl(true);
|
|
||||||
|
|
||||||
expect(actual.ownership).toBe(RCO.ADMINISTRATORS);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when resource control supplied, if user is admin and resource ownership is private, will change ownership to restricted', () => {
|
|
||||||
const resourceControl = buildResourceControl(RCO.PRIVATE);
|
|
||||||
|
|
||||||
const actual = parseFromResourceControl(true, resourceControl.Ownership);
|
|
||||||
expect(actual.ownership).toBe(RCO.RESTRICTED);
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildResourceControl(ownership: RCO): ResourceControlViewModel {
|
|
||||||
return {
|
|
||||||
UserAccesses: [],
|
|
||||||
TeamAccesses: [],
|
|
||||||
Ownership: ownership,
|
|
||||||
Id: 1,
|
|
||||||
Public: false,
|
|
||||||
ResourceId: 1,
|
|
||||||
System: false,
|
|
||||||
Type: ResourceControlType.Config,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
import { ResourceControlOwnership } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
|
||||||
|
|
||||||
import { TeamId } from '@/portainer/teams/types';
|
|
||||||
import { UserId } from '@/portainer/users/types';
|
|
||||||
|
|
||||||
export class AccessControlFormData {
|
|
||||||
accessControlEnabled = true;
|
|
||||||
|
|
||||||
ownership = ResourceControlOwnership.PRIVATE;
|
|
||||||
|
|
||||||
authorizedUsers: UserId[] = [];
|
|
||||||
|
|
||||||
authorizedTeams: TeamId[] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseFromResourceControl(
|
|
||||||
isAdmin: boolean,
|
|
||||||
resourceControlOwnership?: ResourceControlOwnership
|
|
||||||
): AccessControlFormData {
|
|
||||||
const formData = new AccessControlFormData();
|
|
||||||
|
|
||||||
if (resourceControlOwnership) {
|
|
||||||
let ownership = resourceControlOwnership;
|
|
||||||
if (isAdmin && ownership === ResourceControlOwnership.PRIVATE) {
|
|
||||||
ownership = ResourceControlOwnership.RESTRICTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
let accessControl = formData.accessControlEnabled;
|
|
||||||
if (ownership === ResourceControlOwnership.PUBLIC) {
|
|
||||||
accessControl = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
formData.ownership = ownership;
|
|
||||||
formData.accessControlEnabled = accessControl;
|
|
||||||
} else if (isAdmin) {
|
|
||||||
formData.ownership = ResourceControlOwnership.ADMINISTRATORS;
|
|
||||||
}
|
|
||||||
|
|
||||||
return formData;
|
|
||||||
}
|
|
|
@ -1,5 +1,5 @@
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||||
|
|
||||||
angular.module('portainer.app').controller('porAccessControlFormController', [
|
angular.module('portainer.app').controller('porAccessControlFormController', [
|
||||||
'$q',
|
'$q',
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated use only for angularjs components. For react components use ./model.ts
|
* @deprecated use only for angularjs components. For react components use ./model.ts
|
||||||
|
|
|
@ -1,45 +0,0 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useQuery } from 'react-query';
|
|
||||||
|
|
||||||
import { getTeams } from '@/portainer/teams/teams.service';
|
|
||||||
import * as notifications from '@/portainer/services/notifications';
|
|
||||||
import { getUsers } from '@/portainer/services/api/userService';
|
|
||||||
import { UserViewModel } from '@/portainer/models/user';
|
|
||||||
|
|
||||||
export function useLoadState() {
|
|
||||||
const { teams, isLoading: isLoadingTeams } = useTeams();
|
|
||||||
|
|
||||||
const { users, isLoading: isLoadingUsers } = useUsers();
|
|
||||||
|
|
||||||
return { teams, users, isLoading: isLoadingTeams || isLoadingUsers };
|
|
||||||
}
|
|
||||||
|
|
||||||
function useTeams() {
|
|
||||||
const { isError, error, isLoading, data } = useQuery('teams', () =>
|
|
||||||
getTeams()
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isError) {
|
|
||||||
notifications.error('Failure', error as Error, 'Failed retrieving teams');
|
|
||||||
}
|
|
||||||
}, [isError, error]);
|
|
||||||
|
|
||||||
return { isLoading, teams: data };
|
|
||||||
}
|
|
||||||
|
|
||||||
function useUsers() {
|
|
||||||
const { isError, error, isLoading, data } = useQuery<
|
|
||||||
unknown,
|
|
||||||
unknown,
|
|
||||||
UserViewModel[]
|
|
||||||
>('users', () => getUsers());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isError) {
|
|
||||||
notifications.error('Failure', error as Error, 'Failed retrieving users');
|
|
||||||
}
|
|
||||||
}, [isError, error]);
|
|
||||||
|
|
||||||
return { isLoading, users: data };
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
angular.module('portainer.app').component('porAccessControlPanel', {
|
|
||||||
templateUrl: './porAccessControlPanel.html',
|
|
||||||
controller: 'porAccessControlPanelController',
|
|
||||||
bindings: {
|
|
||||||
// The component will use this identifier when updating the resource control object.
|
|
||||||
resourceId: '<',
|
|
||||||
// The component will display information about this resource control object.
|
|
||||||
resourceControl: '=',
|
|
||||||
// This component is usually displayed inside a resource-details view.
|
|
||||||
// This variable specifies the type of the associated resource.
|
|
||||||
// Accepted values: 'container', 'service' or 'volume'.
|
|
||||||
resourceType: '<',
|
|
||||||
// Allow to disable the Ownership edition based on non resource control data
|
|
||||||
disableOwnershipChange: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,215 +0,0 @@
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-12" ng-if="$ctrl.state.displayAccessControlPanel">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-header icon="fa-eye" title-text="Access control"></rd-widget-header>
|
|
||||||
<rd-widget-body classes="no-padding">
|
|
||||||
<table class="table">
|
|
||||||
<tbody>
|
|
||||||
<!-- ownership -->
|
|
||||||
<tr data-cy="access-ownership">
|
|
||||||
<td>Ownership</td>
|
|
||||||
<td>
|
|
||||||
<i ng-class="$ctrl.resourceControl.Ownership | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
|
||||||
<span ng-if="!$ctrl.resourceControl">
|
|
||||||
administrators
|
|
||||||
<portainer-tooltip message="This resource can only be managed by administrators." position="bottom" style="margin-left: -3px"></portainer-tooltip>
|
|
||||||
</span>
|
|
||||||
<span ng-if="$ctrl.resourceControl">
|
|
||||||
{{ $ctrl.resourceControl.Ownership }}
|
|
||||||
<portainer-tooltip
|
|
||||||
ng-if="$ctrl.resourceControl.Ownership === $ctrl.RCO.PUBLIC"
|
|
||||||
message="This resource can be managed by any user with access to this environment."
|
|
||||||
position="bottom"
|
|
||||||
style="margin-left: -3px"
|
|
||||||
></portainer-tooltip>
|
|
||||||
<portainer-tooltip
|
|
||||||
ng-if="$ctrl.resourceControl.Ownership === $ctrl.RCO.PRIVATE"
|
|
||||||
message="Management of this resource is restricted to a single user."
|
|
||||||
position="bottom"
|
|
||||||
style="margin-left: -3px"
|
|
||||||
></portainer-tooltip>
|
|
||||||
<portainer-tooltip
|
|
||||||
ng-if="$ctrl.resourceControl.Ownership === $ctrl.RCO.RESTRICTED"
|
|
||||||
message="This resource can be managed by a restricted set of users and/or teams."
|
|
||||||
position="bottom"
|
|
||||||
style="margin-left: -3px"
|
|
||||||
></portainer-tooltip>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- !ownership -->
|
|
||||||
<tr ng-if="$ctrl.resourceControl.Type === $ctrl.RCTI.SERVICE && $ctrl.resourceType === $ctrl.RCTS.CONTAINER">
|
|
||||||
<td colspan="2">
|
|
||||||
<i class="fa fa-info-circle" aria-hidden="true" style="margin-right: 2px"></i>
|
|
||||||
Access control on this resource is inherited from the following service:
|
|
||||||
<a ui-sref="docker.services.service({ id: $ctrl.resourceControl.ResourceId })">{{ $ctrl.resourceControl.ResourceId | truncate }}</a>
|
|
||||||
<portainer-tooltip
|
|
||||||
message="Access control applied on a service is also applied on each container of that service."
|
|
||||||
position="bottom"
|
|
||||||
style="margin-left: 2px"
|
|
||||||
></portainer-tooltip>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="$ctrl.resourceControl.Type === $ctrl.RCTI.CONTAINER && $ctrl.resourceType === $ctrl.RCTS.VOLUME">
|
|
||||||
<td colspan="2">
|
|
||||||
<i class="fa fa-info-circle" aria-hidden="true" style="margin-right: 2px"></i>
|
|
||||||
Access control on this resource is inherited from the following container:
|
|
||||||
<a ui-sref="docker.containers.container({ id: $ctrl.resourceControl.ResourceId })">{{ $ctrl.resourceControl.ResourceId | truncate }}</a>
|
|
||||||
<portainer-tooltip
|
|
||||||
message="Access control applied on a container created using a template is also applied on each volume associated to the container."
|
|
||||||
position="bottom"
|
|
||||||
style="margin-left: 2px"
|
|
||||||
></portainer-tooltip>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="$ctrl.resourceControl.Type === $ctrl.RCTI.STACK && $ctrl.resourceType !== $ctrl.RCTS.STACK">
|
|
||||||
<td colspan="2">
|
|
||||||
<i class="fa fa-info-circle" aria-hidden="true" style="margin-right: 2px"></i>
|
|
||||||
Access control on this resource is inherited from the following stack: {{ $ctrl.removeEndpointId($ctrl.resourceControl.ResourceId) }}
|
|
||||||
<portainer-tooltip
|
|
||||||
message="Access control applied on a stack is also applied on each resource in the stack."
|
|
||||||
position="bottom"
|
|
||||||
style="margin-left: 2px"
|
|
||||||
></portainer-tooltip>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- authorized-users -->
|
|
||||||
<tr ng-if="$ctrl.resourceControl.UserAccesses.length > 0" data-cy="access-authorisedUsers">
|
|
||||||
<td>Authorized users</td>
|
|
||||||
<td>
|
|
||||||
<span ng-repeat="user in $ctrl.authorizedUsers">{{ user.Username }}{{ $last ? '' : ', ' }} </span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- !authorized-users -->
|
|
||||||
<!-- authorized-teams -->
|
|
||||||
<tr ng-if="$ctrl.resourceControl.TeamAccesses.length > 0" data-cy="access-authorisedTeams">
|
|
||||||
<td>Authorized teams</td>
|
|
||||||
<td>
|
|
||||||
<span ng-repeat="team in $ctrl.authorizedTeams">{{ team.Name }}{{ $last ? '' : ', ' }} </span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- !authorized-teams -->
|
|
||||||
<!-- edit-ownership -->
|
|
||||||
<tr ng-if="$ctrl.canEditOwnership();">
|
|
||||||
<td colspan="2">
|
|
||||||
<a ng-click="$ctrl.state.editOwnership = true"><i class="fa fa-edit space-right" aria-hidden="true"></i>Change ownership</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- !edit-ownership -->
|
|
||||||
<!-- edit-ownership-choices -->
|
|
||||||
<tr ng-if="$ctrl.state.editOwnership">
|
|
||||||
<td colspan="2" style="white-space: inherit">
|
|
||||||
<div class="boxselector_wrapper">
|
|
||||||
<div ng-if="$ctrl.isAdmin">
|
|
||||||
<input type="radio" id="access_administrators" ng-model="$ctrl.formValues.Ownership" ng-value="$ctrl.RCO.ADMINISTRATORS" />
|
|
||||||
<label for="access_administrators">
|
|
||||||
<div class="boxselector_header">
|
|
||||||
<i ng-class="'administrators' | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
|
||||||
Administrators
|
|
||||||
</div>
|
|
||||||
<p>I want to restrict the management of this resource to administrators only</p>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div ng-if="$ctrl.isAdmin">
|
|
||||||
<input type="radio" id="access_restricted" ng-model="$ctrl.formValues.Ownership" ng-value="$ctrl.RCO.RESTRICTED" />
|
|
||||||
<label for="access_restricted">
|
|
||||||
<div class="boxselector_header">
|
|
||||||
<i ng-class="'restricted' | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
|
||||||
Restricted
|
|
||||||
</div>
|
|
||||||
<p> I want to restrict the management of this resource to a set of users and/or teams </p>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div ng-if="!$ctrl.isAdmin && $ctrl.state.canChangeOwnershipToTeam && $ctrl.availableTeams.length > 0">
|
|
||||||
<input type="radio" id="access_restricted" ng-model="$ctrl.formValues.Ownership" ng-value="$ctrl.RCO.RESTRICTED" />
|
|
||||||
<label for="access_restricted">
|
|
||||||
<div class="boxselector_header">
|
|
||||||
<i ng-class="'restricted' | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
|
||||||
Restricted
|
|
||||||
</div>
|
|
||||||
<p ng-if="$ctrl.availableTeams.length === 1">
|
|
||||||
I want any member of my team (<b>{{ $ctrl.availableTeams[0].Name }}</b
|
|
||||||
>) to be able to manage this resource
|
|
||||||
</p>
|
|
||||||
<p ng-if="$ctrl.availableTeams.length > 1"> I want to restrict the management of this resource to one or more of my teams </p>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input type="radio" id="access_public" ng-model="$ctrl.formValues.Ownership" ng-value="$ctrl.RCO.PUBLIC" />
|
|
||||||
<label for="access_public">
|
|
||||||
<div class="boxselector_header">
|
|
||||||
<i ng-class="'public' | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
|
||||||
Public
|
|
||||||
</div>
|
|
||||||
<p>I want any user with access to this environment to be able to manage this resource</p>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- edit-ownership-choices -->
|
|
||||||
<!-- select-teams -->
|
|
||||||
<tr ng-if="$ctrl.state.editOwnership && $ctrl.formValues.Ownership === $ctrl.RCO.RESTRICTED && ($ctrl.isAdmin || (!$ctrl.isAdmin && $ctrl.availableTeams.length > 1))">
|
|
||||||
<td colspan="2">
|
|
||||||
<span>Teams</span>
|
|
||||||
<span ng-if="$ctrl.isAdmin && $ctrl.availableTeams.length === 0" class="small text-muted" style="margin-left: 10px">
|
|
||||||
You have not yet created any teams. Head over to the <a ui-sref="portainer.teams">Teams view</a> to manage teams.
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
isteven-multi-select
|
|
||||||
ng-if="($ctrl.isAdmin && $ctrl.availableTeams.length > 0) || (!$ctrl.isAdmin && $ctrl.availableTeams.length > 1)"
|
|
||||||
input-model="$ctrl.availableTeams"
|
|
||||||
output-model="$ctrl.formValues.Ownership_Teams"
|
|
||||||
button-label="Name"
|
|
||||||
item-label="Name"
|
|
||||||
tick-property="selected"
|
|
||||||
helper-elements="filter"
|
|
||||||
search-property="Name"
|
|
||||||
max-labels="3"
|
|
||||||
translation="{nothingSelected: 'Select one or more teams', search: 'Search...'}"
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- !select-teams -->
|
|
||||||
<!-- select-users -->
|
|
||||||
<tr ng-if="$ctrl.isAdmin && $ctrl.state.editOwnership && $ctrl.formValues.Ownership === $ctrl.RCO.RESTRICTED">
|
|
||||||
<td colspan="2">
|
|
||||||
<span>Users</span>
|
|
||||||
<span ng-if="$ctrl.availableUsers.length === 0" class="small text-muted" style="margin-left: 10px">
|
|
||||||
You have not yet created any users. Head over to the <a ui-sref="portainer.users">Users view</a> to manage users.
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
isteven-multi-select
|
|
||||||
ng-if="$ctrl.availableUsers.length > 0"
|
|
||||||
input-model="$ctrl.availableUsers"
|
|
||||||
output-model="$ctrl.formValues.Ownership_Users"
|
|
||||||
button-label="Username"
|
|
||||||
item-label="Username"
|
|
||||||
tick-property="selected"
|
|
||||||
helper-elements="filter"
|
|
||||||
search-property="Username"
|
|
||||||
max-labels="3"
|
|
||||||
translation="{nothingSelected: 'Select one or more users', search: 'Search...'}"
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- !select-users -->
|
|
||||||
<!-- ownership-actions -->
|
|
||||||
<tr ng-if="$ctrl.state.editOwnership">
|
|
||||||
<td colspan="2">
|
|
||||||
<div>
|
|
||||||
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.state.editOwnership = false">Cancel</a>
|
|
||||||
<a type="button" class="btn btn-primary btn-sm" ng-click="$ctrl.confirmUpdateOwnership()">Update ownership</a>
|
|
||||||
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px">{{ $ctrl.state.formValidationError }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- !ownership-actions -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,154 +0,0 @@
|
||||||
import _ from 'lodash-es';
|
|
||||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
|
||||||
import { ResourceControlTypeInt as RCTI, ResourceControlTypeString as RCTS } from 'Portainer/models/resourceControl/resourceControlTypes';
|
|
||||||
import { AccessControlPanelData } from './porAccessControlPanelModel';
|
|
||||||
|
|
||||||
angular.module('portainer.app').controller('porAccessControlPanelController', [
|
|
||||||
'$q',
|
|
||||||
'$state',
|
|
||||||
'UserService',
|
|
||||||
'TeamService',
|
|
||||||
'ResourceControlHelper',
|
|
||||||
'ResourceControlService',
|
|
||||||
'Notifications',
|
|
||||||
'Authentication',
|
|
||||||
'ModalService',
|
|
||||||
'FormValidator',
|
|
||||||
function ($q, $state, UserService, TeamService, ResourceControlHelper, ResourceControlService, Notifications, Authentication, ModalService, FormValidator) {
|
|
||||||
var ctrl = this;
|
|
||||||
|
|
||||||
ctrl.RCO = RCO;
|
|
||||||
ctrl.RCTS = RCTS;
|
|
||||||
ctrl.RCTI = RCTI;
|
|
||||||
ctrl.state = {
|
|
||||||
displayAccessControlPanel: false,
|
|
||||||
canEditOwnership: false,
|
|
||||||
editOwnership: false,
|
|
||||||
formValidationError: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
ctrl.formValues = new AccessControlPanelData();
|
|
||||||
|
|
||||||
ctrl.authorizedUsers = [];
|
|
||||||
ctrl.availableUsers = [];
|
|
||||||
ctrl.authorizedTeams = [];
|
|
||||||
ctrl.availableTeams = [];
|
|
||||||
|
|
||||||
ctrl.canEditOwnership = function () {
|
|
||||||
const hasRC = ctrl.resourceControl;
|
|
||||||
const inheritedVolume = hasRC && ctrl.resourceControl.Type === RCTI.CONTAINER && ctrl.resourceType === RCTS.VOLUME;
|
|
||||||
const inheritedContainer = hasRC && ctrl.resourceControl.Type === RCTI.SERVICE && ctrl.resourceType === RCTS.CONTAINER;
|
|
||||||
const inheritedFromStack = hasRC && ctrl.resourceControl.Type === RCTI.STACK && ctrl.resourceType !== RCTS.STACK;
|
|
||||||
const hasSpecialDisable = ctrl.disableOwnershipChange;
|
|
||||||
|
|
||||||
return !inheritedVolume && !inheritedContainer && !inheritedFromStack && !hasSpecialDisable && !ctrl.state.editOwnership && (ctrl.isAdmin || ctrl.state.canEditOwnership);
|
|
||||||
};
|
|
||||||
|
|
||||||
ctrl.confirmUpdateOwnership = function () {
|
|
||||||
if (!validateForm()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ModalService.confirmAccessControlUpdate(function (confirmed) {
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateOwnership();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
ctrl.removeEndpointId = function (stackName) {
|
|
||||||
if (!stackName) {
|
|
||||||
return stackName;
|
|
||||||
}
|
|
||||||
const firstUnderlineIndex = stackName.indexOf('_');
|
|
||||||
if (firstUnderlineIndex < 0) {
|
|
||||||
return stackName;
|
|
||||||
}
|
|
||||||
return stackName.substring(firstUnderlineIndex + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
function validateForm() {
|
|
||||||
ctrl.state.formValidationError = '';
|
|
||||||
var error = '';
|
|
||||||
|
|
||||||
var accessControlData = {
|
|
||||||
AccessControlEnabled: ctrl.formValues.Ownership === RCO.PUBLIC ? false : true,
|
|
||||||
Ownership: ctrl.formValues.Ownership,
|
|
||||||
AuthorizedUsers: ctrl.formValues.Ownership_Users,
|
|
||||||
AuthorizedTeams: ctrl.formValues.Ownership_Teams,
|
|
||||||
};
|
|
||||||
var isAdmin = ctrl.isAdmin;
|
|
||||||
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
|
|
||||||
if (error) {
|
|
||||||
ctrl.state.formValidationError = error;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateOwnership() {
|
|
||||||
ResourceControlService.applyResourceControlChange(ctrl.resourceType, ctrl.resourceId, ctrl.resourceControl, ctrl.formValues)
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('Access control successfully updated');
|
|
||||||
$state.reload();
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to update access control');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$onInit = $onInit;
|
|
||||||
function $onInit() {
|
|
||||||
var userDetails = Authentication.getUserDetails();
|
|
||||||
var isAdmin = Authentication.isAdmin();
|
|
||||||
var userId = userDetails.ID;
|
|
||||||
ctrl.isAdmin = isAdmin;
|
|
||||||
var resourceControl = ctrl.resourceControl;
|
|
||||||
|
|
||||||
if (isAdmin && resourceControl) {
|
|
||||||
ctrl.formValues.Ownership = resourceControl.Ownership === RCO.PRIVATE ? RCO.RESTRICTED : resourceControl.Ownership;
|
|
||||||
} else {
|
|
||||||
ctrl.formValues.Ownership = RCO.ADMINISTRATORS;
|
|
||||||
}
|
|
||||||
|
|
||||||
ResourceControlService.retrieveOwnershipDetails(resourceControl)
|
|
||||||
.then(function success(data) {
|
|
||||||
ctrl.authorizedUsers = data.authorizedUsers;
|
|
||||||
ctrl.authorizedTeams = data.authorizedTeams;
|
|
||||||
return ResourceControlService.retrieveUserPermissionsOnResource(userId, isAdmin, resourceControl);
|
|
||||||
})
|
|
||||||
.then(function success(data) {
|
|
||||||
ctrl.state.canEditOwnership = data.isPartOfRestrictedUsers || data.isLeaderOfAnyRestrictedTeams;
|
|
||||||
ctrl.state.canChangeOwnershipToTeam = data.isPartOfRestrictedUsers;
|
|
||||||
|
|
||||||
return $q.all({
|
|
||||||
availableUsers: isAdmin ? UserService.users(false) : [],
|
|
||||||
availableTeams: isAdmin || data.isPartOfRestrictedUsers ? TeamService.teams() : [],
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then(function success(data) {
|
|
||||||
ctrl.availableUsers = _.orderBy(data.availableUsers, 'Username', 'asc');
|
|
||||||
angular.forEach(ctrl.availableUsers, function (user) {
|
|
||||||
var found = _.find(ctrl.authorizedUsers, { Id: user.Id });
|
|
||||||
if (found) {
|
|
||||||
user.selected = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ctrl.availableTeams = _.orderBy(data.availableTeams, 'Name', 'asc');
|
|
||||||
angular.forEach(data.availableTeams, function (team) {
|
|
||||||
var found = _.find(ctrl.authorizedTeams, { Id: team.Id });
|
|
||||||
if (found) {
|
|
||||||
team.selected = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (data.availableTeams.length === 1) {
|
|
||||||
ctrl.formValues.Ownership_Teams.push(data.availableTeams[0]);
|
|
||||||
}
|
|
||||||
ctrl.state.displayAccessControlPanel = true;
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to retrieve access control information');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]);
|
|
|
@ -1,7 +0,0 @@
|
||||||
import { ResourceControlOwnership } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
|
||||||
|
|
||||||
export function AccessControlPanelData() {
|
|
||||||
this.Ownership = ResourceControlOwnership.ADMINISTRATORS;
|
|
||||||
this.Ownership_Users = [];
|
|
||||||
this.Ownership_Teams = [];
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
import './datatable.css';
|
import './datatable.css';
|
||||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||||
|
|
||||||
function isBetween(value, a, b) {
|
function isBetween(value, a, b) {
|
||||||
return (value >= a && value <= b) || (value >= b && value <= a);
|
return (value >= a && value <= b) || (value >= b && value <= a);
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
.root :global .selector__control {
|
||||||
|
border: 1px solid var(--border-multiselect);
|
||||||
|
background-color: var(--bg-multiselect-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global .selector__multi-value {
|
||||||
|
background-color: var(--grey-51);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global :root[theme='dark'] :local .root :global .selector__multi-value {
|
||||||
|
background-color: var(--grey-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global :root[theme='highcontrast'] :local .root :global .selector__multi-value {
|
||||||
|
background-color: var(--grey-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global .selector__multi-value__label {
|
||||||
|
color: var(--black-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global :root[theme='dark'] :local .root :global .selector__multi-value__label {
|
||||||
|
color: var(--white-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global :root[theme='highcontrast'] :local .root :global .selector__multi-value__label {
|
||||||
|
color: var(--white-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global .selector__menu {
|
||||||
|
background-color: var(--bg-multiselect-color);
|
||||||
|
border: 1px solid var(--border-multiselect);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global .selector__option {
|
||||||
|
background-color: var(--bg-multiselect-color);
|
||||||
|
border: 1px solid var(--border-multiselect);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global .selector__option:active,
|
||||||
|
.root :global .selector__option--is-focused {
|
||||||
|
background-color: var(--blue-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global :root[theme='dark'] :local .root :global .selector__option:active,
|
||||||
|
:global :root[theme='dark'] :local .root :global .selector__option--is-focused {
|
||||||
|
background-color: var(--blue-2);
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import ReactSelect, { GroupBase, Props as SelectProps } from 'react-select';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { RefAttributes } from 'react';
|
||||||
|
import ReactSelectType from 'react-select/dist/declarations/src/Select';
|
||||||
|
|
||||||
|
import styles from './ReactSelect.module.css';
|
||||||
|
|
||||||
|
export function Select<
|
||||||
|
Option = unknown,
|
||||||
|
IsMulti extends boolean = false,
|
||||||
|
Group extends GroupBase<Option> = GroupBase<Option>
|
||||||
|
>({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectProps<Option, IsMulti, Group> &
|
||||||
|
RefAttributes<ReactSelectType<Option, IsMulti, Group>>) {
|
||||||
|
return (
|
||||||
|
<ReactSelect
|
||||||
|
className={clsx(styles.root, className)}
|
||||||
|
classNamePrefix="selector"
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -45,7 +45,7 @@ export function WidgetTitle({
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<span className={clsx('pull-left', className)}>
|
<span className={clsx('pull-left', className)}>
|
||||||
{typeof icon === 'string' ? <i className={clsx('fa', icon)} /> : icon}
|
{typeof icon === 'string' ? <i className={clsx('fa', icon)} /> : icon}
|
||||||
{title}
|
<span>{title}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={clsx('pull-right', className)}>{children}</span>
|
<span className={clsx('pull-right', className)}>{children}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,13 +1,19 @@
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { TeamId } from '@/portainer/teams/types';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
import {
|
import { EnvironmentId } from '../types';
|
||||||
EnvironmentId,
|
|
||||||
TeamAccessPolicies,
|
|
||||||
UserAccessPolicies,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
import { buildUrl } from './utils';
|
import { buildUrl } from './utils';
|
||||||
|
|
||||||
|
export type RoleId = number;
|
||||||
|
interface AccessPolicy {
|
||||||
|
RoleId: RoleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserAccessPolicies = Record<UserId, AccessPolicy>; // map[UserID]AccessPolicy
|
||||||
|
type TeamAccessPolicies = Record<TeamId, AccessPolicy>;
|
||||||
|
|
||||||
export type RegistryId = number;
|
export type RegistryId = number;
|
||||||
export interface Registry {
|
export interface Registry {
|
||||||
Id: RegistryId;
|
Id: RegistryId;
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import { TagId } from '@/portainer/tags/types';
|
import { TagId } from '@/portainer/tags/types';
|
||||||
import { EnvironmentGroupId } from '@/portainer/environment-groups/types';
|
import { EnvironmentGroupId } from '@/portainer/environment-groups/types';
|
||||||
import { UserId } from '@/portainer/users/types';
|
|
||||||
import { TeamId } from '@/portainer/teams/types';
|
|
||||||
|
|
||||||
export type EnvironmentId = number;
|
export type EnvironmentId = number;
|
||||||
|
|
||||||
|
@ -113,10 +111,3 @@ export interface EnvironmentSettings {
|
||||||
// Whether host management features are enabled
|
// Whether host management features are enabled
|
||||||
enableHostManagementFeatures: boolean;
|
enableHostManagementFeatures: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoleId = number;
|
|
||||||
interface AccessPolicy {
|
|
||||||
RoleId: RoleId;
|
|
||||||
}
|
|
||||||
export type UserAccessPolicies = Record<UserId, AccessPolicy>;
|
|
||||||
export type TeamAccessPolicies = Record<TeamId, AccessPolicy>;
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import moment from 'moment';
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
import filesize from 'filesize';
|
import filesize from 'filesize';
|
||||||
|
|
||||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||||
|
|
||||||
export function truncateLeftRight(text, max, left, right) {
|
export function truncateLeftRight(text, max, left, right) {
|
||||||
max = isNaN(max) ? 50 : max;
|
max = isNaN(max) ? 50 : max;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||||
import { ResourceControlOwnershipParameters } from '../models/resourceControl/resourceControlOwnershipParameters';
|
import { ResourceControlOwnershipParameters } from '../models/resourceControl/resourceControlOwnershipParameters';
|
||||||
|
|
||||||
class ResourceControlHelper {
|
class ResourceControlHelper {
|
||||||
|
@ -68,19 +68,6 @@ class ResourceControlHelper {
|
||||||
return new ResourceControlOwnershipParameters(adminOnly, publicOnly, users, teams, subResources);
|
return new ResourceControlOwnershipParameters(adminOnly, publicOnly, users, teams, subResources);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform AccessControlPanelData to ResourceControlOwnershipParameters
|
|
||||||
* @param {AccessControlPanelData} formValues Form data (generated by AccessControlPanel)
|
|
||||||
*/
|
|
||||||
RCPanelDataToOwnershipParameters(formValues) {
|
|
||||||
const adminOnly = formValues.Ownership === RCO.ADMINISTRATORS;
|
|
||||||
const publicOnly = formValues.Ownership === RCO.PUBLIC;
|
|
||||||
const userIds = publicOnly || adminOnly ? [] : _.map(formValues.Ownership_Users, (user) => user.Id);
|
|
||||||
const teamIds = publicOnly || adminOnly ? [] : _.map(formValues.Ownership_Teams, (team) => team.Id);
|
|
||||||
|
|
||||||
return new ResourceControlOwnershipParameters(adminOnly, publicOnly, userIds, teamIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
retrieveAuthorizedUsers(resourceControl, users) {
|
retrieveAuthorizedUsers(resourceControl, users) {
|
||||||
const authorizedUsers = [];
|
const authorizedUsers = [];
|
||||||
_.forEach(resourceControl.UserAccesses, (access) => {
|
_.forEach(resourceControl.UserAccesses, (access) => {
|
||||||
|
@ -102,18 +89,6 @@ class ResourceControlHelper {
|
||||||
});
|
});
|
||||||
return authorizedTeams;
|
return authorizedTeams;
|
||||||
}
|
}
|
||||||
|
|
||||||
isLeaderOfAnyRestrictedTeams(userMemberships, resourceControl) {
|
|
||||||
let isTeamLeader = false;
|
|
||||||
_.forEach(userMemberships, (membership) => {
|
|
||||||
const found = _.find(resourceControl.TeamAccesses, { TeamId: membership.TeamId });
|
|
||||||
if (found && membership.Role === 1) {
|
|
||||||
isTeamLeader = true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return isTeamLeader;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ResourceControlHelper;
|
export default ResourceControlHelper;
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
import { UserViewModel } from '../models/user';
|
|
||||||
|
|
||||||
export function filterNonAdministratorUsers(users: UserViewModel[]) {
|
|
||||||
return users.filter((user) => user.Role !== 1);
|
|
||||||
}
|
|
|
@ -9,14 +9,13 @@ import {
|
||||||
useMemo,
|
useMemo,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { getUser } from '@/portainer/services/api/userService';
|
import { getUser } from '../users/user.service';
|
||||||
|
import { User, UserId } from '../users/types';
|
||||||
import { UserViewModel } from '../models/user';
|
|
||||||
|
|
||||||
import { useLocalStorage } from './useLocalStorage';
|
import { useLocalStorage } from './useLocalStorage';
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
user?: UserViewModel | null;
|
user?: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserContext = createContext<State | null>(null);
|
export const UserContext = createContext<State | null>(null);
|
||||||
|
@ -28,7 +27,10 @@ export function useUser() {
|
||||||
throw new Error('should be nested under UserProvider');
|
throw new Error('should be nested under UserProvider');
|
||||||
}
|
}
|
||||||
|
|
||||||
return context;
|
return useMemo(
|
||||||
|
() => ({ user: context.user, isAdmin: isAdmin(context.user) }),
|
||||||
|
[context.user]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuthorizations(
|
export function useAuthorizations(
|
||||||
|
@ -41,6 +43,10 @@ export function useAuthorizations(
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const { params } = useCurrentStateAndParams();
|
const { params } = useCurrentStateAndParams();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.PORTAINER_EDITION === 'CE') {
|
if (process.env.PORTAINER_EDITION === 'CE') {
|
||||||
return !adminOnlyCE || isAdmin(user);
|
return !adminOnlyCE || isAdmin(user);
|
||||||
}
|
}
|
||||||
|
@ -50,10 +56,6 @@ export function useAuthorizations(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAdmin(user)) {
|
if (isAdmin(user)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -88,7 +90,7 @@ interface UserProviderProps {
|
||||||
|
|
||||||
export function UserProvider({ children }: UserProviderProps) {
|
export function UserProvider({ children }: UserProviderProps) {
|
||||||
const [jwt] = useLocalStorage('JWT', '');
|
const [jwt] = useLocalStorage('JWT', '');
|
||||||
const [user, setUser] = useState<UserViewModel | null>(null);
|
const [user, setUser] = useState<User | undefined>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (jwt !== '') {
|
if (jwt !== '') {
|
||||||
|
@ -114,13 +116,13 @@ export function UserProvider({ children }: UserProviderProps) {
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
async function loadUser(id: number) {
|
async function loadUser(id: UserId) {
|
||||||
const user = await getUser(id);
|
const user = await getUser(id);
|
||||||
setUser(user);
|
setUser(user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAdmin(user?: UserViewModel | null): boolean {
|
function isAdmin(user?: User): boolean {
|
||||||
return !!user && user.Role === 1;
|
return !!user && user.Role === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,84 +0,0 @@
|
||||||
import { ResourceControlOwnership as RCO } from './resourceControlOwnership';
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResourceControlResponse {
|
|
||||||
Id: number;
|
|
||||||
Type: ResourceControlType;
|
|
||||||
ResourceId: string | number;
|
|
||||||
UserAccesses: unknown[];
|
|
||||||
TeamAccesses: unknown[];
|
|
||||||
Public: boolean;
|
|
||||||
AdministratorsOnly: boolean;
|
|
||||||
System: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ResourceControlViewModel {
|
|
||||||
Id: number;
|
|
||||||
|
|
||||||
Type: ResourceControlType;
|
|
||||||
|
|
||||||
ResourceId: string | number;
|
|
||||||
|
|
||||||
UserAccesses: unknown[];
|
|
||||||
|
|
||||||
TeamAccesses: unknown[];
|
|
||||||
|
|
||||||
Public: boolean;
|
|
||||||
|
|
||||||
System: boolean;
|
|
||||||
|
|
||||||
Ownership: RCO;
|
|
||||||
|
|
||||||
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(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function determineOwnership(resourceControl: ResourceControlViewModel) {
|
|
||||||
if (resourceControl.Public) {
|
|
||||||
return RCO.PUBLIC;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
resourceControl.UserAccesses.length === 1 &&
|
|
||||||
resourceControl.TeamAccesses.length === 0
|
|
||||||
) {
|
|
||||||
return RCO.PRIVATE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
resourceControl.UserAccesses.length > 1 ||
|
|
||||||
resourceControl.TeamAccesses.length > 0
|
|
||||||
) {
|
|
||||||
return RCO.RESTRICTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
return RCO.ADMINISTRATORS;
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
export enum ResourceControlOwnership {
|
|
||||||
PUBLIC = 'public',
|
|
||||||
PRIVATE = 'private',
|
|
||||||
RESTRICTED = 'restricted',
|
|
||||||
ADMINISTRATORS = 'administrators',
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
export const ResourceControlTypeString = Object.freeze({
|
|
||||||
CONFIG: 'config',
|
|
||||||
CONTAINER: 'container',
|
|
||||||
NETWORK: 'network',
|
|
||||||
SECRET: 'secret',
|
|
||||||
SERVICE: 'service',
|
|
||||||
STACK: 'stack',
|
|
||||||
VOLUME: 'volume',
|
|
||||||
CUSTOM_TEMPLATE: 'custom-template',
|
|
||||||
CONTAINER_GROUP: 'container-group',
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ResourceType int defined in portainer.go as ResourceControlType
|
|
||||||
*/
|
|
||||||
export const ResourceControlTypeInt = Object.freeze({
|
|
||||||
CONTAINER: 1,
|
|
||||||
SERVICE: 2,
|
|
||||||
VOLUME: 3,
|
|
||||||
NETWORK: 4,
|
|
||||||
SECRET: 5,
|
|
||||||
STACK: 6,
|
|
||||||
CONFIG: 7,
|
|
||||||
CUSTOM_TEMPLATE: 8,
|
|
||||||
CONTAINER_GROUP: 9,
|
|
||||||
});
|
|
|
@ -1,10 +0,0 @@
|
||||||
/**
|
|
||||||
* Payload for resourceControlUpdate operation
|
|
||||||
* @param {ResourceControlOwnershipParameters} data
|
|
||||||
*/
|
|
||||||
export function ResourceControlUpdatePayload(data) {
|
|
||||||
this.Public = data.Public;
|
|
||||||
this.AdministratorsOnly = data.AdministratorsOnly;
|
|
||||||
this.Users = data.Users;
|
|
||||||
this.Teams = data.Teams;
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||||
|
|
||||||
export function StackViewModel(data) {
|
export function StackViewModel(data) {
|
||||||
this.Id = data.Id;
|
this.Id = data.Id;
|
||||||
|
|
|
@ -1,51 +0,0 @@
|
||||||
import { ResourceControlOwnership } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
|
||||||
|
|
||||||
import { AccessControlFormData } from '../components/accessControlForm/model';
|
|
||||||
import { TeamId } from '../teams/types';
|
|
||||||
import { UserId } from '../users/types';
|
|
||||||
|
|
||||||
import { OwnershipParameters } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(
|
|
||||||
userId: UserId,
|
|
||||||
formValues: AccessControlFormData,
|
|
||||||
subResources: (number | string)[] = []
|
|
||||||
): OwnershipParameters {
|
|
||||||
let { ownership } = formValues;
|
|
||||||
if (!formValues.accessControlEnabled) {
|
|
||||||
ownership = ResourceControlOwnership.PUBLIC;
|
|
||||||
}
|
|
||||||
|
|
||||||
let adminOnly = false;
|
|
||||||
let publicOnly = false;
|
|
||||||
let users: UserId[] = [];
|
|
||||||
let teams: TeamId[] = [];
|
|
||||||
switch (ownership) {
|
|
||||||
case ResourceControlOwnership.PUBLIC:
|
|
||||||
publicOnly = true;
|
|
||||||
break;
|
|
||||||
case ResourceControlOwnership.PRIVATE:
|
|
||||||
users.push(userId);
|
|
||||||
break;
|
|
||||||
case ResourceControlOwnership.RESTRICTED:
|
|
||||||
users = formValues.authorizedUsers;
|
|
||||||
teams = formValues.authorizedTeams;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
adminOnly = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
administratorsOnly: adminOnly,
|
|
||||||
public: publicOnly,
|
|
||||||
users,
|
|
||||||
teams,
|
|
||||||
subResources,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,48 +0,0 @@
|
||||||
import { UserId } from '@/portainer/users/types';
|
|
||||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/model';
|
|
||||||
import { ResourceControlResponse } from '@/portainer/models/resourceControl/resourceControl';
|
|
||||||
|
|
||||||
import axios, { parseAxiosError } from '../services/axios';
|
|
||||||
|
|
||||||
import { parseOwnershipParameters } from './helper';
|
|
||||||
import { OwnershipParameters } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a ResourceControl after Resource creation
|
|
||||||
* @param userId ID of User performing the action
|
|
||||||
* @param accessControlData ResourceControl to apply
|
|
||||||
* @param resourceControl ResourceControl to update
|
|
||||||
* @param subResources SubResources managed by the ResourceControl
|
|
||||||
*/
|
|
||||||
export function applyResourceControl(
|
|
||||||
userId: UserId,
|
|
||||||
accessControlData: AccessControlFormData,
|
|
||||||
resourceControl: ResourceControlResponse,
|
|
||||||
subResources: (number | string)[] = []
|
|
||||||
) {
|
|
||||||
const ownershipParameters = parseOwnershipParameters(
|
|
||||||
userId,
|
|
||||||
accessControlData,
|
|
||||||
subResources
|
|
||||||
);
|
|
||||||
return updateResourceControl(resourceControl.Id, ownershipParameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a ResourceControl
|
|
||||||
* @param resourceControlId ID of involved resource
|
|
||||||
* @param ownershipParameters Transient type from view data to payload
|
|
||||||
*/
|
|
||||||
async function updateResourceControl(
|
|
||||||
resourceControlId: string | number,
|
|
||||||
ownershipParameters: OwnershipParameters
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await axios.put(
|
|
||||||
`/resource_controls/${resourceControlId}`,
|
|
||||||
ownershipParameters
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
throw parseAxiosError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
import { TeamId } from '@/portainer/teams/types';
|
|
||||||
import { UserId } from '@/portainer/users/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transient type from view data to payload
|
|
||||||
*/
|
|
||||||
export interface OwnershipParameters {
|
|
||||||
administratorsOnly: boolean;
|
|
||||||
public: boolean;
|
|
||||||
users: UserId[];
|
|
||||||
teams: TeamId[];
|
|
||||||
subResources: (number | string)[];
|
|
||||||
}
|
|
|
@ -7,10 +7,7 @@ angular.module('portainer.app').factory('ResourceControl', [
|
||||||
API_ENDPOINT_RESOURCE_CONTROLS + '/:id',
|
API_ENDPOINT_RESOURCE_CONTROLS + '/:id',
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
create: { method: 'POST', ignoreLoadingBar: true },
|
|
||||||
get: { method: 'GET', params: { id: '@id' } },
|
|
||||||
update: { method: 'PUT', params: { id: '@id' } },
|
update: { method: 'PUT', params: { id: '@id' } },
|
||||||
remove: { method: 'DELETE', params: { id: '@id' } },
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import _ from 'lodash-es';
|
|
||||||
|
|
||||||
angular.module('portainer.app').factory('ResourceControlService', [
|
angular.module('portainer.app').factory('ResourceControlService', [
|
||||||
'$q',
|
'$q',
|
||||||
'ResourceControl',
|
'ResourceControl',
|
||||||
|
@ -11,34 +9,13 @@ angular.module('portainer.app').factory('ResourceControlService', [
|
||||||
const service = {};
|
const service = {};
|
||||||
|
|
||||||
service.duplicateResourceControl = duplicateResourceControl;
|
service.duplicateResourceControl = duplicateResourceControl;
|
||||||
service.applyResourceControlChange = applyResourceControlChange;
|
|
||||||
service.applyResourceControl = applyResourceControl;
|
service.applyResourceControl = applyResourceControl;
|
||||||
service.retrieveOwnershipDetails = retrieveOwnershipDetails;
|
service.retrieveOwnershipDetails = retrieveOwnershipDetails;
|
||||||
service.retrieveUserPermissionsOnResource = retrieveUserPermissionsOnResource;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PRIVATE SECTION
|
* PRIVATE SECTION
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a ResourceControl
|
|
||||||
* @param {ResourceControlTypeString} rcType Type of ResourceControl
|
|
||||||
* @param {string} rcID ID of involved resource
|
|
||||||
* @param {ResourceControlOwnershipParameters} ownershipParameters Transcient type from view data to payload
|
|
||||||
*/
|
|
||||||
function createResourceControl(rcType, resourceID, ownershipParameters) {
|
|
||||||
var payload = {
|
|
||||||
Type: rcType,
|
|
||||||
Public: ownershipParameters.Public,
|
|
||||||
AdministratorsOnly: ownershipParameters.AdministratorsOnly,
|
|
||||||
ResourceID: resourceID,
|
|
||||||
Users: ownershipParameters.Users,
|
|
||||||
Teams: ownershipParameters.Teams,
|
|
||||||
SubResourceIds: ownershipParameters.SubResourceIDs,
|
|
||||||
};
|
|
||||||
return ResourceControl.create({}, payload).$promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a ResourceControl
|
* Update a ResourceControl
|
||||||
* @param {String} rcID ID of involved resource
|
* @param {String} rcID ID of involved resource
|
||||||
|
@ -86,22 +63,6 @@ angular.module('portainer.app').factory('ResourceControlService', [
|
||||||
return updateResourceControl(newResourceControl.Id, ownershipParameters);
|
return updateResourceControl(newResourceControl.Id, ownershipParameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing ResourceControl or create a new one on existing resource without RC
|
|
||||||
* @param {ResourceControlTypeString} rcType Type of ResourceControl
|
|
||||||
* @param {String} resourceId ID of involved Resource
|
|
||||||
* @param {ResourceControlViewModel} resourceControl Previous ResourceControl (can be undefined)
|
|
||||||
* @param {AccessControlPanelData} formValues View data generated by AccessControlPanel
|
|
||||||
*/
|
|
||||||
function applyResourceControlChange(rcType, resourceId, resourceControl, formValues) {
|
|
||||||
const ownershipParameters = ResourceControlHelper.RCPanelDataToOwnershipParameters(formValues);
|
|
||||||
if (resourceControl) {
|
|
||||||
return updateResourceControl(resourceControl.Id, ownershipParameters);
|
|
||||||
} else {
|
|
||||||
return createResourceControl(rcType, resourceId, ownershipParameters);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve users and team details for ResourceControlViewModel
|
* Retrieve users and team details for ResourceControlViewModel
|
||||||
* @param {ResourceControlViewModel} resourceControl ResourceControl view model
|
* @param {ResourceControlViewModel} resourceControl ResourceControl view model
|
||||||
|
@ -130,33 +91,6 @@ angular.module('portainer.app').factory('ResourceControlService', [
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function retrieveUserPermissionsOnResource(userID, isAdministrator, resourceControl) {
|
|
||||||
var deferred = $q.defer();
|
|
||||||
|
|
||||||
if (!resourceControl || isAdministrator) {
|
|
||||||
deferred.resolve({ isPartOfRestrictedUsers: false, isLeaderOfAnyRestrictedTeams: false });
|
|
||||||
return deferred.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
var found = _.find(resourceControl.UserAccesses, { UserId: userID });
|
|
||||||
if (found) {
|
|
||||||
deferred.resolve({ isPartOfRestrictedUsers: true, isLeaderOfAnyRestrictedTeams: false });
|
|
||||||
} else {
|
|
||||||
var isTeamLeader = false;
|
|
||||||
UserService.userMemberships(userID)
|
|
||||||
.then(function success(data) {
|
|
||||||
var memberships = data;
|
|
||||||
isTeamLeader = ResourceControlHelper.isLeaderOfAnyRestrictedTeams(memberships, resourceControl);
|
|
||||||
deferred.resolve({ isPartOfRestrictedUsers: false, isLeaderOfAnyRestrictedTeams: isTeamLeader });
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
deferred.reject({ msg: 'Unable to retrieve user memberships', err: err });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return deferred.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* END PUBLIC SECTION
|
* END PUBLIC SECTION
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -1,47 +1,25 @@
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
|
import { UserTokenModel, UserViewModel } from '@/portainer/models/user';
|
||||||
|
import { getUser, getUsers } from '@/portainer/users/user.service';
|
||||||
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|
||||||
|
|
||||||
const BASE_URL = '/users';
|
|
||||||
|
|
||||||
import { filterNonAdministratorUsers } from '@/portainer/helpers/userHelper';
|
|
||||||
import { UserViewModel, UserTokenModel } from '../../models/user';
|
|
||||||
import { TeamMembershipModel } from '../../models/teamMembership';
|
import { TeamMembershipModel } from '../../models/teamMembership';
|
||||||
|
|
||||||
export async function getUsers(includeAdministrators) {
|
|
||||||
try {
|
|
||||||
let { data } = await axios.get(BASE_URL);
|
|
||||||
|
|
||||||
const users = data.map((user) => new UserViewModel(user));
|
|
||||||
|
|
||||||
if (includeAdministrators) {
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
|
|
||||||
return filterNonAdministratorUsers(users);
|
|
||||||
} catch (e) {
|
|
||||||
throw parseAxiosError(e, 'Unable to retrieve users');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUser(id) {
|
|
||||||
try {
|
|
||||||
const { data: user } = await axios.get(`${BASE_URL}/${id}`);
|
|
||||||
|
|
||||||
return new UserViewModel(user);
|
|
||||||
} catch (e) {
|
|
||||||
throw parseAxiosError(e, 'Unable to retrieve user details');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
export function UserService($q, Users, TeamService, TeamMembershipService) {
|
export function UserService($q, Users, TeamService, TeamMembershipService) {
|
||||||
'use strict';
|
'use strict';
|
||||||
var service = {};
|
var service = {};
|
||||||
|
|
||||||
service.users = getUsers;
|
service.users = async function (includeAdministrators) {
|
||||||
|
const users = await getUsers(includeAdministrators);
|
||||||
|
|
||||||
service.user = getUser;
|
return users.map((u) => new UserViewModel(u));
|
||||||
|
};
|
||||||
|
|
||||||
|
service.user = async function (includeAdministrators) {
|
||||||
|
const user = await getUser(includeAdministrators);
|
||||||
|
|
||||||
|
return new UserViewModel(user);
|
||||||
|
};
|
||||||
|
|
||||||
service.createUser = function (username, password, role, teamIds) {
|
service.createUser = function (username, password, role, teamIds) {
|
||||||
var deferred = $q.defer();
|
var deferred = $q.defer();
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||||
|
|
||||||
angular.module('portainer.app').factory('FormValidator', [
|
angular.module('portainer.app').factory('FormValidator', [
|
||||||
function FormValidatorFactory() {
|
function FormValidatorFactory() {
|
||||||
|
|
|
@ -55,21 +55,6 @@ export function confirm(options: ConfirmOptions) {
|
||||||
applyBoxCSS(box);
|
applyBoxCSS(box);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function confirmAccessControlUpdate(callback: ConfirmCallback) {
|
|
||||||
confirm({
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
callback,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function confirmImageForceRemoval(callback: ConfirmCallback) {
|
export function confirmImageForceRemoval(callback: ConfirmCallback) {
|
||||||
confirm({
|
confirm({
|
||||||
title: 'Are you sure?',
|
title: 'Are you sure?',
|
||||||
|
|
|
@ -3,7 +3,6 @@ import bootbox from 'bootbox';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cancelRegistryRepositoryAction,
|
cancelRegistryRepositoryAction,
|
||||||
confirmAccessControlUpdate,
|
|
||||||
confirmAsync,
|
confirmAsync,
|
||||||
confirmDeassociate,
|
confirmDeassociate,
|
||||||
confirmDeletion,
|
confirmDeletion,
|
||||||
|
@ -43,7 +42,6 @@ export function ModalServiceAngular() {
|
||||||
confirmWebEditorDiscard,
|
confirmWebEditorDiscard,
|
||||||
confirmAsync,
|
confirmAsync,
|
||||||
confirm,
|
confirm,
|
||||||
confirmAccessControlUpdate,
|
|
||||||
confirmImageForceRemoval,
|
confirmImageForceRemoval,
|
||||||
cancelRegistryRepositoryAction,
|
cancelRegistryRepositoryAction,
|
||||||
confirmDeletion,
|
confirmDeletion,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import toastr from 'toastr';
|
import toastr from 'toastr';
|
||||||
|
|
||||||
import { error, success, warning } from './notifications';
|
import { notifyError, notifySuccess, notifyWarning } from './notifications';
|
||||||
|
|
||||||
jest.mock('toastr');
|
jest.mock('toastr');
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ it('calling success should show success message', () => {
|
||||||
const title = 'title';
|
const title = 'title';
|
||||||
const text = 'text';
|
const text = 'text';
|
||||||
|
|
||||||
success(title, text);
|
notifySuccess(title, text);
|
||||||
|
|
||||||
expect(toastr.success).toHaveBeenCalledWith(text, title);
|
expect(toastr.success).toHaveBeenCalledWith(text, title);
|
||||||
});
|
});
|
||||||
|
@ -25,7 +25,7 @@ it('calling error with Error should show error message', () => {
|
||||||
const errorMessage = 'message';
|
const errorMessage = 'message';
|
||||||
const fallback = 'fallback';
|
const fallback = 'fallback';
|
||||||
|
|
||||||
error(title, new Error(errorMessage), fallback);
|
notifyError(title, new Error(errorMessage), fallback);
|
||||||
|
|
||||||
expect(toastr.error).toHaveBeenCalledWith(
|
expect(toastr.error).toHaveBeenCalledWith(
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
@ -44,7 +44,7 @@ it('calling error without Error should show fallback message', () => {
|
||||||
|
|
||||||
const fallback = 'fallback';
|
const fallback = 'fallback';
|
||||||
|
|
||||||
error(title, undefined, fallback);
|
notifyError(title, undefined, fallback);
|
||||||
|
|
||||||
expect(toastr.error).toHaveBeenCalledWith(fallback, title, expect.anything());
|
expect(toastr.error).toHaveBeenCalledWith(fallback, title, expect.anything());
|
||||||
consoleErrorFn.mockRestore();
|
consoleErrorFn.mockRestore();
|
||||||
|
@ -54,7 +54,7 @@ it('calling warning should show warning message', () => {
|
||||||
const title = 'title';
|
const title = 'title';
|
||||||
const text = 'text';
|
const text = 'text';
|
||||||
|
|
||||||
warning(title, text);
|
notifyWarning(title, text);
|
||||||
|
|
||||||
expect(toastr.warning).toHaveBeenCalledWith(text, title, expect.anything());
|
expect(toastr.warning).toHaveBeenCalledWith(text, title, expect.anything());
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,15 +9,15 @@ toastr.options = {
|
||||||
tapToDismiss: false,
|
tapToDismiss: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function success(title: string, text: string) {
|
export function notifySuccess(title: string, text?: string) {
|
||||||
toastr.success(sanitize(_.escape(text)), sanitize(title));
|
toastr.success(sanitize(_.escape(text)), sanitize(title));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function warning(title: string, text: string) {
|
export function notifyWarning(title: string, text: string) {
|
||||||
toastr.warning(sanitize(_.escape(text)), sanitize(title), { timeOut: 6000 });
|
toastr.warning(sanitize(_.escape(text)), sanitize(title), { timeOut: 6000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function error(title: string, e?: Error, fallbackText = '') {
|
export function notifyError(title: string, e?: Error, fallbackText = '') {
|
||||||
const msg = pickErrorMsg(e) || fallbackText;
|
const msg = pickErrorMsg(e) || fallbackText;
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
|
@ -28,12 +28,16 @@ export function error(title: string, e?: Error, fallbackText = '') {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const success = notifySuccess;
|
||||||
|
export const error = notifyError;
|
||||||
|
export const warning = notifyWarning;
|
||||||
|
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
export function Notifications() {
|
export function Notifications() {
|
||||||
return {
|
return {
|
||||||
success,
|
success: notifySuccess,
|
||||||
warning,
|
warning: notifyWarning,
|
||||||
error,
|
error: notifyError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
|
import { getTeams } from './teams.service';
|
||||||
|
import { Team } from './types';
|
||||||
|
|
||||||
|
export function useTeams<T = Team[]>(
|
||||||
|
enabled = true,
|
||||||
|
select: (data: Team[]) => T = (data) => data as unknown as T
|
||||||
|
) {
|
||||||
|
const teams = useQuery(['teams'], () => getTeams(), {
|
||||||
|
meta: {
|
||||||
|
error: { title: 'Failure', message: 'Unable to load teams' },
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
|
||||||
|
return teams;
|
||||||
|
}
|
|
@ -1,6 +1,20 @@
|
||||||
|
import { UserId } from '../users/types';
|
||||||
|
|
||||||
export type TeamId = number;
|
export type TeamId = number;
|
||||||
|
|
||||||
|
export enum Role {
|
||||||
|
TeamLeader = 1,
|
||||||
|
TeamMember,
|
||||||
|
}
|
||||||
|
|
||||||
export interface Team {
|
export interface Team {
|
||||||
Id: TeamId;
|
Id: TeamId;
|
||||||
Name: string;
|
Name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TeamMembership {
|
||||||
|
Id: number;
|
||||||
|
UserID: UserId;
|
||||||
|
TeamID: TeamId;
|
||||||
|
Role: Role;
|
||||||
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue