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:
parent
437831fa80
commit
f5f84c5fa4
22 changed files with 338 additions and 83 deletions
|
@ -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);
|
||||
}
|
||||
|
|
118
app/portainer/authorization-guard.test.ts
Normal file
118
app/portainer/authorization-guard.test.ts
Normal 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');
|
||||
});
|
||||
});
|
99
app/portainer/authorization-guard.ts
Normal file
99
app/portainer/authorization-guard.ts
Normal 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);
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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'>;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue