feat(app): highlight be provided value [EE-882] (#5703) (#5835)

pull/5851/head
Chaim Lev-Ari 2021-10-07 01:59:53 +03:00 committed by GitHub
parent 8096c5e8bc
commit b7841e7fc3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
175 changed files with 5627 additions and 1216 deletions

View File

@ -18,6 +18,7 @@ import (
"github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/helm"
"github.com/portainer/portainer/api/http/handler/kubernetes"
"github.com/portainer/portainer/api/http/handler/ldap"
"github.com/portainer/portainer/api/http/handler/motd"
"github.com/portainer/portainer/api/http/handler/registries"
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
@ -53,6 +54,7 @@ type Handler struct {
HelmTemplatesHandler *helm.Handler
KubernetesHandler *kubernetes.Handler
FileHandler *file.Handler
LDAPHandler *ldap.Handler
MOTDHandler *motd.Handler
RegistryHandler *registries.Handler
ResourceControlHandler *resourcecontrols.Handler
@ -189,6 +191,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
default:
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
}
case strings.HasPrefix(r.URL.Path, "/api/ldap"):
http.StripPrefix("/api", h.LDAPHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/motd"):
http.StripPrefix("/api", h.MOTDHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/registries"):

View File

@ -0,0 +1,53 @@
package ldap
import (
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
)
// Handler is the HTTP handler used to handle LDAP search Operations
type Handler struct {
*mux.Router
DataStore portainer.DataStore
FileService portainer.FileService
LDAPService portainer.LDAPService
}
// NewHandler returns a new Handler
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/ldap/check",
bouncer.AdminAccess(httperror.LoggerHandler(h.ldapCheck))).Methods(http.MethodPost)
return h
}
func (handler *Handler) prefillSettings(ldapSettings *portainer.LDAPSettings) error {
if !ldapSettings.AnonymousMode && ldapSettings.Password == "" {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return err
}
ldapSettings.Password = settings.LDAPSettings.Password
}
if (ldapSettings.TLSConfig.TLS || ldapSettings.StartTLS) && !ldapSettings.TLSConfig.TLSSkipVerify {
caCertPath, err := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA)
if err != nil {
return err
}
ldapSettings.TLSConfig.TLSCACertPath = caCertPath
}
return nil
}

View File

@ -1,4 +1,4 @@
package settings
package ldap
import (
"net/http"
@ -7,42 +7,43 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
)
type settingsLDAPCheckPayload struct {
type checkPayload struct {
LDAPSettings portainer.LDAPSettings
}
func (payload *settingsLDAPCheckPayload) Validate(r *http.Request) error {
func (payload *checkPayload) Validate(r *http.Request) error {
return nil
}
// @id SettingsLDAPCheck
// @id LDAPCheck
// @summary Test LDAP connectivity
// @description Test LDAP connectivity using LDAP details
// @description **Access policy**: administrator
// @tags settings
// @tags ldap
// @security jwt
// @accept json
// @param body body settingsLDAPCheckPayload true "details"
// @param body body checkPayload true "details"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /settings/ldap/check [put]
func (handler *Handler) settingsLDAPCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload settingsLDAPCheckPayload
// @router /ldap/check [post]
func (handler *Handler) ldapCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload checkPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
if (payload.LDAPSettings.TLSConfig.TLS || payload.LDAPSettings.StartTLS) && !payload.LDAPSettings.TLSConfig.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA)
payload.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath
settings := &payload.LDAPSettings
err = handler.prefillSettings(settings)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to fetch default settings", err}
}
err = handler.LDAPService.TestConnectivity(&payload.LDAPSettings)
err = handler.LDAPService.TestConnectivity(settings)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to connect to LDAP server", err}
}

View File

@ -5,7 +5,7 @@ import (
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
)
@ -35,8 +35,6 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.settingsUpdate))).Methods(http.MethodPut)
h.Handle("/settings/public",
bouncer.PublicAccess(httperror.LoggerHandler(h.settingsPublic))).Methods(http.MethodGet)
h.Handle("/settings/authentication/checkLDAP",
bouncer.AdminAccess(httperror.LoggerHandler(h.settingsLDAPCheck))).Methods(http.MethodPut)
return h
}

View File

