diff --git a/app/portainer/components/accessControlForm/porAccessControlFormController.js b/app/portainer/components/accessControlForm/porAccessControlFormController.js index 3fa3a0bf6..d8d247ec9 100644 --- a/app/portainer/components/accessControlForm/porAccessControlFormController.js +++ b/app/portainer/components/accessControlForm/porAccessControlFormController.js @@ -64,7 +64,7 @@ angular.module('portainer.app').controller('porAccessControlFormController', [ this.$onInit = $onInit; function $onInit() { - var isAdmin = Authentication.isAdmin(); + var isAdmin = Authentication.isPureAdmin(); ctrl.isAdmin = isAdmin; if (isAdmin) { diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index 86e167e29..09e9bccad 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -1,4 +1,5 @@ import { getCurrentUser } from '../users/queries/useLoadCurrentUser'; +import * as userHelpers from '../users/user.helpers'; import { clear as clearSessionStorage } from './session-storage'; const DEFAULT_USER = 'admin'; @@ -25,6 +26,9 @@ angular.module('portainer.app').factory('Authentication', [ service.isAuthenticated = isAuthenticated; service.getUserDetails = getUserDetails; service.isAdmin = isAdmin; + service.isEdgeAdmin = isEdgeAdmin; + service.isPureAdmin = isPureAdmin; + service.hasAuthorizations = hasAuthorizations; async function initAsync() { try { @@ -120,8 +124,36 @@ angular.module('portainer.app').factory('Authentication', [ return login(DEFAULT_USER, DEFAULT_PASSWORD); } + // To avoid creating divergence between CE and EE + // isAdmin checks if the user is a portainer admin or edge admin + function isEdgeAdmin() { + const environment = EndpointProvider.currentEndpoint(); + return userHelpers.isEdgeAdmin({ Role: user.role }, environment); + } + + /** + * @deprecated use Authentication.isAdmin instead + */ function isAdmin() { - return !!user && user.role === 1; + return isEdgeAdmin(); + } + + // To avoid creating divergence between CE and EE + // isPureAdmin checks if the user is portainer admin only + function isPureAdmin() { + return userHelpers.isPureAdmin({ Role: user.role }); + } + + function hasAuthorizations(authorizations) { + const endpointId = EndpointProvider.endpointID(); + if (isAdmin()) { + return true; + } + if (!user.endpointAuthorizations || !user.endpointAuthorizations[endpointId]) { + return false; + } + const userEndpointAuthorizations = user.endpointAuthorizations[endpointId]; + return authorizations.some((authorization) => userEndpointAuthorizations[authorization]); } if (process.env.NODE_ENV === 'development') { diff --git a/app/portainer/users/queries.ts b/app/portainer/users/queries.ts index cb4c51f9f..ab7c0d246 100644 --- a/app/portainer/users/queries.ts +++ b/app/portainer/users/queries.ts @@ -1,9 +1,9 @@ import { useQuery } from 'react-query'; import { TeamRole, TeamMembership } from '@/react/portainer/users/teams/types'; +import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser'; import { User, UserId } from './types'; -import { isAdmin } from './user.helpers'; import { getUserMemberships, getUsers } from './user.service'; interface UseUserMembershipOptions { @@ -22,14 +22,21 @@ export function useUserMembership( ); } -export function useIsTeamLeader(user: User) { +export function useIsCurrentUserTeamLeader() { + const { user } = useCurrentUser(); + const isAdminQuery = useIsEdgeAdmin(); + const query = useUserMembership(user.Id, { - enabled: !isAdmin(user), + enabled: !isAdminQuery.isLoading && !isAdminQuery.isAdmin, select: (memberships) => memberships.some((membership) => membership.Role === TeamRole.Leader), }); - return isAdmin(user) ? true : query.data; + if (isAdminQuery.isLoading) { + return false; + } + + return isAdminQuery.isAdmin ? true : !!query.data; } export function useUsers( diff --git a/app/portainer/users/types.ts b/app/portainer/users/types.ts index b505bbcc0..fb686813c 100644 --- a/app/portainer/users/types.ts +++ b/app/portainer/users/types.ts @@ -7,6 +7,7 @@ export { type UserId }; export enum Role { Admin = 1, Standard, + EdgeAdmin, } interface AuthorizationMap { diff --git a/app/portainer/users/user.helpers.ts b/app/portainer/users/user.helpers.ts index 82f829975..39900a180 100644 --- a/app/portainer/users/user.helpers.ts +++ b/app/portainer/users/user.helpers.ts @@ -1,9 +1,30 @@ +import { Environment } from '@/react/portainer/environments/types'; +import { isEdgeEnvironment } from '@/react/portainer/environments/utils'; + import { Role, User } from './types'; export function filterNonAdministratorUsers(users: User[]) { return users.filter((user) => user.Role !== Role.Admin); } -export function isAdmin(user?: User): boolean { - return !!user && user.Role === 1; +type UserLike = Pick; + +// To avoid creating divergence between CE and EE +// isAdmin checks if the user is portainer admin or edge admin +export function isEdgeAdmin( + user: UserLike | undefined, + environment?: Pick | null +): boolean { + return ( + isPureAdmin(user) || + (user?.Role === Role.EdgeAdmin && + (!environment || isEdgeEnvironment(environment.Type))) + ); +} + +// To avoid creating divergence between CE and EE +// isPureAdmin checks only 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/azure/container-instances/CreateView/CreateContainerInstanceForm.tsx b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.tsx index 5ba975bf8..477ba4dee 100644 --- a/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.tsx +++ b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.tsx @@ -4,7 +4,7 @@ import { Plus } from 'lucide-react'; import { ContainerInstanceFormValues } from '@/react/azure/types'; import * as notifications from '@/portainer/services/notifications'; -import { useUser } from '@/react/hooks/useUser'; +import { useCurrentUser } from '@/react/hooks/useUser'; import { AccessControlForm } from '@/react/portainer/access-control/AccessControlForm'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; @@ -24,7 +24,7 @@ import { useCreateInstanceMutation } from './useCreateInstanceMutation'; export function CreateContainerInstanceForm() { const environmentId = useEnvironmentId(); - const { isAdmin } = useUser(); + const { isPureAdmin } = useCurrentUser(); const { providers, subscriptions, resourceGroups, isLoading } = useLoadFormState(environmentId); @@ -49,7 +49,7 @@ export function CreateContainerInstanceForm() { return ( initialValues={initialValues} - validationSchema={() => validationSchema(isAdmin)} + validationSchema={() => validationSchema(isPureAdmin)} onSubmit={onSubmit} validateOnMount validateOnChange diff --git a/app/react/azure/container-instances/CreateView/useLoadFormState.ts b/app/react/azure/container-instances/CreateView/useLoadFormState.ts index 83c68822d..5f1501978 100644 --- a/app/react/azure/container-instances/CreateView/useLoadFormState.ts +++ b/app/react/azure/container-instances/CreateView/useLoadFormState.ts @@ -37,7 +37,7 @@ export function useFormState( resourceGroups: Record = {}, providers: Record = {} ) { - const { isAdmin, user } = useCurrentUser(); + const { user, isPureAdmin } = useCurrentUser(); const subscriptionOptions = subscriptions.map((s) => ({ value: s.subscriptionId, @@ -67,7 +67,7 @@ export function useFormState( cpu: 1, ports: [{ container: 80, host: 80, protocol: 'TCP' }], allocatePublicIP: true, - accessControl: parseAccessControlFormData(isAdmin, user.Id), + accessControl: parseAccessControlFormData(isPureAdmin, user.Id), }; return { diff --git a/app/react/components/ImageConfigFieldset/RateLimits.tsx b/app/react/components/ImageConfigFieldset/RateLimits.tsx index 6dd568f8d..64771c896 100644 --- a/app/react/components/ImageConfigFieldset/RateLimits.tsx +++ b/app/react/components/ImageConfigFieldset/RateLimits.tsx @@ -66,7 +66,7 @@ function RateLimitsInner({ environment: Environment; }) { const pullRateLimits = useRateLimits(registryId, environment, onRateLimit); - const { isAdmin } = useCurrentUser(); + const { isPureAdmin } = useCurrentUser(); if (!pullRateLimits) { return null; @@ -88,7 +88,7 @@ function RateLimitsInner({ ) : ( <> - {isAdmin ? ( + {isPureAdmin ? ( <> You are currently using an anonymous account to pull images from DockerHub and will be limited to 100 pulls every 6 diff --git a/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts b/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts index e915ddd62..f97ce1cd4 100644 --- a/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts +++ b/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts @@ -10,7 +10,7 @@ import { Values } from './BaseForm'; export function toViewModel( config: ContainerResponse, - isAdmin: boolean, + isPureAdmin: boolean, currentUserId: UserId, nodeName: string, image: Values['image'], @@ -18,7 +18,7 @@ export function toViewModel( ): Values { // accessControl shouldn't be copied to new container - const accessControl = parseAccessControlFormData(isAdmin, currentUserId); + const accessControl = parseAccessControlFormData(isPureAdmin, currentUserId); if (config.Portainer?.ResourceControl?.Public) { accessControl.ownership = ResourceControlOwnership.PUBLIC; @@ -38,11 +38,11 @@ export function toViewModel( } export function getDefaultViewModel( - isAdmin: boolean, + isPureAdmin: boolean, currentUserId: UserId, nodeName: string ): Values { - const accessControl = parseAccessControlFormData(isAdmin, currentUserId); + const accessControl = parseAccessControlFormData(isPureAdmin, currentUserId); return { nodeName, diff --git a/app/react/docker/containers/CreateView/CreateView.tsx b/app/react/docker/containers/CreateView/CreateView.tsx index 8bf0d9668..bd8fcc751 100644 --- a/app/react/docker/containers/CreateView/CreateView.tsx +++ b/app/react/docker/containers/CreateView/CreateView.tsx @@ -2,7 +2,7 @@ import { Formik } from 'formik'; import { useRouter } from '@uirouter/react'; import { useEffect, useState } from 'react'; -import { useCurrentUser, useIsEnvironmentAdmin } from '@/react/hooks/useUser'; +import { useIsEdgeAdmin, useIsEnvironmentAdmin } from '@/react/hooks/useUser'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries'; @@ -48,7 +48,7 @@ function CreateForm() { const environmentId = useEnvironmentId(); const router = useRouter(); const { trackEvent } = useAnalytics(); - const { isAdmin } = useCurrentUser(); + const isAdminQuery = useIsEdgeAdmin(); const isEnvironmentAdmin = useIsEnvironmentAdmin(); const [isDockerhubRateLimited, setIsDockerhubRateLimited] = useState(false); @@ -67,7 +67,7 @@ function CreateForm() { const envQuery = useCurrentEnvironment(); const validationSchema = useValidation({ - isAdmin, + isAdmin: isAdminQuery.isAdmin, maxCpu, maxMemory, isDuplicating: initialValuesQuery?.isDuplicating, diff --git a/app/react/docker/containers/CreateView/InnerForm.tsx b/app/react/docker/containers/CreateView/InnerForm.tsx index 9b619b872..df7184cb8 100644 --- a/app/react/docker/containers/CreateView/InnerForm.tsx +++ b/app/react/docker/containers/CreateView/InnerForm.tsx @@ -102,7 +102,7 @@ export function InnerForm({ } errors={errors.volumes} allowBindMounts={ - isEnvironmentAdmin || + isEnvironmentAdmin.authorized || environment.SecuritySettings .allowBindMountsForRegularUsers } @@ -166,18 +166,18 @@ export function InnerForm({ setFieldValue(`resources.${field}`, value) } allowPrivilegedMode={ - isEnvironmentAdmin || + isEnvironmentAdmin.authorized || environment.SecuritySettings .allowPrivilegedModeForRegularUsers } isDevicesFieldVisible={ - isEnvironmentAdmin || + isEnvironmentAdmin.authorized || environment.SecuritySettings .allowDeviceMappingForRegularUsers } isInitFieldVisible={apiVersion >= 1.37} isSysctlFieldVisible={ - isEnvironmentAdmin || + isEnvironmentAdmin.authorized || environment.SecuritySettings .allowSysctlSettingForRegularUsers } diff --git a/app/react/docker/containers/CreateView/useInitialValues.ts b/app/react/docker/containers/CreateView/useInitialValues.ts index 92fda52f7..de624497f 100644 --- a/app/react/docker/containers/CreateView/useInitialValues.ts +++ b/app/react/docker/containers/CreateView/useInitialValues.ts @@ -62,7 +62,8 @@ export function useInitialValues(submitting: boolean) { params: { nodeName, from }, } = useCurrentStateAndParams(); const environmentId = useEnvironmentId(); - const { isAdmin, user } = useCurrentUser(); + const { user, isPureAdmin } = useCurrentUser(); + const networksQuery = useNetworksForSelector(); const fromContainerQuery = useContainer(environmentId, from, { @@ -85,7 +86,7 @@ export function useInitialValues(submitting: boolean) { if (!from) { return { - initialValues: defaultValues(isAdmin, user.Id, nodeName), + initialValues: defaultValues(isPureAdmin, user.Id, nodeName), }; } @@ -136,7 +137,7 @@ export function useInitialValues(submitting: boolean) { env: envVarsTabUtils.toViewModel(fromContainer), ...baseFormUtils.toViewModel( fromContainer, - isAdmin, + isPureAdmin, user.Id, nodeName, imageConfig, @@ -148,7 +149,7 @@ export function useInitialValues(submitting: boolean) { } function defaultValues( - isAdmin: boolean, + isPureAdmin: boolean, currentUserId: UserId, nodeName: string ): Values { @@ -161,6 +162,6 @@ function defaultValues( resources: resourcesTabUtils.getDefaultViewModel(), capabilities: capabilitiesTabUtils.getDefaultViewModel(), env: envVarsTabUtils.getDefaultViewModel(), - ...baseFormUtils.getDefaultViewModel(isAdmin, currentUserId, nodeName), + ...baseFormUtils.getDefaultViewModel(isPureAdmin, currentUserId, nodeName), }; } diff --git a/app/react/docker/networks/ItemView/NetworkDetailsTable.test.tsx b/app/react/docker/networks/ItemView/NetworkDetailsTable.test.tsx index d1ce8805c..64973f304 100644 --- a/app/react/docker/networks/ItemView/NetworkDetailsTable.test.tsx +++ b/app/react/docker/networks/ItemView/NetworkDetailsTable.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@/react-tools/test-utils'; +import { renderWithQueryClient } from '@/react-tools/test-utils'; import { UserContext } from '@/react/hooks/useUser'; import { UserViewModel } from '@/portainer/models/user'; @@ -50,7 +50,7 @@ test('Non system networks should have a delete button', async () => { async function renderComponent(isAdmin: boolean, network: DockerNetwork) { const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 }); - const queries = render( + const queries = renderWithQueryClient( - allowSelection(item, isAdmin, canManageStacks) + allowSelection(item, isAdminQuery.isAdmin, canManageStacks.authorized) } getRowId={(item) => item.Id.toString()} initialTableState={{ diff --git a/app/react/docker/stacks/ListView/StacksDatatable/columns/name.tsx b/app/react/docker/stacks/ListView/StacksDatatable/columns/name.tsx index 58de9ae47..a8f800fa1 100644 --- a/app/react/docker/stacks/ListView/StacksDatatable/columns/name.tsx +++ b/app/react/docker/stacks/ListView/StacksDatatable/columns/name.tsx @@ -1,6 +1,6 @@ import { CellContext, Column } from '@tanstack/react-table'; -import { useCurrentUser } from '@/react/hooks/useUser'; +import { useIsEdgeAdmin } from '@/react/hooks/useUser'; import { getValueAsArrayOfStrings } from '@/portainer/helpers/array'; import { StackStatus } from '@/react/common/stacks/types'; import { @@ -67,7 +67,7 @@ function NameCell({ } function NameLink({ item }: { item: DecoratedStack }) { - const { isAdmin } = useCurrentUser(); + const isAdminQuery = useIsEdgeAdmin(); const name = item.Name; @@ -87,7 +87,7 @@ function NameLink({ item }: { item: DecoratedStack }) { ); } - if (!isAdmin && isOrphanedStack(item)) { + if (!isAdminQuery.isAdmin && isOrphanedStack(item)) { return <>{name}; } diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/TableActions.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/TableActions.tsx index f8a6a6c91..81f01af17 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/TableActions.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/TableActions.tsx @@ -4,6 +4,7 @@ import { notifySuccess } from '@/portainer/services/notifications'; import { useDeleteEnvironmentsMutation } from '@/react/portainer/environments/queries/useDeleteEnvironmentsMutation'; import { Environment } from '@/react/portainer/environments/types'; import { withReactQuery } from '@/react-tools/withReactQuery'; +import { useIsPureAdmin } from '@/react/hooks/useUser'; import { Button } from '@@/buttons'; import { ModalType, openModal } from '@@/modals'; @@ -28,6 +29,7 @@ export function TableActions({ }: { selectedRows: WaitingRoomEnvironment[]; }) { + const isPureAdmin = useIsPureAdmin(); const associateMutation = useAssociateDeviceMutation(); const removeMutation = useDeleteEnvironmentsMutation(); const licenseOverused = useLicenseOverused(selectedRows.length); @@ -58,7 +60,9 @@ export function TableActions({