diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index 2528e67bc..19567a36b 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -489,12 +489,12 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo }, }; - const resourcePoolAccess = { + const namespaceAccess = { name: 'kubernetes.resourcePools.resourcePool.access', url: '/access', views: { 'content@': { - component: 'kubernetesResourcePoolAccessView', + component: 'kubernetesNamespaceAccessView', }, }, data: { @@ -647,7 +647,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo $stateRegistryProvider.register(resourcePools); $stateRegistryProvider.register(namespaceCreation); $stateRegistryProvider.register(resourcePool); - $stateRegistryProvider.register(resourcePoolAccess); + $stateRegistryProvider.register(namespaceAccess); $stateRegistryProvider.register(volumes); $stateRegistryProvider.register(volume); $stateRegistryProvider.register(registries); diff --git a/app/kubernetes/converters/configMap.js b/app/kubernetes/converters/configMap.js index be0bdffcf..7c86df603 100644 --- a/app/kubernetes/converters/configMap.js +++ b/app/kubernetes/converters/configMap.js @@ -1,9 +1,8 @@ import _ from 'lodash-es'; import { KubernetesConfigMap, KubernetesPortainerAccessConfigMap } from 'Kubernetes/models/config-map/models'; import { KubernetesConfigMapCreatePayload, KubernetesConfigMapUpdatePayload } from 'Kubernetes/models/config-map/payloads'; -import { KubernetesPortainerConfigurationOwnerLabel } from 'Kubernetes/models/configuration/models'; import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues'; - +import { ConfigurationOwnerUsernameLabel } from '@/react/kubernetes/configs/constants'; class KubernetesConfigMapConverter { static apiToPortainerAccessConfigMap(data) { const res = new KubernetesPortainerAccessConfigMap(); @@ -35,7 +34,7 @@ class KubernetesConfigMapConverter { res.Id = data.metadata.uid; res.Name = data.metadata.name; res.Namespace = data.metadata.namespace; - res.ConfigurationOwner = data.metadata.labels ? data.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : ''; + res.ConfigurationOwner = data.metadata.labels ? data.metadata.labels[ConfigurationOwnerUsernameLabel] : ''; res.CreationDate = data.metadata.creationTimestamp; res.Yaml = yaml ? yaml.data : ''; res.Labels = data.metadata.labels; @@ -79,7 +78,7 @@ class KubernetesConfigMapConverter { res.metadata.name = data.Name; res.metadata.namespace = data.Namespace.Namespace.Name; const configurationOwner = _.truncate(data.ConfigurationOwner, { length: 63, omission: '' }); - res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = configurationOwner; + res.metadata.labels[ConfigurationOwnerUsernameLabel] = configurationOwner; _.forEach(data.Data, (entry) => { if (entry.IsBinary) { @@ -100,7 +99,7 @@ class KubernetesConfigMapConverter { res.metadata.name = data.Name; res.metadata.namespace = data.Namespace; res.metadata.labels = data.Labels || {}; - res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = data.ConfigurationOwner; + res.metadata.labels[ConfigurationOwnerUsernameLabel] = data.ConfigurationOwner; _.forEach(data.Data, (entry) => { if (entry.IsBinary) { res.binaryData[entry.Key] = entry.Value; diff --git a/app/kubernetes/converters/secret.js b/app/kubernetes/converters/secret.js index 64594d9c0..01425d4f0 100644 --- a/app/kubernetes/converters/secret.js +++ b/app/kubernetes/converters/secret.js @@ -2,7 +2,7 @@ import _ from 'lodash-es'; import { KubernetesSecretCreatePayload, KubernetesSecretUpdatePayload } from 'Kubernetes/models/secret/payloads'; import { KubernetesApplicationSecret } from 'Kubernetes/models/secret/models'; import { KubernetesPortainerConfigurationDataAnnotation } from 'Kubernetes/models/configuration/models'; -import { KubernetesPortainerConfigurationOwnerLabel } from 'Kubernetes/models/configuration/models'; +import { ConfigurationOwnerUsernameLabel } from '@/react/kubernetes/configs/constants'; import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues'; import { KubernetesSecretTypeOptions } from 'Kubernetes/models/configuration/models'; class KubernetesSecretConverter { @@ -12,7 +12,7 @@ class KubernetesSecretConverter { res.metadata.namespace = secret.Namespace.Namespace.Name; res.type = secret.Type; const configurationOwner = _.truncate(secret.ConfigurationOwner, { length: 63, omission: '' }); - res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = configurationOwner; + res.metadata.labels[ConfigurationOwnerUsernameLabel] = configurationOwner; let annotation = ''; _.forEach(secret.Data, (entry) => { @@ -40,7 +40,7 @@ class KubernetesSecretConverter { res.metadata.namespace = secret.Namespace; res.type = secret.Type; res.metadata.labels = secret.Labels || {}; - res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = secret.ConfigurationOwner; + res.metadata.labels[ConfigurationOwnerUsernameLabel] = secret.ConfigurationOwner; let annotation = ''; _.forEach(secret.Data, (entry) => { @@ -69,7 +69,7 @@ class KubernetesSecretConverter { res.Namespace = payload.metadata.namespace; res.Type = payload.type; res.Labels = payload.metadata.labels || {}; - res.ConfigurationOwner = payload.metadata.labels ? payload.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : ''; + res.ConfigurationOwner = payload.metadata.labels ? payload.metadata.labels[ConfigurationOwnerUsernameLabel] : ''; res.CreationDate = payload.metadata.creationTimestamp; res.Annotations = payload.metadata.annotations; diff --git a/app/kubernetes/react/components/namespaces.ts b/app/kubernetes/react/components/namespaces.ts index 58f36a3fa..4e5c09447 100644 --- a/app/kubernetes/react/components/namespaces.ts +++ b/app/kubernetes/react/components/namespaces.ts @@ -6,7 +6,7 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { NamespacesDatatable } from '@/react/kubernetes/namespaces/ListView/NamespacesDatatable'; import { NamespaceAppsDatatable } from '@/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable'; -import { NamespaceAccessDatatable } from '@/react/kubernetes/namespaces/AccessView/AccessDatatable'; +import { AccessDatatable } from '@/react/kubernetes/namespaces/AccessView/AccessDatatable/AccessDatatable'; export const namespacesModule = angular .module('portainer.kubernetes.react.components.namespaces', []) @@ -24,8 +24,5 @@ export const namespacesModule = angular ) .component( 'namespaceAccessDatatable', - r2a(withUIRouter(withReactQuery(NamespaceAccessDatatable)), [ - 'dataset', - 'onRemove', - ]) + r2a(withUIRouter(withReactQuery(AccessDatatable)), []) ).name; diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts index 8ee8538e3..55b4ac927 100644 --- a/app/kubernetes/react/views/index.ts +++ b/app/kubernetes/react/views/index.ts @@ -19,6 +19,7 @@ import { ServiceAccountsView } from '@/react/kubernetes/more-resources/ServiceAc import { ClusterRolesView } from '@/react/kubernetes/more-resources/ClusterRolesView'; import { RolesView } from '@/react/kubernetes/more-resources/RolesView'; import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView'; +import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView'; export const viewsModule = angular .module('portainer.kubernetes.react.views', []) @@ -30,6 +31,10 @@ export const viewsModule = angular 'kubernetesNamespacesView', r2a(withUIRouter(withReactQuery(withCurrentUser(NamespacesView))), []) ) + .component( + 'kubernetesNamespaceAccessView', + r2a(withUIRouter(withReactQuery(withCurrentUser(AccessView))), []) + ) .component( 'kubernetesServicesView', r2a(withUIRouter(withReactQuery(withCurrentUser(ServicesView))), []) diff --git a/app/kubernetes/services/configMapService.js b/app/kubernetes/services/configMapService.js index 8e7cee884..b976859cf 100644 --- a/app/kubernetes/services/configMapService.js +++ b/app/kubernetes/services/configMapService.js @@ -3,7 +3,6 @@ import _ from 'lodash-es'; import PortainerError from 'Portainer/error'; import KubernetesConfigMapConverter from 'Kubernetes/converters/configMap'; import { KubernetesCommonParams } from 'Kubernetes/models/common/params'; -import { KubernetesPortainerAccessConfigMap } from 'Kubernetes/models/config-map/models'; class KubernetesConfigMapService { /* @ngInject */ @@ -18,22 +17,6 @@ class KubernetesConfigMapService { this.deleteAsync = this.deleteAsync.bind(this); } - getAccess(namespace, name) { - return this.$async(async () => { - try { - const params = new KubernetesCommonParams(); - params.id = name; - const raw = await this.KubernetesConfigMaps(namespace).get(params).$promise; - return KubernetesConfigMapConverter.apiToPortainerAccessConfigMap(raw); - } catch (err) { - if (err.status === 404) { - return new KubernetesPortainerAccessConfigMap(); - } - throw new PortainerError('Unable to retrieve Portainer accesses', err); - } - }); - } - createAccess(config) { return this.$async(async () => { try { diff --git a/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html b/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html deleted file mode 100644 index 08c68c3e9..000000000 --- a/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - - -
-
-
- - - - - - - - - - -
Name - {{ ctrl.pool.Namespace.Name }} -
-
-
-
-
- -
-
- - - -
-
-
-
- -
-
-

Your cluster does not have Kubernetes role-based access control (RBAC) enabled.

-

This means you can't use Portainer RBAC functionality to regulate access to environment resources based on user roles.

-

- To enable RBAC, start the API server with the --authorization-mode flag set to a - comma-separated list that includes RBAC, for example:  - kube-apiserver --authorization-mode=Example1,RBAC,Example2. -

-
-
- -

- - Adding user access will require the affected user(s) to logout and login for the changes to be taken into account. -

-
-
-
- -
- - No user nor team access has been set on the environment. Head over to the - Environments view to manage them. - - -
-
- - -
-
- -
-
- -
-
-
-
-
- - -
diff --git a/app/kubernetes/views/resource-pools/access/resourcePoolAccess.js b/app/kubernetes/views/resource-pools/access/resourcePoolAccess.js deleted file mode 100644 index 4bb584b9f..000000000 --- a/app/kubernetes/views/resource-pools/access/resourcePoolAccess.js +++ /dev/null @@ -1,9 +0,0 @@ -angular.module('portainer.kubernetes').component('kubernetesResourcePoolAccessView', { - templateUrl: './resourcePoolAccess.html', - controller: 'KubernetesResourcePoolAccessController', - controllerAs: 'ctrl', - bindings: { - $transition$: '<', - endpoint: '<', - }, -}); diff --git a/app/kubernetes/views/resource-pools/access/resourcePoolAccessController.js b/app/kubernetes/views/resource-pools/access/resourcePoolAccessController.js deleted file mode 100644 index d9802586f..000000000 --- a/app/kubernetes/views/resource-pools/access/resourcePoolAccessController.js +++ /dev/null @@ -1,145 +0,0 @@ -import angular from 'angular'; -import _ from 'lodash-es'; -import { KubernetesPortainerConfigMapConfigName, KubernetesPortainerConfigMapNamespace, KubernetesPortainerConfigMapAccessKey } from 'Kubernetes/models/config-map/models'; -import { UserAccessViewModel, TeamAccessViewModel } from 'Portainer/models/access'; -import KubernetesConfigMapHelper from 'Kubernetes/helpers/configMapHelper'; -import { getIsRBACEnabled } from '@/react/kubernetes/cluster/getIsRBACEnabled'; - -class KubernetesResourcePoolAccessController { - /* @ngInject */ - constructor($async, $state, $scope, Notifications, KubernetesResourcePoolService, KubernetesConfigMapService, GroupService, AccessService, EndpointProvider) { - this.$async = $async; - this.$state = $state; - this.$scope = $scope; - this.EndpointProvider = EndpointProvider; - this.Notifications = Notifications; - this.KubernetesResourcePoolService = KubernetesResourcePoolService; - this.KubernetesConfigMapService = KubernetesConfigMapService; - - this.GroupService = GroupService; - this.AccessService = AccessService; - - this.onInit = this.onInit.bind(this); - this.authorizeAccessAsync = this.authorizeAccessAsync.bind(this); - this.unauthorizeAccessAsync = this.unauthorizeAccessAsync.bind(this); - this.onUsersAndTeamsChange = this.onUsersAndTeamsChange.bind(this); - this.unauthorizeAccess = this.unauthorizeAccess.bind(this); - } - - initAccessConfigMap(configMap) { - configMap.Name = KubernetesPortainerConfigMapConfigName; - configMap.Namespace = KubernetesPortainerConfigMapNamespace; - configMap.Data[KubernetesPortainerConfigMapAccessKey] = {}; - return configMap; - } - - /** - * Init - */ - async onInit() { - const endpoint = this.endpoint; - this.state = { - actionInProgress: false, - viewReady: false, - }; - - this.formValues = { - multiselectOutput: [], - }; - - // default to true if error is thrown - this.isRBACEnabled = true; - - try { - const name = this.$transition$.params().id; - let [pool, configMap] = await Promise.all([ - this.KubernetesResourcePoolService.get(name), - this.KubernetesConfigMapService.getAccess(KubernetesPortainerConfigMapNamespace, KubernetesPortainerConfigMapConfigName), - ]); - this.isRBACEnabled = await getIsRBACEnabled(this.EndpointProvider.endpointID()); - const group = await this.GroupService.group(endpoint.GroupId); - const roles = []; - const endpointAccesses = await this.AccessService.accesses(endpoint, group, roles); - this.pool = pool; - if (configMap.Id === 0) { - configMap = this.initAccessConfigMap(configMap); - } - configMap = KubernetesConfigMapHelper.parseJSONData(configMap); - - this.authorizedUsersAndTeams = []; - this.accessConfigMap = configMap; - const poolAccesses = configMap.Data[KubernetesPortainerConfigMapAccessKey][name]; - if (poolAccesses) { - this.authorizedUsersAndTeams = _.filter(endpointAccesses.authorizedUsersAndTeams, (item) => { - if (item instanceof UserAccessViewModel && poolAccesses.UserAccessPolicies) { - return poolAccesses.UserAccessPolicies[item.Id] !== undefined; - } else if (item instanceof TeamAccessViewModel && poolAccesses.TeamAccessPolicies) { - return poolAccesses.TeamAccessPolicies[item.Id] !== undefined; - } - return false; - }); - } - - this.availableUsersAndTeams = _.without(endpointAccesses.authorizedUsersAndTeams, ...this.authorizedUsersAndTeams); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve namespace information'); - } finally { - this.state.viewReady = true; - } - } - - $onInit() { - return this.$async(this.onInit); - } - - /** - * Authorize access - */ - async authorizeAccessAsync() { - try { - this.state.actionInProgress = true; - const newAccesses = _.concat(this.authorizedUsersAndTeams, this.formValues.multiselectOutput); - const accessConfigMap = KubernetesConfigMapHelper.modifiyNamespaceAccesses(angular.copy(this.accessConfigMap), this.pool.Namespace.Name, newAccesses); - await this.KubernetesConfigMapService.updateAccess(accessConfigMap); - this.Notifications.success('Success', 'Access successfully created'); - this.$state.reload(this.$state.current); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to create accesses'); - } - } - - onUsersAndTeamsChange(value) { - this.$scope.$evalAsync(() => { - this.formValues.multiselectOutput = value; - }); - } - - authorizeAccess() { - return this.$async(this.authorizeAccessAsync); - } - - /** - * - */ - async unauthorizeAccessAsync(selectedItems) { - try { - this.state.actionInProgress = true; - const newAccesses = _.without(this.authorizedUsersAndTeams, ...selectedItems); - const accessConfigMap = KubernetesConfigMapHelper.modifiyNamespaceAccesses(angular.copy(this.accessConfigMap), this.pool.Namespace.Name, newAccesses); - await this.KubernetesConfigMapService.updateAccess(accessConfigMap); - this.Notifications.success('Success', 'Access successfully removed'); - this.$state.reload(this.$state.current); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to remove accesses'); - } finally { - this.state.actionInProgress = false; - } - } - - unauthorizeAccess(selectedItems) { - return this.$async(this.unauthorizeAccessAsync, selectedItems); - } -} - -export default KubernetesResourcePoolAccessController; -angular.module('portainer.kubernetes').controller('KubernetesResourcePoolAccessController', KubernetesResourcePoolAccessController); diff --git a/app/portainer/users/user.helpers.ts b/app/portainer/users/user.helpers.ts index 4ce6c1dc6..c087d4d1e 100644 --- a/app/portainer/users/user.helpers.ts +++ b/app/portainer/users/user.helpers.ts @@ -10,7 +10,9 @@ export function filterNonAdministratorUsers(users: User[]) { type UserLike = Pick; // To avoid creating divergence between CE and EE -// isAdmin checks if the user is portainer admin or edge admin +/** + * isEdgeAdmin checks if the user is edge admin or admin + */ export function isEdgeAdmin( user: UserLike | undefined, environment?: Pick | null @@ -23,8 +25,10 @@ export function isEdgeAdmin( } // To avoid creating divergence between CE and EE -// isPureAdmin checks only if the user is portainer admin -// See bouncer.IsAdmin and bouncer.PureAdminAccess +/** + * isPureAdmin checks if the user is portainer admin. + * See bouncer.IsAdmin and bouncer.PureAdminAccess + */ export function isPureAdmin(user?: UserLike): boolean { return !!user && user.Role === Role.Admin; } diff --git a/app/react/edge/components/EdgeScriptForm/types.ts b/app/react/edge/components/EdgeScriptForm/types.ts index 4113563e7..472a0f3f8 100644 --- a/app/react/edge/components/EdgeScriptForm/types.ts +++ b/app/react/edge/components/EdgeScriptForm/types.ts @@ -1,5 +1,5 @@ import { TagId } from '@/portainer/tags/types'; -import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; +import { EnvironmentGroupId } from '@/react/portainer/environments/types'; import { EdgeGroup } from '../../edge-groups/types'; diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/GroupSelector.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/GroupSelector.tsx index 3d561dd74..d87abe1c1 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/GroupSelector.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/GroupSelector.tsx @@ -1,7 +1,7 @@ import { useField } from 'formik'; import { useGroups } from '@/react/portainer/environments/environment-groups/queries'; -import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; +import { EnvironmentGroupId } from '@/react/portainer/environments/types'; import { useCreateGroupMutation } from '@/react/portainer/environments/environment-groups/queries/useCreateGroupMutation'; import { notifySuccess } from '@/portainer/services/notifications'; diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/types.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/types.ts index 480069523..c0fad7e67 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/types.ts +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/types.ts @@ -1,6 +1,6 @@ import { TagId } from '@/portainer/tags/types'; import { EdgeGroup } from '@/react/edge/edge-groups/types'; -import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; +import { EnvironmentGroupId } from '@/react/portainer/environments/types'; export interface FormValues { group: EnvironmentGroupId | null; diff --git a/app/react/hooks/useUser.tsx b/app/react/hooks/useUser.tsx index fffd7409e..829803e7c 100644 --- a/app/react/hooks/useUser.tsx +++ b/app/react/hooks/useUser.tsx @@ -54,7 +54,7 @@ export function useIsPureAdmin() { } /** - * Load the admin status of the user, (admin >= edge admin) + * Load the admin status of the user, returning true if the user is edge admin or admin. * @param forceEnvironmentId to force the environment id, used where the environment id can't be loaded from the router, like sidebar * @returns query result with isLoading and isAdmin - isAdmin is true if the user edge admin or admin. */ diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx index bd5911f53..b3cff0f19 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx @@ -2,7 +2,7 @@ import { KeyToPath, Pod, Secret } from 'kubernetes-types/core/v1'; import { Asterisk, Plus } from 'lucide-react'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; -import { useSecrets } from '@/react/kubernetes/configs/secret.service'; +import { useK8sSecrets } from '@/react/kubernetes/configs/queries/useK8sSecrets'; import { Icon } from '@@/Icon'; import { Link } from '@@/Link'; @@ -18,7 +18,7 @@ type Props = { export function ApplicationVolumeConfigsTable({ namespace, app }: Props) { const containerVolumeConfigs = getApplicationVolumeConfigs(app); - const { data: secrets } = useSecrets(useEnvironmentId(), namespace); + const { data: secrets } = useK8sSecrets(useEnvironmentId(), namespace); if (containerVolumeConfigs.length === 0) { return null; diff --git a/app/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection.tsx b/app/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection.tsx index 84a9fd5e0..03761146d 100644 --- a/app/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection.tsx +++ b/app/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection.tsx @@ -1,7 +1,7 @@ import { FormikErrors } from 'formik'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; -import { useConfigMaps } from '@/react/kubernetes/configs/configmap.service'; +import { useK8sConfigMaps } from '@/react/kubernetes/configs/queries/useK8sConfigMaps'; import { FormSection } from '@@/form-components/FormSection/FormSection'; import { TextTip } from '@@/Tip/TextTip'; @@ -24,7 +24,7 @@ export function ConfigMapsFormSection({ errors, namespace, }: Props) { - const configMapsQuery = useConfigMaps(useEnvironmentId(), namespace); + const configMapsQuery = useK8sConfigMaps(useEnvironmentId(), namespace); const configMaps = configMapsQuery.data || []; if (configMapsQuery.isLoading) { diff --git a/app/react/kubernetes/applications/components/ConfigurationsFormSection/SecretsFormSection.tsx b/app/react/kubernetes/applications/components/ConfigurationsFormSection/SecretsFormSection.tsx index 2262550b1..220b9754d 100644 --- a/app/react/kubernetes/applications/components/ConfigurationsFormSection/SecretsFormSection.tsx +++ b/app/react/kubernetes/applications/components/ConfigurationsFormSection/SecretsFormSection.tsx @@ -1,7 +1,7 @@ import { FormikErrors } from 'formik'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; -import { useSecrets } from '@/react/kubernetes/configs/secret.service'; +import { useK8sSecrets } from '@/react/kubernetes/configs/queries/useK8sSecrets'; import { FormSection } from '@@/form-components/FormSection/FormSection'; import { TextTip } from '@@/Tip/TextTip'; @@ -24,7 +24,7 @@ export function SecretsFormSection({ errors, namespace, }: Props) { - const secretsQuery = useSecrets(useEnvironmentId(), namespace); + const secretsQuery = useK8sSecrets(useEnvironmentId(), namespace); const secrets = secretsQuery.data || []; if (secretsQuery.isLoading) { diff --git a/app/react/kubernetes/cluster/ConfigureView/.keep b/app/react/kubernetes/cluster/ConfigureView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx index 7c809f52e..cc87798c2 100644 --- a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx @@ -23,7 +23,7 @@ import { InsightsBox } from '@@/InsightsBox'; import { useIngressControllerClassMapQuery } from '../../ingressClass/useIngressControllerClassMap'; import { IngressControllerClassMap } from '../../ingressClass/types'; -import { useIsRBACEnabledQuery } from '../../getIsRBACEnabled'; +import { useIsRBACEnabled } from '../../useIsRBACEnabled'; import { getIngressClassesFormValues } from '../../ingressClass/IngressClassDatatable/utils'; import { useStorageClassesFormValues } from './useStorageClasses'; @@ -102,7 +102,7 @@ function InnerForm({ environmentId: EnvironmentId; }) { const { data: isRBACEnabled, ...isRBACEnabledQuery } = - useIsRBACEnabledQuery(environmentId); + useIsRBACEnabled(environmentId); const onChangeControllers = useCallback( (controllerClassMap: IngressControllerClassMap[]) => diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx.rej b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx.rej deleted file mode 100644 index 8866d8e9c..000000000 --- a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx.rej +++ /dev/null @@ -1,13 +0,0 @@ -diff a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx (rejected hunks) -@@ -103,7 +103,10 @@ export function EnableMetricsInput({ value, error, environmentId }: Props) { - - Unable to reach metrics API. You can enable the metrics-server - addon in the{' '} -- Cluster Details view. -+ -+ Cluster Details view -+ -+ . - - )} - {metricsFound === true && ( diff --git a/app/react/kubernetes/cluster/getIsRBACEnabled.ts b/app/react/kubernetes/cluster/useIsRBACEnabled.ts similarity index 69% rename from app/react/kubernetes/cluster/getIsRBACEnabled.ts rename to app/react/kubernetes/cluster/useIsRBACEnabled.ts index e6f9e5963..02f604071 100644 --- a/app/react/kubernetes/cluster/getIsRBACEnabled.ts +++ b/app/react/kubernetes/cluster/useIsRBACEnabled.ts @@ -2,20 +2,20 @@ import { useQuery } from '@tanstack/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { withError } from '@/react-tools/react-query'; +import { withGlobalError } from '@/react-tools/react-query'; -export function useIsRBACEnabledQuery(environmentId: EnvironmentId) { +export function useIsRBACEnabled(environmentId: EnvironmentId) { return useQuery( ['environments', environmentId, 'rbacEnabled'], () => getIsRBACEnabled(environmentId), { enabled: !!environmentId, - ...withError('Unable to check if RBAC is enabled.'), + ...withGlobalError('Unable to check if RBAC is enabled.'), } ); } -export async function getIsRBACEnabled(environmentId: EnvironmentId) { +async function getIsRBACEnabled(environmentId: EnvironmentId) { try { const { data } = await axios.get( `kubernetes/${environmentId}/rbac_enabled` diff --git a/app/react/kubernetes/configs/constants.ts b/app/react/kubernetes/configs/constants.ts index a444ddf74..c0d15a55d 100644 --- a/app/react/kubernetes/configs/constants.ts +++ b/app/react/kubernetes/configs/constants.ts @@ -1,2 +1,11 @@ -export const configurationOwnerUsernameLabel = +export const ConfigurationOwnerUsernameLabel = 'io.portainer.kubernetes.configuration.owner'; + +export const ConfigurationOwnerIdLabel = + 'io.portainer.kubernetes.configuration.owner.id'; + +export const PortainerNamespaceAccessesConfigMap = { + namespace: 'portainer', + configMapName: 'portainer-config', + accessKey: 'NamespaceAccessPolicies', +}; diff --git a/app/react/kubernetes/configs/queries/useConfigMap.ts b/app/react/kubernetes/configs/queries/useConfigMap.ts index 2dfbed86f..e7937d328 100644 --- a/app/react/kubernetes/configs/queries/useConfigMap.ts +++ b/app/react/kubernetes/configs/queries/useConfigMap.ts @@ -9,33 +9,37 @@ import { Configuration } from '../types'; import { configMapQueryKeys } from './query-keys'; import { ConfigMapQueryParams } from './types'; -export function useConfigMap( +export function useConfigMap( environmentId: EnvironmentId, namespace: string, configMap: string, - options?: { autoRefreshRate?: number } & ConfigMapQueryParams + options?: { + autoRefreshRate?: number; + select?: (data: Configuration) => T; + enabled?: boolean; + } & ConfigMapQueryParams ) { return useQuery( configMapQueryKeys.configMap(environmentId, namespace, configMap), () => getConfigMap(environmentId, namespace, configMap, { withData: true }), { - ...withGlobalError('Unable to retrieve ConfigMaps for cluster'), - refetchInterval() { - return options?.autoRefreshRate ?? false; - }, + select: options?.select, + enabled: options?.enabled, + refetchInterval: () => options?.autoRefreshRate ?? false, + ...withGlobalError(`Unable to retrieve ConfigMap '${configMap}'`), } ); } // get a configmap -async function getConfigMap( +export async function getConfigMap( environmentId: EnvironmentId, namespace: string, configMap: string, params?: { withData?: boolean } ) { try { - const { data } = await axios.get( + const { data } = await axios.get( `/kubernetes/${environmentId}/namespaces/${namespace}/configmaps/${configMap}`, { params } ); diff --git a/app/react/kubernetes/configs/queries/useK8sConfigMaps.ts b/app/react/kubernetes/configs/queries/useK8sConfigMaps.ts new file mode 100644 index 000000000..75aabc1a2 --- /dev/null +++ b/app/react/kubernetes/configs/queries/useK8sConfigMaps.ts @@ -0,0 +1,63 @@ +import { ConfigMap, ConfigMapList } from 'kubernetes-types/core/v1'; +import { useQuery } from '@tanstack/react-query'; + +import axios from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { error as notifyError } from '@/portainer/services/notifications'; + +import { parseKubernetesAxiosError } from '../../axiosError'; + +export const configMapQueryKeys = { + configMaps: (environmentId: EnvironmentId, namespace?: string) => [ + 'environments', + environmentId, + 'kubernetes', + 'configmaps', + 'namespaces', + namespace, + ], +}; + +/** + * returns a usequery hook for the list of configmaps from the kubernetes API + */ +export function useK8sConfigMaps( + environmentId: EnvironmentId, + namespace?: string +) { + return useQuery( + configMapQueryKeys.configMaps(environmentId, namespace), + () => (namespace ? getConfigMaps(environmentId, namespace) : []), + { + onError: (err) => { + notifyError( + 'Failure', + err as Error, + `Unable to get ConfigMaps in namespace '${namespace}'` + ); + }, + enabled: !!namespace, + } + ); +} + +// get all configmaps for a namespace +async function getConfigMaps(environmentId: EnvironmentId, namespace: string) { + try { + const { data } = await axios.get( + buildUrl(environmentId, namespace) + ); + const configMapsWithKind: ConfigMap[] = data.items.map((configmap) => ({ + ...configmap, + kind: 'ConfigMap', + })); + return configMapsWithKind; + } catch (e) { + throw parseKubernetesAxiosError(e, 'Unable to retrieve ConfigMaps'); + } +} + +function buildUrl(environmentId: number, namespace: string, name?: string) { + const url = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/configmaps`; + return name ? `${url}/${name}` : url; +} diff --git a/app/react/kubernetes/configs/queries/useK8sSecrets.ts b/app/react/kubernetes/configs/queries/useK8sSecrets.ts new file mode 100644 index 000000000..71649ddb2 --- /dev/null +++ b/app/react/kubernetes/configs/queries/useK8sSecrets.ts @@ -0,0 +1,63 @@ +import { Secret, SecretList } from 'kubernetes-types/core/v1'; +import { useQuery } from '@tanstack/react-query'; + +import axios from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { error as notifyError } from '@/portainer/services/notifications'; + +import { parseKubernetesAxiosError } from '../../axiosError'; + +export const secretQueryKeys = { + secrets: (environmentId: EnvironmentId, namespace?: string) => [ + 'environments', + environmentId, + 'kubernetes', + 'secrets', + 'namespaces', + namespace, + ], +}; + +/** + * returns a usequery hook for the list of secrets from the kubernetes API + */ +export function useK8sSecrets( + environmentId: EnvironmentId, + namespace?: string +) { + return useQuery( + secretQueryKeys.secrets(environmentId, namespace), + () => (namespace ? getSecrets(environmentId, namespace) : []), + { + onError: (err) => { + notifyError( + 'Failure', + err as Error, + `Unable to get secrets in namespace '${namespace}'` + ); + }, + enabled: !!namespace, + } + ); +} + +// get all secrets for a namespace +async function getSecrets(environmentId: EnvironmentId, namespace: string) { + try { + const { data } = await axios.get( + buildUrl(environmentId, namespace) + ); + const secretsWithKind: Secret[] = data.items.map((secret) => ({ + ...secret, + kind: 'Secret', + })); + return secretsWithKind; + } catch (e) { + throw parseKubernetesAxiosError(e, 'Unable to retrieve secrets'); + } +} + +function buildUrl(environmentId: number, namespace: string, name?: string) { + const url = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/secrets`; + return name ? `${url}/${name}` : url; +} diff --git a/app/react/kubernetes/configs/queries/useUpdateK8sConfigMapMutation.ts b/app/react/kubernetes/configs/queries/useUpdateK8sConfigMapMutation.ts new file mode 100644 index 000000000..789e8a3a1 --- /dev/null +++ b/app/react/kubernetes/configs/queries/useUpdateK8sConfigMapMutation.ts @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ConfigMap } from 'kubernetes-types/core/v1'; + +import axios from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { withInvalidate } from '@/react-tools/react-query'; + +import { parseKubernetesAxiosError } from '../../axiosError'; + +import { configMapQueryKeys } from './useK8sConfigMaps'; + +/** + * useUpdateK8sConfigMapMutation returns a mutation hook for updating a Kubernetes ConfigMap using the Kubernetes proxy API. + */ +export function useUpdateK8sConfigMapMutation( + environmentId: EnvironmentId, + namespace: string +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + data, + configMapName, + }: { + data: ConfigMap; + configMapName: string; + }) => updateConfigMap(environmentId, namespace, configMapName, data), + ...withInvalidate(queryClient, [ + configMapQueryKeys.configMaps(environmentId, namespace), + ]), + // handle success notifications in the calling component + }); +} + +async function updateConfigMap( + environmentId: EnvironmentId, + namespace: string, + configMap: string, + data: ConfigMap +) { + try { + return await axios.put( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/configmaps/${configMap}`, + data + ); + } catch (e) { + throw parseKubernetesAxiosError( + e, + `Unable to update ConfigMap '${configMap}'` + ); + } +} diff --git a/app/react/kubernetes/configs/secret.service.ts b/app/react/kubernetes/configs/secret.service.ts deleted file mode 100644 index 232714a8e..000000000 --- a/app/react/kubernetes/configs/secret.service.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { Secret, SecretList } from 'kubernetes-types/core/v1'; -import { useMutation, useQuery } from '@tanstack/react-query'; - -import { queryClient, withError } from '@/react-tools/react-query'; -import axios from '@/portainer/services/axios'; -import { EnvironmentId } from '@/react/portainer/environments/types'; -import { - error as notifyError, - notifySuccess, -} from '@/portainer/services/notifications'; -import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils'; -import { pluralize } from '@/portainer/helpers/strings'; - -import { parseKubernetesAxiosError } from '../axiosError'; - -export const secretQueryKeys = { - secrets: (environmentId: EnvironmentId, namespace?: string) => [ - 'environments', - environmentId, - 'kubernetes', - 'secrets', - 'namespaces', - namespace, - ], - secretsForCluster: (environmentId: EnvironmentId) => [ - 'environments', - environmentId, - 'kubernetes', - 'secrets', - ], -}; - -// returns a usequery hook for the list of secrets from the kubernetes API -export function useSecrets(environmentId: EnvironmentId, namespace?: string) { - return useQuery( - secretQueryKeys.secrets(environmentId, namespace), - () => (namespace ? getSecrets(environmentId, namespace) : []), - { - onError: (err) => { - notifyError( - 'Failure', - err as Error, - `Unable to get secrets in namespace '${namespace}'` - ); - }, - enabled: !!namespace, - } - ); -} - -export function useSecretsForCluster( - environmentId: EnvironmentId, - namespaces?: string[], - options?: { autoRefreshRate?: number } -) { - return useQuery( - secretQueryKeys.secretsForCluster(environmentId), - () => namespaces && getSecretsForCluster(environmentId, namespaces), - { - ...withError('Unable to retrieve secrets for cluster'), - enabled: !!namespaces?.length, - refetchInterval() { - return options?.autoRefreshRate ?? false; - }, - } - ); -} - -export function useMutationDeleteSecrets(environmentId: EnvironmentId) { - return useMutation( - async (secrets: { namespace: string; name: string }[]) => { - const promises = await Promise.allSettled( - secrets.map(({ namespace, name }) => - deleteSecret(environmentId, namespace, name) - ) - ); - const successfulSecrets = promises - .filter(isFulfilled) - .map((_, index) => secrets[index].name); - const failedSecrets = promises - .filter(isRejected) - .map(({ reason }, index) => ({ - name: secrets[index].name, - reason, - })); - return { failedSecrets, successfulSecrets }; - }, - { - ...withError('Unable to remove secrets'), - onSuccess: ({ failedSecrets, successfulSecrets }) => { - queryClient.invalidateQueries( - secretQueryKeys.secretsForCluster(environmentId) - ); - // show an error message for each secret that failed to delete - failedSecrets.forEach(({ name, reason }) => { - notifyError( - `Failed to remove secret '${name}'`, - new Error(reason.message) as Error - ); - }); - // show one summary message for all successful deletes - if (successfulSecrets.length) { - notifySuccess( - `${pluralize( - successfulSecrets.length, - 'Secret' - )} successfully removed`, - successfulSecrets.join(', ') - ); - } - }, - } - ); -} - -async function getSecretsForCluster( - environmentId: EnvironmentId, - namespaces: string[] -) { - const secrets = await Promise.all( - namespaces.map((namespace) => getSecrets(environmentId, namespace)) - ); - return secrets.flat(); -} - -// get all secrets for a namespace -async function getSecrets(environmentId: EnvironmentId, namespace: string) { - try { - const { data } = await axios.get( - buildUrl(environmentId, namespace) - ); - const secretsWithKind: Secret[] = data.items.map((secret) => ({ - ...secret, - kind: 'Secret', - })); - return secretsWithKind; - } catch (e) { - throw parseKubernetesAxiosError(e, 'Unable to retrieve secrets'); - } -} - -async function deleteSecret( - environmentId: EnvironmentId, - namespace: string, - name: string -) { - try { - await axios.delete(buildUrl(environmentId, namespace, name)); - } catch (e) { - throw parseKubernetesAxiosError(e, 'Unable to remove secret'); - } -} - -function buildUrl(environmentId: number, namespace: string, name?: string) { - const url = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/secrets`; - return name ? `${url}/${name}` : url; -} diff --git a/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx b/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx index 414c6d625..04b544dbe 100644 --- a/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx +++ b/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx @@ -4,7 +4,7 @@ import { v4 as uuidv4 } from 'uuid'; import { debounce } from 'lodash'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; -import { useSecrets } from '@/react/kubernetes/configs/secret.service'; +import { useK8sSecrets } from '@/react/kubernetes/configs/queries/useK8sSecrets'; import { useNamespaceServices } from '@/react/kubernetes/networks/services/queries'; import { notifyError, notifySuccess } from '@/portainer/services/notifications'; import { useAuthorizations } from '@/react/hooks/useUser'; @@ -70,7 +70,7 @@ export function CreateIngressView() { useNamespacesQuery(environmentId); const { data: allServices } = useNamespaceServices(environmentId, namespace); - const secretsResults = useSecrets(environmentId, namespace); + const secretsResults = useK8sSecrets(environmentId, namespace); const ingressesResults = useIngresses(environmentId); const { data: ingressControllers, ...ingressControllersQuery } = useIngressControllers(environmentId, namespace); diff --git a/app/react/kubernetes/namespaces/AccessView/.keep b/app/react/kubernetes/namespaces/AccessView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/namespaces/AccessView/AccessDatatable.tsx b/app/react/kubernetes/namespaces/AccessView/AccessDatatable.tsx deleted file mode 100644 index 20915e494..000000000 --- a/app/react/kubernetes/namespaces/AccessView/AccessDatatable.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { UserX } from 'lucide-react'; - -import { name } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/columns/name'; -import { type } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/columns/type'; -import { Access } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/types'; -import { RemoveAccessButton } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/RemoveAccessButton'; - -import { createPersistedStore } from '@@/datatables/types'; -import { useTableState } from '@@/datatables/useTableState'; -import { Datatable } from '@@/datatables'; - -const tableKey = 'kubernetes_resourcepool_access'; -const columns = [name, type]; -const store = createPersistedStore(tableKey); - -export function NamespaceAccessDatatable({ - dataset, - onRemove, -}: { - dataset?: Array; - onRemove(items: Array): void; -}) { - const tableState = useTableState(store, tableKey); - - return ( - ( - - )} - /> - ); -} diff --git a/app/react/kubernetes/namespaces/AccessView/AccessDatatable/AccessDatatable.tsx b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/AccessDatatable.tsx new file mode 100644 index 000000000..c30a06a31 --- /dev/null +++ b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/AccessDatatable.tsx @@ -0,0 +1,107 @@ +import { UserX } from 'lucide-react'; +import { useMemo } from 'react'; +import { useCurrentStateAndParams, useRouter } from '@uirouter/react'; + +import { useUsers } from '@/portainer/users/queries'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useTeams } from '@/react/portainer/users/teams/queries'; +import { PortainerNamespaceAccessesConfigMap } from '@/react/kubernetes/configs/constants'; +import { useConfigMap } from '@/react/kubernetes/configs/queries/useConfigMap'; +import { useUpdateK8sConfigMapMutation } from '@/react/kubernetes/configs/queries/useUpdateK8sConfigMapMutation'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; + +import { createPersistedStore } from '@@/datatables/types'; +import { useTableState } from '@@/datatables/useTableState'; +import { Datatable } from '@@/datatables'; +import { DeleteButton } from '@@/buttons/DeleteButton'; + +import { parseNamespaceAccesses } from '../parseNamespaceAccesses'; +import { NamespaceAccess } from '../types'; +import { createUnauthorizeAccessConfigMapPayload } from '../createAccessConfigMapPayload'; + +import { entityType } from './columns/type'; +import { name } from './columns/name'; + +const tableKey = 'kubernetes_resourcepool_access'; +const columns = [name, entityType]; +const store = createPersistedStore(tableKey); + +export function AccessDatatable() { + const { + params: { id: namespaceName }, + } = useCurrentStateAndParams(); + const router = useRouter(); + const environmentId = useEnvironmentId(); + const tableState = useTableState(store, tableKey); + const usersQuery = useUsers(false, environmentId); + const teamsQuery = useTeams(false, environmentId); + const accessConfigMapQuery = useConfigMap( + environmentId, + PortainerNamespaceAccessesConfigMap.namespace, + PortainerNamespaceAccessesConfigMap.configMapName + ); + const namespaceAccesses = useMemo( + () => + parseNamespaceAccesses( + accessConfigMapQuery.data ?? null, + namespaceName, + usersQuery.data ?? [], + teamsQuery.data ?? [] + ), + [accessConfigMapQuery.data, usersQuery.data, teamsQuery.data, namespaceName] + ); + const configMap = accessConfigMapQuery.data; + + const updateConfigMapMutation = useUpdateK8sConfigMapMutation( + environmentId, + PortainerNamespaceAccessesConfigMap.namespace + ); + + return ( + `${row.type}-${row.id}`} + renderTableActions={(selectedItems) => ( + handleUpdate(selectedItems)} + disabled={ + selectedItems.length === 0 || + usersQuery.isLoading || + teamsQuery.isLoading || + accessConfigMapQuery.isLoading + } + data-cy="remove-access-button" + /> + )} + /> + ); + + async function handleUpdate(selectedItemsToRemove: Array) { + try { + const configMapPayload = createUnauthorizeAccessConfigMapPayload( + namespaceAccesses, + selectedItemsToRemove, + namespaceName, + configMap + ); + await updateConfigMapMutation.mutateAsync({ + data: configMapPayload, + configMapName: PortainerNamespaceAccessesConfigMap.configMapName, + }); + notifySuccess('Success', 'Namespace access updated'); + router.stateService.reload(); + } catch (error) { + notifyError('Failed to update namespace access', error as Error); + } + } +} diff --git a/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/helper.ts b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/helper.ts new file mode 100644 index 000000000..19d260766 --- /dev/null +++ b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/helper.ts @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { NamespaceAccess } from '../../types'; + +export const helper = createColumnHelper(); diff --git a/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/name.ts b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/name.ts new file mode 100644 index 000000000..34b63728c --- /dev/null +++ b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/name.ts @@ -0,0 +1,5 @@ +import { helper } from './helper'; + +export const name = helper.accessor('name', { + header: 'Name', +}); diff --git a/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/type.ts b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/type.ts new file mode 100644 index 000000000..317c4998d --- /dev/null +++ b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/type.ts @@ -0,0 +1,5 @@ +import { helper } from './helper'; + +export const entityType = helper.accessor('type', { + header: 'Type', +}); diff --git a/app/react/kubernetes/namespaces/AccessView/AccessView.tsx b/app/react/kubernetes/namespaces/AccessView/AccessView.tsx new file mode 100644 index 000000000..495c99b50 --- /dev/null +++ b/app/react/kubernetes/namespaces/AccessView/AccessView.tsx @@ -0,0 +1,39 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; + +import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect'; + +import { PageHeader } from '@@/PageHeader'; + +import { NamespaceDetailsWidget } from './NamespaceDetailsWidget'; +import { AccessDatatable } from './AccessDatatable/AccessDatatable'; +import { CreateAccessWidget } from './CreateAccessWidget/CreateAccessWidget'; + +export function AccessView() { + const { + params: { id: namespaceName }, + } = useCurrentStateAndParams(); + useUnauthorizedRedirect( + { authorizations: ['K8sResourcePoolDetailsW'] }, + { to: 'kubernetes.resourcePools' } + ); + return ( + <> + + + + + + ); +} diff --git a/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessInnerForm.tsx b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessInnerForm.tsx new file mode 100644 index 000000000..78797394d --- /dev/null +++ b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessInnerForm.tsx @@ -0,0 +1,199 @@ +import { Form, FormikProps } from 'formik'; +import { Plus } from 'lucide-react'; +import { useMemo } from 'react'; + +import { useEnvironment } from '@/react/portainer/environments/queries'; +import { useGroup } from '@/react/portainer/environments/environment-groups/queries'; +import { useUsers } from '@/portainer/users/queries'; +import { useTeams } from '@/react/portainer/users/teams/queries/useTeams'; +import { User } from '@/portainer/users/types'; +import { Environment } from '@/react/portainer/environments/types'; +import { Team } from '@/react/portainer/users/teams/types'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types'; +import { useIsEdgeAdmin } from '@/react/hooks/useUser'; + +import { LoadingButton } from '@@/buttons'; +import { FormControl } from '@@/form-components/FormControl'; +import { Link } from '@@/Link'; + +import { NamespaceAccessUsersSelector } from '../NamespaceAccessUsersSelector'; +import { EnvironmentAccess, NamespaceAccess } from '../types'; + +import { CreateAccessValues } from './types'; + +export function CreateAccessInnerForm({ + values, + handleSubmit, + setFieldValue, + isSubmitting, + isValid, + dirty, + namespaceAccessesGranted, +}: FormikProps & { + namespaceAccessesGranted: NamespaceAccess[]; +}) { + const environmentId = useEnvironmentId(); + const environmentQuery = useEnvironment(environmentId); + const groupQuery = useGroup(environmentQuery.data?.GroupId); + const usersQuery = useUsers(false, environmentId); + const teamsQuery = useTeams(); + const availableTeamOrUserOptions: EnvironmentAccess[] = + useAvailableTeamOrUserOptions( + values.selectedUsersAndTeams, + namespaceAccessesGranted, + environmentQuery.data, + groupQuery.data, + usersQuery.data, + teamsQuery.data + ); + const isAdminQuery = useIsEdgeAdmin(); + return ( +
+ + {availableTeamOrUserOptions.length > 0 || + values.selectedUsersAndTeams.length > 0 ? ( + setFieldValue('selectedUsersAndTeams', opts)} + value={values.selectedUsersAndTeams} + dataCy="namespaceAccess-usersSelector" + /> + ) : ( + + No user or team access has been set on the environment. + {isAdminQuery.isAdmin && ( + <> + {' '} + Head over to the{' '} + + Environments view + {' '} + to manage them. + + )} + + )} + +
+
+ + Create access + +
+
+
+ ); +} + +/** + * Returns the team and user options that can be added to the namespace, excluding the ones that already have access. + */ +function useAvailableTeamOrUserOptions( + selectedAccesses: EnvironmentAccess[], + namespaceAccessesGranted: NamespaceAccess[], + environment?: Environment, + group?: EnvironmentGroup, + users?: User[], + teams?: Team[] +) { + return useMemo(() => { + // get unique users and teams from environment accesses (the keys are the IDs) + const environmentAccessPolicies = environment?.UserAccessPolicies ?? {}; + const environmentTeamAccessPolicies = environment?.TeamAccessPolicies ?? {}; + const environmentGroupAccessPolicies = group?.UserAccessPolicies ?? {}; + const environmentGroupTeamAccessPolicies = group?.TeamAccessPolicies ?? {}; + + // get all users that have access to the environment + const userAccessPolicies = { + ...environmentAccessPolicies, + ...environmentGroupAccessPolicies, + }; + const uniqueUserIds = new Set(Object.keys(userAccessPolicies)); + const userAccessOptions: EnvironmentAccess[] = Array.from(uniqueUserIds) + .map((id) => { + const userId = parseInt(id, 10); + const user = users?.find((u) => u.Id === userId); + if (!user) { + return null; + } + // role from the userAccessPolicies is used by default, if not found, role from the environmentTeamAccessPolicies is used + const userAccessPolicy = + environmentAccessPolicies[userId] ?? + environmentGroupAccessPolicies[userId]; + const userAccess: EnvironmentAccess = { + id: user?.Id, + name: user?.Username, + type: 'user', + role: { + name: 'Standard user', + id: userAccessPolicy?.RoleId, + }, + }; + return userAccess; + }) + .filter((u) => u !== null); + + // get all teams that have access to the environment + const teamAccessPolicies = { + ...environmentTeamAccessPolicies, + ...environmentGroupTeamAccessPolicies, + }; + const uniqueTeamIds = new Set(Object.keys(teamAccessPolicies)); + const teamAccessOptions: EnvironmentAccess[] = Array.from(uniqueTeamIds) + .map((id) => { + const teamId = parseInt(id, 10); + const team = teams?.find((t) => t.Id === teamId); + if (!team) { + return null; + } + const teamAccessPolicy = + environmentTeamAccessPolicies[teamId] ?? + environmentGroupTeamAccessPolicies[teamId]; + const teamAccess: EnvironmentAccess = { + id: team?.Id, + name: team?.Name, + type: 'team', + role: { + name: 'Standard user', + id: teamAccessPolicy?.RoleId, + }, + }; + return teamAccess; + }) + .filter((t) => t !== null); + + // filter out users and teams that already have access to the namespace + const userAndTeamEnvironmentAccesses = [ + ...userAccessOptions, + ...teamAccessOptions, + ]; + const filteredAccessOptions = userAndTeamEnvironmentAccesses.filter( + (t) => + !selectedAccesses.some((e) => e.id === t.id && e.type === t.type) && + !namespaceAccessesGranted.some( + (e) => e.id === t.id && e.type === t.type + ) + ); + + return filteredAccessOptions; + }, [ + namespaceAccessesGranted, + selectedAccesses, + environment, + group, + users, + teams, + ]); +} diff --git a/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessWidget.tsx b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessWidget.tsx new file mode 100644 index 000000000..3625d373e --- /dev/null +++ b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessWidget.tsx @@ -0,0 +1,119 @@ +import { UserPlusIcon } from 'lucide-react'; +import { Formik } from 'formik'; +import { useMemo } from 'react'; +import { useCurrentStateAndParams } from '@uirouter/react'; + +import { useIsRBACEnabled } from '@/react/kubernetes/cluster/useIsRBACEnabled'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { RBACAlert } from '@/react/kubernetes/cluster/ConfigureView/ConfigureForm/RBACAlert'; +import { useUsers } from '@/portainer/users/queries'; +import { PortainerNamespaceAccessesConfigMap } from '@/react/kubernetes/configs/constants'; +import { useConfigMap } from '@/react/kubernetes/configs/queries/useConfigMap'; +import { useTeams } from '@/react/portainer/users/teams/queries'; +import { useUpdateK8sConfigMapMutation } from '@/react/kubernetes/configs/queries/useUpdateK8sConfigMapMutation'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; + +import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; +import { TextTip } from '@@/Tip/TextTip'; + +import { EnvironmentAccess } from '../types'; +import { createAuthorizeAccessConfigMapPayload } from '../createAccessConfigMapPayload'; +import { parseNamespaceAccesses } from '../parseNamespaceAccesses'; + +import { CreateAccessValues } from './types'; +import { CreateAccessInnerForm } from './CreateAccessInnerForm'; +import { validationSchema } from './createAccess.validation'; + +export function CreateAccessWidget() { + const { + params: { id: namespaceName }, + } = useCurrentStateAndParams(); + const environmentId = useEnvironmentId(); + const isRBACEnabledQuery = useIsRBACEnabled(environmentId); + const initialValues: { + selectedUsersAndTeams: EnvironmentAccess[]; + } = { + selectedUsersAndTeams: [], + }; + const usersQuery = useUsers(false, environmentId); + const teamsQuery = useTeams(false, environmentId); + const accessConfigMapQuery = useConfigMap( + environmentId, + PortainerNamespaceAccessesConfigMap.namespace, + PortainerNamespaceAccessesConfigMap.configMapName + ); + const namespaceAccesses = useMemo( + () => + parseNamespaceAccesses( + accessConfigMapQuery.data ?? null, + namespaceName, + usersQuery.data ?? [], + teamsQuery.data ?? [] + ), + [accessConfigMapQuery.data, usersQuery.data, teamsQuery.data, namespaceName] + ); + const configMap = accessConfigMapQuery.data; + + const updateConfigMapMutation = useUpdateK8sConfigMapMutation( + environmentId, + PortainerNamespaceAccessesConfigMap.namespace + ); + + return ( +
+
+ + + + {isRBACEnabledQuery.data === false && } + + Adding user access will require the affected user(s) to logout and + login for the changes to be taken into account. + + {isRBACEnabledQuery.data !== false && ( + + initialValues={initialValues} + enableReinitialize + validationSchema={validationSchema} + onSubmit={onSubmit} + validateOnMount + > + {(formikProps) => ( + + )} + + )} + + +
+
+ ); + + async function onSubmit( + values: { + selectedUsersAndTeams: EnvironmentAccess[]; + }, + { resetForm }: { resetForm: () => void } + ) { + try { + const configMapPayload = createAuthorizeAccessConfigMapPayload( + namespaceAccesses, + values.selectedUsersAndTeams, + namespaceName, + configMap + ); + await updateConfigMapMutation.mutateAsync({ + data: configMapPayload, + configMapName: PortainerNamespaceAccessesConfigMap.configMapName, + }); + notifySuccess('Success', 'Namespace access updated'); + resetForm(); + } catch (error) { + notifyError('Failed to update namespace access', error as Error); + } + } +} diff --git a/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/createAccess.validation.ts b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/createAccess.validation.ts new file mode 100644 index 000000000..1f634a776 --- /dev/null +++ b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/createAccess.validation.ts @@ -0,0 +1,19 @@ +import { object, array, string, number, SchemaOf, mixed } from 'yup'; + +import { CreateAccessValues } from './types'; + +export function validationSchema(): SchemaOf { + return object().shape({ + selectedUsersAndTeams: array( + object().shape({ + type: mixed().oneOf(['team', 'user']).required(), + name: string().required(), + id: number().required(), + role: object().shape({ + id: number().required(), + name: string().required(), + }), + }) + ).min(1), + }); +} diff --git a/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/types.ts b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/types.ts new file mode 100644 index 000000000..a5421a105 --- /dev/null +++ b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/types.ts @@ -0,0 +1,5 @@ +import { EnvironmentAccess } from '../types'; + +export type CreateAccessValues = { + selectedUsersAndTeams: EnvironmentAccess[]; +}; diff --git a/app/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector.tsx b/app/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector.tsx index 77c371f28..f3d9f02f1 100644 --- a/app/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector.tsx +++ b/app/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector.tsx @@ -3,14 +3,13 @@ import { OptionProps, components, MultiValueGenericProps } from 'react-select'; import { Select } from '@@/form-components/ReactSelect'; -type Role = { Name: string }; -type Option = { Type: 'user' | 'team'; Id: number; Name: string; Role: Role }; +import { EnvironmentAccess } from './types'; interface Props { name?: string; - value: Option[]; - onChange(value: readonly Option[]): void; - options: Option[]; + value: EnvironmentAccess[]; + onChange(value: readonly EnvironmentAccess[]): void; + options: EnvironmentAccess[]; dataCy: string; inputId?: string; placeholder?: string; @@ -29,8 +28,8 @@ export function NamespaceAccessUsersSelector({