@ -29,6 +29,7 @@ import (
"github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/helm"
kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes"
"github.com/portainer/portainer/api/http/handler/ldap"
"github.com/portainer/portainer/api/http/handler/motd"
"github.com/portainer/portainer/api/http/handler/registries"
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
@ -175,6 +176,11 @@ func (server *Server) Start() error {
var helmTemplatesHandler = helm.NewTemplateHandler(requestBouncer, server.HelmPackageManager)
var ldapHandler = ldap.NewHandler(requestBouncer)
ldapHandler.DataStore = server.DataStore
ldapHandler.FileService = server.FileService
ldapHandler.LDAPService = server.LDAPService
var motdHandler = motd.NewHandler(requestBouncer)
var registryHandler = registries.NewHandler(requestBouncer)
@ -255,6 +261,7 @@ func (server *Server) Start() error {
EndpointEdgeHandler: endpointEdgeHandler,
EndpointProxyHandler: endpointProxyHandler,
FileHandler: fileHandler,
LDAPHandler: ldapHandler,
HelmTemplatesHandler: helmTemplatesHandler,
KubernetesHandler: kubernetesHandler,
MOTDHandler: motdHandler,

View File

@ -1,11 +1,11 @@
package ldap
import (
"errors"
"fmt"
"strings"
ldap "github.com/go-ldap/ldap/v3"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
httperrors "github.com/portainer/portainer/api/http/errors"
@ -20,55 +20,28 @@ var (
// Service represents a service used to authenticate users against a LDAP/AD.
type Service struct{}
func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) {
var userDN string
found := false
usernameEscaped := ldap.EscapeFilter(username)
for _, searchSettings := range settings {
searchRequest := ldap.NewSearchRequest(
searchSettings.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, usernameEscaped),
[]string{"dn"},
nil,
)
// Deliberately skip errors on the search request so that we can jump to other search settings
// if any issue arise with the current one.
sr, err := conn.Search(searchRequest)
if err != nil {
continue
}
if len(sr.Entries) == 1 {
found = true
userDN = sr.Entries[0].DN
break
}
}
if !found {
return "", errUserNotFound
}
return userDN, nil
}
func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
conn, err := createConnectionForURL(settings.URL, settings)
if err != nil {
return nil, errors.Wrap(err, "failed creating LDAP connection")
}
return conn, nil
}
func createConnectionForURL(url string, settings *portainer.LDAPSettings) (*ldap.Conn, error) {
if settings.TLSConfig.TLS || settings.StartTLS {
config, err := crypto.CreateTLSConfigurationFromDisk(settings.TLSConfig.TLSCACertPath, settings.TLSConfig.TLSCertPath, settings.TLSConfig.TLSKeyPath, settings.TLSConfig.TLSSkipVerify)
if err != nil {
return nil, err
}
config.ServerName = strings.Split(settings.URL, ":")[0]
config.ServerName = strings.Split(url, ":")[0]
if settings.TLSConfig.TLS {
return ldap.DialTLS("tcp", settings.URL, config)
return ldap.DialTLS("tcp", url, config)
}
conn, err := ldap.Dial("tcp", settings.URL)
conn, err := ldap.Dial("tcp", url)
if err != nil {
return nil, err
}
@ -81,7 +54,7 @@ func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
return conn, nil
}
return ldap.Dial("tcp", settings.URL)
return ldap.Dial("tcp", url)
}
// AuthenticateUser is used to authenticate a user against a LDAP/AD.
@ -133,13 +106,157 @@ func (*Service) GetUserGroups(username string, settings *portainer.LDAPSettings)
return nil, err
}
userGroups := getGroups(userDN, connection, settings.GroupSearchSettings)
userGroups := getGroupsByUser(userDN, connection, settings.GroupSearchSettings)
return userGroups, nil
}
// SearchUsers searches for users with the specified settings
func (*Service) SearchUsers(settings *portainer.LDAPSettings) ([]string, error) {
connection, err := createConnection(settings)
if err != nil {
return nil, err
}
defer connection.Close()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return nil, err
}
}
users := map[string]bool{}
for _, searchSettings := range settings.SearchSettings {
searchRequest := ldap.NewSearchRequest(
searchSettings.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
searchSettings.Filter,
[]string{"dn", searchSettings.UserNameAttribute},
nil,
)
sr, err := connection.Search(searchRequest)
if err != nil {
return nil, err
}
for _, user := range sr.Entries {
username := user.GetAttributeValue(searchSettings.UserNameAttribute)
if username != "" {
users[username] = true
}
}
}
usersList := []string{}
for user := range users {
usersList = append(usersList, user)
}
return usersList, nil
}
// SearchGroups searches for groups with the specified settings
func (*Service) SearchGroups(settings *portainer.LDAPSettings) ([]portainer.LDAPUser, error) {
type groupSet map[string]bool
connection, err := createConnection(settings)
if err != nil {
return nil, err
}
defer connection.Close()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return nil, err
}
}
userGroups := map[string]groupSet{}
for _, searchSettings := range settings.GroupSearchSettings {
searchRequest := ldap.NewSearchRequest(
searchSettings.GroupBaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
searchSettings.GroupFilter,
[]string{"cn", searchSettings.GroupAttribute},
nil,
)
sr, err := connection.Search(searchRequest)
if err != nil {
return nil, err
}
for _, entry := range sr.Entries {
members := entry.GetAttributeValues(searchSettings.GroupAttribute)
for _, username := range members {
_, ok := userGroups[username]
if !ok {
userGroups[username] = groupSet{}
}
userGroups[username][entry.GetAttributeValue("cn")] = true
}
}
}
users := []portainer.LDAPUser{}
for username, groups := range userGroups {
groupList := []string{}
for group := range groups {
groupList = append(groupList, group)
}
user := portainer.LDAPUser{
Name: username,
Groups: groupList,
}
users = append(users, user)
}
return users, nil
}
func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) {
var userDN string
found := false
usernameEscaped := ldap.EscapeFilter(username)
for _, searchSettings := range settings {
searchRequest := ldap.NewSearchRequest(
searchSettings.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, usernameEscaped),
[]string{"dn"},
nil,
)
// Deliberately skip errors on the search request so that we can jump to other search settings
// if any issue arise with the current one.
sr, err := conn.Search(searchRequest)
if err != nil {
continue
}
if len(sr.Entries) == 1 {
found = true
userDN = sr.Entries[0].DN
break
}
}
if !found {
return "", errUserNotFound
}
return userDN, nil
}
// Get a list of group names for specified user from LDAP/AD
func getGroups(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string {
func getGroupsByUser(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string {
groups := make([]string, 0)
userDNEscaped := ldap.EscapeFilter(userDN)
@ -179,9 +296,18 @@ func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error {
}
defer connection.Close()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return err
}
} else {
err = connection.UnauthenticatedBind("")
if err != nil {
return err
}
}
return nil
}

View File

@ -513,6 +513,12 @@ type (
AutoCreateUsers bool `json:"AutoCreateUsers" example:"true"`
}
// LDAPUser represents a LDAP user
LDAPUser struct {
Name string
Groups []string
}
// LicenseInformation represents information about an extension license
LicenseInformation struct {
LicenseKey string `json:"LicenseKey,omitempty"`
@ -1295,6 +1301,8 @@ type (
AuthenticateUser(username, password string, settings *LDAPSettings) error
TestConnectivity(settings *LDAPSettings) error
GetUserGroups(username string, settings *LDAPSettings) ([]string, error)
SearchGroups(settings *LDAPSettings) ([]LDAPUser, error)
SearchUsers(settings *LDAPSettings) ([]string, error)
}
// OAuthService represents a service used to authenticate users using OAuth

View File

@ -816,10 +816,6 @@ json-tree .branch-preview {
}
/* !spinkit override */
.w-full {
width: 100%;
}
/* uib-typeahead override */
#scrollable-dropdown-menu .dropdown-menu {
max-height: 300px;
@ -827,17 +823,33 @@ json-tree .branch-preview {
}
/* !uib-typeahead override */
.my-8 {
margin-top: 2rem;
margin-bottom: 2rem;
}
.kubectl-shell {
display: block;
text-align: center;
padding-bottom: 5px;
}
.w-full {
width: 100%;
}
.flex {
display: flex;
}
.block {
display: block;
}
.items-center {
align-items: center;
}
.my-8 {
margin-top: 2rem;
margin-bottom: 2rem;
}
.text-wrap {
word-break: break-all;
white-space: normal;

View File

@ -89,8 +89,13 @@ html {
--green-1: #164;
--green-2: #1ec863;
--green-3: #23ae89;
--orange-1: #e86925;
--BE-only: var(--orange-1);
}
/* Default Theme */
:root {
--bg-card-color: var(--grey-10);
--bg-main-color: var(--white-color);

View File

@ -401,3 +401,14 @@ input:-webkit-autofill {
color: var(--white-color);
}
/* Overide Vendor CSS */
.btn.disabled,
.btn[disabled],
fieldset[disabled] .btn {
pointer-events: none;
touch-action: none;
}
.multiSelect.inlineBlock button {
margin: 0;
}

View File

@ -178,14 +178,14 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Allow resource over-commit
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" checked disabled /><i data-cy="kubeSetup-resourceOverCommitToggle"></i> </label>
<span class="text-muted small" style="margin-left: 15px;">
<i class="fa fa-user" aria-hidden="true"></i>
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-setup-overcommit" target="_blank"> Portainer Business Edition</a>.
</span>
<por-switch-field
label="Allow resource over-commit"
name="resource-over-commit-switch"
feature="ctrl.limitedFeature"
ng-model="ctrl.formValues.EnableResourceOverCommit"
ng-change="ctrl.onChangeEnableResourceOverCommit()"
ng-data-cy="kubeSetup-resourceOverCommitToggle"
></por-switch-field>
</div>
</div>

View File

@ -6,7 +6,7 @@ import { KubernetesIngressClass } from 'Kubernetes/ingress/models';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { K8S_SETUP_DEFAULT } from '@/portainer/feature-flags/feature-ids';
class KubernetesConfigureController {
/* #region CONSTRUCTOR */
@ -38,6 +38,7 @@ class KubernetesConfigureController {
this.onInit = this.onInit.bind(this);
this.configureAsync = this.configureAsync.bind(this);
this.limitedFeature = K8S_SETUP_DEFAULT;
}
/* #endregion */

View File

@ -162,14 +162,13 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Load Balancer quota
</label>
<label class="switch" style="margin-left: 20px;" data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"> <input type="checkbox" disabled /><i></i> </label>
<span class="text-muted small" style="margin-left: 15px;">
<i class="fa fa-user" aria-hidden="true"></i>
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-lbquota" target="_blank"> Portainer Business Edition</a>.
</span>
<por-switch-field
ng-data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"
label="Load Balancer quota"
name="k8s-resourcepool-Ibquota"
feature="$ctrl.LBQuotaFeatureId"
ng-model="lbquota"
></por-switch-field>
</div>
</div>
<!-- #endregion -->
@ -192,15 +191,13 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Enable quota
</label>
<label class="switch" style="margin-left: 20px;" data-cy="k8sNamespaceCreate-enableQuotaToggle"> <input type="checkbox" disabled /><i></i> </label>
<span class="text-muted small" style="margin-left: 15px;">
<i class="fa fa-user" aria-hidden="true"></i>
This feature is available in
<a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-storagequota" target="_blank"> Portainer Business Edition</a>.
</span>
<por-switch-field
ng-data-cy="k8sNamespaceCreate-enableQuotaToggle"
label="Enable quota"
name="k8s-resourcepool-storagequota"
feature="$ctrl.StorageQuotaFeatureId"
ng-model="storagequota"
></por-switch-field>
</div>
</div>
<!-- #endregion -->

View File

@ -12,6 +12,8 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids';
class KubernetesCreateResourcePoolController {
/* #region CONSTRUCTOR */
/* @ngInject */
@ -28,6 +30,8 @@ class KubernetesCreateResourcePoolController {
});
this.IngressClassTypes = KubernetesIngressClassTypes;
this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA;
this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA;
}
/* #endregion */

View File

@ -146,14 +146,13 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Load Balancer quota
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" disabled /><i></i> </label>
<span class="text-muted small" style="margin-left: 15px;">
<i class="fa fa-user" aria-hidden="true"></i>
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-lbquota" target="_blank"> Portainer Business Edition</a>.
</span>
<por-switch-field
ng-data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"
label="Load Balancer quota"
name="k8s-resourcepool-Lbquota"
feature="ctrl.LBQuotaFeatureId"
ng-model="lbquota"
></por-switch-field>
</div>
</div>
<!-- #endregion -->
@ -389,15 +388,13 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Enable quota
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" disabled /><i></i> </label>
<span class="text-muted small" style="margin-left: 15px;">
<i class="fa fa-user" aria-hidden="true"></i>
This feature is available in
<a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-storagequota" target="_blank"> Portainer Business Edition</a>.
</span>
<por-switch-field
ng-data-cy="k8sNamespaceCreate-enableQuotaToggle"
label="Enable quota"
name="k8s-resourcepool-storagequota"
feature="ctrl.StorageQuotaFeatureId"
ng-model="storagequota"
></por-switch-field>
</div>
</div>
<!-- #endregion -->

View File

@ -16,6 +16,7 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids';
class KubernetesResourcePoolController {
/* #region CONSTRUCTOR */
@ -60,6 +61,9 @@ class KubernetesResourcePoolController {
this.IngressClassTypes = KubernetesIngressClassTypes;
this.ResourceQuotaDefaults = KubernetesResourceQuotaDefaults;
this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA;
this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA;
this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this);
this.getEvents = this.getEvents.bind(this);
}

View File

@ -1,7 +1,10 @@
import _ from 'lodash-es';
import './rbac';
import componentsModule from './components';
import settingsModule from './settings';
import featureFlagModule from './feature-flags';
import userActivityModule from './user-activity';
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh();
@ -18,7 +21,7 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat
return await Authentication.init();
}
angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsModule]).config([
angular.module('portainer.app', ['portainer.oauth', 'portainer.rbac', componentsModule, settingsModule, featureFlagModule, userActivityModule, 'portainer.shared.datatable']).config([
'$stateRegistryProvider',
function ($stateRegistryProvider) {
'use strict';
@ -51,6 +54,18 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo
controller: 'SidebarController',
},
},
resolve: {
featuresServiceInitialized: /* @ngInject */ function featuresServiceInitialized($async, featureService, Notifications) {
return $async(async () => {
try {
await featureService.init();
} catch (e) {
Notifications.error('Failed initializing features service', e);
throw e;
}
});
},
},
};
var endpointRoot = {
@ -403,16 +418,6 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo
},
};
var roles = {
name: 'portainer.roles',
url: '/roles',
views: {
'content@': {
templateUrl: './views/roles/roles.html',
},
},
};
$stateRegistryProvider.register(root);
$stateRegistryProvider.register(endpointRoot);
$stateRegistryProvider.register(portainer);
@ -444,7 +449,6 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo
$stateRegistryProvider.register(user);
$stateRegistryProvider.register(teams);
$stateRegistryProvider.register(team);
$stateRegistryProvider.register(roles);
},
]);

View File

@ -11,8 +11,8 @@
ng-if="$ctrl.options.length > 0"
input-model="$ctrl.options"
output-model="$ctrl.value"
button-label="icon '-' Name"
item-label="icon '-' Name"
button-label="icon Name"
item-label="icon Name"
tick-property="ticked"
helper-elements="filter"
search-property="Name"

View File

@ -9,5 +9,6 @@ export const porAccessManagement = {
updateAccess: '<',
actionInProgress: '<',
filterUsers: '<',
limitedFeature: '<',
},
};

View File

