-
-
+
diff --git a/app/portainer/oauth/components/oauth-settings/providers.js b/app/portainer/oauth/components/oauth-settings/providers.js
new file mode 100644
index 000000000..fc04de927
--- /dev/null
+++ b/app/portainer/oauth/components/oauth-settings/providers.js
@@ -0,0 +1,43 @@
+export default {
+ microsoft: {
+ authUrl: 'https://login.microsoftonline.com/TENANT_ID/oauth2/authorize',
+ accessTokenUrl: 'https://login.microsoftonline.com/TENANT_ID/oauth2/token',
+ resourceUrl: 'https://graph.windows.net/TENANT_ID/me?api-version=2013-11-08',
+ logoutUrl: `https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri=${window.location.origin}/#!/auth`,
+ userIdentifier: 'userPrincipalName',
+ scopes: 'id,email,name',
+ },
+ google: {
+ authUrl: 'https://accounts.google.com/o/oauth2/auth',
+ accessTokenUrl: 'https://accounts.google.com/o/oauth2/token',
+ resourceUrl: 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json',
+ logoutUrl: `https://www.google.com/accounts/Logout?continue=https://appengine.google.com/_ah/logout?continue=${window.location.origin}/#!/auth`,
+ userIdentifier: 'email',
+ scopes: 'profile email',
+ },
+ github: {
+ authUrl: 'https://github.com/login/oauth/authorize',
+ accessTokenUrl: 'https://github.com/login/oauth/access_token',
+ resourceUrl: 'https://api.github.com/user',
+ logoutUrl: `https://github.com/logout`,
+ userIdentifier: 'login',
+ scopes: 'id email name',
+ },
+ custom: { authUrl: '', accessTokenUrl: '', resourceUrl: '', logoutUrl: '', userIdentifier: '', scopes: '' },
+};
+
+export function getProviderByUrl(providerAuthURL = '') {
+ if (providerAuthURL.includes('login.microsoftonline.com')) {
+ return 'microsoft';
+ }
+
+ if (providerAuthURL.includes('accounts.google.com')) {
+ return 'google';
+ }
+
+ if (providerAuthURL.includes('github.com')) {
+ return 'github';
+ }
+
+ return 'custom';
+}
diff --git a/app/portainer/rbac/components/access-viewer/access-viewer-datatable/access-viewer-datatable.html b/app/portainer/rbac/components/access-viewer/access-viewer-datatable/access-viewer-datatable.html
new file mode 100644
index 000000000..66a856a90
--- /dev/null
+++ b/app/portainer/rbac/components/access-viewer/access-viewer-datatable/access-viewer-datatable.html
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+ Environment
+
+
+
+ |
+
+
+ Role
+
+
+
+ |
+ Access origin |
+
+
+
+
+ {{ item.EndpointName }} |
+ {{ item.RoleName }} |
+ {{ item.TeamName ? 'Team' : 'User' }} {{ item.TeamName }} access defined on {{ item.AccessLocation }}
+ {{ item.GroupName }}
+ Manage access
+
+ Manage access
+
+ |
+
+
+ Select a user to show associated access and role |
+
+
+ The selected user does not have access to any environment(s) |
+
+
+
+
+
+
diff --git a/app/portainer/components/datatables/access-viewer-datatable/accessViewerDatatable.js b/app/portainer/rbac/components/access-viewer/access-viewer-datatable/index.js
similarity index 56%
rename from app/portainer/components/datatables/access-viewer-datatable/accessViewerDatatable.js
rename to app/portainer/rbac/components/access-viewer/access-viewer-datatable/index.js
index 39bf39f70..e096ef51a 100644
--- a/app/portainer/components/datatables/access-viewer-datatable/accessViewerDatatable.js
+++ b/app/portainer/rbac/components/access-viewer/access-viewer-datatable/index.js
@@ -1,5 +1,5 @@
-angular.module('portainer.app').component('accessViewerDatatable', {
- templateUrl: './accessViewerDatatable.html',
+export const accessViewerDatatable = {
+ templateUrl: './access-viewer-datatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
@@ -8,4 +8,4 @@ angular.module('portainer.app').component('accessViewerDatatable', {
orderBy: '@',
dataset: '<',
},
-});
+};
diff --git a/app/portainer/rbac/components/access-viewer/access-viewer.controller.js b/app/portainer/rbac/components/access-viewer/access-viewer.controller.js
new file mode 100644
index 000000000..a4a03e483
--- /dev/null
+++ b/app/portainer/rbac/components/access-viewer/access-viewer.controller.js
@@ -0,0 +1,128 @@
+import _ from 'lodash-es';
+
+import AccessViewerPolicyModel from '../../models/access';
+
+export default class AccessViewerController {
+ /* @ngInject */
+ constructor(featureService, Notifications, RoleService, UserService, EndpointService, GroupService, TeamService, TeamMembershipService) {
+ this.featureService = featureService;
+ this.Notifications = Notifications;
+ this.RoleService = RoleService;
+ this.UserService = UserService;
+ this.EndpointService = EndpointService;
+ this.GroupService = GroupService;
+ this.TeamService = TeamService;
+ this.TeamMembershipService = TeamMembershipService;
+
+ this.limitedFeature = 'rbac-roles';
+ this.users = [];
+ }
+
+ onUserSelect() {
+ this.userRoles = [];
+ const userRoles = {};
+ const user = this.selectedUser;
+ const userMemberships = _.filter(this.teamMemberships, { UserId: user.Id });
+
+ for (const [, endpoint] of _.entries(this.endpoints)) {
+ let role = this.getRoleFromUserEndpointPolicy(user, endpoint);
+ if (role) {
+ userRoles[endpoint.Id] = role;
+ continue;
+ }
+
+ role = this.getRoleFromUserEndpointGroupPolicy(user, endpoint);
+ if (role) {
+ userRoles[endpoint.Id] = role;
+ continue;
+ }
+
+ role = this.getRoleFromTeamEndpointPolicies(userMemberships, endpoint);
+ if (role) {
+ userRoles[endpoint.Id] = role;
+ continue;
+ }
+
+ role = this.getRoleFromTeamEndpointGroupPolicies(userMemberships, endpoint);
+ if (role) {
+ userRoles[endpoint.Id] = role;
+ }
+ }
+
+ this.userRoles = _.values(userRoles);
+ }
+
+ findLowestRole(policies) {
+ return _.first(_.orderBy(policies, 'RolePriority', 'desc'));
+ }
+
+ getRoleFromUserEndpointPolicy(user, endpoint) {
+ const policyRoles = [];
+ const policy = (endpoint.UserAccessPolicies || {})[user.Id];
+ if (policy) {
+ const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, null, null);
+ policyRoles.push(accessPolicy);
+ }
+ return this.findLowestRole(policyRoles);
+ }
+
+ getRoleFromUserEndpointGroupPolicy(user, endpoint) {
+ const policyRoles = [];
+ const policy = this.groupUserAccessPolicies[endpoint.GroupId][user.Id];
+ if (policy) {
+ const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, this.groups[endpoint.GroupId], null);
+ policyRoles.push(accessPolicy);
+ }
+ return this.findLowestRole(policyRoles);
+ }
+
+ getRoleFromTeamEndpointPolicies(memberships, endpoint) {
+ const policyRoles = [];
+ for (const membership of memberships) {
+ const policy = (endpoint.TeamAccessPolicies || {})[membership.TeamId];
+ if (policy) {
+ const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, null, this.teams[membership.TeamId]);
+ policyRoles.push(accessPolicy);
+ }
+ }
+ return this.findLowestRole(policyRoles);
+ }
+
+ getRoleFromTeamEndpointGroupPolicies(memberships, endpoint) {
+ const policyRoles = [];
+ for (const membership of memberships) {
+ const policy = this.groupTeamAccessPolicies[endpoint.GroupId][membership.TeamId];
+ if (policy) {
+ const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, this.groups[endpoint.GroupId], this.teams[membership.TeamId]);
+ policyRoles.push(accessPolicy);
+ }
+ }
+ return this.findLowestRole(policyRoles);
+ }
+
+ async $onInit() {
+ try {
+ const limitedToBE = this.featureService.isLimitedToBE(this.limitedFeature);
+
+ if (limitedToBE) {
+ return;
+ }
+
+ this.users = await this.UserService.users();
+ this.endpoints = _.keyBy((await this.EndpointService.endpoints()).value, 'Id');
+ const groups = await this.GroupService.groups();
+ this.groupUserAccessPolicies = {};
+ this.groupTeamAccessPolicies = {};
+ _.forEach(groups, (group) => {
+ this.groupUserAccessPolicies[group.Id] = group.UserAccessPolicies;
+ this.groupTeamAccessPolicies[group.Id] = group.TeamAccessPolicies;
+ });
+ this.groups = _.keyBy(groups, 'Id');
+ this.roles = _.keyBy(await this.RoleService.roles(), 'Id');
+ this.teams = _.keyBy(await this.TeamService.teams(), 'Id');
+ this.teamMemberships = await this.TeamMembershipService.memberships();
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Unable to retrieve accesses');
+ }
+ }
+}
diff --git a/app/portainer/rbac/components/access-viewer/access-viewer.html b/app/portainer/rbac/components/access-viewer/access-viewer.html
new file mode 100644
index 000000000..1f42912d1
--- /dev/null
+++ b/app/portainer/rbac/components/access-viewer/access-viewer.html
@@ -0,0 +1,43 @@
+
+
+
+
+ Effective access viewer
+
+
+
+
+
+
+
+
diff --git a/app/portainer/rbac/components/access-viewer/index.js b/app/portainer/rbac/components/access-viewer/index.js
new file mode 100644
index 000000000..334f0f0f7
--- /dev/null
+++ b/app/portainer/rbac/components/access-viewer/index.js
@@ -0,0 +1,6 @@
+import controller from './access-viewer.controller';
+
+export const accessViewer = {
+ templateUrl: './access-viewer.html',
+ controller,
+};
diff --git a/app/portainer/rbac/components/roles-datatable/index.js b/app/portainer/rbac/components/roles-datatable/index.js
new file mode 100644
index 000000000..06af2dcf4
--- /dev/null
+++ b/app/portainer/rbac/components/roles-datatable/index.js
@@ -0,0 +1,15 @@
+import controller from './roles-datatable.controller';
+import './roles-datatable.css';
+
+export const rolesDatatable = {
+ templateUrl: './roles-datatable.html',
+ controller,
+ bindings: {
+ titleText: '@',
+ titleIcon: '@',
+ dataset: '<',
+ tableKey: '@',
+ orderBy: '@',
+ reverseOrder: '<',
+ },
+};
diff --git a/app/portainer/rbac/components/roles-datatable/roles-datatable.controller.js b/app/portainer/rbac/components/roles-datatable/roles-datatable.controller.js
new file mode 100644
index 000000000..ff980bbac
--- /dev/null
+++ b/app/portainer/rbac/components/roles-datatable/roles-datatable.controller.js
@@ -0,0 +1,15 @@
+import angular from 'angular';
+import { RoleTypes } from '../../models/role';
+
+export default class RolesDatatableController {
+ /* @ngInject */
+ constructor($controller, $scope) {
+ this.limitedFeature = 'rbac-roles';
+
+ angular.extend(this, $controller('GenericDatatableController', { $scope }));
+ }
+
+ isDefaultItem(item) {
+ return item.ID === RoleTypes.STANDARD;
+ }
+}
diff --git a/app/portainer/rbac/components/roles-datatable/roles-datatable.css b/app/portainer/rbac/components/roles-datatable/roles-datatable.css
new file mode 100644
index 000000000..14def6618
--- /dev/null
+++ b/app/portainer/rbac/components/roles-datatable/roles-datatable.css
@@ -0,0 +1,7 @@
+th.be-visual-indicator-col {
+ width: 300px;
+}
+
+td.be-visual-indicator-col {
+ text-align: center;
+}
diff --git a/app/portainer/rbac/components/roles-datatable/roles-datatable.html b/app/portainer/rbac/components/roles-datatable/roles-datatable.html
new file mode 100644
index 000000000..59c7e4003
--- /dev/null
+++ b/app/portainer/rbac/components/roles-datatable/roles-datatable.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+
+
+
+ |
+
+
+ Description
+
+
+
+ |
+ |
+
+
+
+
+ {{ item.Name }} |
+ {{ item.Description }} |
+
+
+
+
+ Default
+ |
+
+
+ Loading... |
+
+
+ No role available. |
+
+
+
+
+
+
+
+
diff --git a/app/portainer/rbac/index.js b/app/portainer/rbac/index.js
new file mode 100644
index 000000000..28647e586
--- /dev/null
+++ b/app/portainer/rbac/index.js
@@ -0,0 +1,33 @@
+import { rolesView } from './views/roles';
+import { accessViewer } from './components/access-viewer';
+import { accessViewerDatatable } from './components/access-viewer/access-viewer-datatable';
+import { rolesDatatable } from './components/roles-datatable';
+
+import { RoleService } from './services/role.service';
+import { RolesFactory } from './services/role.rest';
+
+angular
+ .module('portainer.rbac', ['ngResource'])
+ .constant('API_ENDPOINT_ROLES', 'api/roles')
+ .component('accessViewer', accessViewer)
+ .component('accessViewerDatatable', accessViewerDatatable)
+ .component('rolesDatatable', rolesDatatable)
+ .component('rolesView', rolesView)
+ .factory('RoleService', RoleService)
+ .factory('Roles', RolesFactory)
+ .config(config);
+
+/* @ngInject */
+function config($stateRegistryProvider) {
+ const roles = {
+ name: 'portainer.roles',
+ url: '/roles',
+ views: {
+ 'content@': {
+ component: 'rolesView',
+ },
+ },
+ };
+
+ $stateRegistryProvider.register(roles);
+}
diff --git a/app/portainer/rbac/models/access.js b/app/portainer/rbac/models/access.js
new file mode 100644
index 000000000..18b7269fe
--- /dev/null
+++ b/app/portainer/rbac/models/access.js
@@ -0,0 +1,16 @@
+export default function AccessViewerPolicyModel(policy, endpoint, roles, group, team) {
+ this.EndpointId = endpoint.Id;
+ this.EndpointName = endpoint.Name;
+ this.RoleId = policy.RoleId;
+ this.RoleName = roles[policy.RoleId].Name;
+ this.RolePriority = roles[policy.RoleId].Priority;
+ if (group) {
+ this.GroupId = group.Id;
+ this.GroupName = group.Name;
+ }
+ if (team) {
+ this.TeamId = team.Id;
+ this.TeamName = team.Name;
+ }
+ this.AccessLocation = group ? 'environment group' : 'environment';
+}
diff --git a/app/portainer/rbac/models/role.js b/app/portainer/rbac/models/role.js
new file mode 100644
index 000000000..44cb4a377
--- /dev/null
+++ b/app/portainer/rbac/models/role.js
@@ -0,0 +1,14 @@
+export function RoleViewModel(id, name, description, authorizations) {
+ this.ID = id;
+ this.Name = name;
+ this.Description = description;
+ this.Authorizations = authorizations;
+}
+
+export const RoleTypes = Object.freeze({
+ ENDPOINT_ADMIN: 1,
+ HELPDESK: 2,
+ STANDARD: 3,
+ READ_ONLY: 4,
+ OPERATOR: 5,
+});
diff --git a/app/portainer/rbac/services/role.rest.js b/app/portainer/rbac/services/role.rest.js
new file mode 100644
index 000000000..121360957
--- /dev/null
+++ b/app/portainer/rbac/services/role.rest.js
@@ -0,0 +1,14 @@
+/* @ngInject */
+export function RolesFactory($resource, API_ENDPOINT_ROLES) {
+ return $resource(
+ API_ENDPOINT_ROLES + '/:id',
+ {},
+ {
+ create: { method: 'POST', ignoreLoadingBar: true },
+ query: { method: 'GET', isArray: true },
+ get: { method: 'GET', params: { id: '@id' } },
+ update: { method: 'PUT', params: { id: '@id' } },
+ remove: { method: 'DELETE', params: { id: '@id' } },
+ }
+ );
+}
diff --git a/app/portainer/rbac/services/role.service.js b/app/portainer/rbac/services/role.service.js
new file mode 100644
index 000000000..14b763b31
--- /dev/null
+++ b/app/portainer/rbac/services/role.service.js
@@ -0,0 +1,19 @@
+import { RoleViewModel, RoleTypes } from '../models/role';
+
+export function RoleService() {
+ const rolesData = [
+ new RoleViewModel(RoleTypes.ENDPOINT_ADMIN, 'Environment administrator', 'Full control of all resources in an environment', []),
+ new RoleViewModel(RoleTypes.OPERATOR, 'Operator', 'Operational Control of all existing resources in an environment', []),
+ new RoleViewModel(RoleTypes.HELPDESK, 'Helpdesk', 'Read-only access of all resources in an environment', []),
+ new RoleViewModel(RoleTypes.READ_ONLY, 'Read-only user', 'Read-only access of assigned resources in an environment', []),
+ new RoleViewModel(RoleTypes.STANDARD, 'Standard user', 'Full control of assigned resources in an environment', []),
+ ];
+
+ return {
+ roles,
+ };
+
+ function roles() {
+ return rolesData;
+ }
+}
diff --git a/app/portainer/rbac/views/roles/index.js b/app/portainer/rbac/views/roles/index.js
new file mode 100644
index 000000000..c1e91cf9f
--- /dev/null
+++ b/app/portainer/rbac/views/roles/index.js
@@ -0,0 +1,6 @@
+import controller from './roles.controller';
+
+export const rolesView = {
+ templateUrl: './roles.html',
+ controller,
+};
diff --git a/app/portainer/rbac/views/roles/roles.controller.js b/app/portainer/rbac/views/roles/roles.controller.js
new file mode 100644
index 000000000..742c243c3
--- /dev/null
+++ b/app/portainer/rbac/views/roles/roles.controller.js
@@ -0,0 +1,20 @@
+import _ from 'lodash-es';
+
+export default class RolesController {
+ /* @ngInject */
+ constructor(Notifications, RoleService) {
+ this.Notifications = Notifications;
+ this.RoleService = RoleService;
+ }
+
+ async $onInit() {
+ this.roles = [];
+
+ try {
+ this.roles = await this.RoleService.roles();
+ this.roles = _.orderBy(this.roles, 'Priority', 'asc');
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Unable to retrieve roles');
+ }
+ }
+}
diff --git a/app/portainer/rbac/views/roles/roles.html b/app/portainer/rbac/views/roles/roles.html
new file mode 100644
index 000000000..85ee70459
--- /dev/null
+++ b/app/portainer/rbac/views/roles/roles.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ Role management
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/auth-method-constants.js b/app/portainer/settings/authentication/auth-method-constants.js
new file mode 100644
index 000000000..dc9bf0a0c
--- /dev/null
+++ b/app/portainer/settings/authentication/auth-method-constants.js
@@ -0,0 +1,11 @@
+export const authenticationMethodTypesMap = {
+ INTERNAL: 1,
+ LDAP: 2,
+ OAUTH: 3,
+};
+
+export const authenticationMethodTypesLabels = {
+ [authenticationMethodTypesMap.INTERNAL]: 'Internal',
+ [authenticationMethodTypesMap.LDAP]: 'LDAP',
+ [authenticationMethodTypesMap.OAUTH]: 'OAuth',
+};
diff --git a/app/portainer/settings/authentication/auth-type-constants.js b/app/portainer/settings/authentication/auth-type-constants.js
new file mode 100644
index 000000000..84de1d959
--- /dev/null
+++ b/app/portainer/settings/authentication/auth-type-constants.js
@@ -0,0 +1,11 @@
+export const authenticationActivityTypesMap = {
+ AuthSuccess: 1,
+ AuthFailure: 2,
+ Logout: 3,
+};
+
+export const authenticationActivityTypesLabels = {
+ [authenticationActivityTypesMap.AuthSuccess]: 'Authentication success',
+ [authenticationActivityTypesMap.AuthFailure]: 'Authentication failure',
+ [authenticationActivityTypesMap.Logout]: 'Logout',
+};
diff --git a/app/portainer/settings/authentication/auto-user-provision-toggle/auto-user-provision-toggle.html b/app/portainer/settings/authentication/auto-user-provision-toggle/auto-user-provision-toggle.html
new file mode 100644
index 000000000..aac43263a
--- /dev/null
+++ b/app/portainer/settings/authentication/auto-user-provision-toggle/auto-user-provision-toggle.html
@@ -0,0 +1,14 @@
+
+ Automatic user provisioning
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/auto-user-provision-toggle/index.js b/app/portainer/settings/authentication/auto-user-provision-toggle/index.js
new file mode 100644
index 000000000..68c7b95d1
--- /dev/null
+++ b/app/portainer/settings/authentication/auto-user-provision-toggle/index.js
@@ -0,0 +1,9 @@
+export const autoUserProvisionToggle = {
+ templateUrl: './auto-user-provision-toggle.html',
+ transclude: {
+ description: 'fieldDescription',
+ },
+ bindings: {
+ ngModel: '=',
+ },
+};
diff --git a/app/portainer/settings/authentication/index.js b/app/portainer/settings/authentication/index.js
new file mode 100644
index 000000000..897e48042
--- /dev/null
+++ b/app/portainer/settings/authentication/index.js
@@ -0,0 +1,10 @@
+import angular from 'angular';
+
+import ldapModule from './ldap';
+
+import { autoUserProvisionToggle } from './auto-user-provision-toggle';
+
+export default angular
+ .module('portainer.settings.authentication', [ldapModule])
+
+ .component('autoUserProvisionToggle', autoUserProvisionToggle).name;
diff --git a/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js
new file mode 100644
index 000000000..41d29e0ef
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js
@@ -0,0 +1,62 @@
+import _ from 'lodash-es';
+import { HIDE_INTERNAL_AUTH } from '@/portainer/feature-flags/feature-ids';
+
+export default class AdSettingsController {
+ /* @ngInject */
+ constructor(LDAPService) {
+ this.LDAPService = LDAPService;
+
+ this.domainSuffix = '';
+ this.limitedFeatureId = HIDE_INTERNAL_AUTH;
+ this.onTlscaCertChange = this.onTlscaCertChange.bind(this);
+ this.searchUsers = this.searchUsers.bind(this);
+ this.searchGroups = this.searchGroups.bind(this);
+ this.parseDomainName = this.parseDomainName.bind(this);
+ this.onAccountChange = this.onAccountChange.bind(this);
+ }
+
+ parseDomainName(account) {
+ this.domainName = '';
+
+ if (!account || !account.includes('@')) {
+ return;
+ }
+
+ const [, domainName] = account.split('@');
+ if (!domainName) {
+ return;
+ }
+
+ const parts = _.compact(domainName.split('.'));
+ this.domainSuffix = parts.map((part) => `dc=${part}`).join(',');
+ }
+
+ onAccountChange(account) {
+ this.parseDomainName(account);
+ }
+
+ searchUsers() {
+ return this.LDAPService.users(this.settings);
+ }
+
+ searchGroups() {
+ return this.LDAPService.groups(this.settings);
+ }
+
+ onTlscaCertChange(file) {
+ this.tlscaCert = file;
+ }
+
+ addLDAPUrl() {
+ this.settings.URLs.push('');
+ }
+
+ removeLDAPUrl(index) {
+ this.settings.URLs.splice(index, 1);
+ }
+
+ $onInit() {
+ this.tlscaCert = this.settings.TLSCACert;
+ this.parseDomainName(this.settings.ReaderDN);
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.html b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.html
new file mode 100644
index 000000000..ffe36a0f4
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.html
@@ -0,0 +1,157 @@
+
+
+
+
+
+ With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group name(s).
+ If disabled, users must be created in Portainer beforehand.
+
+
+
+
+
+ Information
+
+
+ When using Microsoft AD authentication, Portainer will delegate user authentication to the Domain Controller(s) configured below; if there is no connectivity, Portainer will
+ fallback to internal authentication.
+
+
+
+
+ AD configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ad-settings/index.js b/app/portainer/settings/authentication/ldap/ad-settings/index.js
new file mode 100644
index 000000000..59a474097
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ad-settings/index.js
@@ -0,0 +1,12 @@
+import controller from './ad-settings.controller';
+
+export const adSettings = {
+ templateUrl: './ad-settings.html',
+ controller,
+ bindings: {
+ settings: '=',
+ tlscaCert: '=',
+ state: '=',
+ connectivityCheck: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/index.js b/app/portainer/settings/authentication/ldap/index.js
new file mode 100644
index 000000000..2b3612be8
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/index.js
@@ -0,0 +1,44 @@
+import angular from 'angular';
+
+import { adSettings } from './ad-settings';
+import { ldapSettings } from './ldap-settings';
+import { ldapSettingsCustom } from './ldap-settings-custom';
+import { ldapSettingsOpenLdap } from './ldap-settings-openldap';
+
+import { ldapConnectivityCheck } from './ldap-connectivity-check';
+import { ldapGroupsDatatable } from './ldap-groups-datatable';
+import { ldapGroupSearch } from './ldap-group-search';
+import { ldapGroupSearchItem } from './ldap-group-search-item';
+import { ldapUserSearch } from './ldap-user-search';
+import { ldapUserSearchItem } from './ldap-user-search-item';
+import { ldapSettingsDnBuilder } from './ldap-settings-dn-builder';
+import { ldapSettingsGroupDnBuilder } from './ldap-settings-group-dn-builder';
+import { ldapCustomGroupSearch } from './ldap-custom-group-search';
+import { ldapSettingsSecurity } from './ldap-settings-security';
+import { ldapSettingsTestLogin } from './ldap-settings-test-login';
+import { ldapCustomUserSearch } from './ldap-custom-user-search';
+import { ldapUsersDatatable } from './ldap-users-datatable';
+import { LDAPService } from './ldap.service';
+import { LDAP } from './ldap.rest';
+
+export default angular
+ .module('portainer.settings.authentication.ldap', [])
+ .service('LDAPService', LDAPService)
+ .service('LDAP', LDAP)
+ .component('ldapConnectivityCheck', ldapConnectivityCheck)
+ .component('ldapGroupsDatatable', ldapGroupsDatatable)
+ .component('ldapSettings', ldapSettings)
+ .component('adSettings', adSettings)
+ .component('ldapGroupSearch', ldapGroupSearch)
+ .component('ldapGroupSearchItem', ldapGroupSearchItem)
+ .component('ldapUserSearch', ldapUserSearch)
+ .component('ldapUserSearchItem', ldapUserSearchItem)
+ .component('ldapSettingsCustom', ldapSettingsCustom)
+ .component('ldapSettingsDnBuilder', ldapSettingsDnBuilder)
+ .component('ldapSettingsGroupDnBuilder', ldapSettingsGroupDnBuilder)
+ .component('ldapCustomGroupSearch', ldapCustomGroupSearch)
+ .component('ldapSettingsOpenLdap', ldapSettingsOpenLdap)
+ .component('ldapSettingsSecurity', ldapSettingsSecurity)
+ .component('ldapSettingsTestLogin', ldapSettingsTestLogin)
+ .component('ldapCustomUserSearch', ldapCustomUserSearch)
+ .component('ldapUsersDatatable', ldapUsersDatatable).name;
diff --git a/app/portainer/settings/authentication/ldap/ldap-connectivity-check/index.js b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/index.js
new file mode 100644
index 000000000..93784cb42
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/index.js
@@ -0,0 +1,9 @@
+export const ldapConnectivityCheck = {
+ templateUrl: './ldap-connectivity-check.html',
+ bindings: {
+ settings: '<',
+ state: '<',
+ connectivityCheck: '<',
+ limitedFeatureId: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-connectivity-check/ldap-connectivity-check.html b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/ldap-connectivity-check.html
new file mode 100644
index 000000000..0d88ade53
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/ldap-connectivity-check.html
@@ -0,0 +1,21 @@
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-group-search/index.js b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/index.js
new file mode 100644
index 000000000..1f51f32f8
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/index.js
@@ -0,0 +1,11 @@
+import controller from './ldap-custom-group-search.controller';
+
+export const ldapCustomGroupSearch = {
+ templateUrl: './ldap-custom-group-search.html',
+ controller,
+ bindings: {
+ settings: '=',
+ onSearchClick: '<',
+ limitedFeatureId: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.controller.js
new file mode 100644
index 000000000..4c746f50a
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.controller.js
@@ -0,0 +1,34 @@
+export default class LdapCustomGroupSearchController {
+ /* @ngInject */
+ constructor($async, Notifications) {
+ Object.assign(this, { $async, Notifications });
+
+ this.groups = null;
+ this.showTable = false;
+
+ this.onRemoveClick = this.onRemoveClick.bind(this);
+ this.onAddClick = this.onAddClick.bind(this);
+ this.search = this.search.bind(this);
+ }
+
+ onAddClick() {
+ this.settings.push({ GroupBaseDN: '', GroupAttribute: '', GroupFilter: '' });
+ }
+
+ onRemoveClick(index) {
+ this.settings.splice(index, 1);
+ }
+
+ search() {
+ return this.$async(async () => {
+ try {
+ this.groups = null;
+ this.showTable = true;
+ this.groups = await this.onSearchClick();
+ } catch (error) {
+ this.showTable = false;
+ this.Notifications.error('Failure', error, 'Failed to search users');
+ }
+ });
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.html b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.html
new file mode 100644
index 000000000..e7db2ed5b
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.html
@@ -0,0 +1,117 @@
+
+ Teams auto-population configurations
+
+
+
+
+
+
+ Extra search configuration
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-user-search/index.js b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/index.js
new file mode 100644
index 000000000..06fdedd24
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/index.js
@@ -0,0 +1,11 @@
+import controller from './ldap-custom-user-search.controller';
+
+export const ldapCustomUserSearch = {
+ templateUrl: './ldap-custom-user-search.html',
+ controller,
+ bindings: {
+ settings: '=',
+ onSearchClick: '<',
+ limitedFeatureId: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.controller.js
new file mode 100644
index 000000000..e672e9ed4
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.controller.js
@@ -0,0 +1,33 @@
+export default class LdapCustomUserSearchController {
+ /* @ngInject */
+ constructor($async, Notifications) {
+ Object.assign(this, { $async, Notifications });
+
+ this.users = null;
+
+ this.onRemoveClick = this.onRemoveClick.bind(this);
+ this.onAddClick = this.onAddClick.bind(this);
+ this.search = this.search.bind(this);
+ }
+
+ onAddClick() {
+ this.settings.push({ BaseDN: '', UserNameAttribute: '', Filter: '' });
+ }
+
+ onRemoveClick(index) {
+ this.settings.splice(index, 1);
+ }
+
+ search() {
+ return this.$async(async () => {
+ try {
+ this.users = null;
+ this.showTable = true;
+ this.users = await this.onSearchClick();
+ } catch (error) {
+ this.showTable = false;
+ this.Notifications.error('Failure', error, 'Failed to search users');
+ }
+ });
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.html b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.html
new file mode 100644
index 000000000..331d8e8ab
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.html
@@ -0,0 +1,117 @@
+
+ User search configurations
+
+
+
+
+
+
+ Extra search configuration
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search-item/index.js b/app/portainer/settings/authentication/ldap/ldap-group-search-item/index.js
new file mode 100644
index 000000000..62fcd5b1f
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-group-search-item/index.js
@@ -0,0 +1,15 @@
+import controller from './ldap-group-search-item.controller';
+
+export const ldapGroupSearchItem = {
+ templateUrl: './ldap-group-search-item.html',
+ controller,
+ bindings: {
+ config: '=',
+ index: '<',
+ domainSuffix: '@',
+ baseFilter: '@',
+
+ onRemoveClick: '<',
+ limitedFeatureId: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.controller.js b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.controller.js
new file mode 100644
index 000000000..95a1cc31a
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.controller.js
@@ -0,0 +1,51 @@
+export default class LdapSettingsAdGroupSearchItemController {
+ /* @ngInject */
+ constructor(Notifications) {
+ Object.assign(this, { Notifications });
+
+ this.groups = [];
+
+ this.onChangeBaseDN = this.onChangeBaseDN.bind(this);
+ }
+
+ onChangeBaseDN(baseDN) {
+ this.config.GroupBaseDN = baseDN;
+ }
+
+ addGroup() {
+ this.groups.push({ type: 'ou', value: '' });
+ }
+
+ removeGroup($index) {
+ this.groups.splice($index, 1);
+ this.onGroupsChange();
+ }
+
+ onGroupsChange() {
+ const groupsFilter = this.groups.map(({ type, value }) => `(${type}=${value})`).join('');
+ this.onFilterChange(groupsFilter ? `(&${this.baseFilter}(|${groupsFilter}))` : `${this.baseFilter}`);
+ }
+
+ onFilterChange(filter) {
+ this.config.GroupFilter = filter;
+ }
+
+ parseGroupFilter() {
+ const match = this.config.GroupFilter.match(/^\(&\(objectClass=(\w+)\)\(\|((\(\w+=.+\))+)\)\)$/);
+ if (!match) {
+ return;
+ }
+
+ const [, , groupFilter = ''] = match;
+
+ this.groups = groupFilter
+ .slice(1, -1)
+ .split(')(')
+ .map((str) => str.split('='))
+ .map(([type, value]) => ({ type, value }));
+ }
+
+ $onInit() {
+ this.parseGroupFilter();
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.html b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.html
new file mode 100644
index 000000000..cb3791e28
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.html
@@ -0,0 +1,93 @@
+
+
+
+
+ Extra search configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search/index.js b/app/portainer/settings/authentication/ldap/ldap-group-search/index.js
new file mode 100644
index 000000000..8b610ddcb
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-group-search/index.js
@@ -0,0 +1,14 @@
+import controller from './ldap-group-search.controller';
+
+export const ldapGroupSearch = {
+ templateUrl: './ldap-group-search.html',
+ controller,
+ bindings: {
+ settings: '=',
+ domainSuffix: '@',
+ baseFilter: '@',
+
+ onSearchClick: '<',
+ limitedFeatureId: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.controller.js
new file mode 100644
index 000000000..c431bb230
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.controller.js
@@ -0,0 +1,36 @@
+import _ from 'lodash-es';
+
+export default class LdapGroupSearchController {
+ /* @ngInject */
+ constructor($async, Notifications) {
+ Object.assign(this, { $async, Notifications });
+
+ this.groups = null;
+
+ this.onRemoveClick = this.onRemoveClick.bind(this);
+ this.onAddClick = this.onAddClick.bind(this);
+ this.search = this.search.bind(this);
+ }
+
+ onAddClick() {
+ const lastSetting = _.last(this.settings);
+ this.settings.push({ GroupBaseDN: this.domainSuffix, GroupAttribute: lastSetting.GroupAttribute, GroupFilter: this.baseFilter });
+ }
+
+ onRemoveClick(index) {
+ this.settings.splice(index, 1);
+ }
+
+ search() {
+ return this.$async(async () => {
+ try {
+ this.groups = null;
+ this.showTable = true;
+ this.groups = await this.onSearchClick();
+ } catch (error) {
+ this.showTable = false;
+ this.Notifications.error('Failure', error, 'Failed to search users');
+ }
+ });
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.html b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.html
new file mode 100644
index 000000000..473aade4b
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.html
@@ -0,0 +1,39 @@
+
+ Teams auto-population configurations
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/components/datatables/roles-datatable/rolesDatatable.js b/app/portainer/settings/authentication/ldap/ldap-groups-datatable/index.js
similarity index 63%
rename from app/portainer/components/datatables/roles-datatable/rolesDatatable.js
rename to app/portainer/settings/authentication/ldap/ldap-groups-datatable/index.js
index 3cb7cf24a..28cacef0c 100644
--- a/app/portainer/components/datatables/roles-datatable/rolesDatatable.js
+++ b/app/portainer/settings/authentication/ldap/ldap-groups-datatable/index.js
@@ -1,5 +1,5 @@
-angular.module('portainer.app').component('rolesDatatable', {
- templateUrl: './rolesDatatable.html',
+export const ldapGroupsDatatable = {
+ templateUrl: './ldap-groups-datatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
@@ -9,4 +9,4 @@ angular.module('portainer.app').component('rolesDatatable', {
orderBy: '@',
reverseOrder: '<',
},
-});
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-groups-datatable/ldap-groups-datatable.html b/app/portainer/settings/authentication/ldap/ldap-groups-datatable/ldap-groups-datatable.html
new file mode 100644
index 000000000..061448f70
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-groups-datatable/ldap-groups-datatable.html
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ User Name
+
+
+
+ |
+
+ Groups
+ |
+
+
+
+
+
+ {{ item.Name }}
+ |
+
+ {{ group }}
+ |
+
+
+ Loading... |
+
+
+ No groups found. |
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-custom/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-custom/index.js
new file mode 100644
index 000000000..321223717
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/index.js
@@ -0,0 +1,15 @@
+import controller from './ldap-settings-custom.controller';
+
+export const ldapSettingsCustom = {
+ templateUrl: './ldap-settings-custom.html',
+ controller,
+ bindings: {
+ settings: '=',
+ tlscaCert: '=',
+ state: '=',
+ onTlscaCertChange: '<',
+ connectivityCheck: '<',
+ onSearchUsersClick: '<',
+ onSearchGroupsClick: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js
new file mode 100644
index 000000000..b4c3de4d5
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js
@@ -0,0 +1,15 @@
+import { EXTERNAL_AUTH_LDAP } from '@/portainer/feature-flags/feature-ids';
+
+export default class LdapSettingsCustomController {
+ constructor() {
+ this.limitedFeatureId = EXTERNAL_AUTH_LDAP;
+ }
+
+ addLDAPUrl() {
+ this.settings.URLs.push('');
+ }
+
+ removeLDAPUrl(index) {
+ this.settings.URLs.splice(index, 1);
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.html b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.html
new file mode 100644
index 000000000..00690e750
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.html
@@ -0,0 +1,119 @@
+
+
+ Information
+
+
+ When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails.
+
+
+
+
+ LDAP configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/index.js
new file mode 100644
index 000000000..32ab9fe29
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/index.js
@@ -0,0 +1,16 @@
+import controller from './ldap-settings-dn-builder.controller';
+
+export const ldapSettingsDnBuilder = {
+ templateUrl: './ldap-settings-dn-builder.html',
+ controller,
+ bindings: {
+ // ngModel: string (dc=,cn=,)
+ ngModel: '<',
+ // onChange(string) => void
+ onChange: '<',
+ // suffix: string (dc=,dc=,)
+ suffix: '@',
+ label: '@',
+ limitedFeatureId: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.controller.js
new file mode 100644
index 000000000..4b829967a
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.controller.js
@@ -0,0 +1,84 @@
+export default class LdapSettingsBaseDnBuilderController {
+ /* @ngInject */
+ constructor() {
+ this.entries = [];
+ }
+
+ addEntry() {
+ this.entries.push({ type: 'ou', value: '' });
+ }
+
+ removeEntry($index) {
+ this.entries.splice($index, 1);
+ this.onEntriesChange();
+ }
+
+ moveUp($index) {
+ if ($index <= 0) {
+ return;
+ }
+ arrayMove(this.entries, $index, $index - 1);
+ this.onEntriesChange();
+ }
+
+ moveDown($index) {
+ if ($index >= this.entries.length - 1) {
+ return;
+ }
+ arrayMove(this.entries, $index, $index + 1);
+ this.onEntriesChange();
+ }
+
+ onEntriesChange() {
+ const dn = this.entries
+ .filter(({ value }) => value)
+ .map(({ type, value }) => `${type}=${value}`)
+ .concat(this.suffix)
+ .filter((value) => value)
+ .join(',');
+
+ this.onChange(dn);
+ }
+
+ getOUValues(dn, domainSuffix = '') {
+ const regex = /(\w+)=(\w*),?/;
+ let ouValues = [];
+ let left = dn;
+ let match = left.match(regex);
+ while (match && left !== domainSuffix) {
+ const [, type, value] = match;
+ ouValues.push({ type, value });
+ left = left.replace(regex, '');
+ match = left.match(/(\w+)=(\w+),?/);
+ }
+ return ouValues;
+ }
+
+ parseBaseDN() {
+ this.entries = this.getOUValues(this.ngModel, this.suffix);
+ }
+
+ $onChanges({ suffix, ngModel }) {
+ if ((!suffix && !ngModel) || (suffix && suffix.isFirstChange())) {
+ return;
+ }
+ this.onEntriesChange();
+ }
+
+ $onInit() {
+ this.parseBaseDN();
+ }
+}
+
+function arrayMove(array, fromIndex, toIndex) {
+ if (!checkValidIndex(array, fromIndex) || !checkValidIndex(array, toIndex)) {
+ throw new Error('index is out of bounds');
+ }
+ const [item] = array.splice(fromIndex, 1);
+
+ array.splice(toIndex, 0, item);
+
+ function checkValidIndex(array, index) {
+ return index >= 0 && index <= array.length;
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.html b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.html
new file mode 100644
index 000000000..ab1902981
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.html
@@ -0,0 +1,69 @@
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/index.js
new file mode 100644
index 000000000..21a2a8625
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/index.js
@@ -0,0 +1,18 @@
+import controller from './ldap-settings-group-dn-builder.controller';
+
+export const ldapSettingsGroupDnBuilder = {
+ templateUrl: './ldap-settings-group-dn-builder.html',
+ controller,
+ bindings: {
+ // ngModel: string (dc=,cn=,)
+ ngModel: '<',
+ // onChange(string) => void
+ onChange: '<',
+ // suffix: string (dc=,dc=,)
+ suffix: '@',
+ // index: int >= 0
+ index: '<',
+ onRemoveClick: '<',
+ limitedFeatureId: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.controller.js
new file mode 100644
index 000000000..32ee7f3ee
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.controller.js
@@ -0,0 +1,55 @@
+export default class LdapSettingsGroupDnBuilderController {
+ /* @ngInject */
+ constructor() {
+ this.groupName = '';
+ this.entries = '';
+
+ this.onEntriesChange = this.onEntriesChange.bind(this);
+ this.onGroupNameChange = this.onGroupNameChange.bind(this);
+ this.onGroupChange = this.onGroupChange.bind(this);
+ this.removeGroup = this.removeGroup.bind(this);
+ }
+
+ onEntriesChange(entries) {
+ this.onGroupChange(this.groupName, entries);
+ }
+
+ onGroupNameChange() {
+ this.onGroupChange(this.groupName, this.entries);
+ }
+
+ onGroupChange(groupName, entries) {
+ if (!groupName) {
+ return;
+ }
+ const groupNameEntry = `cn=${groupName}`;
+ this.onChange(this.index, entries || this.suffix ? `${groupNameEntry},${entries || this.suffix}` : groupNameEntry);
+ }
+
+ removeGroup() {
+ this.onRemoveClick(this.index);
+ }
+
+ parseEntries(value, suffix) {
+ if (value === suffix) {
+ this.groupName = '';
+ this.entries = suffix;
+ return;
+ }
+
+ const [groupName, entries] = this.ngModel.split(/,(.+)/);
+ this.groupName = groupName.replace('cn=', '');
+ this.entries = entries || '';
+ }
+
+ $onChange({ ngModel, suffix }) {
+ if ((!suffix || suffix.isFirstChange()) && !ngModel) {
+ return;
+ }
+ this.parseEntries(ngModel.value, suffix.value);
+ }
+
+ $onInit() {
+ this.parseEntries(this.ngModel, this.suffix);
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.html b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.html
new file mode 100644
index 000000000..b00f63c47
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.html
@@ -0,0 +1,37 @@
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-openldap/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/index.js
new file mode 100644
index 000000000..b88f8008c
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/index.js
@@ -0,0 +1,16 @@
+import controller from './ldap-settings-openldap.controller';
+
+export const ldapSettingsOpenLdap = {
+ templateUrl: './ldap-settings-openldap.html',
+ controller,
+ bindings: {
+ settings: '=',
+ tlscaCert: '=',
+ state: '=',
+ connectivityCheck: '<',
+
+ onTlscaCertChange: '<',
+ onSearchUsersClick: '<',
+ onSearchGroupsClick: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js
new file mode 100644
index 000000000..5115548d1
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js
@@ -0,0 +1,45 @@
+import { EXTERNAL_AUTH_LDAP } from '@/portainer/feature-flags/feature-ids';
+
+export default class LdapSettingsOpenLDAPController {
+ /* @ngInject */
+ constructor() {
+ this.domainSuffix = '';
+ this.limitedFeatureId = EXTERNAL_AUTH_LDAP;
+
+ this.findDomainSuffix = this.findDomainSuffix.bind(this);
+ this.parseDomainSuffix = this.parseDomainSuffix.bind(this);
+ this.onAccountChange = this.onAccountChange.bind(this);
+ }
+
+ findDomainSuffix() {
+ const serviceAccount = this.settings.ReaderDN;
+ let domainSuffix = this.parseDomainSuffix(serviceAccount);
+ if (!domainSuffix && this.settings.SearchSettings.length > 0) {
+ const searchSettings = this.settings.SearchSettings[0];
+ domainSuffix = this.parseDomainSuffix(searchSettings.BaseDN);
+ }
+
+ this.domainSuffix = domainSuffix;
+ }
+
+ parseDomainSuffix(string = '') {
+ const index = string.toLowerCase().indexOf('dc=');
+ return index !== -1 ? string.substring(index) : '';
+ }
+
+ onAccountChange(serviceAccount) {
+ this.domainSuffix = this.parseDomainSuffix(serviceAccount);
+ }
+
+ addLDAPUrl() {
+ this.settings.URLs.push('');
+ }
+
+ removeLDAPUrl(index) {
+ this.settings.URLs.splice(index, 1);
+ }
+
+ $onInit() {
+ this.findDomainSuffix();
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.html b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.html
new file mode 100644
index 000000000..b6893961b
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.html
@@ -0,0 +1,187 @@
+
+
+
+
+
+ Information
+
+
+ When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails.
+
+
+
+
+ LDAP configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-security/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-security/index.js
new file mode 100644
index 000000000..75f9fce5e
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-security/index.js
@@ -0,0 +1,11 @@
+export const ldapSettingsSecurity = {
+ templateUrl: './ldap-settings-security.html',
+ bindings: {
+ settings: '=',
+ tlscaCert: '<',
+ onTlscaCertChange: '<',
+ uploadInProgress: '<',
+ title: '@',
+ limitedFeatureId: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-security/ldap-settings-security.html b/app/portainer/settings/authentication/ldap/ldap-settings-security/ldap-settings-security.html
new file mode 100644
index 000000000..6bab54bff
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-security/ldap-settings-security.html
@@ -0,0 +1,72 @@
+
+ {{ $ctrl.title || 'LDAP security' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-test-login/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/index.js
new file mode 100644
index 000000000..b5298616c
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/index.js
@@ -0,0 +1,11 @@
+import controller from './ldap-settings-test-login.controller';
+
+export const ldapSettingsTestLogin = {
+ templateUrl: './ldap-settings-test-login.html',
+ controller,
+ bindings: {
+ settings: '=',
+ limitedFeatureId: '<',
+ showBeIndicatorIfNeeded: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.controller.js
new file mode 100644
index 000000000..811f70aa9
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.controller.js
@@ -0,0 +1,31 @@
+const TEST_STATUS = {
+ LOADING: 'LOADING',
+ SUCCESS: 'SUCCESS',
+ FAILURE: 'FAILURE',
+};
+
+export default class LdapSettingsTestLogin {
+ /* @ngInject */
+ constructor($async, LDAPService, Notifications) {
+ Object.assign(this, { $async, LDAPService, Notifications });
+
+ this.TEST_STATUS = TEST_STATUS;
+
+ this.state = {
+ testStatus: '',
+ };
+ }
+
+ async testLogin(username, password) {
+ return this.$async(async () => {
+ this.state.testStatus = TEST_STATUS.LOADING;
+ try {
+ const response = await this.LDAPService.testLogin(this.settings, username, password);
+ this.state.testStatus = response.valid ? TEST_STATUS.SUCCESS : TEST_STATUS.FAILURE;
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Unable to test login');
+ this.state.testStatus = TEST_STATUS.FAILURE;
+ }
+ });
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.html b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.html
new file mode 100644
index 000000000..5a0871a61
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.html
@@ -0,0 +1,45 @@
+
+ Test login
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings.model.js b/app/portainer/settings/authentication/ldap/ldap-settings.model.js
new file mode 100644
index 000000000..d14711eec
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings.model.js
@@ -0,0 +1,54 @@
+export function buildLdapSettingsModel() {
+ return {
+ AnonymousMode: true,
+ ReaderDN: '',
+ URLs: [''],
+ ServerType: 0,
+ TLSConfig: {
+ TLS: false,
+ TLSSkipVerify: false,
+ },
+ StartTLS: false,
+ SearchSettings: [
+ {
+ BaseDN: '',
+ Filter: '',
+ UserNameAttribute: '',
+ },
+ ],
+ GroupSearchSettings: [
+ {
+ GroupBaseDN: '',
+ GroupFilter: '',
+ GroupAttribute: '',
+ },
+ ],
+ AutoCreateUsers: true,
+ };
+}
+
+export function buildAdSettingsModel() {
+ const settings = buildLdapSettingsModel();
+
+ settings.ServerType = 2;
+ settings.AnonymousMode = false;
+ settings.SearchSettings[0].UserNameAttribute = 'sAMAccountName';
+ settings.SearchSettings[0].Filter = '(objectClass=user)';
+ settings.GroupSearchSettings[0].GroupAttribute = 'member';
+ settings.GroupSearchSettings[0].GroupFilter = '(objectClass=group)';
+
+ return settings;
+}
+
+export function buildOpenLDAPSettingsModel() {
+ const settings = buildLdapSettingsModel();
+
+ settings.ServerType = 1;
+ settings.AnonymousMode = false;
+ settings.SearchSettings[0].UserNameAttribute = 'uid';
+ settings.SearchSettings[0].Filter = '(objectClass=inetOrgPerson)';
+ settings.GroupSearchSettings[0].GroupAttribute = 'member';
+ settings.GroupSearchSettings[0].GroupFilter = '(objectClass=groupOfNames)';
+
+ return settings;
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/index.js b/app/portainer/settings/authentication/ldap/ldap-settings/index.js
new file mode 100644
index 000000000..90e86951e
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings/index.js
@@ -0,0 +1,11 @@
+import controller from './ldap-settings.controller';
+
+export const ldapSettings = {
+ templateUrl: './ldap-settings.html',
+ controller,
+ bindings: {
+ settings: '=',
+ state: '<',
+ connectivityCheck: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js
new file mode 100644
index 000000000..b989393ab
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js
@@ -0,0 +1,67 @@
+const SERVER_TYPES = {
+ CUSTOM: 0,
+ OPEN_LDAP: 1,
+ AD: 2,
+};
+
+import { buildOpenLDAPSettingsModel } from '@/portainer/settings/authentication/ldap/ldap-settings.model';
+import { EXTERNAL_AUTH_LDAP } from '@/portainer/feature-flags/feature-ids';
+
+const DEFAULT_GROUP_FILTER = '(objectClass=groupOfNames)';
+const DEFAULT_USER_FILTER = '(objectClass=inetOrgPerson)';
+
+export default class LdapSettingsController {
+ /* @ngInject */
+ constructor(LDAPService) {
+ Object.assign(this, { LDAPService, SERVER_TYPES });
+
+ this.tlscaCert = null;
+
+ this.boxSelectorOptions = [
+ { id: 'ldap_custom', value: SERVER_TYPES.CUSTOM, label: 'Custom', icon: 'fa fa-server' },
+ { id: 'ldap_openldap', value: SERVER_TYPES.OPEN_LDAP, label: 'OpenLDAP', icon: 'fa fa-server', feature: EXTERNAL_AUTH_LDAP },
+ ];
+
+ this.onTlscaCertChange = this.onTlscaCertChange.bind(this);
+ this.searchUsers = this.searchUsers.bind(this);
+ this.searchGroups = this.searchGroups.bind(this);
+ this.onChangeServerType = this.onChangeServerType.bind(this);
+ }
+
+ onTlscaCertChange(file) {
+ this.tlscaCert = file;
+ }
+
+ $onInit() {
+ this.tlscaCert = this.settings.TLSCACert;
+ }
+
+ onChangeServerType(serverType) {
+ switch (serverType) {
+ case SERVER_TYPES.OPEN_LDAP:
+ return this.onChangeToOpenLDAP();
+ default:
+ break;
+ }
+ }
+
+ onChangeToOpenLDAP() {
+ this.settings = buildOpenLDAPSettingsModel();
+ }
+
+ searchUsers() {
+ const settings = {
+ ...this.settings,
+ SearchSettings: this.settings.SearchSettings.map((search) => ({ ...search, Filter: search.Filter || DEFAULT_USER_FILTER })),
+ };
+ return this.LDAPService.users(settings);
+ }
+
+ searchGroups() {
+ const settings = {
+ ...this.settings,
+ GroupSearchSettings: this.settings.GroupSearchSettings.map((search) => ({ ...search, GroupFilter: search.GroupFilter || DEFAULT_GROUP_FILTER })),
+ };
+ return this.LDAPService.groups(settings);
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html
new file mode 100644
index 000000000..ccad9ca6b
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html
@@ -0,0 +1,41 @@
+
+
+
+ With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group name(s).
+ If disabled, users must be created in Portainer beforehand.
+
+
+
+
+ Server Type
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search-item/index.js b/app/portainer/settings/authentication/ldap/ldap-user-search-item/index.js
new file mode 100644
index 000000000..32dd36786
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-user-search-item/index.js
@@ -0,0 +1,15 @@
+import controller from './ldap-user-search-item.controller';
+
+export const ldapUserSearchItem = {
+ templateUrl: './ldap-user-search-item.html',
+ controller,
+ bindings: {
+ config: '=',
+ index: '<',
+ showUsernameFormat: '<',
+ domainSuffix: '@',
+ baseFilter: '@',
+ onRemoveClick: '<',
+ limitedFeatureId: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.controller.js b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.controller.js
new file mode 100644
index 000000000..a42ffdc72
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.controller.js
@@ -0,0 +1,67 @@
+export default class LdapUserSearchItemController {
+ /* @ngInject */
+ constructor() {
+ this.groups = [];
+
+ this.onBaseDNChange = this.onBaseDNChange.bind(this);
+ this.onGroupChange = this.onGroupChange.bind(this);
+ this.onGroupsChange = this.onGroupsChange.bind(this);
+ this.removeGroup = this.removeGroup.bind(this);
+ }
+
+ onBaseDNChange(baseDN) {
+ this.config.BaseDN = baseDN;
+ }
+
+ onGroupChange(index, group) {
+ this.groups[index] = group;
+ this.onGroupsChange(this.groups);
+ }
+
+ onGroupsChange(groups) {
+ this.config.Filter = this.generateUserFilter(groups);
+ }
+
+ removeGroup(index) {
+ this.groups.splice(index, 1);
+ this.onGroupsChange(this.groups);
+ }
+
+ addGroup() {
+ this.groups.push(this.domainSuffix ? `cn=,${this.domainSuffix}` : 'cn=');
+ }
+
+ generateUserFilter(groups) {
+ const filteredGroups = groups.filter((group) => group !== this.domainSuffix);
+
+ if (!filteredGroups.length) {
+ return this.baseFilter;
+ }
+
+ const groupsFilter = filteredGroups.map((group) => `(memberOf=${group})`);
+
+ return `(&${this.baseFilter}${groupsFilter.length > 1 ? `(|${groupsFilter.join('')})` : groupsFilter[0]})`;
+ }
+
+ parseFilter() {
+ const filter = this.config.Filter;
+ if (filter === this.baseFilter) {
+ return;
+ }
+
+ if (!filter.includes('|')) {
+ const index = filter.indexOf('memberOf=');
+ if (index > -1) {
+ this.groups = [filter.slice(index + 9, -2)];
+ }
+ return;
+ }
+
+ const members = filter.slice(filter.indexOf('|') + 2, -3);
+ this.groups = members.split(')(').map((member) => member.replace('memberOf=', ''));
+ }
+
+ $onInit() {
+ this.parseFilter();
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.html b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.html
new file mode 100644
index 000000000..4e75783ff
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.html
@@ -0,0 +1,106 @@
+
+
+
+
+ Extra search configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search/index.js b/app/portainer/settings/authentication/ldap/ldap-user-search/index.js
new file mode 100644
index 000000000..380ddcfd3
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-user-search/index.js
@@ -0,0 +1,15 @@
+import controller from './ldap-user-search.controller';
+
+export const ldapUserSearch = {
+ templateUrl: './ldap-user-search.html',
+ controller,
+ bindings: {
+ settings: '=',
+ domainSuffix: '@',
+ showUsernameFormat: '<',
+ baseFilter: '@',
+ limitedFeatureId: '<',
+
+ onSearchClick: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.controller.js
new file mode 100644
index 000000000..6d5ff11eb
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.controller.js
@@ -0,0 +1,38 @@
+import _ from 'lodash';
+
+export default class LdapUserSearchController {
+ /* @ngInject */
+ constructor($async, Notifications) {
+ Object.assign(this, { $async, Notifications });
+
+ this.users = null;
+ this.showTable = false;
+
+ this.onRemoveClick = this.onRemoveClick.bind(this);
+ this.onAddClick = this.onAddClick.bind(this);
+ this.search = this.search.bind(this);
+ }
+
+ onAddClick() {
+ const lastSetting = _.last(this.settings);
+ this.settings.push({ BaseDN: this.domainSuffix, UserNameAttribute: lastSetting.UserNameAttribute, Filter: this.baseFilter });
+ }
+
+ onRemoveClick(index) {
+ this.settings.splice(index, 1);
+ }
+
+ search() {
+ return this.$async(async () => {
+ try {
+ this.users = null;
+ this.showTable = true;
+ const users = await this.onSearchClick();
+ this.users = _.compact(users);
+ } catch (error) {
+ this.Notifications.error('Failure', error, 'Failed to search users');
+ this.showTable = false;
+ }
+ });
+ }
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.html b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.html
new file mode 100644
index 000000000..ae8741fce
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.html
@@ -0,0 +1,40 @@
+
+ User search configurations
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-users-datatable/index.js b/app/portainer/settings/authentication/ldap/ldap-users-datatable/index.js
new file mode 100644
index 000000000..4c80771d4
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-users-datatable/index.js
@@ -0,0 +1,12 @@
+export const ldapUsersDatatable = {
+ templateUrl: './ldap-users-datatable.html',
+ controller: 'GenericDatatableController',
+ bindings: {
+ titleText: '@',
+ titleIcon: '@',
+ dataset: '<',
+ tableKey: '@',
+ orderBy: '@',
+ reverseOrder: '<',
+ },
+};
diff --git a/app/portainer/settings/authentication/ldap/ldap-users-datatable/ldap-users-datatable.html b/app/portainer/settings/authentication/ldap/ldap-users-datatable/ldap-users-datatable.html
new file mode 100644
index 000000000..9817654f2
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap-users-datatable/ldap-users-datatable.html
@@ -0,0 +1,71 @@
+
diff --git a/app/portainer/settings/authentication/ldap/ldap.rest.js b/app/portainer/settings/authentication/ldap/ldap.rest.js
new file mode 100644
index 000000000..e93d5277c
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap.rest.js
@@ -0,0 +1,15 @@
+const API_ENDPOINT_LDAP = 'api/ldap';
+
+/* @ngInject */
+export function LDAP($resource) {
+ return $resource(
+ `${API_ENDPOINT_LDAP}/:action`,
+ {},
+ {
+ check: { method: 'POST', params: { action: 'check' } },
+ users: { method: 'POST', isArray: true, params: { action: 'users' } },
+ groups: { method: 'POST', isArray: true, params: { action: 'groups' } },
+ testLogin: { method: 'POST', params: { action: 'test' } },
+ }
+ );
+}
diff --git a/app/portainer/settings/authentication/ldap/ldap.service.js b/app/portainer/settings/authentication/ldap/ldap.service.js
new file mode 100644
index 000000000..875f83a02
--- /dev/null
+++ b/app/portainer/settings/authentication/ldap/ldap.service.js
@@ -0,0 +1,29 @@
+/* @ngInject */
+export function LDAPService(LDAP) {
+ return { users, groups, check, testLogin };
+
+ function users(ldapSettings) {
+ return LDAP.users({ ldapSettings }).$promise;
+ }
+
+ async function groups(ldapSettings) {
+ const userGroups = await LDAP.groups({ ldapSettings }).$promise;
+ return userGroups.map(({ Name, Groups }) => {
+ let name = Name;
+ if (Name.includes(',') && Name.includes('=')) {
+ const [cnName] = Name.split(',');
+ const split = cnName.split('=');
+ name = split[1];
+ }
+ return { Groups, Name: name };
+ });
+ }
+
+ function check(ldapSettings) {
+ return LDAP.check({ ldapSettings }).$promise;
+ }
+
+ function testLogin(ldapSettings, username, password) {
+ return LDAP.testLogin({ ldapSettings, username, password }).$promise;
+ }
+}
diff --git a/app/portainer/settings/index.js b/app/portainer/settings/index.js
index 42e4e25ac..629f9b5ed 100644
--- a/app/portainer/settings/index.js
+++ b/app/portainer/settings/index.js
@@ -1,5 +1,6 @@
import angular from 'angular';
+import authenticationModule from './authentication';
import generalModule from './general';
-export default angular.module('portainer.settings', [generalModule]).name;
+export default angular.module('portainer.settings', [authenticationModule, generalModule]).name;
diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.controller.js b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.controller.js
new file mode 100644
index 000000000..aa3f0c854
--- /dev/null
+++ b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.controller.js
@@ -0,0 +1,38 @@
+export default class ActivityLogsDatatableController {
+ /* @ngInject */
+ constructor($controller, $scope, PaginationService) {
+ this.PaginationService = PaginationService;
+
+ this.tableKey = 'authLogs';
+
+ const $onInit = this.$onInit;
+ angular.extend(this, $controller('GenericDatatableController', { $scope }));
+
+ this.changeSort = this.changeSort.bind(this);
+ this.handleChangeLimit = this.handleChangeLimit.bind(this);
+ this.$onInit = $onInit.bind(this);
+ }
+
+ changeSort(key) {
+ let desc = false;
+ if (key === this.sort.key) {
+ desc = !this.sort.desc;
+ }
+
+ this.onChangeSort({ key, desc });
+ }
+
+ handleChangeLimit(limit) {
+ this.PaginationService.setPaginationLimit(this.tableKey, limit);
+ this.onChangeLimit(limit);
+ }
+
+ $onInit() {
+ this.$onInitGeneric();
+
+ const limit = this.PaginationService.getPaginationLimit(this.tableKey);
+ if (limit) {
+ this.onChangeLimit(+limit);
+ }
+ }
+}
diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.css b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.css
new file mode 100644
index 000000000..42eb3b401
--- /dev/null
+++ b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.css
@@ -0,0 +1,3 @@
+.activity-logs-datatable .small-column {
+ width: 150px;
+}
diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.html b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.html
new file mode 100644
index 000000000..ac2f1bbaf
--- /dev/null
+++ b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.html
@@ -0,0 +1,66 @@
+
diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/index.js b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/index.js
new file mode 100644
index 000000000..9550fb36e
--- /dev/null
+++ b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/index.js
@@ -0,0 +1,24 @@
+import './activity-logs-datatable.css';
+
+import controller from './activity-logs-datatable.controller.js';
+
+export const activityLogsDatatable = {
+ templateUrl: './activity-logs-datatable.html',
+ controller,
+ bindings: {
+ logs: '<',
+ keyword: '<',
+ sort: '<',
+ limit: '<',
+ totalItems: '<',
+ currentPage: '<',
+ feature: '@',
+
+ onChangeContextFilter: '<',
+ onChangeKeyword: '<',
+ onChangeSort: '<',
+
+ onChangeLimit: '<',
+ onChangePage: '<',
+ },
+};
diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-view.controller.js b/app/portainer/user-activity/activity-logs-view/activity-logs-view.controller.js
new file mode 100644
index 000000000..77957cb59
--- /dev/null
+++ b/app/portainer/user-activity/activity-logs-view/activity-logs-view.controller.js
@@ -0,0 +1,93 @@
+import moment from 'moment';
+import { ACTIVITY_AUDIT } from '@/portainer/feature-flags/feature-ids';
+export default class ActivityLogsViewController {
+ /* @ngInject */
+ constructor($async, Notifications) {
+ this.$async = $async;
+ this.Notifications = Notifications;
+ this.limitedFeature = ACTIVITY_AUDIT;
+ this.state = {
+ keyword: '',
+ date: {
+ from: 0,
+ to: 0,
+ },
+ sort: {
+ key: 'Timestamp',
+ desc: true,
+ },
+ page: 1,
+ limit: 10,
+ totalItems: 0,
+ logs: null,
+ };
+
+ this.today = moment().endOf('day');
+ this.minValidDate = moment().subtract(7, 'd').startOf('day');
+
+ this.onChangeDate = this.onChangeDate.bind(this);
+ this.onChangeKeyword = this.onChangeKeyword.bind(this);
+ this.onChangeSort = this.onChangeSort.bind(this);
+ this.loadLogs = this.loadLogs.bind(this);
+ this.onChangePage = this.onChangePage.bind(this);
+ this.onChangeLimit = this.onChangeLimit.bind(this);
+ }
+
+ onChangePage(page) {
+ this.state.page = page;
+ this.loadLogs();
+ }
+
+ onChangeLimit(limit) {
+ this.state.page = 1;
+ this.state.limit = limit;
+ this.loadLogs();
+ }
+
+ onChangeSort(sort) {
+ this.state.page = 1;
+ this.state.sort = sort;
+ this.loadLogs();
+ }
+
+ onChangeKeyword(keyword) {
+ this.state.page = 1;
+ this.state.keyword = keyword;
+ this.loadLogs();
+ }
+
+ onChangeDate({ startDate, endDate }) {
+ this.state.page = 1;
+ this.state.date = { to: endDate, from: startDate };
+ this.loadLogs();
+ }
+
+ async export() {
+ return this.$async(async () => {
+ try {
+ await this.UserActivityService.saveLogsAsCSV(this.state.sort, this.state.keyword, this.state.date, this.state.contextFilter);
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Failed loading user activity logs csv');
+ }
+ });
+ }
+
+ async loadLogs() {
+ return this.$async(async () => {
+ this.state.logs = null;
+ try {
+ const { logs, totalCount } = { logs: [{}, {}, {}, {}, {}], totalCount: 5 };
+ this.state.logs = logs;
+ this.state.totalItems = totalCount;
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Failed loading user activity logs');
+ }
+ });
+ }
+
+ $onInit() {
+ return this.$async(async () => {
+ this.loadLogs();
+ });
+ }
+}
diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-view.html b/app/portainer/user-activity/activity-logs-view/activity-logs-view.html
new file mode 100644
index 000000000..b33b728e7
--- /dev/null
+++ b/app/portainer/user-activity/activity-logs-view/activity-logs-view.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+ Activity Logs
+
+
+
+
+
+
+
+
+
+ Portainer user activity logs have a maximum retention of 7 days.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-view.js b/app/portainer/user-activity/activity-logs-view/activity-logs-view.js
new file mode 100644
index 000000000..52082d363
--- /dev/null
+++ b/app/portainer/user-activity/activity-logs-view/activity-logs-view.js
@@ -0,0 +1,6 @@
+import controller from './activity-logs-view.controller.js';
+
+export const activityLogsView = {
+ templateUrl: './activity-logs-view.html',
+ controller,
+};
diff --git a/app/portainer/user-activity/activity-logs-view/index.js b/app/portainer/user-activity/activity-logs-view/index.js
new file mode 100644
index 000000000..da8c69cb4
--- /dev/null
+++ b/app/portainer/user-activity/activity-logs-view/index.js
@@ -0,0 +1,9 @@
+import angular from 'angular';
+
+import { activityLogsView } from './activity-logs-view';
+import { activityLogsDatatable } from './activity-logs-datatable';
+
+export default angular
+ .module('portainer.app.user-activity.activity-logs-view', [])
+ .component('activityLogsDatatable', activityLogsDatatable)
+ .component('activityLogsView', activityLogsView).name;
diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.controller.js b/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.controller.js
new file mode 100644
index 000000000..cd053f5c7
--- /dev/null
+++ b/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.controller.js
@@ -0,0 +1,68 @@
+import { authenticationMethodTypesMap, authenticationMethodTypesLabels } from '@/portainer/settings/authentication/auth-method-constants';
+import { authenticationActivityTypesMap, authenticationActivityTypesLabels } from '@/portainer/settings/authentication/auth-type-constants';
+
+class ActivityLogsDatatableController {
+ /* @ngInject */
+ constructor($controller, $scope, PaginationService) {
+ this.PaginationService = PaginationService;
+
+ this.tableKey = 'authLogs';
+
+ this.contextFilterLabels = Object.values(authenticationMethodTypesMap).map((value) => ({ value, label: authenticationMethodTypesLabels[value] }));
+ this.typeFilterLabels = Object.values(authenticationActivityTypesMap).map((value) => ({ value, label: authenticationActivityTypesLabels[value] }));
+
+ const $onInit = this.$onInit;
+ angular.extend(this, $controller('GenericDatatableController', { $scope }));
+ this.$onInit = $onInit.bind(this);
+
+ this.changeSort = this.changeSort.bind(this);
+ this.handleChangeLimit = this.handleChangeLimit.bind(this);
+ }
+
+ changeSort(key) {
+ let desc = false;
+ if (key === this.sort.key) {
+ desc = !this.sort.desc;
+ }
+
+ this.onChangeSort({ key, desc });
+ }
+
+ contextType(context) {
+ if (!(context in authenticationMethodTypesLabels)) {
+ return '';
+ }
+ return authenticationMethodTypesLabels[context];
+ }
+
+ activityType(type) {
+ if (!(type in authenticationActivityTypesLabels)) {
+ return '';
+ }
+ return authenticationActivityTypesLabels[type];
+ }
+
+ isAuthSuccess(type) {
+ return type === authenticationActivityTypesMap.AuthSuccess;
+ }
+
+ isAuthFailure(type) {
+ return type === authenticationActivityTypesMap.AuthFailure;
+ }
+
+ handleChangeLimit(limit) {
+ this.PaginationService.setPaginationLimit(this.tableKey, limit);
+ this.onChangeLimit(limit);
+ }
+
+ $onInit() {
+ this.$onInitGeneric();
+
+ const limit = this.PaginationService.getPaginationLimit(this.tableKey);
+ if (limit) {
+ this.handleChangeLimit(+limit);
+ }
+ }
+}
+
+export default ActivityLogsDatatableController;
diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.html b/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.html
new file mode 100644
index 000000000..07d687ee8
--- /dev/null
+++ b/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.html
@@ -0,0 +1,64 @@
+
diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/index.js b/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/index.js
new file mode 100644
index 000000000..6488f3549
--- /dev/null
+++ b/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/index.js
@@ -0,0 +1,25 @@
+import controller from './auth-logs-datatable.controller';
+
+export const authLogsDatatable = {
+ templateUrl: './auth-logs-datatable.html',
+ controller,
+ bindings: {
+ logs: '<',
+ keyword: '<',
+ sort: '<',
+ limit: '<',
+ totalItems: '<',
+ currentPage: '<',
+ contextFilter: '<',
+ typeFilter: '<',
+ feature: '@',
+
+ onChangeContextFilter: '<',
+ onChangeTypeFilter: '<',
+ onChangeKeyword: '<',
+ onChangeSort: '<',
+
+ onChangeLimit: '<',
+ onChangePage: '<',
+ },
+};
diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js b/app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js
new file mode 100644
index 000000000..f4ddb81ff
--- /dev/null
+++ b/app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js
@@ -0,0 +1,103 @@
+import moment from 'moment';
+import { ACTIVITY_AUDIT } from '@/portainer/feature-flags/feature-ids';
+
+export default class AuthLogsViewController {
+ /* @ngInject */
+ constructor($async, Notifications) {
+ this.$async = $async;
+ this.Notifications = Notifications;
+
+ this.limitedFeature = ACTIVITY_AUDIT;
+ this.state = {
+ keyword: 'f',
+ date: {
+ from: 0,
+ to: 0,
+ },
+ sort: {
+ key: 'Timestamp',
+ desc: true,
+ },
+ contextFilter: [1, 2, 3],
+ typeFilter: [1, 2, 3],
+ page: 1,
+ limit: 10,
+ totalItems: 0,
+ logs: null,
+ };
+
+ this.today = moment().endOf('day');
+ this.minValidDate = moment().subtract(7, 'd').startOf('day');
+
+ this.onChangeDate = this.onChangeDate.bind(this);
+ this.onChangeKeyword = this.onChangeKeyword.bind(this);
+ this.onChangeSort = this.onChangeSort.bind(this);
+ this.onChangeContextFilter = this.onChangeContextFilter.bind(this);
+ this.onChangeTypeFilter = this.onChangeTypeFilter.bind(this);
+ this.loadLogs = this.loadLogs.bind(this);
+ this.onChangePage = this.onChangePage.bind(this);
+ this.onChangeLimit = this.onChangeLimit.bind(this);
+ }
+
+ onChangePage(page) {
+ this.state.page = page;
+ this.loadLogs();
+ }
+
+ onChangeLimit(limit) {
+ this.state.page = 1;
+ this.state.limit = limit;
+ this.loadLogs();
+ }
+
+ onChangeSort(sort) {
+ this.state.page = 1;
+ this.state.sort = sort;
+ this.loadLogs();
+ }
+
+ onChangeContextFilter(filterKey, filterState) {
+ this.state.contextFilter = filterState;
+ this.loadLogs();
+ }
+
+ onChangeTypeFilter(filterKey, filterState) {
+ this.state.typeFilter = filterState;
+ this.loadLogs();
+ }
+
+ onChangeKeyword(keyword) {
+ this.state.page = 1;
+ this.state.keyword = keyword;
+ this.loadLogs();
+ }
+
+ onChangeDate({ startDate, endDate }) {
+ this.state.page = 1;
+ this.state.date = { to: endDate, from: startDate };
+ this.loadLogs();
+ }
+
+ async loadLogs() {
+ return this.$async(async () => {
+ this.state.logs = null;
+ try {
+ const { logs, totalCount } = { logs: [{}, {}, {}, {}, {}], totalCount: 5 };
+ this.state.logs = decorateLogs(logs);
+ this.state.totalItems = totalCount;
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Failed loading auth activity logs');
+ }
+ });
+ }
+
+ $onInit() {
+ return this.$async(async () => {
+ this.loadLogs();
+ });
+ }
+}
+
+function decorateLogs(logs) {
+ return logs;
+}
diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-view.html b/app/portainer/user-activity/auth-logs-view/auth-logs-view.html
new file mode 100644
index 000000000..6bc1030b9
--- /dev/null
+++ b/app/portainer/user-activity/auth-logs-view/auth-logs-view.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+ User authentication activity
+
+
+
+
+
+
+
+
+
+ Portainer user authentication activity logs have a maximum retention of 7 days.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-view.js b/app/portainer/user-activity/auth-logs-view/auth-logs-view.js
new file mode 100644
index 000000000..c5cd52bb7
--- /dev/null
+++ b/app/portainer/user-activity/auth-logs-view/auth-logs-view.js
@@ -0,0 +1,6 @@
+import controller from './auth-logs-view.controller.js';
+
+export const authLogsView = {
+ templateUrl: './auth-logs-view.html',
+ controller,
+};
diff --git a/app/portainer/user-activity/auth-logs-view/index.js b/app/portainer/user-activity/auth-logs-view/index.js
new file mode 100644
index 000000000..6aa7f7e85
--- /dev/null
+++ b/app/portainer/user-activity/auth-logs-view/index.js
@@ -0,0 +1,6 @@
+import angular from 'angular';
+
+import { authLogsView } from './auth-logs-view';
+import { authLogsDatatable } from './auth-logs-datatable';
+
+export default angular.module('portainer.app.user-activity.auth-logs-view', []).component('authLogsView', authLogsView).component('authLogsDatatable', authLogsDatatable).name;
diff --git a/app/portainer/user-activity/index.js b/app/portainer/user-activity/index.js
new file mode 100644
index 000000000..27056da2f
--- /dev/null
+++ b/app/portainer/user-activity/index.js
@@ -0,0 +1,29 @@
+import angular from 'angular';
+
+import authLogsViewModule from './auth-logs-view';
+import activityLogsViewModule from './activity-logs-view';
+
+export default angular.module('portainer.app.user-activity', [authLogsViewModule, activityLogsViewModule]).config(config).name;
+
+/* @ngInject */
+function config($stateRegistryProvider) {
+ $stateRegistryProvider.register({
+ name: 'portainer.authLogs',
+ url: '/auth-logs',
+ views: {
+ 'content@': {
+ component: 'authLogsView',
+ },
+ },
+ });
+
+ $stateRegistryProvider.register({
+ name: 'portainer.activityLogs',
+ url: '/activity-logs',
+ views: {
+ 'content@': {
+ component: 'activityLogsView',
+ },
+ },
+ });
+}
diff --git a/app/portainer/views/endpoints/access/endpointAccess.html b/app/portainer/views/endpoints/access/endpointAccess.html
index f0c96ea86..1021e3b63 100644
--- a/app/portainer/views/endpoints/access/endpointAccess.html
+++ b/app/portainer/views/endpoints/access/endpointAccess.html
@@ -44,5 +44,6 @@
entity-type="endpoint"
inherit-from="ctrl.group"
update-access="ctrl.updateAccess"
+ limited-feature="ctrl.limitedFeature"
>
diff --git a/app/portainer/views/endpoints/access/endpointAccessController.js b/app/portainer/views/endpoints/access/endpointAccessController.js
index 77f414a31..4600c1977 100644
--- a/app/portainer/views/endpoints/access/endpointAccessController.js
+++ b/app/portainer/views/endpoints/access/endpointAccessController.js
@@ -1,5 +1,7 @@
import angular from 'angular';
+import { RBAC_ROLES } from '@/portainer/feature-flags/feature-ids';
+
class EndpointAccessController {
/* @ngInject */
constructor($state, $transition$, Notifications, EndpointService, GroupService, $async) {
@@ -10,6 +12,8 @@ class EndpointAccessController {
this.GroupService = GroupService;
this.$async = $async;
+ this.limitedFeature = RBAC_ROLES;
+
this.updateAccess = this.updateAccess.bind(this);
this.updateAccessAsync = this.updateAccessAsync.bind(this);
}
diff --git a/app/portainer/views/groups/access/groupAccess.html b/app/portainer/views/groups/access/groupAccess.html
index 595d37e6f..d9404eb4e 100644
--- a/app/portainer/views/groups/access/groupAccess.html
+++ b/app/portainer/views/groups/access/groupAccess.html
@@ -31,4 +31,5 @@
entity-type="group"
action-in-progress="state.actionInProgress"
update-access="updateAccess"
+ limited-feature="limitedFeature"
>
diff --git a/app/portainer/views/groups/access/groupAccessController.js b/app/portainer/views/groups/access/groupAccessController.js
index 5dff41065..e6112be28 100644
--- a/app/portainer/views/groups/access/groupAccessController.js
+++ b/app/portainer/views/groups/access/groupAccessController.js
@@ -1,3 +1,5 @@
+import { RBAC_ROLES } from '@/portainer/feature-flags/feature-ids';
+
angular.module('portainer.app').controller('GroupAccessController', [
'$scope',
'$state',
@@ -5,6 +7,8 @@ angular.module('portainer.app').controller('GroupAccessController', [
'GroupService',
'Notifications',
function ($scope, $state, $transition$, GroupService, Notifications) {
+ $scope.limitedFeature = RBAC_ROLES;
+
$scope.updateAccess = function () {
$scope.state.actionInProgress = true;
GroupService.updateGroup($scope.group, $scope.group.AssociatedEndpoints)
diff --git a/app/portainer/views/roles/roles.html b/app/portainer/views/roles/roles.html
deleted file mode 100644
index 9d73e610a..000000000
--- a/app/portainer/views/roles/roles.html
+++ /dev/null
@@ -1,56 +0,0 @@
-
-
-
-
-
-
- Role management
-
-
-
-
-
-
- This feature is available in Portainer Business Edition.
-
-
-
-
-
-
-
diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html
index 2d36bcd76..3520b5e2a 100644
--- a/app/portainer/views/settings/authentication/settingsAuthentication.html
+++ b/app/portainer/views/settings/authentication/settingsAuthentication.html
@@ -8,7 +8,7 @@
-