1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-07 23:05:26 +02:00

feat(ui): restrict views by role [EE-6595] (#11010)

This commit is contained in:
Chaim Lev-Ari 2024-02-15 13:29:55 +02:00 committed by GitHub
parent 437831fa80
commit f5f84c5fa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 338 additions and 83 deletions

View file

@ -10,6 +10,7 @@ import { reactModule } from './react';
import { sidebarModule } from './react/views/sidebar';
import environmentsModule from './environments';
import { helpersModule } from './helpers';
import { AccessHeaders, requiresAuthHook } from './authorization-guard';
async function initAuthentication(Authentication) {
return await Authentication.init();
@ -60,6 +61,9 @@ angular
component: 'sidebar',
},
},
data: {
access: AccessHeaders.Restricted,
},
};
var endpointRoot = {
@ -122,6 +126,16 @@ angular
},
};
const createHelmRepository = {
name: 'portainer.account.createHelmRepository',
url: '/helm-repository/new',
views: {
'content@': {
component: 'createHelmRepositoryView',
},
},
};
var authentication = {
name: 'portainer.auth',
url: '/auth',
@ -136,6 +150,9 @@ angular
},
'sidebar@': {},
},
data: {
access: undefined,
},
};
const logout = {
@ -152,6 +169,9 @@ angular
},
'sidebar@': {},
},
data: {
access: undefined,
},
};
var endpoints = {
@ -256,6 +276,7 @@ angular
},
data: {
docs: '/admin/environments/groups',
access: AccessHeaders.Admin,
},
};
@ -312,6 +333,9 @@ angular
views: {
'sidebar@': {},
},
data: {
access: undefined,
},
};
var initAdmin = {
@ -336,6 +360,7 @@ angular
},
data: {
docs: '/admin/registries',
access: AccessHeaders.Admin,
},
};
@ -369,6 +394,7 @@ angular
},
data: {
docs: '/admin/settings',
access: AccessHeaders.Admin,
},
};
@ -410,6 +436,7 @@ angular
},
data: {
docs: '/admin/environments/tags',
access: AccessHeaders.Admin,
},
};
@ -424,6 +451,7 @@ angular
},
data: {
docs: '/admin/users',
access: AccessHeaders.Restricted, // allow for team leaders
},
};
@ -438,16 +466,6 @@ angular
},
};
const createHelmRepository = {
name: 'portainer.account.createHelmRepository',
url: '/helm-repository/new',
views: {
'content@': {
component: 'createHelmRepositoryView',
},
},
};
$stateRegistryProvider.register(root);
$stateRegistryProvider.register(endpointRoot);
$stateRegistryProvider.register(portainer);
@ -481,7 +499,8 @@ angular
$stateRegistryProvider.register(user);
$stateRegistryProvider.register(createHelmRepository);
},
]);
])
.run(run);
function isTransitionRequiresAuthentication(transition) {
const UNAUTHENTICATED_ROUTES = ['portainer.logout', 'portainer.auth'];
@ -492,3 +511,8 @@ function isTransitionRequiresAuthentication(transition) {
const nextTransitionName = nextTransition ? nextTransition.name : '';
return !UNAUTHENTICATED_ROUTES.some((route) => nextTransitionName.startsWith(route));
}
/* @ngInject */
function run($transitions) {
requiresAuthHook($transitions);
}

View file

@ -0,0 +1,118 @@
import {
StateDeclaration,
StateService,
Transition,
} from '@uirouter/angularjs';
import { checkAuthorizations } from './authorization-guard';
import { IAuthenticationService } from './services/types';
describe('checkAuthorizations', () => {
let authService = {
init: vi.fn(),
isPureAdmin: vi.fn(),
isAdmin: vi.fn(),
hasAuthorizations: vi.fn(),
getUserDetails: vi.fn(),
isAuthenticated: vi.fn(),
} satisfies IAuthenticationService;
let transition: Transition;
const stateTo: StateDeclaration = {
data: {
access: 'restricted',
},
};
const $state = {
target: vi.fn((t) => t),
} as unknown as StateService;
beforeEach(() => {
authService = {
init: vi.fn(),
isPureAdmin: vi.fn(),
isAdmin: vi.fn(),
hasAuthorizations: vi.fn(),
getUserDetails: vi.fn(),
isAuthenticated: vi.fn(),
};
transition = {
injector: vi.fn().mockReturnValue({
get: vi.fn().mockReturnValue(authService),
}),
to: vi.fn().mockReturnValue(stateTo),
router: {
stateService: $state,
} as Transition['router'],
} as unknown as Transition;
stateTo.data.access = 'restricted';
});
afterEach(() => {
vi.clearAllMocks();
});
it('should return undefined if access is not defined', async () => {
stateTo.data.access = undefined;
const result = await checkAuthorizations(transition);
expect(result).toBeUndefined();
});
it('should return undefined if user is not authenticated and route access is defined', async () => {
stateTo.data.access = 'something';
authService.init.mockResolvedValue(false);
const result = await checkAuthorizations(transition);
expect(result).toBeUndefined();
});
it('should return logout if access is "restricted"', async () => {
const result = await checkAuthorizations(transition);
expect(result).toBeDefined();
expect($state.target).toHaveBeenCalledWith('portainer.logout');
});
it('should return undefined if user is an admin and access is "admin"', async () => {
authService.init.mockResolvedValue(true);
authService.isPureAdmin.mockReturnValue(true);
stateTo.data.access = 'admin';
const result = await checkAuthorizations(transition);
expect(result).toBeUndefined();
});
it('should return undefined if user is an admin and access is "edge-admin"', async () => {
authService.init.mockResolvedValue(true);
authService.isAdmin.mockReturnValue(true);
stateTo.data.access = 'edge-admin';
const result = await checkAuthorizations(transition);
expect(result).toBeUndefined();
});
it('should return undefined if user has the required authorizations', async () => {
authService.init.mockResolvedValue(true);
authService.hasAuthorizations.mockReturnValue(true);
stateTo.data.access = ['permission1', 'permission2'];
const result = await checkAuthorizations(transition);
expect(result).toBeUndefined();
});
it('should redirect to home if user does not have the required authorizations', async () => {
authService.init.mockResolvedValue(true);
authService.hasAuthorizations.mockReturnValue(false);
stateTo.data.access = ['permission1', 'permission2'];
const result = await checkAuthorizations(transition);
expect(result).toBeDefined();
expect($state.target).toHaveBeenCalledWith('portainer.home');
});
});

View file

@ -0,0 +1,99 @@
import { Transition, TransitionService } from '@uirouter/angularjs';
import { IAuthenticationService } from './services/types';
export enum AccessHeaders {
Restricted = 'restricted',
Admin = 'admin',
EdgeAdmin = 'edge-admin',
}
type Authorizations = string[];
type Access =
| AccessHeaders.Restricted
| AccessHeaders.Admin
| AccessHeaders.EdgeAdmin
| Authorizations;
export function requiresAuthHook(transitionService: TransitionService) {
transitionService.onBefore({}, checkAuthorizations);
}
// exported for tests
export async function checkAuthorizations(transition: Transition) {
const authService: IAuthenticationService = transition
.injector()
.get('Authentication');
const stateTo = transition.to();
const $state = transition.router.stateService;
const { access } = stateTo.data || {};
if (!isAccess(access)) {
return undefined;
}
const isLoggedIn = await authService.init();
if (!isLoggedIn) {
// eslint-disable-next-line no-console
console.info(
'User is not authenticated, redirecting to login, access:',
access
);
return $state.target('portainer.logout');
}
if (typeof access === 'string') {
if (access === 'restricted') {
return undefined;
}
if (access === 'admin') {
if (authService.isPureAdmin()) {
return undefined;
}
// eslint-disable-next-line no-console
console.info(
'User is not an admin, redirecting to home, access:',
access
);
return $state.target('portainer.home');
}
if (access === 'edge-admin') {
if (authService.isAdmin()) {
return undefined;
}
// eslint-disable-next-line no-console
console.info(
'User is not an edge admin, redirecting to home, access:',
access
);
return $state.target('portainer.home');
}
}
if (access.length > 0 && !authService.hasAuthorizations(access)) {
// eslint-disable-next-line no-console
console.info(
'User does not have the required authorizations, redirecting to home'
);
return $state.target('portainer.home');
}
return undefined;
}
function isAccess(access: unknown): access is Access {
if (!access || (typeof access !== 'string' && !Array.isArray(access))) {
return false;
}
if (Array.isArray(access)) {
return access.every((a) => typeof a === 'string');
}
return ['restricted', 'admin', 'edge-admin'].includes(access);
}

View file

@ -1,3 +1,4 @@
import { AccessHeaders } from '../authorization-guard';
import { rolesView } from './views/roles';
import { accessViewer } from './components/access-viewer';
import { accessViewerDatatable } from './components/access-viewer/access-viewer-datatable';
@ -29,6 +30,7 @@ function config($stateRegistryProvider) {
},
data: {
docs: '/admin/users/roles',
access: AccessHeaders.Admin,
},
};

View file

@ -6,6 +6,7 @@ import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AccessHeaders } from '@/portainer/authorization-guard';
export const teamsModule = angular
.module('portainer.app.teams', [])
@ -31,6 +32,7 @@ function config($stateRegistryProvider: StateRegistry) {
},
data: {
docs: '/admin/users/teams',
access: AccessHeaders.Restricted, // allow for team leaders
},
});

View file

@ -10,6 +10,7 @@ import {
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AccessHeaders } from '@/portainer/authorization-guard';
export const wizardModule = angular
.module('portainer.app.react.views.wizard', [])
@ -42,6 +43,9 @@ function config($stateRegistryProvider: StateRegistry) {
component: 'wizardMainView',
},
},
data: {
access: AccessHeaders.Admin,
},
});
$stateRegistryProvider.register({

View file

@ -1,34 +1,40 @@
import { hasAuthorizations as useUserHasAuthorization } from '@/react/hooks/useUser';
import { getCurrentUser } from '../users/queries/useLoadCurrentUser';
import * as userHelpers from '../users/user.helpers';
import { clear as clearSessionStorage } from './session-storage';
const DEFAULT_USER = 'admin';
const DEFAULT_PASSWORD = 'K7yJPP5qNK4hf1QsRnfV';
angular.module('portainer.app').factory('Authentication', [
'$async',
'$state',
'Auth',
'OAuth',
'LocalStorage',
'StateManager',
'EndpointProvider',
'ThemeManager',
function AuthenticationFactory($async, Auth, OAuth, LocalStorage, StateManager, EndpointProvider, ThemeManager) {
function AuthenticationFactory($async, $state, Auth, OAuth, LocalStorage, StateManager, EndpointProvider, ThemeManager) {
'use strict';
var service = {};
var user = {};
if (process.env.NODE_ENV === 'development') {
window.login = loginAsync;
}
service.init = init;
service.OAuthLogin = OAuthLogin;
service.login = login;
service.logout = logout;
service.isAuthenticated = isAuthenticated;
service.getUserDetails = getUserDetails;
service.isAdmin = isAdmin;
service.isEdgeAdmin = isEdgeAdmin;
service.isPureAdmin = isPureAdmin;
service.hasAuthorizations = hasAuthorizations;
return {
init,
OAuthLogin,
login,
logout,
isAuthenticated,
getUserDetails,
isAdmin,
isEdgeAdmin,
isPureAdmin,
hasAuthorizations,
redirectIfUnauthorized,
};
async function initAsync() {
try {
@ -126,6 +132,7 @@ angular.module('portainer.app').factory('Authentication', [
// 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);
@ -146,20 +153,25 @@ angular.module('portainer.app').factory('Authentication', [
function hasAuthorizations(authorizations) {
const endpointId = EndpointProvider.endpointID();
if (isAdmin()) {
if (isEdgeAdmin()) {
return true;
}
if (!user.endpointAuthorizations || !user.endpointAuthorizations[endpointId]) {
return false;
return useUserHasAuthorization(
{
EndpointAuthorizations: user.endpointAuthorizations,
},
authorizations,
endpointId
);
}
function redirectIfUnauthorized(authorizations) {
const authorized = hasAuthorizations(authorizations);
if (!authorized) {
$state.go('portainer.home');
}
const userEndpointAuthorizations = user.endpointAuthorizations[endpointId];
return authorizations.some((authorization) => userEndpointAuthorizations[authorization]);
}
if (process.env.NODE_ENV === 'development') {
window.login = loginAsync;
}
return service;
},
]);

View file

@ -9,6 +9,17 @@ export interface StateManager {
export interface IAuthenticationService {
getUserDetails(): { ID: number };
isAuthenticated(): boolean;
isAdmin(): boolean;
isPureAdmin(): boolean;
hasAuthorizations(authorizations: string[]): boolean;
init(): Promise<boolean>;
// OAuthLogin,
// login,
// logout,
// redirectIfUnauthorized,
}
export type AsyncService = <T>(fn: () => Promise<T>) => Promise<T>;

View file

@ -1,6 +1,7 @@
import angular from 'angular';
import { NotificationsViewAngular } from '@/react/portainer/notifications/NotificationsView';
import { AccessHeaders } from '../authorization-guard';
import authLogsViewModule from './auth-logs-view';
import activityLogsViewModule from './activity-logs-view';
@ -18,6 +19,7 @@ function config($stateRegistryProvider) {
},
data: {
docs: '/admin/logs',
access: AccessHeaders.Admin,
},
});
@ -31,6 +33,7 @@ function config($stateRegistryProvider) {
},
data: {
docs: '/admin/logs/activity',
access: AccessHeaders.Admin,
},
});

View file

@ -4,7 +4,7 @@ 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);
return users.filter((user) => !isPureAdmin(user));
}
type UserLike = Pick<User, 'Role'>;