@ -4,17 +4,31 @@
<rd-widget-header icon="fa-user-lock" title-text="Create access"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div ng-if="ctrl.entityType !== 'registry'" class="form-group">
<span class="col-sm-12 small text-warning">
<p>
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Adding user access will require the affected user(s) to logout and login for the changes to be taken into account.
</p>
</span>
</div>
<por-access-management-users-selector options="ctrl.availableUsersAndTeams" value="ctrl.formValues.multiselectOutput"></por-access-management-users-selector>
<div class="form-group" ng-if="ctrl.entityType != 'registry'">
<div class="form-group" ng-if="ctrl.entityType !== 'registry'">
<label class="col-sm-3 col-lg-2 control-label text-left">
Role
</label>
<div class="col-sm-9 col-lg-4">
<span class="text-muted small">
<i class="fa fa-user" aria-hidden="true"></i>
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-rbac-access" target="_blank"> Portainer Business Edition</a>.
</span>
<div class="col-sm-9 col-lg-6">
<div class="flex items-center">
<select
class="form-control"
ng-model="ctrl.formValues.selectedRole"
ng-options="role as ctrl.roleLabel(role) disable when ctrl.isRoleLimitedToBE(role) for role in ctrl.roles"
>
</select>
<be-feature-indicator feature="ctrl.limitedFeature" class="space-left"></be-feature-indicator>
</div>
</div>
</div>
@ -48,6 +62,10 @@
title-icon="fa-user-lock"
table-key="{{ 'access_' + ctrl.entityType }}"
order-by="Name"
show-warning="ctrl.entityType !== 'registry'"
is-update-enabled="ctrl.entityType !== 'registry'"
show-roles="ctrl.entityType !== 'registry'"
roles="ctrl.roles"
inherit-from="ctrl.inheritFrom"
dataset="ctrl.authorizedUsersAndTeams"
update-action="ctrl.updateAction"

View File

@ -1,12 +1,14 @@
import _ from 'lodash-es';
import angular from 'angular';
import { RoleTypes } from '@/portainer/rbac/models/role';
class PorAccessManagementController {
/* @ngInject */
constructor(Notifications, AccessService) {
this.Notifications = Notifications;
this.AccessService = AccessService;
constructor(Notifications, AccessService, RoleService, featureService) {
Object.assign(this, { Notifications, AccessService, RoleService, featureService });
this.limitedToBE = false;
this.unauthorizeAccess = this.unauthorizeAccess.bind(this);
this.updateAction = this.updateAction.bind(this);
@ -29,10 +31,11 @@ class PorAccessManagementController {
const entity = this.accessControlledEntity;
const oldUserAccessPolicies = entity.UserAccessPolicies;
const oldTeamAccessPolicies = entity.TeamAccessPolicies;
const selectedRoleId = this.formValues.selectedRole.Id;
const selectedUserAccesses = _.filter(this.formValues.multiselectOutput, (access) => access.Type === 'user');
const selectedTeamAccesses = _.filter(this.formValues.multiselectOutput, (access) => access.Type === 'team');
const accessPolicies = this.AccessService.generateAccessPolicies(oldUserAccessPolicies, oldTeamAccessPolicies, selectedUserAccesses, selectedTeamAccesses, 0);
const accessPolicies = this.AccessService.generateAccessPolicies(oldUserAccessPolicies, oldTeamAccessPolicies, selectedUserAccesses, selectedTeamAccesses, selectedRoleId);
this.accessControlledEntity.UserAccessPolicies = accessPolicies.userAccessPolicies;
this.accessControlledEntity.TeamAccessPolicies = accessPolicies.teamAccessPolicies;
this.updateAccess();
@ -50,11 +53,41 @@ class PorAccessManagementController {
this.updateAccess();
}
isRoleLimitedToBE(role) {
if (!this.limitedToBE) {
return false;
}
return role.ID !== RoleTypes.STANDARD;
}
roleLabel(role) {
if (!this.limitedToBE) {
return role.Name;
}
if (this.isRoleLimitedToBE(role)) {
return `${role.Name} (Business Edition Feature)`;
}
return `${role.Name} (Default)`;
}
async $onInit() {
try {
if (this.limitedFeature) {
this.limitedToBE = this.featureService.isLimitedToBE(this.limitedFeature);
}
const entity = this.accessControlledEntity;
const parent = this.inheritFrom;
const roles = await this.RoleService.roles();
this.roles = _.orderBy(roles, 'Priority', 'asc');
this.formValues = {
selectedRole: this.roles.find((role) => !this.isRoleLimitedToBE(role)),
};
const data = await this.AccessService.accesses(entity, parent, this.roles);
if (this.filterUsers) {

View File

@ -0,0 +1,18 @@
const BE_URL = 'https://www.portainer.io/business-upsell?from=';
export default class BeIndicatorController {
/* @ngInject */
constructor(featureService) {
Object.assign(this, { featureService });
this.limitedToBE = false;
}
$onInit() {
if (this.feature) {
this.url = `${BE_URL}${this.feature}`;
this.limitedToBE = this.featureService.isLimitedToBE(this.feature);
}
}
}

View File

@ -0,0 +1,26 @@
.be-indicator {
border: solid 1px var(--BE-only);
border-radius: 15px;
padding: 5px 10px;
font-weight: 400;
touch-action: all;
pointer-events: all;
white-space: nowrap;
}
.be-indicator .be-indicator-icon {
color: #000000;
}
.be-indicator:hover {
text-decoration: none;
}
.be-indicator:hover .be-indicator-label {
text-decoration: underline;
}
.be-indicator-container {
border: solid 1px var(--BE-only);
margin: 15px;
}

View File

@ -0,0 +1,5 @@
<a class="be-indicator" href="{{ $ctrl.url }}" target="_blank" rel="noopener" ng-if="$ctrl.limitedToBE">
<ng-transclude></ng-transclude>
<i class="be-indicator-icon fas fa-briefcase space-right"></i>
<span class="be-indicator-label">Business Edition Feature</span>
</a>

View File

@ -0,0 +1,15 @@
import angular from 'angular';
import controller from './be-feature-indicator.controller.js';
import './be-feature-indicator.css';
export const beFeatureIndicator = {
templateUrl: './be-feature-indicator.html',
controller,
bindings: {
feature: '<',
},
transclude: true,
};
angular.module('portainer.app').component('beFeatureIndicator', beFeatureIndicator);

View File

@ -0,0 +1,23 @@
export default class BoxSelectorItemController {
/* @ngInject */
constructor(featureService) {
Object.assign(this, { featureService });
this.limitedToBE = false;
}
handleChange(value) {
this.formCtrl.$setValidity(this.radioName, !this.limitedToBE, this.formCtrl);
this.onChange(value);
}
$onInit() {
if (this.option.feature) {
this.limitedToBE = this.featureService.isLimitedToBE(this.option.feature);
}
}
$onDestroy() {
this.formCtrl.$setValidity(this.radioName, true, this.formCtrl);
}
}

View File

@ -0,0 +1,117 @@
.boxselector_wrapper > div,
.boxselector_wrapper box-selector-item {
--selected-item-color: var(--blue-2);
flex: 1;
padding: 0.5rem;
}
.boxselector_wrapper .boxselector_header {
font-size: 14px;
margin-bottom: 5px;
font-weight: bold;
user-select: none;
}
.boxselector_header .fa,
.fab {
font-weight: normal;
}
.boxselector_wrapper input[type='radio'] {
display: none;
}
.boxselector_wrapper input[type='radio']:not(:disabled) ~ label {
cursor: pointer;
background-color: var(--bg-boxselector-wrapper-disabled-color);
}
.boxselector_wrapper input[type='radio']:not(:disabled):hover ~ label:hover {
cursor: pointer;
}
.boxselector_wrapper label {
font-weight: normal;
font-size: 12px;
display: block;
background: var(--bg-boxselector-color);
border: 1px solid var(--border-boxselector-color);
border-radius: 2px;
padding: 10px 10px 0 10px;
text-align: center;
box-shadow: var(--shadow-boxselector-color);
position: relative;
}
.box-selector-item input:disabled + label,
.boxselector_wrapper label.boxselector_disabled {
background: var(--bg-boxselector-disabled-color) !important;
border-color: #787878;
color: #787878;
cursor: not-allowed;
pointer-events: none;
}
.boxselector_wrapper input[type='radio']:checked + label {
background: var(--selected-item-color);
color: white;
padding-top: 2rem;
border-color: var(--selected-item-color);
}
.boxselector_wrapper input[type='radio']:checked + label::after {
color: var(--selected-item-color);
font-family: 'Font Awesome 5 Free';
border: 2px solid var(--selected-item-color);
content: '\f00c';
font-size: 16px;
font-weight: bold;
position: absolute;
top: -15px;
left: 50%;
transform: translateX(-50%);
height: 30px;
width: 30px;
line-height: 26px;
text-align: center;
border-radius: 50%;
background: white;
box-shadow: 0 2px 5px -2px rgba(0, 0, 0, 0.25);
}
@media only screen and (max-width: 700px) {
.boxselector_wrapper {
flex-direction: column;
}
}
.box-selector-item-description {
height: 1em;
}
.box-selector-item.limited.business {
--selected-item-color: var(--BE-only);
}
.box-selector-item.limited.business label {
border-color: var(--BE-only);
border-width: 2px;
}
.box-selector-item .limited-icon {
position: absolute;
left: 1em;
top: calc(50% - 0.5em);
height: 1em;
}
@media (min-width: 992px) {
.box-selector-item .limited-icon {
left: 2em;
}
}
.box-selector-item.limited.business :checked + label {
background-color: initial;
color: initial;
}

View File

@ -1,5 +1,6 @@
<div
class="box-selector-item"
ng-class="{ business: $ctrl.limitedToBE, limited: $ctrl.limitedToBE }"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
@ -14,11 +15,13 @@
ng-value="$ctrl.option.value"
ng-disabled="$ctrl.disabled"
/>
<label for="{{ $ctrl.option.id }}" ng-click="$ctrl.onChange($ctrl.option.value)" t data-cy="edgeStackCreate-deploymentSelector_{{ $ctrl.option.value }}">
<label for="{{ $ctrl.option.id }}" ng-click="$ctrl.handleChange($ctrl.option.value)">
<i class="fas fa-briefcase limited-icon" ng-if="$ctrl.limitedToBE"></i>
<div class="boxselector_header">
<i ng-class="$ctrl.option.icon" aria-hidden="true" style="margin-right: 2px;"></i>
{{ $ctrl.option.label }}
</div>
<p ng-if="$ctrl.option.description">{{ $ctrl.option.description }}</p>
<p class="box-selector-item-description">{{ $ctrl.option.description }}</p>
</label>
</div>

View File

@ -1,7 +1,15 @@
import angular from 'angular';
import './box-selector-item.css';
import controller from './box-selector-item.controller';
angular.module('portainer.app').component('boxSelectorItem', {
templateUrl: './box-selector-item.html',
controller,
require: {
formCtrl: '^^form',
},
bindings: {
radioName: '@',
isChecked: '<',

View File

@ -4,10 +4,10 @@ export default class BoxSelectorController {
this.change = this.change.bind(this);
}
change(value) {
change(value, limited) {
this.ngModel = value;
if (this.onChange) {
this.onChange(value);
this.onChange(value, limited);
}
}

View File

@ -3,89 +3,3 @@
flex-flow: row wrap;
margin: 0.5rem;
}
.boxselector_wrapper > div,
.boxselector_wrapper box-selector-item {
flex: 1;
padding: 0.5rem;
}
.boxselector_wrapper .boxselector_header {
font-size: 14px;
margin-bottom: 5px;
font-weight: bold;
user-select: none;
}
.boxselector_header .fa,
.fab {
font-weight: normal;
}
.boxselector_wrapper input[type='radio'] {
display: none;
}
.boxselector_wrapper input[type='radio']:not(:disabled) ~ label {
cursor: pointer;
background-color: var(--bg-boxselector-wrapper-disabled-color);
}
.boxselector_wrapper input[type='radio']:not(:disabled):hover ~ label:hover {
cursor: pointer;
}
.boxselector_wrapper label {
font-weight: normal;
font-size: 12px;
display: block;
background: var(--bg-boxselector-color);
border: 1px solid var(--border-boxselector-color);
border-radius: 2px;
padding: 10px 10px 0 10px;
text-align: center;
box-shadow: var(--shadow-boxselector-color);
position: relative;
}
.box-selector-item input:disabled + label,
.boxselector_wrapper label.boxselector_disabled {
background: var(--bg-boxselector-disabled-color) !important;
border-color: #787878;
color: #787878;
cursor: not-allowed;
pointer-events: none;
}
.boxselector_wrapper input[type='radio']:checked + label {
background: #337ab7;
color: white;
padding-top: 2rem;
border-color: #337ab7;
}
.boxselector_wrapper input[type='radio']:checked + label::after {
color: #337ab7;
font-family: 'Font Awesome 5 Free';
border: 2px solid #337ab7;
content: '\f00c';
font-size: 16px;
font-weight: bold;
position: absolute;
top: -15px;
left: 50%;
transform: translateX(-50%);
height: 30px;
width: 30px;
line-height: 26px;
text-align: center;
border-radius: 50%;
background: white;
box-shadow: 0 2px 5px -2px rgba(0, 0, 0, 0.25);
}
@media only screen and (max-width: 700px) {
.boxselector_wrapper {
flex-direction: column;
}
}

View File

@ -15,6 +15,6 @@ angular.module('portainer.app').component('boxSelector', {
},
});
export function buildOption(id, icon, label, description, value) {
return { id, icon, label, description, value };
export function buildOption(id, icon, label, description, value, feature) {
return { id, icon, label, description, value, feature };
}

View File

@ -1,26 +0,0 @@
<div class="datatable">
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" placeholder="Search..." ng-model-options="{ debounce: 300 }" />
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
Environment
</th>
<th>
Role
</th>
<th>Access origin</th>
</tr>
</thead>
<tbody>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Select a user to show associated access and role</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@ -92,6 +92,10 @@
float: none;
}
.datatable.datatable-empty .table > tbody > tr > td {
padding: 15px 0;
}
.tableMenu {
color: #767676;
padding: 10px;

View File

@ -0,0 +1,19 @@
export default class DatatableFilterController {
isEnabled() {
return 0 < this.state.length && this.state.length < this.labels.length;
}
onChangeItem(filterValue) {
if (this.isChecked(filterValue)) {
return this.onChange(
this.filterKey,
this.state.filter((v) => v !== filterValue)
);
}
return this.onChange(this.filterKey, [...this.state, filterValue]);
}
isChecked(filterValue) {
return this.state.includes(filterValue);
}
}

View File

@ -0,0 +1,32 @@
<div uib-dropdown dropdown-append-to-body auto-close="outsideClick" is-open="$ctrl.isOpen">
<span ng-transclude></span>
<div class="filter-button">
<span uib-dropdown-toggle class="table-filter" ng-class="{ 'filter-active': $ctrl.isEnabled() }">
Filter
<i class="fa" ng-class="[$ctrl.isEnabled() ? 'fa-check' : 'fa-filter']" aria-hidden="true"></i>
</span>
</div>
<div class="dropdown-menu" style="min-width: 0;" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuContent">
<div class="md-checkbox" ng-repeat="filter in $ctrl.labels track by filter.value">
<input
id="filter_{{ $ctrl.filterKey }}_{{ $index }}"
type="checkbox"
ng-value="filter.value"
ng-checked="$ctrl.state.includes(filter.value)"
ng-click="$ctrl.onChangeItem(filter.value)"
/>
<label for="filter_{{ $ctrl.filterKey }}_{{ $index }}">
{{ filter.label }}
</label>
</div>
</div>
<div>
<a class="btn btn-default btn-sm" ng-click="$ctrl.isOpen = false;">
Close
</a>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
import controller from './datatable-filter.controller';
export const datatableFilter = {
bindings: {
labels: '<', // [{label, value}]
state: '<', // [filterValue]
filterKey: '@',
onChange: '<',
},
controller,
templateUrl: './datatable-filter.html',
transclude: true,
};

View File

@ -128,7 +128,11 @@ angular.module('portainer.app').controller('GenericDatatableController', [
* https://github.com/portainer/portainer/pull/2877#issuecomment-503333425
* https://github.com/portainer/portainer/pull/2877#issuecomment-503537249
*/
this.$onInit = function () {
this.$onInit = function $onInit() {
this.$onInitGeneric();
};
this.$onInitGeneric = function $onInitGeneric() {
this.setDefaults();
this.prepareTableFromDataset();

View File

@ -0,0 +1,16 @@
import angular from 'angular';
import 'angular-utils-pagination';
import { datatableTitlebar } from './titlebar';
import { datatableSearchbar } from './searchbar';
import { datatableSortIcon } from './sort-icon';
import { datatablePagination } from './pagination';
import { datatableFilter } from './filter';
export default angular
.module('portainer.shared.datatable', ['angularUtils.directives.dirPagination'])
.component('datatableTitlebar', datatableTitlebar)
.component('datatableSearchbar', datatableSearchbar)
.component('datatableSortIcon', datatableSortIcon)
.component('datatablePagination', datatablePagination)
.component('datatableFilter', datatableFilter).name;

View File

@ -0,0 +1,9 @@
export const datatablePagination = {
bindings: {
onChangeLimit: '<',
limit: '<',
enableNoLimit: '<',
onChangePage: '<',
},
templateUrl: './pagination.html',
};

View File

@ -0,0 +1,15 @@
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;"> Items per page </span>
<select class="form-control" ng-model="$ctrl.limit" ng-change="$ctrl.onChangeLimit($ctrl.limit)">
<option ng-if="$ctrl.enableNoLimit" ng-value="0">All</option>
<option ng-value="10">10</option>
<option ng-value="25">25</option>
<option ng-value="50">50</option>
<option ng-value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5" on-page-change="$ctrl.onChangePage(newPageNumber)"> </dir-pagination-controls>
</form>
</div>

View File

@ -87,15 +87,10 @@
</td>
<td>
<a ng-if="$ctrl.canManageAccess(item)" ng-click="$ctrl.redirectToManageAccess(item)"> <i class="fa fa-users" aria-hidden="true"></i> Manage access </a>
<span
ng-if="$ctrl.canBrowse(item)"
class="text-muted space-left"
style="cursor: pointer;"
data-toggle="tooltip"
title="This feature is available in Portainer Business Edition"
>
<i class="fa fa-search" aria-hidden="true"></i> Browse</span
>
<be-feature-indicator feature="$ctrl.limitedFeature" ng-if="$ctrl.canBrowse(item)">
<span class="text-muted space-left" style="padding-right: 5px;"> <i class="fa fa-search" aria-hidden="true"></i> Browse</span>
</be-feature-indicator>
<span ng-if="!$ctrl.canBrowse(item) && !$ctrl.canManageAccess(item)"> - </span>
</td>
</tr>

View File

@ -1,5 +1,5 @@
import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
import { REGISTRY_MANAGEMENT } from '@/portainer/feature-flags/feature-ids';
angular.module('portainer.docker').controller('RegistriesDatatableController', RegistriesDatatableController);
/* @ngInject */
@ -45,6 +45,7 @@ function RegistriesDatatableController($scope, $controller, $state, Authenticati
};
this.$onInit = function () {
this.limitedFeature = REGISTRY_MANAGEMENT;
this.isAdmin = Authentication.isAdmin();
this.setDefaults();
this.prepareTableFromDataset();

View File

@ -1,64 +0,0 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" placeholder="Search..." auto-focus ng-model-options="{ debounce: 300 }" />
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
Name
</th>
<th>
Description
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-muted">Environment administrator</td>
<td class="text-muted">Full control of all resources in an environment</td>
</tr>
<tr>
<td class="text-muted">Helpdesk</td>
<td class="text-muted">Read-only access of all resources in an environment</td>
</tr>
<tr>
<td class="text-muted">Read-only user</td>
<td class="text-muted">Read-only access of assigned resources in an environment</td>
</tr>
<tr>
<td class="text-muted">Standard user</td>
<td class="text-muted">Full control of assigned resources in an environment</td>
</tr>
</tbody>
</table>
<div class="footer">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@ -0,0 +1,4 @@
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.filter" ng-change="$ctrl.onChange($ctrl.filter)" placeholder="Search..." ng-model-options="{ debounce: 300 }" />
</div>

View File

@ -0,0 +1,7 @@
export const datatableSearchbar = {
bindings: {
onChange: '<',
ngModel: '<',
},
templateUrl: './datatable-searchbar.html',
};

View File

@ -0,0 +1,5 @@
export default class datatableSortIconController {
isCurrentSortOrder() {
return this.selectedSortKey === this.key;
}
}

View File

@ -0,0 +1,9 @@
<i
class="fa fa-sort-alpha-down"
ng-class="{
'fa-sort-alpha-down': !$ctrl.reverseOrder,
'fa-sort-alpha-up': $ctrl.reverseOrder,
}"
aria-hidden="true"
ng-if="$ctrl.isCurrentSortOrder()"
></i>

View File

@ -0,0 +1,11 @@
import controller from './datatable-sort-icon.controller';
export const datatableSortIcon = {
bindings: {
key: '@',
selectedSortKey: '@',
reverseOrder: '<',
},
controller,
templateUrl: './datatable-sort-icon.html',
};

View File

@ -0,0 +1,7 @@
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.icon" aria-hidden="true" style="margin-right: 2px;"></i>
<span style="margin-right: 10px;">{{ $ctrl.title }}</span>
<be-feature-indicator feature="$ctrl.feature"></be-feature-indicator>
</div>
</div>

View File

@ -0,0 +1,8 @@
export const datatableTitlebar = {
bindings: {
icon: '@',
title: '@',
feature: '@',
},
templateUrl: './datatable-titlebar.html',
};

View File

@ -10,5 +10,7 @@
ng-model="$ctrl.ngModel"
disabled="$ctrl.disabled"
on-change="($ctrl.onChange)"
feature="$ctrl.feature"
ng-data-cy="{{::$ctrl.ngDataCy}}"
></por-switch>
</label>

View File

@ -1,7 +1,5 @@
import angular from 'angular';
import './por-switch-field.css';
export const porSwitchField = {
templateUrl: './por-switch-field.html',
bindings: {
@ -10,8 +8,10 @@ export const porSwitchField = {
label: '@',
name: '@',
labelClass: '@',
ngDataCy: '@',
disabled: '<',
onChange: '<',
feature: '<', // feature id
},
};

View File

@ -0,0 +1,14 @@
export default class PorSwitchController {
/* @ngInject */
constructor(featureService) {
Object.assign(this, { featureService });
this.limitedToBE = false;
}
$onInit() {
if (this.feature) {
this.limitedToBE = this.featureService.isLimitedToBE(this.feature);
}
}
}

View File

@ -51,3 +51,18 @@
opacity: 0.5;
cursor: not-allowed;
}
.switch.limited {
pointer-events: none;
touch-action: none;
}
.switch.limited i {
opacity: 1;
cursor: not-allowed;
}
.switch.business i {
background-color: var(--BE-only);
box-shadow: inset 0 0 1px rgb(0 0 0 / 50%), inset 0 0 40px var(--BE-only);
}

View File

@ -1,4 +1,12 @@
<label class="switch" ng-class="$ctrl.className" style="margin-bottom: 0;">
<input type="checkbox" name="{{::$ctrl.name}}" id="{{::$ctrl.id}}" ng-model="$ctrl.ngModel" ng-disabled="$ctrl.disabled" ng-change="$ctrl.onChange($ctrl.ngModel)" />
<i></i>
<label class="switch" ng-class="[$ctrl.className, { business: $ctrl.limitedToBE, limited: $ctrl.limitedToBE }]" style="margin-bottom: 0;">
<input
type="checkbox"
name="{{::$ctrl.name}}"
id="{{::$ctrl.id}}"
ng-model="$ctrl.ngModel"
ng-disabled="$ctrl.disabled || $ctrl.limitedToBE"
ng-change="$ctrl.onChange($ctrl.ngModel)"
/>
<i data-cy="{{::$ctrl.ngDataCy}}"></i>
</label>
<be-feature-indicator ng-if="$ctrl.limitedToBE" feature="$ctrl.feature"></be-feature-indicator>

View File

@ -1,14 +1,20 @@
import angular from 'angular';
import controller from './por-switch.controller';
import './por-switch.css';
const porSwitch = {
templateUrl: './por-switch.html',
controller,
bindings: {
ngModel: '=',
id: '@',
className: '@',
name: '@',
ngDataCy: '@',
disabled: '<',
onChange: '<',
feature: '<', // feature id
},
};

View File

@ -6,9 +6,20 @@ angular.module('portainer.app').directive('rdWidgetHeader', function rdWidgetTit
icon: '@',
classes: '@?',
},
transclude: true,
template:
'<div class="widget-header"><div class="row"><span ng-class="classes" class="pull-left"><i class="fa" ng-class="icon"></i> {{titleText}} </span><span ng-class="classes" class="pull-right" ng-transclude></span></div></div>',
transclude: {
title: '?headerTitle',
},
template: `
<div class="widget-header">
<div class="row">
<span ng-class="classes" class="pull-left">
<i class="fa" ng-class="icon"></i>
<span ng-transclude="title">{{ titleText }}</span>
</span>
<span ng-class="classes" class="pull-right" ng-transclude></span>
</div>
</div>
`,
restrict: 'E',
};
return directive;

View File

@ -0,0 +1,10 @@
export const EDITIONS = {
CE: 0,
BE: 1,
};
export const STATES = {
HIDDEN: 0,
VISIBLE: 1,
LIMITED_BE: 2,
};

View File

@ -0,0 +1,26 @@
.form-control.limited-be {
border-color: var(--BE-only);
}
.form-control.limited-be.no-border {
border-color: var(--border-form-control-color);
}
button.limited-be {
background-color: var(--BE-only);
border-color: var(--BE-only);
}
ng-form.limited-be,
form.limited-be,
div.limited-be {
border: solid 1px var(--BE-only);
padding: 10px;
pointer-events: none;
touch-action: none;
display: block;
}
.form-control.limited-be[disabled] {
background-color: transparent !important;
}

View File

@ -0,0 +1,57 @@
import { EDITIONS, STATES } from './enums';
import * as FEATURE_IDS from './feature-ids';
export function featureService() {
const state = {
currentEdition: undefined,
features: {},
};
return {
selectShow,
init,
isLimitedToBE,
};
async function init() {
// will be loaded on runtime
const currentEdition = EDITIONS.CE;
const features = {
[FEATURE_IDS.K8S_RESOURCE_POOL_LB_QUOTA]: EDITIONS.BE,
[FEATURE_IDS.K8S_RESOURCE_POOL_STORAGE_QUOTA]: EDITIONS.BE,
[FEATURE_IDS.ACTIVITY_AUDIT]: EDITIONS.BE,
[FEATURE_IDS.EXTERNAL_AUTH_LDAP]: EDITIONS.BE,
[FEATURE_IDS.HIDE_INTERNAL_AUTH]: EDITIONS.BE,
[FEATURE_IDS.HIDE_INTERNAL_AUTHENTICATION_PROMPT]: EDITIONS.BE,
[FEATURE_IDS.K8S_SETUP_DEFAULT]: EDITIONS.BE,
[FEATURE_IDS.RBAC_ROLES]: EDITIONS.BE,
[FEATURE_IDS.REGISTRY_MANAGEMENT]: EDITIONS.BE,
[FEATURE_IDS.S3_BACKUP_SETTING]: EDITIONS.BE,
[FEATURE_IDS.TEAM_MEMBERSHIP]: EDITIONS.BE,
};
state.currentEdition = currentEdition;
state.features = features;
}
function selectShow(featureId) {
if (!state.features[featureId]) {
return STATES.HIDDEN;
}
if (state.features[featureId] <= state.currentEdition) {
return STATES.VISIBLE;
}
if (state.features[featureId] === EDITIONS.BE) {
return STATES.LIMITED_BE;
}
return STATES.HIDDEN;
}
function isLimitedToBE(featureId) {
return selectShow(featureId) === STATES.LIMITED_BE;
}
}

View File

@ -0,0 +1,11 @@
export const K8S_RESOURCE_POOL_LB_QUOTA = 'k8s-resourcepool-Ibquota';
export const K8S_RESOURCE_POOL_STORAGE_QUOTA = 'k8s-resourcepool-storagequota';
export const RBAC_ROLES = 'rbac-roles';
export const REGISTRY_MANAGEMENT = 'registry-management';
export const K8S_SETUP_DEFAULT = 'k8s-setup-default';
export const S3_BACKUP_SETTING = 's3-backup-setting';
export const HIDE_INTERNAL_AUTHENTICATION_PROMPT = 'hide-internal-authentication-prompt';
export const TEAM_MEMBERSHIP = 'team-membership';
export const HIDE_INTERNAL_AUTH = 'hide-internal-auth';
export const EXTERNAL_AUTH_LDAP = 'external-auth-ldap';
export const ACTIVITY_AUDIT = 'activity-audit';

View File

@ -0,0 +1,7 @@
import angular from 'angular';
import { limitedFeatureDirective } from './limited-feature.directive';
import { featureService } from './feature-flags.service';
import './feature-flags.css';
export default angular.module('portainer.feature-flags', []).directive('limitedFeatureDir', limitedFeatureDirective).factory('featureService', featureService).name;

View File

@ -0,0 +1,41 @@
import _ from 'lodash-es';
import { STATES } from '@/portainer/feature-flags/enums';
const BASENAME = 'limitedFeature';
/* @ngInject */
export function limitedFeatureDirective(featureService) {
return {
restrict: 'A',
link,
};
function link(scope, elem, attrs) {
const { limitedFeatureDir: featureId } = attrs;
if (!featureId) {
return;
}
const limitedFeatureAttrs = Object.keys(attrs)
.filter((attr) => attr.startsWith(BASENAME) && attr !== `${BASENAME}Dir`)
.map((attr) => [_.kebabCase(attr.replace(BASENAME, '')), attrs[attr]]);
const state = featureService.selectShow(featureId);
if (state === STATES.HIDDEN) {
elem.hide();
return;
}
if (state === STATES.VISIBLE) {
return;
}
limitedFeatureAttrs.forEach(([attr, value = attr]) => {
const currentValue = elem.attr(attr) || '';
elem.attr(attr, `${currentValue} ${value}`.trim());
});
}
}

View File

@ -1,8 +1,11 @@
import angular from 'angular';
import controller from './oauth-provider-selector.controller';
angular.module('portainer.oauth').component('oauthProvidersSelector', {
templateUrl: './oauth-providers-selector.html',
bindings: {
onSelect: '<',
provider: '=',
onChange: '<',
value: '<',
},
controller: 'OAuthProviderSelectorController',
controller,
});

View File

@ -1,39 +0,0 @@
angular.module('portainer.oauth').controller('OAuthProviderSelectorController', function OAuthProviderSelectorController() {
var ctrl = this;
this.providers = [
{
authUrl: '',
accessTokenUrl: '',
resourceUrl: '',
userIdentifier: '',
scopes: '',
name: 'custom',
label: 'Custom',
description: 'Custom OAuth provider',
icon: 'fa fa-user-check',
},
];
this.$onInit = onInit;
function onInit() {
if (ctrl.provider.authUrl) {
ctrl.provider = getProviderByURL(ctrl.provider.authUrl);
} else {
ctrl.provider = ctrl.providers[0];
}
ctrl.onSelect(ctrl.provider, false);
}
function getProviderByURL(providerAuthURL) {
if (providerAuthURL.indexOf('login.microsoftonline.com') !== -1) {
return ctrl.providers[0];
} else if (providerAuthURL.indexOf('accounts.google.com') !== -1) {
return ctrl.providers[1];
} else if (providerAuthURL.indexOf('github.com') !== -1) {
return ctrl.providers[2];
}
return ctrl.providers[3];
}
});

View File

@ -0,0 +1,14 @@
import { HIDE_INTERNAL_AUTH } from '@/portainer/feature-flags/feature-ids';
import { buildOption } from '@/portainer/components/box-selector';
export default class OAuthProviderSelectorController {
constructor() {
this.options = [
buildOption('microsoft', 'fab fa-microsoft', 'Microsoft', 'Microsoft OAuth provider', 'microsoft', HIDE_INTERNAL_AUTH),
buildOption('google', 'fab fa-google', 'Google', 'Google OAuth provider', 'google', HIDE_INTERNAL_AUTH),
buildOption('github', 'fab fa-github', 'Github', 'Github OAuth provider', 'github', HIDE_INTERNAL_AUTH),
buildOption('custom', 'fa fa-user-check', 'Custom', 'Custom OAuth provider', 'custom'),
];
}
}

View File

@ -2,18 +2,4 @@
Provider
</div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0;">
<div class="boxselector_wrapper">
<div ng-repeat="provider in $ctrl.providers" ng-click="$ctrl.onSelect(provider, true)">
<input type="radio" id="{{ 'oauth_provider_' + provider.name }}" ng-model="$ctrl.provider" ng-value="provider" />
<label for="{{ 'oauth_provider_' + provider.name }}">
<div class="boxselector_header">
<i ng-class="provider.icon" aria-hidden="true" style="margin-right: 2px;"></i>
{{ provider.label }}
</div>
<p>{{ provider.description }}</p>
</label>
</div>
</div>
</div>
<box-selector ng-model="$ctrl.value" options="$ctrl.options" on-change="($ctrl.onChange)" radio-name="oauth_provider"></box-selector>

View File

@ -1,8 +1,11 @@
import angular from 'angular';
import controller from './oauth-settings.controller';
angular.module('portainer.oauth').component('oauthSettings', {
templateUrl: './oauth-settings.html',
bindings: {
settings: '=',
teams: '<',
},
controller: 'OAuthSettingsController',
controller,
});

View File

@ -1,23 +0,0 @@
angular.module('portainer.oauth').controller('OAuthSettingsController', function OAuthSettingsController() {
var ctrl = this;
this.state = {
provider: {},
};
this.$onInit = $onInit;
function $onInit() {
if (ctrl.settings.RedirectURI === '') {
ctrl.settings.RedirectURI = window.location.origin;
}
if (ctrl.settings.AuthorizationURI !== '') {
ctrl.state.provider.authUrl = ctrl.settings.AuthorizationURI;
}
if (ctrl.settings.DefaultTeamID === 0) {
ctrl.settings.DefaultTeamID = null;
}
}
});

View File

@ -0,0 +1,109 @@
import { HIDE_INTERNAL_AUTH } from '@/portainer/feature-flags/feature-ids';
import providers, { getProviderByUrl } from './providers';
export default class OAuthSettingsController {
/* @ngInject */
constructor(featureService) {
this.featureService = featureService;
this.limitedFeature = HIDE_INTERNAL_AUTH;
this.state = {
provider: 'custom',
overrideConfiguration: false,
microsoftTenantID: '',
};
this.$onInit = this.$onInit.bind(this);
this.onSelectProvider = this.onSelectProvider.bind(this);
this.onMicrosoftTenantIDChange = this.onMicrosoftTenantIDChange.bind(this);
this.useDefaultProviderConfiguration = this.useDefaultProviderConfiguration.bind(this);
this.updateSSO = this.updateSSO.bind(this);
this.addTeamMembershipMapping = this.addTeamMembershipMapping.bind(this);
this.removeTeamMembership = this.removeTeamMembership.bind(this);
}
onMicrosoftTenantIDChange() {
const tenantID = this.state.microsoftTenantID;
this.settings.AuthorizationURI = `https://login.microsoftonline.com/${tenantID}/oauth2/authorize`;
this.settings.AccessTokenURI = `https://login.microsoftonline.com/${tenantID}/oauth2/token`;
this.settings.ResourceURI = `https://graph.windows.net/${tenantID}/me?api-version=2013-11-08`;
}
useDefaultProviderConfiguration(providerId) {
const provider = providers[providerId];
this.state.overrideConfiguration = false;
if (!this.isLimitedToBE || providerId === 'custom') {
this.settings.AuthorizationURI = provider.authUrl;
this.settings.AccessTokenURI = provider.accessTokenUrl;
this.settings.ResourceURI = provider.resourceUrl;
this.settings.LogoutURI = provider.logoutUrl;
this.settings.UserIdentifier = provider.userIdentifier;
this.settings.Scopes = provider.scopes;
if (providerId === 'microsoft' && this.state.microsoftTenantID !== '') {
this.onMicrosoftTenantIDChange();
}
} else {
this.settings.ClientID = '';
this.settings.ClientSecret = '';
}
}
onSelectProvider(provider) {
this.state.provider = provider;
this.useDefaultProviderConfiguration(provider);
}
updateSSO() {
this.settings.HideInternalAuth = this.settings.SSO;
}
addTeamMembershipMapping() {
this.settings.TeamMemberships.OAuthClaimMappings.push({ ClaimValRegex: '', Team: this.settings.DefaultTeamID });
}
removeTeamMembership(index) {
this.settings.TeamMemberships.OAuthClaimMappings.splice(index, 1);
}
$onInit() {
this.isLimitedToBE = this.featureService.isLimitedToBE(this.limitedFeature);
if (this.isLimitedToBE) {
return;
}
if (this.settings.RedirectURI === '') {
this.settings.RedirectURI = window.location.origin;
}
if (this.settings.AuthorizationURI) {
const authUrl = this.settings.AuthorizationURI;
this.state.provider = getProviderByUrl(authUrl);
if (this.state.provider === 'microsoft') {
const tenantID = authUrl.match(/login.microsoftonline.com\/(.*?)\//)[1];
this.state.microsoftTenantID = tenantID;
this.onMicrosoftTenantIDChange();
}
}
if (this.settings.DefaultTeamID === 0) {
this.settings.DefaultTeamID = null;
}
if (this.settings.TeamMemberships == null) {
this.settings.TeamMemberships = {};
}
if (this.settings.TeamMemberships.OAuthClaimMappings === null) {
this.settings.TeamMemberships.OAuthClaimMappings = [];
}
}
}

View File

@ -1,54 +1,50 @@
<div
><div class="col-sm-12 form-section-title">
<ng-form name="$ctrl.oauthSettingsForm">
<div class="col-sm-12 form-section-title">
Single Sign-On
</div>
<!-- SSO -->
<div class="form-group">
<label for="use-sso" class="control-label col-sm-2 text-left" style="padding-top: 0;">
Use SSO
<portainer-tooltip position="bottom" message="When using SSO the OAuth provider is not forced to prompt for credentials."></portainer-tooltip>
</label>
<div class="col-sm-9">
<label class="switch"> <input id="use-sso" type="checkbox" ng-model="$ctrl.settings.SSO" /><i></i> </label>
<div class="col-sm-12">
<por-switch-field
label="Use SSO"
tooltip="When using SSO the OAuth provider is not forced to prompt for credentials."
name="use-sso"
ng-model="$ctrl.settings.SSO"
on-change="$ctrl.updateSSO()"
></por-switch-field>
</div>
</div>
<!-- !SSO -->
<!-- HideInternalAuth -->
<div class="form-group" ng-if="$ctrl.settings.SSO">
<label for="hide-internal-auth" class="control-label col-sm-2 text-left" style="padding-top: 0;">
Hide internal authentication prompt
</label>
<div class="col-sm-9">
<label class="switch"> <input id="hide-internal-auth" type="checkbox" disabled /><i></i> </label>
<span class="text-muted small" style="margin-left: 15px;">
This feature is available in <a href="https://www.portainer.io/business-upsell?from=hide-internal-auth" target="_blank"> Portainer Business Edition</a>.
</span>
<div class="col-sm-12">
<por-switch-field
label="Hide internal authentication prompt"
name="hide-internal-auth"
feature="$ctrl.limitedFeature"
ng-model="$ctrl.settings.HideInternalAuth"
></por-switch-field>
</div>
</div>
<!-- !HideInternalAuth -->
<div class="col-sm-12 form-section-title">
Automatic user provisioning
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
<auto-user-provision-toggle ng-model="$ctrl.settings.OAuthAutoCreateUsers">
<field-description>
With automatic user provisioning enabled, Portainer will create user(s) automatically with the standard user role. If disabled, users must be created beforehand in Portainer
in order to login.
</span>
</div>
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label text-left">Automatic user provisioning</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.settings.OAuthAutoCreateUsers" /><i></i> </label>
</div>
</field-description>
</auto-user-provision-toggle>
<div ng-if="$ctrl.settings.OAuthAutoCreateUsers">
<div class="form-group">
<span class="col-sm-12 text-muted small">
<p>The users created by the automatic provisioning feature can be added to a default team on creation.</p>
<p
>By assigning newly created users to a team, they will be able to access the environments associated to that team. This setting is optional and if not set, newly created
users won't be able to access any environments.</p
>
<p>
By assigning newly created users to a team, they will be able to access the environments associated to that team. This setting is optional and if not set, newly created
users won't be able to access any environments.
</p>
</span>
</div>
<div class="form-group">
@ -56,14 +52,34 @@
<span class="small text-muted" style="margin-left: 20px;" ng-if="$ctrl.teams.length === 0">
You have not yet created any teams. Head over to the <a ui-sref="portainer.teams">Teams view</a> to manage teams.
</span>
<button type="button" class="btn btn-sm btn-danger" ng-click="$ctrl.settings.DefaultTeamID = null" ng-disabled="!$ctrl.settings.DefaultTeamID" ng-if="$ctrl.teams.length > 0"
><i class="fa fa-times" aria-hidden="true"></i
></button>
<div class="col-sm-9 col-lg-9" ng-if="$ctrl.teams.length > 0">
<select class="form-control" ng-model="$ctrl.settings.DefaultTeamID" ng-options="team.Id as team.Name for team in $ctrl.teams">
<div class="col-sm-9" ng-if="$ctrl.teams.length > 0">
<div class="col-sm-12 small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
The default team option will be disabled when automatic team membership is enabled
</p>
</div>
<div class="col-xs-11">
<select
class="form-control"
ng-disabled="$ctrl.settings.OAuthAutoMapTeamMemberships"
ng-model="$ctrl.settings.DefaultTeamID"
ng-options="team.Id as team.Name for team in $ctrl.teams"
>
<option value="">No team</option>
</select>
</div>
<button
type="button"
class="btn btn-sm btn-danger"
ng-click="$ctrl.settings.DefaultTeamID = null"
ng-disabled="!$ctrl.settings.DefaultTeamID || $ctrl.settings.OAuthAutoMapTeamMemberships"
ng-if="$ctrl.teams.length > 0"
>
<i class="fa fa-times" aria-hidden="true"> </i
></button>
</div>
</div>
</div>
@ -71,38 +87,147 @@
Team membership
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
<div class="col-sm-12 text-muted small">
Automatic team membership synchronizes the team membership based on a custom claim in the token from the OAuth provider.
</span>
</div>
</div>
<div class="form-group">
<span class="text-muted small" style="margin-left: 15px;">
<i class="fa fa-user" aria-hidden="true"></i>
This feature is available in <a href="https://www.portainer.io/business-upsell?from=oauth-group-membership" target="_blank"> Portainer Business Edition</a>.
</span>
<div class="col-sm-12">
<por-switch-field label="Automatic team membership" name="tls" feature="$ctrl.limitedFeature" ng-model="$ctrl.settings.OAuthAutoMapTeamMemberships"></por-switch-field>
</div>
</div>
<div ng-if="$ctrl.settings.OAuthAutoMapTeamMemberships">
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label text-left">
Claim name
<portainer-tooltip position="bottom" message="The OpenID Connect UserInfo Claim name that contains the team identifier the user belongs to."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<div class="col-xs-11 col-lg-10">
<input type="text" class="form-control" id="oauth_token_claim_name" ng-model="$ctrl.settings.TeamMemberships.OAuthClaimName" placeholder="groups" />
</div>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label text-left">
Statically assigned teams
</label>
<div class="col-sm-9 col-lg-10">
<span class="label label-default interactive" style="margin-left: 1.4em;" ng-click="$ctrl.addTeamMembershipMapping()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add team mapping
</span>
<div class="col-sm-12 form-inline" ng-repeat="mapping in $ctrl.settings.TeamMemberships.OAuthClaimMappings" style="margin-top: 0.75em;">
<div class="input-group input-group-sm col-sm-5">
<span class="input-group-addon">claim value regex</span>
<input type="text" class="form-control" ng-model="mapping.ClaimValRegex" />
</div>
<span style="margin: 0px 0.5em;">maps to</span>
<div class="input-group input-group-sm col-sm-3 col-lg-4">
<span class="input-group-addon">team</span>
<select
class="form-control"
ng-init="mapping.Team = mapping.Team || $ctrl.settings.DefaultTeamID"
ng-model="mapping.Team"
ng-options="team.Id as team.Name for team in $ctrl.teams"
>
<option selected value="">Select a team</option>
</select>
</div>
<button type="button" class="btn btn-sm btn-danger" ng-click="$ctrl.removeTeamMembership($index)"> <i class="fa fa-trash" aria-hidden="true"> </i></button>
<div class="small text-warning" ng-show="!mapping.ClaimValRegex" style="margin-top: 0.4em;">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Claim value regex is required.
</div>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-12 text-muted small" style="margin-bottom: 0.5em;">
The default team will be assigned when the user does not belong to any other team
</div>
<label class="col-sm-3 col-lg-2 control-label text-left">Default team</label>
<span class="small text-muted" style="margin-left: 20px;" ng-if="$ctrl.teams.length === 0">
You have not yet created any teams. Head over to the <a ui-sref="portainer.teams">Teams view</a> to manage teams.
</span>
<div class="col-sm-9" ng-if="$ctrl.teams.length > 0">
<div class="col-xs-11">
<select class="form-control" ng-model="$ctrl.settings.DefaultTeamID" ng-options="team.Id as team.Name for team in $ctrl.teams">
<option value="">No team</option>
</select>
</div>
</div>
</div>
</div>
<oauth-providers-selector on-change="($ctrl.onSelectProvider)" value="$ctrl.state.provider"></oauth-providers-selector>
<div class="col-sm-12 form-section-title">OAuth Configuration</div>
<div class="form-group" ng-if="$ctrl.state.provider == 'microsoft'">
<label for="oauth_microsoft_tenant_id" class="col-sm-3 col-lg-2 control-label text-left">
Tenant ID
<portainer-tooltip position="bottom" message="ID of the Azure Directory you wish to authenticate against. Also known as the Directory ID"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
id="oauth_microsoft_tenant_id"
placeholder="xxxxxxxxxxxxxxxxxxxx"
ng-model="$ctrl.state.microsoftTenantID"
ng-change="$ctrl.onMicrosoftTenantIDChange()"
limited-feature-dir="{{::$ctrl.limitedFeature}}"
limited-feature-class="limited-be"
limited-feature-disabled
limited-feature-tabindex="-1"
required
/>
</div>
</div>
<div class="form-group">
<label for="oauth_client_id" class="col-sm-3 col-lg-2 control-label text-left">
Client ID
{{ $ctrl.state.provider == 'microsoft' ? 'Application ID' : 'Client ID' }}
<portainer-tooltip position="bottom" message="Public identifier of the OAuth application"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_client_id" ng-model="$ctrl.settings.ClientID" placeholder="xxxxxxxxxxxxxxxxxxxx" />
<input
type="text"
id="oauth_client_id"
ng-model="$ctrl.settings.ClientID"
placeholder="xxxxxxxxxxxxxxxxxxxx"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
tabindex="{{ $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' ? -1 : 0 }}"
/>
</div>
</div>
<div class="form-group">
<label for="oauth_client_secret" class="col-sm-3 col-lg-2 control-label text-left">
Client secret
{{ $ctrl.state.provider == 'microsoft' ? 'Application key' : 'Client secret' }}
</label>
<div class="col-sm-9 col-lg-10">
<input type="password" class="form-control" id="oauth_client_secret" ng-model="$ctrl.settings.ClientSecret" placeholder="xxxxxxxxxxxxxxxxxxxx" autocomplete="new-password" />
<input
type="password"
class="form-control"
id="oauth_client_secret"
ng-model="$ctrl.settings.ClientSecret"
placeholder="xxxxxxxxxxxxxxxxxxxx"
autocomplete="new-password"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
tabindex="{{ $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' ? -1 : 0 }}"
/>
</div>
</div>
<div ng-if="$ctrl.state.provider == 'custom' || $ctrl.state.overrideConfiguration">
<div class="form-group">
<label for="oauth_authorization_uri" class="col-sm-3 col-lg-2 control-label text-left">
Authorization URL
@ -112,7 +237,15 @@
></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_authorization_uri" ng-model="$ctrl.settings.AuthorizationURI" placeholder="https://example.com/oauth/authorize" />
<input
type="text"
class="form-control"
id="oauth_authorization_uri"
ng-model="$ctrl.settings.AuthorizationURI"
placeholder="https://example.com/oauth/authorize"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
@ -122,7 +255,15 @@
<portainer-tooltip position="bottom" message="URL used by Portainer to exchange a valid OAuth authentication code for an access token"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_access_token_uri" ng-model="$ctrl.settings.AccessTokenURI" placeholder="https://example.com/oauth/token" />
<input
type="text"
class="form-control"
id="oauth_access_token_uri"
ng-model="$ctrl.settings.AccessTokenURI"
placeholder="https://example.com/oauth/token"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
@ -132,7 +273,15 @@
<portainer-tooltip position="bottom" message="URL used by Portainer to retrieve information about the authenticated user"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_resource_uri" ng-model="$ctrl.settings.ResourceURI" placeholder="https://example.com/user" />
<input
type="text"
class="form-control"
id="oauth_resource_uri"
ng-model="$ctrl.settings.ResourceURI"
placeholder="https://example.com/user"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
@ -145,9 +294,18 @@
></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_redirect_uri" ng-model="$ctrl.settings.RedirectURI" placeholder="http://yourportainer.com/" />
<input
type="text"
class="form-control"
id="oauth_redirect_uri"
ng-model="$ctrl.settings.RedirectURI"
placeholder="http://yourportainer.com/"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
<div class="form-group">
<label for="oauth_logout_url" class="col-sm-3 col-lg-2 control-label text-left">
Logout URL
@ -157,9 +315,17 @@
></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_logout_url" ng-model="$ctrl.settings.LogoutURI" />
<input
type="text"
class="form-control"
id="oauth_logout_url"
ng-model="$ctrl.settings.LogoutURI"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
<div class="form-group">
<label for="oauth_user_identifier" class="col-sm-3 col-lg-2 control-label text-left">
User identifier
@ -169,7 +335,15 @@
></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_user_identifier" ng-model="$ctrl.settings.UserIdentifier" placeholder="id" />
<input
type="text"
class="form-control"
id="oauth_user_identifier"
ng-model="$ctrl.settings.UserIdentifier"
placeholder="id"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
@ -182,7 +356,27 @@
></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_scopes" ng-model="$ctrl.settings.Scopes" placeholder="id,email,name" />
<input
type="text"
class="form-control"
id="oauth_scopes"
ng-model="$ctrl.settings.Scopes"
placeholder="id,email,name"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
</div>
<div class="form-group" ng-if="$ctrl.state.provider != 'custom'">
<div class="col-sm-12">
<a class="small interactive" ng-if="!$ctrl.state.overrideConfiguration" ng-click="$ctrl.state.overrideConfiguration = true;">
<i class="fa fa-wrench space-right" aria-hidden="true"></i> Override default configuration
</a>
<a class="small interactive" ng-if="$ctrl.state.overrideConfiguration" ng-click="$ctrl.useDefaultProviderConfiguration($ctrl.state.provider)">
<i class="fa fa-cogs space-right" aria-hidden="true"></i> Use default configuration
</a>
</div>
</div>
</ng-form>

View File

@ -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';
}

View File

@ -0,0 +1,73 @@
<div class="datatable">
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" ng-change="$ctrl.onTextFilterChange()" placeholder="Search..." ng-model-options="{ debounce: 300 }" />
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('EndpointName')">
Environment
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'EndpointName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'EndpointName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('RoleName')">
Role
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'RoleName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'RoleName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>Access origin</th>
</tr>
</thead>
<tbody>
<tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit)) track by $index"
>
<td>{{ item.EndpointName }}</td>
<td>{{ item.RoleName }}</td>
<td
>{{ item.TeamName ? 'Team' : 'User' }} <code ng-if="item.TeamName">{{ item.TeamName }}</code> access defined on {{ item.AccessLocation }}
<code ng-if="item.GroupName">{{ item.GroupName }}</code>
<a ng-if="item.AccessLocation === 'endpoint'" ui-sref="portainer.endpoints.endpoint.access({id: item.EndpointId})"
><i style="margin-left: 5px;" class="fa fa-users" aria-hidden="true"></i> Manage access
</a>
<a ng-if="item.AccessLocation === 'endpoint group'" ui-sref="portainer.groups.group.access({id: item.GroupId})"
><i style="margin-left: 5px;" class="fa fa-users" aria-hidden="true"></i> Manage access
</a>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Select a user to show associated access and role</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-center text-muted">The selected user does not have access to any environment(s)</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</div>

View File

@ -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: '<',
},
});
};

View File

@ -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');
}
}
}

View File

@ -0,0 +1,43 @@
<div class="col-sm-12" style="margin-bottom: 0px;">
<rd-widget>
<rd-widget-header icon="fa-user-lock">
<header-title>
Effective access viewer
<be-feature-indicator feature="$ctrl.limitedFeature" class="space-left"></be-feature-indicator>
</header-title>
</rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="col-sm-12 form-section-title">
User
</div>
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted" ng-if="$ctrl.users.length === 0">
No user available
</span>
<ui-select ng-if="$ctrl.users.length > 0" ng-model="$ctrl.selectedUser" ng-change="$ctrl.onUserSelect()">
<ui-select-match placeholder="Select a user">
<span>{{ $select.selected.Username }}</span>
</ui-select-match>
<ui-select-choices repeat="item in ($ctrl.users | filter: $select.search)">
<span>{{ item.Username }}</span>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="col-sm-12 form-section-title">
Access
</div>
<div>
<div class="small text-muted" style="margin-bottom: 15px;">
<i class="fa fa-info-circle blue-icon space-right" aria-hidden="true"></i>
Effective role for each environment will be displayed for the selected user
</div>
</div>
<access-viewer-datatable table-key="access_viewer" dataset="$ctrl.userRoles" order-by="EndpointName"> </access-viewer-datatable>
</form>
</rd-widget-body>
</rd-widget>
</div>

View File

@ -0,0 +1,6 @@
import controller from './access-viewer.controller';
export const accessViewer = {
templateUrl: './access-viewer.html',
controller,
};

View File

@ -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: '<',
},
};

View File

@ -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;
}
}

View File

@ -0,0 +1,7 @@
th.be-visual-indicator-col {
width: 300px;
}
td.be-visual-indicator-col {
text-align: center;
}

View File

@ -0,0 +1,85 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('Name')">
Name
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Description')">
Description
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Description' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Description' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th class="be-visual-indicator-col"></th>
</tr>
</thead>
<tbody>
<tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
ng-class="{ active: item.Checked }"
>
<td>{{ item.Name }}</td>
<td>{{ item.Description }}</td>
<td class="be-visual-indicator-col" ng-switch on="$ctrl.isDefaultItem(item)">
<span ng-switch-when="false">
<be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator>
</span>
<b ng-switch-when="true">Default</b>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-center text-muted">No role available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@ -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);
}

View File

@ -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';
}

View File

@ -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,
});

View File

@ -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' } },
}
);
}

View File

@ -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;
}
}

View File

@ -0,0 +1,6 @@
import controller from './roles.controller';
export const rolesView = {
templateUrl: './roles.html',
controller,
};

View File

@ -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');
}
}
}

View File

@ -0,0 +1,18 @@
<rd-header>
<rd-header-title title-text="Roles">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.roles" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Role management</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<roles-datatable title-text="Roles" title-icon="fa-file-code" dataset="$ctrl.roles" table-key="roles"></roles-datatable>
</div>
</div>
<div class="row">
<access-viewer> </access-viewer>
</div>

View File

@ -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',
};

View File

@ -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',
};

View File

@ -0,0 +1,14 @@
<div class="col-sm-12 form-section-title">
Automatic user provisioning
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small" ng-transclude="description"> </span>
</div>
<div class="form-group">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
Automatic user provisioning
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.ngModel" /><i></i> </label>
</div>
</div>

View File

@ -0,0 +1,9 @@
export const autoUserProvisionToggle = {
templateUrl: './auto-user-provision-toggle.html',
transclude: {
description: 'fieldDescription',
},
bindings: {
ngModel: '=',
},
};

View File

@ -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;

View File

@ -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);
}
}

View File

@ -0,0 +1,157 @@
<ng-form class="ad-settings" limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-class="limited-be">
<be-feature-indicator feature="$ctrl.limitedFeatureId" class="my-8 block"></be-feature-indicator>
<auto-user-provision-toggle ng-model="$ctrl.settings.AutoCreateUsers">
<field-description>
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.
</field-description>
</auto-user-provision-toggle>
<div>
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group col-sm-12 text-muted small">
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.
</div>
</div>
<div class="col-sm-12 form-section-title">
AD configuration
</div>
<div class="form-group">
<div class="col-sm-12 small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
You can configure multiple AD Controllers for authentication fallback. Make sure all servers are using the same configuration (i.e. if TLS is enabled, they should all use
the same certificates).
</p>
</div>
</div>
<div class="form-group">
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left" style="display: flex; flex-wrap: wrap;">
AD Controller
<button
type="button"
class="label label-default interactive"
style="border: 0;"
ng-click="$ctrl.addLDAPUrl()"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<i class="fa fa-plus-circle" aria-hidden="true"></i> Add additional server
</button>
</label>
<div class="col-sm-9 col-lg-10">
<div ng-repeat="url in $ctrl.settings.URLs track by $index" style="display: flex; margin-bottom: 10px;">
<input
type="text"
class="form-control"
id="ldap_url"
ng-model="$ctrl.settings.URLs[$index]"
placeholder="e.g. 10.0.0.10:389 or myldap.domain.tld:389"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
<button
ng-if="$index > 0"
class="btn btn-sm btn-danger"
type="button"
ng-click="$ctrl.removeLDAPUrl($index)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div class="form-group">
<label for="ldap_username" class="col-sm-3 control-label text-left">
Service Account
<portainer-tooltip position="bottom" message="Account that will be used to search for users."></portainer-tooltip>
</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
id="ldap_username"
ng-model="$ctrl.settings.ReaderDN"
placeholder="reader@domain.tld"
ng-change="$ctrl.onAccountChange($ctrl.settings.ReaderDN)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
</div>
</div>
<div class="form-group">
<label for="ldap_password" class="col-sm-3 control-label text-left">
Service Account Password
<portainer-tooltip position="bottom" message="If you do not enter a password, Portainer will leave the current password unchanged."></portainer-tooltip>
</label>
<div class="col-sm-9">
<input
type="password"
class="form-control"
id="ldap_password"
ng-model="$ctrl.settings.Password"
placeholder="password"
autocomplete="new-password"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
</div>
</div>
<ldap-connectivity-check
ng-if="!$ctrl.settings.TLSConfig.TLS && !$ctrl.settings.StartTLS"
settings="$ctrl.settings"
state="$ctrl.state"
connectivity-check="$ctrl.connectivityCheck"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-connectivity-check>
<ldap-settings-security
title="AD Connectivity Security"
settings="$ctrl.settings"
tlsca-cert="$ctrl.tlscaCert"
upload-in-progress="$ctrl.state.uploadInProgress"
on-tlsca-cert-change="($ctrl.onTlscaCertChange)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-settings-security>
<ldap-connectivity-check
ng-if="$ctrl.settings.TLSConfig.TLS || $ctrl.settings.StartTLS"
settings="$ctrl.settings"
state="$ctrl.state"
connectivity-check="$ctrl.connectivityCheck"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-connectivity-check>
<ldap-user-search
style="margin-top: 5px;"
show-username-format="true"
settings="$ctrl.settings.SearchSettings"
domain-suffix="{{ $ctrl.domainSuffix }}"
base-filter="(objectClass=user)"
on-search-click="($ctrl.searchUsers)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-user-search>
<ldap-group-search
style="margin-top: 5px;"
settings="$ctrl.settings.GroupSearchSettings"
domain-suffix="{{ $ctrl.domainSuffix }}"
base-filter="(objectClass=group)"
on-search-click="($ctrl.searchGroups)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-group-search>
<ldap-settings-test-login settings="$ctrl.settings" limited-feature-id="$ctrl.limitedFeatureId"></ldap-settings-test-login>
</ng-form>

View File

@ -0,0 +1,12 @@
import controller from './ad-settings.controller';
export const adSettings = {
templateUrl: './ad-settings.html',
controller,
bindings: {
settings: '=',
tlscaCert: '=',
state: '=',
connectivityCheck: '<',
},
};

View File

@ -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;

View File

@ -0,0 +1,9 @@
export const ldapConnectivityCheck = {
templateUrl: './ldap-connectivity-check.html',
bindings: {
settings: '<',
state: '<',
connectivityCheck: '<',
limitedFeatureId: '<',
},
};

Some files were not shown because too many files have changed in this diff Show More