Compare commits

...

43 Commits

Author SHA1 Message Date
Prabhat Khera
280a2fe093 fix(kubernetes): clear user token from kube token cache on logout + update cluster rolebindings for user on change of team/user authorization [EE-6298] (#10603)
Some checks failed
Test / test-client (push) Has been cancelled
Test / test-server (push) Has been cancelled
2023-11-10 10:06:50 +13:00
Prabhat Khera
ddd30dd17a fix(app): disable deploy when there are no namespaces [EE-6295] (#10608)
* fix(app): hide services section when there are no namespaces [EE-6295] (#10588)

Co-authored-by: testa113 <testa113>

* fix(app): disable deploy when there are no namespaces [EE-6295] (#10606)

Co-authored-by: testa113 <testa113>

---------

Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2023-11-09 20:02:02 +13:00
Chaim Lev-Ari
15df3277ca fix(edge/updates): hide sidebar item when disabled [EE-6294] (#10581) 2023-11-05 13:41:16 +02:00
Prabhat Khera
47845523a5 fix(users): hide admin users for non admins from user list API [EE-6290] (#10579)
* hide admin users for non admins from user list API

* address review comments
2023-11-02 16:08:22 +13:00
LP B
2af2827cba fix(app/logout): always perform API logout + make API logout route public [EE-6198] (#10447)
* feat(api/logout): make logout route public

* feat(app/logout): always perform API logout on /logout redirect

* fix(app): send a logout event to AngularJS when axios hits a 401
2023-10-27 14:02:18 +02:00
andres-portainer
8f4f5fddcc fix(gitops): only attempt to redeploy when the environment appears to be online EE-6182 (#10463) 2023-10-24 11:20:54 -03:00
Oscar Zhou
8b7436e4d0 fix(edge): introduce pause and rollback status [EE-5992] (#10466) 2023-10-19 11:25:43 +13:00
Chaim Lev-Ari
5b8a0471e9 fix(edge/updates): allow group search [EE-6179] (#10407) 2023-10-12 08:30:25 +03:00
Oscar Zhou
0b9e5c564f feat(fs): support to update stack file by version (#10417) 2023-10-06 09:08:34 +13:00
Chaim Lev-Ari
1ed2c8b346 chore(deps): upgrade golangci [EE-5685] (#10413) 2023-10-05 10:31:48 +03:00
Ali
c43f771a88 fix(teasers): add teaser message full stops [EE-6035] (#10402) 2023-10-02 21:22:52 +01:00
Matt Hook
8755a22fee add support for forward proxy (#10334) 2023-09-29 12:54:53 +13:00
cmeng
8e3c47719e fix(websocket): abort websocket when logout EE-6058 (#10371) 2023-09-29 12:13:18 +13:00
Matt Hook
157393c965 support proxy for helm repo validation (#10359) 2023-09-29 11:37:30 +13:00
Ali
6163aaa577 fix(teasers): updated muted styles from qa feedback [EE-6035] (#10391)
* fix(teasers): updated muted styles from qa feedback [EE-6035]
2023-09-28 11:32:48 +01:00
Prabhat Khera
d9a3b98275 fix team lead access to view user names (#10389) 2023-09-28 12:40:58 +13:00
Chaim Lev-Ari
c0c689c2af fix(docker/services): show cred spec configs [EE-5276] (#10082) 2023-09-27 07:57:43 +03:00
Chaim Lev-Ari
4efe66d33f fix(stacks): mark stack as start after autoupdate [EE-6165] (#10375) 2023-09-27 07:53:36 +03:00
Prabhat Khera
80415ab68f fix(authorization): disable user list api call if not authorised [EE-5825] (#10380)
* fix tests
* disable user list api call if not authorised
* fix lint issues
2023-09-27 10:12:40 +13:00
Chaim Lev-Ari
fa087f0bb9 style(kubernetes): disable autoFocus warning [EE-5752] (#10367) 2023-09-25 20:13:35 +03:00
LP B
3994d74c71 feat(app/home): tooltip aside edge agent version on mismatch with Portainer version (#10288)
* feat(app/home): tooltip aside edge agent version on mismatch with Portainer version

* fix(app/home): split agent and edge version display + display warning for agents before 2.15
2023-09-25 11:56:03 +02:00
Matt Hook
537585e78c chore: bump version 2.19.2 [EE-6153] (#10370) 2023-09-25 14:26:54 +13:00
Prabhat Khera
78202cfb25 fix(permissions): non admin access to view users [EE-5825] (#10353)
* fix(security): added restrictions to see user names [EE-5825]
2023-09-25 09:08:37 +13:00
Ali
b60f32a25b fix(be-teaser): mute styles [EE-6035] (#10350) 2023-09-24 19:56:18 +01:00
Matt Hook
8f42ba0254 allow libhelm to use forward proxy (#10330)
Some checks failed
Test / test-client (push) Has been cancelled
Test / test-server (push) Has been cancelled
2023-09-19 18:07:41 +12:00
Chaim Lev-Ari
6f81fcc169 fix(api): restore deleted apis [EE-6090] (#10266) 2023-09-19 13:44:55 +12:00
Oscar Zhou
46949508a4 fix(db/migration): avoid fatal error from being overwritten (#10317) 2023-09-18 14:32:57 +12:00
Matt Hook
034157be9a improved user update validation (#10322) 2023-09-18 12:29:12 +12:00
Dakota Walsh
011a1ce720 fix(kubernetes): add prefix only when needed EE-6068 (#3918) (#10311) 2023-09-15 07:59:37 +12:00
Prabhat Khera
a4922eb693 fix(docker): revert PR #10297 and #10242 [EE-5825] (#10308)
* revert PR #10297 and #10242
2023-09-14 15:51:19 +12:00
cmeng
8c77c5ffbe fix(backup): add chisel key to backup EE-6105 (#10282) 2023-09-13 09:01:31 +12:00
andres-portainer
a062c36ff5 fix(gitops): avoid cancelling the auto updates for any error EE-5604 (#10295) 2023-09-12 17:52:52 -03:00
Oscar Zhou
122fd835dc fix(db/init): check server version and db schema version (#10299) 2023-09-12 15:55:15 +12:00
Prabhat Khera
f7ff07833f fix(security): added restrictions to see user names [EE-5825] (#10297)
* fix(security): added restrictions to see user names [EE-5825]

* use pluralize method
2023-09-12 13:15:29 +12:00
matias-portainer
8010167006 fix(authentication): allow nested whitespaces on AD OU names EE-5206 (#10261) 2023-09-07 11:03:04 -03:00
Matt Hook
4c79e9ef6b prevent regular users changing their username (#10246) 2023-09-06 08:44:24 +12:00
Matt Hook
88ea0cb64f non-admins must supply existing passwd when changing passwd (#10248) 2023-09-06 07:53:31 +12:00
Dakota Walsh
5f50f20a7a fix(security): block user access policies for non admins EE-5826 (#10244) 2023-09-05 09:18:17 +12:00
Dakota Walsh
bbc26682dd fix(security): block non-admins from user info listing EE-5825 (#10242) 2023-09-05 09:17:10 +12:00
Matt Hook
f74704fca4 Bump 2.19.0 release to 2.19.1 (#10237) 2023-09-04 12:06:47 +12:00
Chaim Lev-Ari
9b52bd50d9 fix(ui/switch): reduce label size [EE-3803] (#10018) 2023-09-03 10:26:33 +01:00
Prabhat Khera
04073f0d1f add tls options to the tls dropdown (#10222) 2023-09-01 10:42:26 +12:00
Ali
c035e4a778 fix(k8sconfigure): make ingress restrict be only [EE-6062] (#10217)
Co-authored-by: testa113 <testa113>
2023-09-01 06:11:43 +12:00
135 changed files with 2117 additions and 1168 deletions

View File

@@ -41,6 +41,6 @@ jobs:
- name: GolangCI-Lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.52.2
version: v1.54.1
working-directory: api
args: --timeout=10m -c .golangci.yaml

View File

@@ -10,17 +10,17 @@ linters:
- exportloopref
linters-settings:
depguard:
list-type: denylist
include-go-root: true
packages:
- github.com/sirupsen/logrus
- golang.org/x/exp
packages-with-error-message:
- github.com/sirupsen/logrus: 'logging is allowed only by github.com/rs/zerolog'
ignore-file-rules:
- '**/*_test.go'
- '**/base.go'
- '**/base_tx.go'
rules:
main:
deny:
- pkg: 'github.com/sirupsen/logrus'
desc: 'logging is allowed only by github.com/rs/zerolog'
- pkg: 'golang.org/x/exp'
desc: 'exp is not allowed'
files:
- '!**/*_test.go'
- '!**/base.go'
- '!**/base_tx.go'
# errorlint is causing a typecheck error for some reason. The go compiler will report these
# anyway, so ignore them from the linter

View File

@@ -30,6 +30,7 @@ var filesToBackup = []string{
"portainer.key",
"portainer.pub",
"tls",
"chisel",
}
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.

View File

@@ -75,10 +75,11 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("max_alive_minutes", maxAlive.Minutes()).
Msg("start")
Msg("KeepTunnelAlive: start")
maxAliveTicker := time.NewTicker(maxAlive)
defer maxAliveTicker.Stop()
pingTicker := time.NewTicker(tunnelCleanupInterval)
defer pingTicker.Stop()
@@ -91,13 +92,13 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
log.Debug().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("ping agent")
Msg("KeepTunnelAlive: ping agent")
}
case <-maxAliveTicker.C:
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("timeout_minutes", maxAlive.Minutes()).
Msg("tunnel keep alive timeout")
Msg("KeepTunnelAlive: tunnel keep alive timeout")
return
case <-ctx.Done():
@@ -105,7 +106,7 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
log.Debug().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("tunnel stop")
Msg("KeepTunnelAlive: tunnel stop")
return
}

View File

@@ -157,6 +157,16 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
return store
}
// checkDBSchemaServerVersionMatch checks if the server version matches the db scehma version
func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersion string, serverEdition int) bool {
v, err := dbStore.Version().Version()
if err != nil {
return false
}
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
}
func initComposeStackManager(composeDeployer libstack.Deployer, proxyManager *proxy.Manager) portainer.ComposeStackManager {
composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager)
if err != nil {
@@ -388,6 +398,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
log.Fatal().Err(err).Msg("")
}
// check if the db schema version matches with server version
if !checkDBSchemaServerVersionMatch(dataStore, portainer.APIVersion, int(portainer.Edition)) {
log.Fatal().Msg("The database schema version does not align with the server version. Please consider reverting to the previous server version or addressing the database migration issue.")
}
instanceID, err := dataStore.Version().InstanceID()
if err != nil {
log.Fatal().Err(err).Msg("failed getting instance id")

View File

@@ -5,6 +5,7 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
// BucketName represents the name of the bucket where this service stores data.
@@ -144,6 +145,23 @@ func (service *Service) Create(endpoint *portainer.Endpoint) error {
})
}
func (service *Service) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
var endpoints = make([]portainer.Endpoint, 0)
return endpoints, service.connection.GetAll(
BucketName,
&portainer.Endpoint{},
dataservices.FilterFn(&endpoints, func(e portainer.Endpoint) bool {
for t := range e.TeamAccessPolicies {
if t == teamID {
return true
}
}
return false
}),
)
}
// GetNextIdentifier returns the next identifier for an environment(endpoint).
func (service *Service) GetNextIdentifier() int {
var identifier int

View File

@@ -122,6 +122,23 @@ func (service ServiceTx) Create(endpoint *portainer.Endpoint) error {
return nil
}
func (service ServiceTx) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
var endpoints = make([]portainer.Endpoint, 0)
return endpoints, service.tx.GetAll(
BucketName,
&portainer.Endpoint{},
dataservices.FilterFn(&endpoints, func(e portainer.Endpoint) bool {
for t := range e.TeamAccessPolicies {
if t == teamID {
return true
}
}
return false
}),
)
}
// GetNextIdentifier returns the next identifier for an environment(endpoint).
func (service ServiceTx) GetNextIdentifier() int {
return service.tx.GetNextIdentifier(BucketName)

View File

@@ -89,6 +89,7 @@ type (
EndpointService interface {
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool)
EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error)
Heartbeat(endpointID portainer.EndpointID) (int64, bool)
UpdateHeartbeat(endpointID portainer.EndpointID)
Endpoints() ([]portainer.Endpoint, error)

View File

@@ -51,9 +51,9 @@ func (store *Store) MigrateData() error {
err = errors.Wrap(err, "failed to migrate database")
log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
err = store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
if err != nil {
return errors.Wrap(err, "failed to restore database")
restorErr := store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
if restorErr != nil {
return errors.Wrap(restorErr, "failed to restore database")
}
log.Info().Msg("database restored to previous version")

View File

@@ -1,7 +1,7 @@
package datastore
import (
portaineree "github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices"
)
@@ -72,7 +72,7 @@ func dbVersionToSemanticVersion(dbVersion int) string {
func (store *Store) getOrMigrateLegacyVersion() (*models.Version, error) {
// Very old versions of portainer did not have a version bucket, lets set some defaults
dbVersion := 24
edition := int(portaineree.PortainerCE)
edition := int(portainer.PortainerCE)
instanceId := ""
// If we already have a version key, we don't need to migrate

View File

@@ -944,6 +944,6 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.19.0\",\"MigratorCount\":3,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.19.2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}
}

View File

@@ -302,6 +302,38 @@ func (service *Service) UpdateStoreStackFileFromBytes(stackIdentifier, fileName
return service.wrapFileStore(stackStorePath), nil
}
// UpdateStoreStackFileFromBytesByVersion makes stack file backup and updates a new file from bytes.
// It returns the path to the folder where the file is stored.
func (service *Service) UpdateStoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, commitHash string, data []byte) (string, error) {
stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier)
versionStr := ""
if version != 0 {
versionStr = fmt.Sprintf("v%d", version)
}
if commitHash != "" {
versionStr = commitHash
}
if versionStr != "" {
stackStorePath = JoinPaths(stackStorePath, versionStr)
}
composeFilePath := JoinPaths(stackStorePath, fileName)
err := service.createBackupFileInStore(composeFilePath)
if err != nil {
return "", err
}
r := bytes.NewReader(data)
err = service.createFileInStore(composeFilePath, r)
if err != nil {
return "", err
}
return service.wrapFileStore(stackStorePath), nil
}
// RemoveStackFileBackup removes the stack file backup in the ComposeStorePath.
func (service *Service) RemoveStackFileBackup(stackIdentifier, fileName string) error {
stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier)

View File

@@ -40,7 +40,7 @@ require (
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a
github.com/portainer/libhttp v0.0.0-20230615144939-a999f666d9a9
github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e146
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230928223730-157393c965ce
github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146
github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73
github.com/robfig/cron/v3 v3.0.1

View File

@@ -320,6 +320,10 @@ github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e1
github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e146/go.mod h1:x4Lpq/BjFhZmuNB8e8FO0ObRPQ/Z/V9rTe54bMedf1A=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146 h1:1qW7quKyFG4tOnMcnnqyYsDVfL09etO1h/Cu/3ak7KU=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146/go.mod h1:cFRD6PvOwpd2pf/O1r/IMKl+ZB12pWfo/Evleh3aCfM=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230919060741-8f42ba025479 h1:DbmhSQZpDo5f0cr+CKLJqoqhQiuxp8QFXdZsjPS1lI4=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230919060741-8f42ba025479/go.mod h1:cFRD6PvOwpd2pf/O1r/IMKl+ZB12pWfo/Evleh3aCfM=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230928223730-157393c965ce h1:DQTMXYH1zn2DzuAe+4rT40JqdHLhpHHJ2pzRFhvZ/+c=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230928223730-157393c965ce/go.mod h1:cFRD6PvOwpd2pf/O1r/IMKl+ZB12pWfo/Evleh3aCfM=
github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146 h1:ZGj+j5HoajaO+mXgCm6NzOU+zUdIlJK2amagB+QIDvc=
github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146/go.mod h1:+zCK2UbsH6A3yEGi0yZ45ec5VFRP7svob5Q2lW6LFgk=
github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73 h1:7bPOnwucE0nor0so1BQJxQKCL5t+vCWO4nAz/S0lci0=

View File

@@ -24,6 +24,7 @@ type Handler struct {
ProxyManager *proxy.Manager
KubernetesTokenCacheManager *kubernetes.TokenCacheManager
passwordStrengthChecker security.PasswordStrengthChecker
bouncer security.BouncerService
}
// NewHandler creates a handler to manage authentication operations.
@@ -31,6 +32,7 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
h := &Handler{
Router: mux.NewRouter(),
passwordStrengthChecker: passwordStrengthChecker,
bouncer: bouncer,
}
h.Handle("/auth/oauth/validate",
@@ -38,7 +40,6 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
h.Handle("/auth",
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost)
h.Handle("/auth/logout",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost)
bouncer.PublicAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost)
return h
}

View File

@@ -5,25 +5,26 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/logoutcontext"
)
// @id Logout
// @summary Logout
// @description **Access policy**: authenticated
// @description **Access policy**: public
// @security ApiKeyAuth
// @security jwt
// @tags auth
// @success 204 "Success"
// @failure 500 "Server error"
// @router /auth/logout [post]
func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user details from authentication token", err)
}
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
tokenData := handler.bouncer.JWTAuthLookup(r)
if tokenData != nil {
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
logoutcontext.Cancel(tokenData.Token)
}
return response.Empty(w)
}

View File

@@ -3,6 +3,7 @@ package customtemplates
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"regexp"
@@ -472,3 +473,29 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
return customTemplate, nil
}
// @id CustomTemplateCreate
// @summary Create a custom template
// @description Create a custom template.
// @description **Access policy**: authenticated
// @tags custom_templates
// @security ApiKeyAuth
// @security jwt
// @accept json,multipart/form-data
// @produce json
// @param method query string true "method for creating template" Enums(string, file, repository)
// @param body body object true "for body documentation see the relevant /custom_templates/{method} endpoint"
// @success 200 {object} portainer.CustomTemplate
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @deprecated
// @router /custom_templates [post]
func deprecatedCustomTemplateCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method", err)
}
url := fmt.Sprintf("/custom_templates/create/%s", method)
return url, nil
}

View File

@@ -8,6 +8,7 @@ import (
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
)
@@ -32,6 +33,7 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/custom_templates/create/{method}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost)
h.Handle("/custom_templates", middlewares.Deprecated(h, deprecatedCustomTemplateCreateUrlParser)).Methods(http.MethodPost) // Deprecated
h.Handle("/custom_templates",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet)
h.Handle("/custom_templates/{id}",

View File

@@ -2,6 +2,7 @@ package edgejobs
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
@@ -287,3 +288,26 @@ func (handler *Handler) addAndPersistEdgeJob(tx dataservices.DataStoreTx, edgeJo
return tx.EdgeJob().CreateWithID(edgeJob.ID, edgeJob)
}
// @id EdgeJobCreate
// @summary Create an EdgeJob
// @description **Access policy**: administrator
// @tags edge_jobs
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param method query string true "Creation Method" Enums(file, string)
// @param body body object true "for body documentation see the relevant /edge_jobs/create/{method} endpoint"
// @success 200 {object} portainer.EdgeGroup
// @failure 503 "Edge compute features are disabled"
// @failure 500
// @deprecated
// @router /edge_jobs [post]
func deprecatedEdgeJobCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
}
return fmt.Sprintf("/edge_jobs/create/%s", method), nil
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/gorilla/mux"
@@ -29,6 +30,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
h.Handle("/edge_jobs",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet)
h.Handle("/edge_jobs",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeJobCreateUrlParser)))).Methods(http.MethodPost)
h.Handle("/edge_jobs/create/{method}",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost)
h.Handle("/edge_jobs/{id}",

View File

@@ -1,6 +1,7 @@
package edgestacks
import (
"fmt"
"net/http"
httperror "github.com/portainer/libhttp/error"
@@ -18,6 +19,7 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
if err != nil {
return httperror.BadRequest("Invalid query parameter: method", err)
}
dryrun, _ := request.RetrieveBooleanQueryParameter(r, "dryrun", true)
tokenData, err := security.RetrieveTokenData(r)
@@ -60,3 +62,26 @@ func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method str
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
}
// @id EdgeStackCreate
// @summary Create an EdgeStack
// @description **Access policy**: administrator
// @tags edge_stacks
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param method query string true "Creation Method" Enums(file,string,repository)
// @param body body object true "for body documentation see the relevant /edge_stacks/create/{method} endpoint"
// @success 200 {object} portainer.EdgeStack
// @failure 500
// @failure 503 "Edge compute features are disabled"
// @deprecated
// @router /edge_stacks [post]
func deprecatedEdgeStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
}
return fmt.Sprintf("/edge_stacks/create/%s", method), nil
}

View File

@@ -38,6 +38,8 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/edge_stacks/create/{method}",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeStackCreateUrlParser)))).Methods(http.MethodPost) // Deprecated
h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
h.Handle("/edge_stacks/{id}",

View File

@@ -84,7 +84,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.19.0
// @version 2.19.2
// @description.markdown api-description.md
// @termsOfService

View File

@@ -14,6 +14,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -58,6 +59,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
h.Handle("/stacks/create/{type}/{method}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost)
h.Handle("/stacks",
bouncer.AuthenticatedAccess(middlewares.Deprecated(h, deprecatedStackCreateUrlParser))).Methods(http.MethodPost) // Deprecated
h.Handle("/stacks",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet)
h.Handle("/stacks/{id}",

View File

@@ -1,6 +1,7 @@
package stacks
import (
"fmt"
"net/http"
"github.com/pkg/errors"
@@ -139,3 +140,53 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
return response.JSON(w, stack)
}
func getStackTypeFromQueryParameter(r *http.Request) (string, error) {
stackType, err := request.RetrieveNumericQueryParameter(r, "type", false)
if err != nil {
return "", err
}
switch stackType {
case 1:
return "swarm", nil
case 2:
return "standalone", nil
case 3:
return "kubernetes", nil
}
return "", errors.New(request.ErrInvalidQueryParameter)
}
// @id StackCreate
// @summary Deploy a new stack
// @description Deploy a new stack into a Docker environment(endpoint) specified via the environment(endpoint) identifier.
// @description **Access policy**: authenticated
// @tags stacks
// @security ApiKeyAuth
// @security jwt
// @accept json,multipart/form-data
// @produce json
// @param type query int true "Stack deployment type. Possible values: 1 (Swarm stack), 2 (Compose stack) or 3 (Kubernetes stack)." Enums(1,2,3)
// @param method query string true "Stack deployment method. Possible values: file, string, repository or url." Enums(string, file, repository, url)
// @param endpointId query int true "Identifier of the environment(endpoint) that will be used to deploy the stack"
// @param body body object true "for body documentation see the relevant /stacks/create/{type}/{method} endpoint"
// @success 200 {object} portainer.Stack
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @deprecated
// @router /stacks [post]
func deprecatedStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
}
stackType, err := getStackTypeFromQueryParameter(r)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: type", err)
}
return fmt.Sprintf("/stacks/create/%s/%s", stackType, method), nil
}

View File

@@ -4,8 +4,12 @@ import (
"net/http"
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/rs/zerolog/log"
"github.com/gorilla/mux"
)
@@ -13,7 +17,8 @@ import (
// Handler is the HTTP handler used to handle team membership operations.
type Handler struct {
*mux.Router
DataStore dataservices.DataStore
DataStore dataservices.DataStore
K8sClientFactory *cli.ClientFactory
}
// NewHandler creates a handler to manage team membership operations.
@@ -31,3 +36,27 @@ func NewHandler(bouncer security.BouncerService) *Handler {
return h
}
func (handler *Handler) updateUserServiceAccounts(membership *portainer.TeamMembership) {
endpoints, err := handler.DataStore.Endpoint().EndpointsByTeamID(membership.TeamID)
if err != nil {
log.Error().Err(err).Msgf("failed fetching environments for team %d", membership.TeamID)
return
}
for _, endpoint := range endpoints {
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
// update kubernenets service accounts if the team is associated with a kubernetes environment
if endpointutils.IsKubernetesEndpoint(&endpoint) {
kubecli, err := handler.K8sClientFactory.GetKubeClient(&endpoint)
if err != nil {
log.Error().Err(err).Msgf("failed getting kube client for environment %d", endpoint.ID)
continue
}
teamIDs := []int{int(membership.TeamID)}
err = kubecli.SetupUserServiceAccount(int(membership.UserID), teamIDs, restrictDefaultNamespace)
if err != nil {
log.Error().Err(err).Msgf("failed setting-up service account for user %d", membership.UserID)
}
}
}
}

View File

@@ -91,5 +91,7 @@ func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to persist team memberships inside the database", err)
}
defer handler.updateUserServiceAccounts(membership)
return response.JSON(w, membership)
}

View File

@@ -52,5 +52,7 @@ func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to remove the team membership from the database", err)
}
defer handler.updateUserServiceAccounts(membership)
return response.Empty(w)
}

View File

@@ -90,5 +90,7 @@ func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to persist membership changes inside the database", err)
}
defer handler.updateUserServiceAccounts(membership)
return response.JSON(w, membership)
}

View File

@@ -22,6 +22,7 @@ var (
errAdminCannotRemoveSelf = errors.New("Cannot remove your own user account. Contact another administrator")
errCannotRemoveLastLocalAdmin = errors.New("Cannot remove the last local administrator account")
errCryptoHashFailure = errors.New("Unable to hash data")
errWrongPassword = errors.New("Wrong password")
)
func hideFields(user *portainer.User) {

View File

@@ -10,6 +10,13 @@ import (
"github.com/portainer/portainer/api/http/security"
)
type User struct {
ID portainer.UserID `json:"Id" example:"1"`
Username string `json:"Username" example:"bob"`
// User role (1 for administrator account and 2 for regular account)
Role portainer.UserRole `json:"Role" example:"1"`
}
// @id UserList
// @summary List users
// @description List Portainer users.
@@ -26,24 +33,25 @@ import (
// @failure 500 "Server error"
// @router /users [get]
func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
users, err := handler.DataStore.User().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve users from the database", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
availableUsers := security.FilterUsers(users, securityContext)
for i := range availableUsers {
hideFields(&availableUsers[i])
if !securityContext.IsAdmin && !securityContext.IsTeamLeader {
return httperror.Forbidden("Permission denied to access users list", err)
}
users, err := handler.DataStore.User().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve users from the database", err)
}
availableUsers := security.FilterUsers(users, securityContext)
endpointID, _ := request.RetrieveNumericQueryParameter(r, "environmentId", true)
if endpointID == 0 {
return response.JSON(w, availableUsers)
return response.JSON(w, sanitizeUsers(availableUsers))
}
// filter out users who do not have access to the specific endpoint
@@ -57,11 +65,11 @@ func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httper
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
}
canAccessEndpoint := make([]portainer.User, 0)
canAccessEndpoint := make([]User, 0)
for _, user := range availableUsers {
// the users who have the endpoint authorization
if _, ok := user.EndpointAuthorizations[endpoint.ID]; ok {
canAccessEndpoint = append(canAccessEndpoint, user)
canAccessEndpoint = append(canAccessEndpoint, sanitizeUser(user))
continue
}
@@ -72,9 +80,25 @@ func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httper
}
if security.AuthorizedEndpointAccess(endpoint, endpointGroup, user.ID, teamMemberships) {
canAccessEndpoint = append(canAccessEndpoint, user)
canAccessEndpoint = append(canAccessEndpoint, sanitizeUser(user))
}
}
return response.JSON(w, canAccessEndpoint)
}
func sanitizeUser(user portainer.User) User {
return User{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}
}
func sanitizeUsers(users []portainer.User) []User {
u := make([]User, len(users))
for i := range users {
u[i] = sanitizeUser(users[i])
}
return u
}

View File

@@ -111,28 +111,14 @@ func Test_userList(t *testing.T) {
}
})
t.Run("standard user cannot list amdin users", func(t *testing.T) {
t.Run("standard user cannot list users", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
is.Equal(http.StatusOK, rr.Code)
body, err := io.ReadAll(rr.Body)
is.NoError(err, "ReadAll should not return error")
var resp []portainer.User
err = json.Unmarshal(body, &resp)
is.NoError(err, "response should be list json")
is.Len(resp, 2)
if len(resp) > 0 {
for _, user := range resp {
is.NotEqual(portainer.AdministratorRole, user.Role)
}
}
is.Equal(http.StatusForbidden, rr.Code)
})
// Case 2: the user is under an environment group and the environment group has endpoint access.

View File

@@ -21,9 +21,10 @@ type themePayload struct {
}
type userUpdatePayload struct {
Username string `validate:"required" example:"bob"`
Password string `validate:"required" example:"cg9Wgky3"`
Theme *themePayload
Username string `validate:"required" example:"bob"`
Password string `validate:"required" example:"cg9Wgky3"`
NewPassword string `validate:"required" example:"asfj2emv"`
Theme *themePayload
// User role (1 for administrator account and 2 for regular account)
Role int `validate:"required" enums:"1,2" example:"2"`
@@ -37,12 +38,14 @@ func (payload *userUpdatePayload) Validate(r *http.Request) error {
if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 {
return errors.New("invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)")
}
return nil
}
// @id UserUpdate
// @summary Update a user
// @description Update user details. A regular user account can only update his details.
// @description A regular user account cannot change their username or role.
// @description **Access policy**: authenticated
// @tags users
// @security ApiKeyAuth
@@ -95,6 +98,10 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
}
if payload.Username != "" && payload.Username != user.Username {
if tokenData.Role != portainer.AdministratorRole {
return httperror.Forbidden("Permission denied. Unable to update username", httperrors.ErrResourceAccessDenied)
}
sameNameUser, err := handler.DataStore.User().UserByUsername(payload.Username)
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
return httperror.InternalServerError("Unable to retrieve users from the database", err)
@@ -106,8 +113,28 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
user.Username = payload.Username
}
if payload.Password != "" {
user.Password, err = handler.CryptoService.Hash(payload.Password)
if payload.Password != "" && payload.NewPassword == "" {
if tokenData.Role == portainer.AdministratorRole {
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password as an admin, you only need 'newPassword' in your request"))
}
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password, you must include both 'password' and 'newPassword' in your request"))
}
if payload.NewPassword != "" {
// Non-admins need to supply the previous password
if tokenData.Role != portainer.AdministratorRole {
err := handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
if err != nil {
return httperror.Forbidden("Current password doesn't match. Password left unchanged", errors.New("Current password does not match the password provided. Please try again"))
}
}
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
}
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)
if err != nil {
return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
}

View File

@@ -87,7 +87,7 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
}
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
return httperror.BadRequest("Password does not meet the requirements", nil)
return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
}
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)

View File

@@ -9,9 +9,11 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/asaskevich/govalidator"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
)
// @summary Attach a websocket
@@ -74,6 +76,13 @@ func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request)
}
func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.Warn().
Err(err).
Msg("unable to retrieve user details from authentication token")
return err
}
r.Header.Del("Origin")
@@ -89,10 +98,15 @@ func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Reque
}
defer websocketConn.Close()
return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID)
return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID, tokenData.Token)
}
func hijackAttachStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, attachID string) error {
func hijackAttachStartOperation(
websocketConn *websocket.Conn,
endpoint *portainer.Endpoint,
attachID string,
token string,
) error {
dial, err := initDial(endpoint)
if err != nil {
return err
@@ -116,7 +130,7 @@ func hijackAttachStartOperation(websocketConn *websocket.Conn, endpoint *portain
return err
}
return hijackRequest(websocketConn, httpConn, attachStartRequest)
return hijackRequest(websocketConn, httpConn, attachStartRequest, token)
}
func createAttachStartRequest(attachID string) (*http.Request, error) {

View File

@@ -11,9 +11,11 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/asaskevich/govalidator"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
)
type execStartOperationPayload struct {
@@ -80,6 +82,14 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h
}
func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.Warn().
Err(err).
Msg("unable to retrieve user details from authentication token")
return err
}
r.Header.Del("Origin")
if params.endpoint.Type == portainer.AgentOnDockerEnvironment {
@@ -94,10 +104,15 @@ func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request
}
defer websocketConn.Close()
return hijackExecStartOperation(websocketConn, params.endpoint, params.ID)
return hijackExecStartOperation(websocketConn, params.endpoint, params.ID, tokenData.Token)
}
func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error {
func hijackExecStartOperation(
websocketConn *websocket.Conn,
endpoint *portainer.Endpoint,
execID string,
token string,
) error {
dial, err := initDial(endpoint)
if err != nil {
return err
@@ -121,7 +136,7 @@ func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer
return err
}
return hijackRequest(websocketConn, httpConn, execStartRequest)
return hijackRequest(websocketConn, httpConn, execStartRequest, token)
}
func createExecStartRequest(execID string) (*http.Request, error) {

View File

@@ -7,9 +7,15 @@ import (
"net/http/httputil"
"github.com/gorilla/websocket"
"github.com/portainer/portainer/api/internal/logoutcontext"
)
func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, request *http.Request) error {
func hijackRequest(
websocketConn *websocket.Conn,
httpConn *httputil.ClientConn,
request *http.Request,
token string,
) error {
// Server hijacks the connection, error 'connection closed' expected
resp, err := httpConn.Do(request)
if !errors.Is(err, httputil.ErrPersistEOF) {
@@ -29,9 +35,15 @@ func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn,
go streamFromReaderToWebsocket(websocketConn, brw, errorChan)
go streamFromWebsocketToWriter(websocketConn, tcpConn, errorChan)
err = <-errorChan
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
return err
logoutCtx := logoutcontext.GetContext(token)
select {
case <-logoutCtx.Done():
return fmt.Errorf("Your session has been logged out.")
case err = <-errorChan:
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
return err
}
}
return nil

View File

@@ -1,15 +1,20 @@
package websocket
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/logoutcontext"
"github.com/gorilla/websocket"
"github.com/koding/websocketproxy"
"github.com/portainer/portainer/api/crypto"
"github.com/rs/zerolog/log"
)
func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
@@ -18,33 +23,12 @@ func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r
return err
}
endpointURL, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port))
agentURL, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port))
if err != nil {
return err
}
endpointURL.Scheme = "ws"
proxy := websocketproxy.NewProxy(endpointURL)
signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}
proxy.Director = func(incoming *http.Request, out http.Header) {
out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey())
out.Set(portainer.PortainerAgentSignatureHeader, signature)
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
}
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive)
proxy.ServeHTTP(w, r)
return nil
return handler.doProxyWebsocketRequest(w, r, params, agentURL, true)
}
func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
@@ -59,17 +43,41 @@ func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *htt
}
agentURL.Scheme = "ws"
proxy := websocketproxy.NewProxy(agentURL)
return handler.doProxyWebsocketRequest(w, r, params, agentURL, false)
}
if params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify {
func (handler *Handler) doProxyWebsocketRequest(
w http.ResponseWriter,
r *http.Request,
params *webSocketRequestParams,
agentURL *url.URL,
isEdge bool,
) error {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.
Warn().
Err(err).
Msg("unable to retrieve user details from authentication token")
return err
}
enableTLS := !isEdge && (params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify)
agentURL.Scheme = "ws"
if enableTLS {
agentURL.Scheme = "wss"
}
proxy := websocketproxy.NewProxy(agentURL)
proxyDialer := *websocket.DefaultDialer
proxy.Dialer = &proxyDialer
if enableTLS {
tlsConfig := crypto.CreateTLSConfiguration()
tlsConfig.InsecureSkipVerify = params.endpoint.TLSConfig.TLSSkipVerify
proxy.Dialer = &websocket.Dialer{
TLSClientConfig: tlsConfig,
}
proxyDialer.TLSClientConfig = tlsConfig
}
signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
@@ -84,7 +92,46 @@ func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *htt
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
}
if isEdge {
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive)
}
abortProxyOnLogout(r.Context(), proxy, tokenData.Token)
proxy.ServeHTTP(w, r)
return nil
}
func abortProxyOnLogout(ctx context.Context, proxy *websocketproxy.WebsocketProxy, token string) {
var wsConn net.Conn
proxy.Dialer.NetDial = func(network, addr string) (net.Conn, error) {
netDialer := &net.Dialer{}
conn, err := netDialer.DialContext(context.Background(), network, addr)
wsConn = conn
return conn, err
}
logoutCtx := logoutcontext.GetContext(token)
go func() {
log.Debug().
Msg("logout watcher for websocket proxy started")
select {
case <-logoutCtx.Done():
log.Debug().
Msg("logout watcher for websocket proxy stopped as user logged out")
if wsConn != nil {
wsConn.Close()
}
case <-ctx.Done():
log.Debug().
Msg("logout watcher for websocket proxy stopped as the ws connection closed")
}
}()
}

View File

@@ -0,0 +1,25 @@
package middlewares
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/rs/zerolog/log"
)
// deprecate api route
func Deprecated(router http.Handler, urlBuilder func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
newUrl, err := urlBuilder(w, r)
if err != nil {
httperror.WriteError(w, err.StatusCode, err.Error(), err)
return
}
log.Warn().Msgf("This api is deprecated. Use %s instead", newUrl)
redirectedRequest := r.Clone(r.Context())
redirectedRequest.URL.Path = newUrl
router.ServeHTTP(w, redirectedRequest)
})
}

View File

@@ -1,10 +1,12 @@
package kubernetes
import (
"fmt"
"os"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
const defaultServiceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token"
@@ -43,28 +45,62 @@ func (manager *tokenManager) GetAdminServiceAccountToken() string {
return manager.adminToken
}
func (manager *tokenManager) setupUserServiceAccounts(userID portainer.UserID, endpoint *portainer.Endpoint) error {
memberships, err := manager.dataStore.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil {
return err
}
teamIds := make([]int, 0, len(memberships))
for _, membership := range memberships {
teamIds = append(teamIds, int(membership.TeamID))
}
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
err = manager.kubecli.SetupUserServiceAccount(int(userID), teamIds, restrictDefaultNamespace)
if err != nil {
return err
}
return nil
}
func (manager *tokenManager) UpdateUserServiceAccountsForEndpoint(endpointID portainer.EndpointID) {
endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
log.Error().Err(err).Msgf("failed fetching environments %d", endpointID)
return
}
userIDs := make([]portainer.UserID, 0)
for u := range endpoint.UserAccessPolicies {
userIDs = append(userIDs, u)
}
for t := range endpoint.TeamAccessPolicies {
memberships, _ := manager.dataStore.TeamMembership().TeamMembershipsByTeamID(portainer.TeamID(t))
for _, membership := range memberships {
userIDs = append(userIDs, membership.UserID)
}
}
for _, userID := range userIDs {
if err := manager.setupUserServiceAccounts(userID, endpoint); err != nil {
log.Error().Err(err).Msgf("failed setting-up service account for user %d", userID)
}
}
}
// GetUserServiceAccountToken setup a user's service account if it does not exist, then retrieve its token
func (manager *tokenManager) GetUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) {
tokenFunc := func() (string, error) {
memberships, err := manager.dataStore.TeamMembership().TeamMembershipsByUserID(portainer.UserID(userID))
if err != nil {
return "", err
}
teamIds := make([]int, 0, len(memberships))
for _, membership := range memberships {
teamIds = append(teamIds, int(membership.TeamID))
}
endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
log.Error().Err(err).Msgf("failed fetching environment %d", endpointID)
return "", err
}
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
err = manager.kubecli.SetupUserServiceAccount(userID, teamIds, restrictDefaultNamespace)
if err != nil {
return "", err
if err := manager.setupUserServiceAccounts(portainer.UserID(userID), endpoint); err != nil {
return "", fmt.Errorf("failed setting-up service account for user %d: %w", userID, err)
}
return manager.kubecli.GetServiceAccountBearerToken(userID)

View File

@@ -49,7 +49,17 @@ func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*
apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/(api|apis/apps)/v[0-9](\.[0-9])?`)
requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
endpointRe := regexp.MustCompile(`([0-9]+)`)
endpointIDMatch := endpointRe.FindAllString(request.RequestURI, 1)
endpointID := 0
if len(endpointIDMatch) > 0 {
endpointID, _ = strconv.Atoi(endpointIDMatch[0])
}
switch {
case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"):
defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID))
return transport.executeKubernetesRequest(request)
case strings.EqualFold(requestPath, "/namespaces"):
return transport.executeKubernetesRequest(request)
case strings.HasPrefix(requestPath, "/namespaces"):

View File

@@ -60,15 +60,15 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService dataservices
}
}
// PublicAccess defines a security check for public API environments(endpoints).
// No authentication is required to access these environments(endpoints).
// PublicAccess defines a security check for public API endpoints.
// No authentication is required to access these endpoints.
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
return mwSecureHeaders(h)
}
// AdminAccess defines a security check for API environments(endpoints) that require an authorization check.
// Authentication is required to access these environments(endpoints).
// The administrator role is required to use these environments(endpoints).
// AdminAccess defines a security check for API endpoints that require an authorization check.
// Authentication is required to access these endpoints.
// The administrator role is required to use these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation
// and resource filtering.
@@ -79,8 +79,8 @@ func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler {
return h
}
// RestrictedAccess defines a security check for restricted API environments(endpoints).
// Authentication is required to access these environments(endpoints).
// RestrictedAccess defines a security check for restricted API endpoints.
// Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation
// and resource filtering.
@@ -104,8 +104,8 @@ func (bouncer *RequestBouncer) TeamLeaderAccess(h http.Handler) http.Handler {
return h
}
// AuthenticatedAccess defines a security check for restricted API environments(endpoints).
// Authentication is required to access these environments(endpoints).
// AuthenticatedAccess defines a security check for restricted API endpoints.
// Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation
// and resource filtering.

View File

@@ -100,6 +100,7 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint
endpointGroup := getAssociatedGroup(&endpoint, groups)
if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) {
endpoint.UserAccessPolicies = nil
endpoints[n] = endpoint
n++
}

View File

@@ -259,6 +259,7 @@ func (server *Server) Start() error {
var teamMembershipHandler = teammemberships.NewHandler(requestBouncer)
teamMembershipHandler.DataStore = server.DataStore
teamMembershipHandler.K8sClientFactory = server.KubernetesClientFactory
var systemHandler = system.NewHandler(requestBouncer,
server.Status,

View File

@@ -0,0 +1,20 @@
package logoutcontext
import (
"context"
)
const LogoutPrefix = "logout-"
func GetContext(token string) context.Context {
return GetService(logoutToken(token)).GetLogoutCtx()
}
func Cancel(token string) {
GetService(logoutToken(token)).Cancel()
RemoveService(logoutToken(token))
}
func logoutToken(token string) string {
return LogoutPrefix + token
}

View File

@@ -0,0 +1,28 @@
package logoutcontext
import (
"context"
)
type (
Service struct {
ctx context.Context
cancel context.CancelFunc
}
)
func NewService() *Service {
ctx, cancel := context.WithCancel(context.Background())
return &Service{
ctx: ctx,
cancel: cancel,
}
}
func (s *Service) Cancel() {
s.cancel()
}
func (s *Service) GetLogoutCtx() context.Context {
return s.ctx
}

View File

@@ -0,0 +1,34 @@
package logoutcontext
import "sync"
type (
ServiceFactory struct {
mu sync.Mutex
services map[string]*Service
}
)
var serviceFactory = ServiceFactory{
services: make(map[string]*Service),
}
func GetService(token string) *Service {
serviceFactory.mu.Lock()
defer serviceFactory.mu.Unlock()
service, ok := serviceFactory.services[token]
if !ok {
service = NewService()
serviceFactory.services[token] = service
}
return service
}
func RemoveService(token string) {
serviceFactory.mu.Lock()
defer serviceFactory.mu.Unlock()
delete(serviceFactory.services, token)
}

View File

@@ -301,6 +301,19 @@ func (s *stubEndpointService) GetNextIdentifier() int {
return len(s.endpoints)
}
func (s *stubEndpointService) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
var endpoints = make([]portainer.Endpoint, 0)
for _, e := range s.endpoints {
for t := range e.TeamAccessPolicies {
if t == teamID {
endpoints = append(endpoints, e)
}
}
}
return endpoints, nil
}
// WithEndpoints option will instruct testDatastore to return provided environments(endpoints)
func WithEndpoints(endpoints []portainer.Endpoint) datastoreOption {
return func(d *testDatastore) {

View File

@@ -137,6 +137,7 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData,
ID: portainer.UserID(cl.UserID),
Username: cl.Username,
Role: portainer.UserRole(cl.Role),
Token: token,
}, nil
}
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -154,17 +155,29 @@ func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.En
}, nil
}
// CreateClient returns a pointer to a new Clientset instance
// CreateClient returns a pointer to a new Clientset instance.
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
switch endpoint.Type {
case portainer.KubernetesLocalEnvironment:
return buildLocalClient()
case portainer.AgentOnKubernetesEnvironment:
return factory.buildAgentClient(endpoint)
case portainer.EdgeAgentOnKubernetesEnvironment:
return factory.buildEdgeClient(endpoint)
case portainer.KubernetesLocalEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.EdgeAgentOnKubernetesEnvironment:
c, err := factory.CreateConfig(endpoint)
if err != nil {
return nil, err
}
return kubernetes.NewForConfig(c)
}
return nil, errors.New("unsupported environment type")
}
// CreateConfig returns a pointer to a new kubeconfig ready to create a client.
func (factory *ClientFactory) CreateConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
switch endpoint.Type {
case portainer.KubernetesLocalEnvironment:
return buildLocalConfig()
case portainer.AgentOnKubernetesEnvironment:
return factory.buildAgentConfig(endpoint)
case portainer.EdgeAgentOnKubernetesEnvironment:
return factory.buildEdgeConfig(endpoint)
}
return nil, errors.New("unsupported environment type")
}
@@ -184,20 +197,64 @@ func (rt *agentHeaderRoundTripper) RoundTrip(req *http.Request) (*http.Response,
return rt.roundTripper.RoundTrip(req)
}
func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL)
func (factory *ClientFactory) buildAgentConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
var clientURL strings.Builder
if !strings.HasPrefix(endpoint.URL, "http") {
clientURL.WriteString("https://")
}
clientURL.WriteString(endpoint.URL)
clientURL.WriteString("/kubernetes")
return factory.createRemoteClient(endpointURL)
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
config, err := clientcmd.BuildConfigFromFlags(clientURL.String(), "")
if err != nil {
return nil, err
}
config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &agentHeaderRoundTripper{
signatureHeader: signature,
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
roundTripper: rt,
}
})
return config, nil
}
func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
return nil, errors.Wrap(err, "failed activating tunnel")
}
endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port)
return factory.createRemoteClient(endpointURL)
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
if err != nil {
return nil, err
}
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &agentHeaderRoundTripper{
signatureHeader: signature,
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
roundTripper: rt,
}
})
return config, nil
}
func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernetes.Clientset, error) {
@@ -227,34 +284,14 @@ func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernete
}
func (factory *ClientFactory) CreateRemoteMetricsClient(endpoint *portainer.Endpoint) (*metricsv.Clientset, error) {
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL)
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
config, err := factory.CreateConfig(endpoint)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create metrics KubeConfig")
}
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
if err != nil {
return nil, err
}
config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &agentHeaderRoundTripper{
signatureHeader: signature,
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
roundTripper: rt,
}
})
return metricsv.NewForConfig(config)
}
func buildLocalClient() (*kubernetes.Clientset, error) {
func buildLocalConfig() (*rest.Config, error) {
config, err := rest.InClusterConfig()
if err != nil {
return nil, err
@@ -263,7 +300,7 @@ func buildLocalClient() (*kubernetes.Clientset, error) {
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
return kubernetes.NewForConfig(config)
return config, nil
}
func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error {

View File

@@ -1269,6 +1269,7 @@ type (
Username string
Role UserRole
ForceChangePassword bool
Token string
}
// TunnelDetails represents information associated to a tunnel
@@ -1403,6 +1404,7 @@ type (
StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error)
StoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, data []byte) (string, error)
UpdateStoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error)
UpdateStoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, commitHash string, data []byte) (string, error)
RemoveStackFileBackup(stackIdentifier, fileName string) error
RemoveStackFileBackupByVersion(stackIdentifier string, version int, fileName string) error
RollbackStackFile(stackIdentifier, fileName string) error
@@ -1559,7 +1561,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.19.0"
APIVersion = "2.19.2"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -1680,6 +1682,12 @@ const (
EdgeStackStatusDeploying
// EdgeStackStatusRemoving represents an Edge stack which is being removed
EdgeStackStatusRemoving
// EdgeStackStatusPausedDeploying represents a paused Edge stack
EdgeStackStatusPausedDeploying
// EdgeStackStatusRollingBack represents an Edge stack which is being rolled back
EdgeStackStatusRollingBack
// EdgeStackStatusRolledBack represents an Edge stack which has rolled back
EdgeStackStatusRolledBack
)
const (

View File

@@ -17,6 +17,18 @@ type Scheduler struct {
mu sync.Mutex
}
type PermanentError struct {
err error
}
func NewPermanentError(err error) *PermanentError {
return &PermanentError{err: err}
}
func (e *PermanentError) Error() string {
return e.err.Error()
}
func NewScheduler(ctx context.Context) *Scheduler {
crontab := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
crontab.Start()
@@ -84,14 +96,24 @@ func (s *Scheduler) StopJob(jobID string) error {
func (s *Scheduler) StartJobEvery(duration time.Duration, job func() error) string {
ctx, cancel := context.WithCancel(context.Background())
j := cron.FuncJob(func() {
if err := job(); err != nil {
log.Debug().Msg("job returned an error")
cancel()
jobFn := cron.FuncJob(func() {
err := job()
if err == nil {
return
}
var permErr *PermanentError
if errors.As(err, &permErr) {
log.Error().Err(permErr).Msg("job returned a permanent error, it will be stopped")
cancel()
return
}
log.Error().Err(err).Msg("job returned an error, it will be rescheduled")
})
entryID := s.crontab.Schedule(cron.Every(duration), j)
entryID := s.crontab.Schedule(cron.Every(duration), jobFn)
s.mu.Lock()
s.activeJobs[entryID] = cancel

View File

@@ -49,7 +49,7 @@ func Test_JobCanBeStopped(t *testing.T) {
assert.False(t, workDone, "job shouldn't had a chance to run")
}
func Test_JobShouldStop_UponError(t *testing.T) {
func Test_JobShouldStop_UponPermError(t *testing.T) {
s := NewScheduler(context.Background())
defer s.Shutdown()
@@ -58,7 +58,7 @@ func Test_JobShouldStop_UponError(t *testing.T) {
s.StartJobEvery(jobInterval, func() error {
acc++
close(ch)
return fmt.Errorf("failed")
return NewPermanentError(fmt.Errorf("failed"))
})
<-time.After(3 * jobInterval)
@@ -66,6 +66,28 @@ func Test_JobShouldStop_UponError(t *testing.T) {
assert.Equal(t, 1, acc, "job stop after the first run because it returns an error")
}
func Test_JobShouldNotStop_UponError(t *testing.T) {
s := NewScheduler(context.Background())
defer s.Shutdown()
var acc int
ch := make(chan struct{})
s.StartJobEvery(jobInterval, func() error {
acc++
if acc == 2 {
close(ch)
return NewPermanentError(fmt.Errorf("failed"))
}
return errors.New("non-permanent error")
})
<-time.After(3 * jobInterval)
<-ch
assert.Equal(t, 2, acc)
}
func Test_CanTerminateAllJobs_ByShuttingDownScheduler(t *testing.T) {
s := NewScheduler(context.Background())

View File

@@ -1,13 +1,17 @@
package deployments
import (
"crypto/tls"
"fmt"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/agent"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/pkg/errors"
@@ -29,7 +33,9 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
log.Debug().Int("stack_id", int(stackID)).Msg("redeploying stack")
stack, err := datastore.Stack().Read(stackID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(errors.WithMessagef(err, "failed to get the stack %v", stackID))
} else if err != nil {
return errors.WithMessagef(err, "failed to get the stack %v", stackID)
}
@@ -38,7 +44,15 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
}
endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(
errors.WithMessagef(err,
"failed to find the environment %v associated to the stack %v",
stack.EndpointID,
stack.ID,
),
)
} else if err != nil {
return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID)
}
@@ -59,6 +73,10 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return &StackAuthorMissingErr{int(stack.ID), author}
}
if !isEnvironmentOnline(endpoint) {
return nil
}
var gitCommitChangedOrForceUpdate bool
if !stack.FromAppTemplate {
updated, newHash, err := update.UpdateGitObject(gitService, fmt.Sprintf("stack:%d", stackID), stack.GitConfig, false, false, stack.ProjectPath)
@@ -78,7 +96,9 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
}
registries, err := getUserRegistries(datastore, user, endpoint.ID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(err)
} else if err != nil {
return err
}
@@ -116,6 +136,8 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type)
}
stack.Status = portainer.StackStatusActive
if err := datastore.Stack().Update(stack.ID, stack); err != nil {
return errors.WithMessagef(err, "failed to update the stack %v", stack.ID)
}
@@ -147,3 +169,17 @@ func getUserRegistries(datastore dataservices.DataStore, user *portainer.User, e
return filteredRegistries, nil
}
func isEnvironmentOnline(endpoint *portainer.Endpoint) bool {
var err error
var tlsConfig *tls.Config
if endpoint.TLSConfig.TLS {
tlsConfig, err = crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
if err != nil {
return false
}
}
_, _, err = agent.GetAgentVersionAndPlatform(endpoint.URL, tlsConfig)
return err == nil
}

View File

@@ -1,18 +1,78 @@
package deployments
import (
"context"
"crypto/tls"
"errors"
"net/http"
"strconv"
"strings"
"testing"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const localhostCert = `-----BEGIN CERTIFICATE-----
MIIEOjCCAiKgAwIBAgIRALg8rJET2/9LjKSxHj0dQhYwDQYJKoZIhvcNAQELBQAw
FzEVMBMGA1UEAxMMUG9ydGFpbmVyIENBMB4XDTIzMTAxMTE5NDcxMVoXDTI1MDQx
MTE5NTM0MVowFDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAx4nNGiwcCqUCxZyVLIHqvjTy20ZtZDVCedssTv1W5tmz
YqOIYGaW3CqzlRn6vBHu9bMHXef4+XfS0igKBn76MAKn5IcTccIWIal+5jq48pI3
c2FzQ3qNujX2zqZPjAjhJnVeVCP3kJu4wUtuubswLPBVLdktGa6EkL+8nu6o0Phw
6scV6s3gUmQk5/lpH4FIff8M7NAdTOxiFImQ1M0vplKtaEeiCnskpgyI8CbZl7X0
38Pu178W3+LqB7N4iMy2gKnBwjsXzw/+1dfUGkKjYdDBD+kNEKrQ4dwkjkrkQVdt
Z+GN26NvXHoeeyX/MLnVgdLbiIjvsf0DDIhabKqTcwIDAQABo4GDMIGAMA4GA1Ud
DwEB/wQEAwIDuDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0O
BBYEFPCefmK5Szzlfs8FRCa5+kRCIEWuMB8GA1UdIwQYMBaAFKZZ074SR/ajD3zE
gxpLGRvFT3XAMA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggIBABcQ
/WPSUpuQvrcVBsmIlOMz74cDZYuIDls/mAcB/yP3mm+oWlO0qvH/F/BMs1P/bkgj
fByQZq8Zmi6/TEZNlGvW7KGx077VxDKi8jd1jL3gLDPmkFjYuGeIWQusgxBu1y3m
0WoTTqnkoism1mzV/dgNwrm3YQIV4H/fi9EEdQSm0UFRTKSAGBkwS7N2pmNb5yQO
U8glFpyznCv4evDJbs/JUUXKYExgFFhWUd25P7iBRLXg/BFfqdSTiUGUj/Msz0pO
Evqmq78eIiXjyyKSxzve6/mEIeq6AE3AC9zH+fwTd6Mhp+T2P/S/iO4EU19IMR4m
sbNBd6h/3GvRekO1KbqQ42awuMnxvWT0NVclSxiU1lMpAmRmk/w9z7wB3r4n7oh4
iiOTl5VSw1UBkcLDOJw+HB/FU2PdVFfIJKRfjLCZOGrcJX9vEcz7dYGpB5HrdqOc
/8q5j1g6f/pGE+20HITrtz6ChguETzqw5dLNeKeolC6bVH8yEtmpnP2n8VPnT9Di
V+hnONcJ+wd/dkBqabGr7LPG24Kj1F2Zp3CDDvJA94FaEsgaLfSg3JD+43uRCOWM
RuqU8bGuhQRqilR2dSIOrFaW2+MeUHsb24cUn/pkHqKpSg+RBEnf6QfGDlIgqYEl
19f/HFVBc/a8lM/D81lMyDbjQ9zH4LDYj4ipBbkL
-----END CERTIFICATE-----`
const localhostKey = `-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAx4nNGiwcCqUCxZyVLIHqvjTy20ZtZDVCedssTv1W5tmzYqOI
YGaW3CqzlRn6vBHu9bMHXef4+XfS0igKBn76MAKn5IcTccIWIal+5jq48pI3c2Fz
Q3qNujX2zqZPjAjhJnVeVCP3kJu4wUtuubswLPBVLdktGa6EkL+8nu6o0Phw6scV
6s3gUmQk5/lpH4FIff8M7NAdTOxiFImQ1M0vplKtaEeiCnskpgyI8CbZl7X038Pu
178W3+LqB7N4iMy2gKnBwjsXzw/+1dfUGkKjYdDBD+kNEKrQ4dwkjkrkQVdtZ+GN
26NvXHoeeyX/MLnVgdLbiIjvsf0DDIhabKqTcwIDAQABAoIBAQCqSP6BPG195A52
iEeCISksw9ERsou+fflKNvIcQvV7swP0xOyooERUhhiVwQMKpx9QDUXXLRV8CHch
JExR+OEYQdv4GhJM/b6XYafLYQfe80thKyQLzTXQWSdUeffe4OEMShODKOKoRUyp
oO9Qj9/wKfX3V6S2iwnU4dxdofztv+YP9rYQyjnhKbv/9OfeCp2Pb9eFKKRsA+QQ
xneDz1+wr8ToTuiTn8HBPNSeSAKvhzXuzyluI7VAetRloNgCtumrA9kpVbW2cDgE
Gk0q3RY125ejFELQO/cOJFuBsqoJlvPxzg8/vHyfyF9hFMqbqvcUw2e1eqHpnJd5
dP4+ZGYZAoGBAOOFuPXMLBts0rN9mfNbVfx36H+aOCL77SafZvWm0D+rH69QN3/q
/ZSWQEjwH5Tzn1e+NVcl/Um2vL/dIyEGBklXQ7yAyJo25gpEOD/rt1U94HKzMOwy
yKtsKghRAOx0piie7ORS6MGbEOQxU3/1Eg1uvd0qoSnALqJ/le75QpFXAoGBAOCD
aZQTszzDddr1cFPzLyqjIGJWfPcDYSONXVcCeQmhvC7mkfw9SWdIfku7JbdNgFYq
ZAAU0klsLX0lEe8f4A12FnHNylKoxmTWdE3wWPptejdA1KUgzt/2kNljgOMFuY0Q
rlCEW/Jabrg5aFMwVVG8bHLZR0xalfniDvXLvnFFAoGACdztJLKiIto31BIYz2Th
OF2WVZnA3ztej3MPioydsHThnb7zePcd4QgWZ1MJe3KIMMyNEWcTMNPcINEcSb0y
HpHK3OwURiMlG8LTUWoNe4OALFi6QTL+YfgBZnTkflucLFyfVlKFxobLV6kPvpdI
Hg7z6heD/wRWwTKYtFBX42cCgYBIeoQJ9rYlRqB0eEm0AEzYweLBfFRJVgD0/j0E
ytqSPnFG3s6AFLTur9t9zUPmwhFNP9Aaqp4cb9zbiq0YejzVe6rRQHMxbiTmBslz
I8VFyzPqRHahfE7sxGeMlm/UWlPFc34ipigcvA8EUBwaxv60LVUBWp2Gy7OhANZ9
iTHI1QKBgQCdHFj9dnbpaEHA426CoaPsyj5cv2nBLRf8p1cs71sq+qQOGlGJfajm
L9x22ol5c5rToZa1qKSnSdSDCud298MyRujMUy2UcUKHeNs3MK9AT41sDv266I7b
vJUUCFYm8+9p6gTVOcoMit+eGSwa81PCPEs1TnU1PV/PaDFeUhn/mg==
-----END RSA PRIVATE KEY-----`
type noopDeployer struct{}
// without unpacker
@@ -54,6 +114,42 @@ func (s *noopDeployer) StopRemoteSwarmStack(stack *portainer.Stack, endpoint *po
return nil
}
func agentServer(t *testing.T) string {
h := http.NewServeMux()
h.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(portainer.PortainerAgentHeader, "v2.19.0")
w.Header().Set(portainer.HTTPResponseAgentPlatform, strconv.Itoa(int(portainer.AgentPlatformDocker)))
response.Empty(w)
})
cert, err := tls.X509KeyPair([]byte(localhostCert), []byte(localhostKey))
require.NoError(t, err)
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
}
l, err := tls.Listen("tcp", "127.0.0.1:0", tlsConfig)
require.NoError(t, err)
s := &http.Server{
Handler: h,
}
go func() {
err := s.Serve(l)
require.ErrorIs(t, err, http.ErrServerClosed)
}()
t.Cleanup(func() {
s.Shutdown(context.Background())
})
return "http://" + l.Addr().String()
}
func Test_redeployWhenChanged_FailsWhenCannotFindStack(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
@@ -114,7 +210,12 @@ func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
assert.NoError(t, err, "error creating an admin")
err = store.Endpoint().Create(&portainer.Endpoint{
ID: 0,
ID: 0,
URL: agentServer(t),
TLSConfig: portainer.TLSConfiguration{
TLS: true,
TLSSkipVerify: true,
},
})
assert.NoError(t, err, "error creating environment")

View File

@@ -87,7 +87,7 @@
--orange-1: #e86925;
--BE-only: var(--ui-warning-7);
--BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
@@ -259,8 +259,7 @@
/* Dark Theme */
[theme='dark'] {
--BE-only: var(--ui-blue-8);
--bg-BE-only: rgba(225, 223, 223, 0.08);
--BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
@@ -434,6 +433,7 @@
/* High Contrast Theme */
[theme='highcontrast'] {
--BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
--text-log-viewer-color-json-yellow: var(--text-log-viewer-color);

View File

@@ -7,10 +7,8 @@ angular.module('portainer.docker').factory('ConfigHelper', [
return {
Id: config.ConfigID,
Name: config.ConfigName,
FileName: config.File.Name,
Uid: config.File.UID,
Gid: config.File.GID,
Mode: config.File.Mode,
...(config.File ? { FileName: config.File.Name, Uid: config.File.UID, Gid: config.File.GID, Mode: config.File.Mode } : {}),
credSpec: !!config.Runtime,
};
}
return {};
@@ -20,12 +18,15 @@ angular.module('portainer.docker').factory('ConfigHelper', [
return {
ConfigID: config.Id,
ConfigName: config.Name,
File: {
Name: config.FileName || config.Name,
UID: config.Uid || '0',
GID: config.Gid || '0',
Mode: config.Mode || 292,
},
File: config.credSpec
? null
: {
Name: config.FileName || config.Name,
UID: config.Uid || '0',
GID: config.Gid || '0',
Mode: config.Mode || 292,
},
Runtime: config.credSpec ? {} : null,
};
}
return {};

View File

@@ -66,7 +66,6 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
}
const params = {
token: LocalStorage.getJWT(),
endpointId: $state.params.endpointId,
id: attachId,
};
@@ -107,7 +106,6 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
ContainerService.createExec(execConfig)
.then(function success(data) {
const params = {
token: LocalStorage.getJWT(),
endpointId: $state.params.endpointId,
id: data.Id,
};
@@ -166,6 +164,9 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
if ($transition$.params().nodeName) {
url += '&nodeName=' + $transition$.params().nodeName;
}
url += '&token=' + LocalStorage.getJWT();
if (url.indexOf('https') > -1) {
url = url.replace('https://', 'wss://');
} else {

View File

@@ -4,7 +4,7 @@
<rd-widget-body classes="no-padding">
<div class="form-inline" style="padding: 10px" authorization="DockerServiceUpdate">
Add a config:
<select class="form-control !h-[30px] !text-[13px]" ng-options="config.Name for config in configs | orderBy: 'Name'" ng-model="newConfig">
<select class="form-control !h-[30px] !text-[13px]" ng-options="config.Name for config in filterConfigs(configs) | orderBy: 'Name'" ng-model="newConfig">
<option selected disabled hidden value="">Select a config</option>
</select>
<a class="btn btn-default btn-sm" ng-click="addConfig(service, newConfig)"> <pr-icon icon="'plus'"></pr-icon> add config </a>
@@ -22,10 +22,10 @@
</thead>
<tbody>
<tr ng-repeat="config in service.ServiceConfigs">
<td
><a ui-sref="docker.configs.config({id: config.Id})">{{ config.Name }}</a></td
>
<td>
<a ui-sref="docker.configs.config({id: config.Id})">{{ config.Name }}</a>
</td>
<td ng-if="!config.credSpec">
<input
class="form-control"
ng-model="config.FileName"
@@ -33,11 +33,13 @@
placeholder="e.g. /path/in/container"
required
disable-authorization="DockerServiceUpdate"
ng-disabled="config.credSpec"
/>
</td>
<td>{{ config.Uid }}</td>
<td>{{ config.Gid }}</td>
<td>{{ config.Mode }}</td>
<td ng-if="!config.credSpec">{{ config.Uid }}</td>
<td ng-if="!config.credSpec">{{ config.Gid }}</td>
<td ng-if="!config.credSpec">{{ config.Mode }}</td>
<td ng-if="config.credSpec" colspan="4">Credential Spec</td>
<td authorization="DockerServiceUpdate">
<button class="btn btn-dangerlight pull-right" type="button" ng-click="removeConfig(service, $index)" ng-disabled="isUpdating">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>

View File

@@ -91,6 +91,7 @@ angular.module('portainer.docker').controller('ServiceController', [
endpoint
) {
$scope.resourceType = ResourceControlType.Service;
$scope.WebhookExists = false;
$scope.onUpdateResourceControlSuccess = function () {
$state.reload();
@@ -462,6 +463,27 @@ angular.module('portainer.docker').controller('ServiceController', [
config.TaskTemplate.ContainerSpec.Secrets = service.ServiceSecrets ? service.ServiceSecrets.map(SecretHelper.secretConfig) : [];
config.TaskTemplate.ContainerSpec.Configs = service.ServiceConfigs ? service.ServiceConfigs.map(ConfigHelper.configConfig) : [];
// support removal and (future) editing of credential specs
const credSpec = service.ServiceConfigs.find((config) => config.credSpec);
const credSpecId = credSpec ? credSpec.Id : '';
const oldCredSpecId =
(config.TaskTemplate.ContainerSpec.Privileges &&
config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec &&
config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec.Config) ||
'';
if (oldCredSpecId && !credSpecId) {
delete config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec;
} else if (oldCredSpecId !== credSpec) {
config.TaskTemplate.ContainerSpec.Privileges = {
...(config.TaskTemplate.ContainerSpec.Privileges || {}),
CredentialSpec: {
...((config.TaskTemplate.ContainerSpec.Privileges && config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec) || {}),
Config: credSpec,
},
};
}
config.TaskTemplate.ContainerSpec.Hosts = service.Hosts ? ServiceHelper.translateHostnameIPToHostsEntries(service.Hosts) : [];
if (service.Mode === 'replicated') {
@@ -582,8 +604,7 @@ angular.module('portainer.docker').controller('ServiceController', [
}
$scope.updateService = function updateService(service) {
let config = {};
service, (config = buildChanges(service));
const config = buildChanges(service);
ServiceService.update(service, config).then(
function (data) {
if (data.message && data.message.match(/^rpc error:/)) {
@@ -735,7 +756,6 @@ angular.module('portainer.docker').controller('ServiceController', [
$scope.isAdmin = Authentication.isAdmin();
$scope.availableNetworks = data.availableNetworks;
$scope.swarmNetworks = _.filter($scope.availableNetworks, (network) => network.Scope === 'swarm');
$scope.WebhookExists = false;
const serviceNetworks = _.uniqBy(_.concat($scope.service.Model.Spec.Networks || [], $scope.service.Model.Spec.TaskTemplate.Networks || []), 'Target');
const networks = _.filter(
@@ -832,6 +852,11 @@ angular.module('portainer.docker').controller('ServiceController', [
return networks.filter((network) => !network.Ingress && (network.Id === current.Id || $scope.service.Networks.every((serviceNetwork) => network.Id !== serviceNetwork.Id)));
}
$scope.filterConfigs = filterConfigs;
function filterConfigs(configs) {
return configs.filter((config) => $scope.service.ServiceConfigs.every((serviceConfig) => config.Id !== serviceConfig.Id));
}
function updateServiceArray(service, name) {
previousServiceValues.push(name);
service.hasChanges = true;

View File

@@ -1302,16 +1302,18 @@
</div>
<!-- kubernetes services options -->
<kube-services-form
on-change="(ctrl.onServicesChange)"
values="ctrl.formValues.Services"
load-balancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
app-name="ctrl.formValues.Name"
selector="ctrl.formValues.Selector"
validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}"
is-edit-mode="ctrl.state.isEdit"
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
></kube-services-form>
<div ng-if="ctrl.formValues.ResourcePool">
<kube-services-form
on-change="(ctrl.onServicesChange)"
values="ctrl.formValues.Services"
load-balancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
app-name="ctrl.formValues.Name"
selector="ctrl.formValues.Selector"
validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}"
is-edit-mode="ctrl.state.isEdit"
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
></kube-services-form>
</div>
<!-- kubernetes services options -->
<!-- summary -->
@@ -1353,15 +1355,17 @@
</div>
</div>
<!-- kubernetes services options -->
<kube-services-form
on-change="(ctrl.onServicesChange)"
values="ctrl.formValues.Services"
app-name="ctrl.formValues.Name"
selector="ctrl.formValues.Selector"
validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}"
is-edit-mode="ctrl.state.isEdit"
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
></kube-services-form>
<div ng-if="ctrl.formValues.ResourcePool">
<kube-services-form
on-change="(ctrl.onServicesChange)"
values="ctrl.formValues.Services"
app-name="ctrl.formValues.Name"
selector="ctrl.formValues.Selector"
validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}"
is-edit-mode="ctrl.state.isEdit"
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
></kube-services-form>
</div>
<!-- kubernetes services options -->
</div>
@@ -1376,7 +1380,7 @@
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.imageValidityIsValid() || ctrl.hasPortErrors()"
ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.imageValidityIsValid() || ctrl.hasPortErrors() || !ctrl.formValues.ResourcePool"
ng-click="ctrl.deployApplication()"
button-spinner="ctrl.state.actionInProgress"
data-cy="k8sAppCreate-deployButton"

View File

@@ -87,6 +87,7 @@
checked="ctrl.formValues.RestrictStandardUserIngressW"
name="'restrictStandardUserIngressW'"
label="'Only allow admins to deploy ingresses'"
feature-id="ctrl.limitedFeatureIngressDeploy"
tooltip="'Enforces only allowing admins to deploy ingresses (and disallows standard users from doing so).'"
on-change="(ctrl.onToggleRestrictStandardUserIngressW)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"

View File

@@ -12,18 +12,33 @@ import { reactModule } from './react';
import { sidebarModule } from './react/views/sidebar';
import environmentsModule from './environments';
import { helpersModule } from './helpers';
import { AXIOS_UNAUTHENTICATED } from './services/axios';
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh();
function handleUnauthenticated(data, performReload) {
if (!_.includes(data.config.url, '/v2/') && !_.includes(data.config.url, '/api/v4/') && isTransitionRequiresAuthentication($state.transition)) {
$state.go('portainer.logout', { error: 'Your session has expired' });
if (performReload) {
window.location.reload();
}
}
}
// The unauthenticated event is broadcasted by the jwtInterceptor when
// hitting a 401. We're using this instead of the usual combination of
// authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector
// to have more controls on which URL should trigger the unauthenticated state.
$rootScope.$on('unauthenticated', function (event, data) {
if (!_.includes(data.config.url, '/v2/') && !_.includes(data.config.url, '/api/v4/') && isTransitionRequiresAuthentication($state.transition)) {
$state.go('portainer.logout', { error: 'Your session has expired' });
window.location.reload();
}
handleUnauthenticated(data, true);
});
// the AXIOS_UNAUTHENTICATED event is emitted by axios when a request returns with a 401 code
// the event contains the entire AxiosError in detail.err
window.addEventListener(AXIOS_UNAUTHENTICATED, (event) => {
const data = event.detail.err;
handleUnauthenticated(data);
});
return await Authentication.init();
@@ -154,7 +169,6 @@ angular
url: '/logout',
params: {
error: '',
performApiLogout: false,
},
views: {
'content@': {

View File

@@ -1,5 +1,5 @@
<a class="vertical-center be-indicator ml-5" href="{{ $ctrl.url }}" target="_blank" rel="noopener" ng-if="$ctrl.limitedToBE">
<ng-transclude></ng-transclude>
<pr-icon icon="'briefcase'" class-name="'space-right be-indicator-icon'"></pr-icon>
<span class="be-indicator-label">Business Edition Feature</span>
<span class="be-indicator-label">Business Feature</span>
</a>

View File

@@ -6,10 +6,11 @@ import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.ser
class PorAccessManagementController {
/* @ngInject */
constructor($scope, Notifications, AccessService, RoleService) {
Object.assign(this, { $scope, Notifications, AccessService, RoleService });
constructor($scope, $state, Notifications, AccessService, RoleService) {
Object.assign(this, { $scope, $state, Notifications, AccessService, RoleService });
this.limitedToBE = false;
this.$state = $state;
this.unauthorizeAccess = this.unauthorizeAccess.bind(this);
this.updateAction = this.updateAction.bind(this);
@@ -75,7 +76,7 @@ class PorAccessManagementController {
}
if (this.isRoleLimitedToBE(role)) {
return `${role.Name} (Business Edition Feature)`;
return `${role.Name} (Business Feature)`;
}
return `${role.Name} (Default)`;
@@ -105,6 +106,7 @@ class PorAccessManagementController {
this.availableUsersAndTeams = _.orderBy(data.availableUsersAndTeams, 'Name', 'asc');
this.authorizedUsersAndTeams = data.authorizedUsersAndTeams;
} catch (err) {
this.$state.go('portainer.home');
this.availableUsersAndTeams = [];
this.authorizedUsersAndTeams = [];
this.Notifications.error('Failure', err, 'Unable to retrieve accesses');

View File

@@ -169,68 +169,46 @@
<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 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">
{{ $ctrl.state.provider == 'microsoft' ? 'Application ID' : 'Client ID' }}
<portainer-tooltip message="'Public identifier of the OAuth application'"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<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">
{{ $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"
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="col-sm-12 form-section-title">OAuth Configuration</div>
<div class="form-group">
<label for="oauth_client_id" class="col-sm-3 col-lg-2 control-label text-left">
{{ $ctrl.state.provider == 'microsoft' ? 'Application ID' : 'Client ID' }}
<portainer-tooltip message="'Public identifier of the OAuth application'"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<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">
{{ $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"
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_authorization_uri" class="col-sm-3 col-lg-2 control-label text-left">
Authorization URL
@@ -363,26 +341,100 @@
/>
</div>
</div>
<save-auth-settings-button
on-save-settings="($ctrl.onSaveSettings)"
save-button-state="($ctrl.saveButtonState)"
save-button-disabled="!$ctrl.isOAuthTeamMembershipFormValid() || oauthSettingsForm.$invalid"
limited-feature-id="$ctrl.limitedFeature"
limited-feature-class="$ctrl.limitedFeatureClass"
class-name="'oauth-save-settings-button'"
></save-auth-settings-button>
</div>
<div class="form-group" ng-if="$ctrl.state.provider != 'custom'">
<div class="col-sm-12">
<a class="small interactive vertical-center" ng-if="!$ctrl.state.overrideConfiguration" ng-click="$ctrl.state.overrideConfiguration = true;">
<pr-icon icon="'wrench'"></pr-icon>
Override default configuration
</a>
<a class="small interactive vertical-center" ng-if="$ctrl.state.overrideConfiguration" ng-click="$ctrl.useDefaultProviderConfiguration($ctrl.state.provider)">
<pr-icon icon="'settings'"></pr-icon>
Use default configuration
</a>
<div ng-if="$ctrl.state.provider != 'custom'" class="limited-be be-indicator-container">
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
<div class="limited-be-content">
<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 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">
{{ $ctrl.state.provider == 'microsoft' ? 'Application ID' : 'Client ID' }}
<portainer-tooltip message="'Public identifier of the OAuth application'"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<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">
{{ $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"
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">
<div class="col-sm-12">
<a class="small interactive vertical-center" ng-if="!$ctrl.state.overrideConfiguration" ng-click="$ctrl.state.overrideConfiguration = true;">
<pr-icon icon="'wrench'"></pr-icon>
Override default configuration
</a>
<a class="small interactive vertical-center" ng-if="$ctrl.state.overrideConfiguration" ng-click="$ctrl.useDefaultProviderConfiguration($ctrl.state.provider)">
<pr-icon icon="'settings'"></pr-icon>
Use default configuration
</a>
</div>
</div>
<save-auth-settings-button
on-save-settings="($ctrl.onSaveSettings)"
save-button-state="($ctrl.saveButtonState)"
save-button-disabled="!$ctrl.isOAuthTeamMembershipFormValid() || oauthSettingsForm.$invalid"
limited-feature-id="$ctrl.limitedFeature"
limited-feature-class="$ctrl.limitedFeatureClass"
class-name="'oauth-save-settings-button'"
></save-auth-settings-button>
</div>
</div>
<save-auth-settings-button
on-save-settings="($ctrl.onSaveSettings)"
save-button-state="($ctrl.saveButtonState)"
save-button-disabled="!$ctrl.isOAuthTeamMembershipFormValid() || oauthSettingsForm.$invalid"
limited-feature-id="$ctrl.limitedFeature"
limited-feature-class="$ctrl.limitedFeatureClass"
class-name="'oauth-save-settings-button'"
></save-auth-settings-button>
</ng-form>

View File

@@ -1,24 +1,26 @@
<div class="col-sm-12" style="margin-bottom: 0px">
<rd-widget>
<rd-widget-header icon="user-x">
<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>
<div class="be-indicator-container limited-be">
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
<div class="limited-be-content">
<rd-widget>
<rd-widget-header icon="user-x">
<header-title> Effective access viewer </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>
<por-select ng-if="$ctrl.users.length > 0" value="$ctrl.selectedUserId" options="$ctrl.users" on-change="($ctrl.onUserSelect)" placeholder="'Select a user'">
</por-select>
</div>
</div>
<access-viewer-datatable table-key="access_viewer" dataset="$ctrl.userRoles" order-by="EndpointName" is-admin="$ctrl.isAdmin"> </access-viewer-datatable>
</form>
</rd-widget-body>
</rd-widget>
<por-select ng-if="$ctrl.users.length > 0" value="$ctrl.selectedUserId" options="$ctrl.users" on-change="($ctrl.onUserSelect)" placeholder="'Select a user'">
</por-select>
</div>
</div>
<access-viewer-datatable table-key="access_viewer" dataset="$ctrl.userRoles" order-by="EndpointName" is-admin="$ctrl.isAdmin"> </access-viewer-datatable>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>

View File

@@ -71,7 +71,6 @@ export const ngModule = angular
'message',
'buttonText',
'className',
'icon',
'buttonClassName',
])
)
@@ -82,7 +81,7 @@ export const ngModule = angular
.component(
'portainerTooltip',
r2a(Tooltip, ['message', 'position', 'className', 'setHtmlMessage'])
r2a(Tooltip, ['message', 'position', 'className', 'setHtmlMessage', 'size'])
)
.component('badge', r2a(Badge, ['type', 'className']))
.component('fileUploadField', fileUploadField)

View File

@@ -54,8 +54,8 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
return Users.remove({ id: id }).$promise;
};
service.updateUser = function (id, { password, role, username }) {
return Users.update({ id }, { password, role, username }).$promise;
service.updateUser = function (id, { newPassword, role, username }) {
return Users.update({ id }, { newPassword, role, username }).$promise;
};
service.updateUserPassword = function (id, currentPassword, newPassword) {

View File

@@ -40,8 +40,8 @@ angular.module('portainer.app').factory('Authentication', [
}
}
async function logoutAsync(performApiLogout) {
if (performApiLogout) {
async function logoutAsync() {
if (isAuthenticated()) {
await Auth.logout().$promise;
}
@@ -53,8 +53,8 @@ angular.module('portainer.app').factory('Authentication', [
tryAutoLoginExtension();
}
function logout(performApiLogout) {
return $async(logoutAsync, performApiLogout);
function logout() {
return $async(logoutAsync);
}
function init() {

View File

@@ -49,6 +49,8 @@ export function agentInterceptor(config: AxiosRequestConfig) {
axios.interceptors.request.use(agentInterceptor);
export const AXIOS_UNAUTHENTICATED = '__axios__unauthenticated__';
/**
* Parses an Axios error and returns a PortainerError.
* @param err The original error.
@@ -72,6 +74,16 @@ export function parseAxiosError(
} else {
resultMsg = msg || details;
}
// dispatch an event for unauthorized errors that AngularJS can catch
if (err.response?.status === 401) {
dispatchEvent(
new CustomEvent(AXIOS_UNAUTHENTICATED, {
detail: {
err,
},
})
);
}
}
return new PortainerError(resultMsg, resultErr);

View File

@@ -1,9 +1,6 @@
<ng-form class="ad-settings" limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-class="limited-be">
<div class="overlay">
<div class="limited-be-link vertical-center"
><be-feature-indicator feature="$ctrl.limitedFeatureId"></be-feature-indicator
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
></div>
<div class="be-indicator-container">
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeatureId"></be-feature-indicator></div>
<div class="limited-be-content">
<auto-user-provision-toggle ng-model="$ctrl.settings.AutoCreateUsers">
<field-description>

View File

@@ -1,131 +1,127 @@
<div class="col-sm-12 form-section-title ldap-custom-admin-group-title" ng-style="$ctrl.isLimitedFeatureSelfContained && { 'padding-bottom': '15px' }">
<div class="col-sm-12 form-section-title flex items-center" style="float: initial">
Auto-populate team admins <be-feature-indicator feature="$ctrl.limitedFeatureId" class="space-left" ng-if="$ctrl.isLimitedFeatureSelfContained"></be-feature-indicator>
</div>
<rd-widget ng-repeat="config in $ctrl.settings.AdminGroupSearchSettings | limitTo: (1 - $ctrl.settings.AdminGroupSearchSettings)">
<rd-widget>
<rd-widget-body>
<div class="form-group mb-3" ng-if="$index > 0">
<span class="col-sm-12 text-muted small"> Extra search configuration </span>
</div>
<div class="form-group">
<label for="ldap_admin_group_basedn_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Base DN
<portainer-tooltip message="'The distinguished name of the element from which the LDAP server will search for groups.'"></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input
type="text"
class="form-control"
id="ldap_admin_group_basedn_{{ $index }}"
ng-model="config.GroupBaseDN"
placeholder="dc=ldap,dc=domain,dc=tld"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
limited-feature-tabindex="-1"
/>
<div class="w-full pt-3" ng-repeat="config in $ctrl.settings.AdminGroupSearchSettings | limitTo: (1 - $ctrl.settings.AdminGroupSearchSettings)">
<div class="form-group mb-3" ng-if="$index > 0">
<span class="col-sm-12 text-muted small"> Extra search configuration </span>
</div>
<label for="ldap_admin_group_att_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Membership Attribute
<portainer-tooltip message="'LDAP attribute which denotes the group membership.'"></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input
type="text"
class="form-control"
id="ldap_admin_group_att_{{ $index }}"
ng-model="config.GroupAttribute"
placeholder="member"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
limited-feature-tabindex="-1"
/>
<div class="form-group">
<label for="ldap_admin_group_basedn_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Base DN
<portainer-tooltip message="'The distinguished name of the element from which the LDAP server will search for groups.'"></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input
type="text"
class="form-control"
id="ldap_admin_group_basedn_{{ $index }}"
ng-model="config.GroupBaseDN"
placeholder="dc=ldap,dc=domain,dc=tld"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
limited-feature-tabindex="-1"
/>
</div>
<label for="ldap_admin_group_att_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Membership Attribute
<portainer-tooltip message="'LDAP attribute which denotes the group membership.'"></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input
type="text"
class="form-control"
id="ldap_admin_group_att_{{ $index }}"
ng-model="config.GroupAttribute"
placeholder="member"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
limited-feature-tabindex="-1"
/>
</div>
</div>
<div class="form-group">
<label for="ldap_admin_group_filter_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Filter
<portainer-tooltip message="'The LDAP search filter used to select group elements, optional.'"></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-10 vertical-center">
<input
type="text"
class="form-control"
id="ldap_admin_group_filter_{{ $index }}"
ng-model="config.GroupFilter"
placeholder="(objectClass=groupOfNames)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
limited-feature-tabindex="-1"
/>
<button class="btn btn-md btn-danger" type="button" ng-click="$ctrl.onRemoveClick($index)" ng-if="$index > 0">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
</button>
</div>
</div>
</div>
<div class="form-group">
<label for="ldap_admin_group_filter_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
Group Filter
<portainer-tooltip message="'The LDAP search filter used to select group elements, optional.'"></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-10 vertical-center">
<input
type="text"
class="form-control"
id="ldap_admin_group_filter_{{ $index }}"
ng-model="config.GroupFilter"
placeholder="(objectClass=groupOfNames)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
limited-feature-tabindex="-1"
/>
<button class="btn btn-md btn-danger" type="button" ng-click="$ctrl.onRemoveClick($index)" ng-if="$index > 0">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
<div class="form-group mt-3">
<div class="col-sm-12">
<button disabled class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()">
<pr-icon icon="'plus'"></pr-icon>
Add group search configuration
</button>
</div>
<div class="col-sm-12 vertical-center mt-2">
<button
class="btn btm-sm btn-primary !ml-0"
type="button"
ng-click="$ctrl.search()"
limited-feature-dir="{{ $ctrl.limitedFeatureId }}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
limited-feature-tabindex="-1"
>
Fetch Admin Group(s)
</button>
<span ng-if="$ctrl.groups && $ctrl.groups.length === 0" class="vertical-center ml-6">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
No groups found
</span>
</div>
</div>
<div class="form-group">
<div class="col-sm-12 vertical-center">
<label for="admin-auto-populate" class="control-label text-muted !pt-0 text-left" ng-class="{ 'text-muted': !$ctrl.enableAssignAdminGroup }">
Assign admin rights to group(s)
</label>
<label class="switch my-0 ml-7" ng-class="{ 'business limited': $ctrl.isLimitedFeatureSelfContained }">
<input id="admin-auto-populate" ng-disabled="!$ctrl.enableAssignAdminGroup" name="admin-auto-populate" type="checkbox" ng-model="$ctrl.settings.AdminAutoPopulate" />
<span class="slider round before:content-['']"></span>
</label>
</div>
</div>
<div class="form-group" ng-if="$ctrl.settings.AdminAutoPopulate && $ctrl.groups">
<label for="group-access" class="control-label col-sm-2 text-left"> Select Group(s) </label>
<div class="col-sm-8">
<por-select
data-cy="'group-access-selector'"
input-id="'group-access'"
value="$ctrl.selectedAdminGroups"
on-change="($ctrl.onAdminGroupChange)"
options="$ctrl.groups"
placeholder="'Select one or more groups'"
is-multi="true"
>
</por-select>
</div>
</div>
</rd-widget-body>
</rd-widget>
<div class="form-group mt-3">
<div class="col-sm-12">
<button
class="label label-default interactive no-border vertical-center"
ng-click="$ctrl.onAddClick()"
limited-feature-dir="{{ $ctrl.limitedFeatureId }}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
limited-feature-disabled
>
<pr-icon icon="'plus-circle'"></pr-icon>
add group search configuration
</button>
</div>
<div class="col-sm-12 vertical-center mt-2">
<button
class="btn btm-sm btn-primary"
type="button"
ng-click="$ctrl.search()"
limited-feature-dir="{{ $ctrl.limitedFeatureId }}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
limited-feature-tabindex="-1"
>
Fetch Admin Group(s)
</button>
<span ng-if="$ctrl.groups && $ctrl.groups.length === 0" class="vertical-center ml-6">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
No groups found
</span>
</div>
</div>
<div class="form-group">
<div class="col-sm-12 vertical-center">
<label for="admin-auto-populate" class="control-label text-muted !pt-0 text-left" ng-class="{ 'text-muted': !$ctrl.enableAssignAdminGroup }">
Assign admin rights to group(s)
</label>
<label class="switch my-0 ml-7" ng-class="{ 'business limited': $ctrl.isLimitedFeatureSelfContained }">
<input id="admin-auto-populate" ng-disabled="!$ctrl.enableAssignAdminGroup" name="admin-auto-populate" type="checkbox" ng-model="$ctrl.settings.AdminAutoPopulate" />
<span class="slider round before:content-['']"></span>
</label>
</div>
</div>
<div class="form-group" ng-if="$ctrl.settings.AdminAutoPopulate && $ctrl.groups">
<label for="group-access" class="control-label col-sm-2 text-left"> Select Group(s) </label>
<div class="col-sm-8">
<por-select
data-cy="'group-access-selector'"
input-id="'group-access'"
value="$ctrl.selectedAdminGroups"
on-change="($ctrl.onAdminGroupChange)"
options="$ctrl.groups"
placeholder="'Select one or more groups'"
is-multi="true"
>
</por-select>
</div>
</div>

View File

@@ -51,24 +51,19 @@
<div class="form-group" style="margin-top: 10px">
<div class="col-sm-12">
<button class="label label-default interactive vertical-center" style="border: 0" ng-click="$ctrl.onAddClick()">
<pr-icon icon="'plus-circle'"></pr-icon>
add group search configuration
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()">
<pr-icon icon="'plus'"></pr-icon>
Add group search configuration
</button>
</div>
<div class="col-sm-12" style="margin-top: 10px">
<button
class="btn btm-sm btn-primary"
type="button"
ng-click="$ctrl.search()"
limited-feature-dir="{{ $ctrl.limitedFeatureId }}"
limited-feature-tabindex="-1"
limited-feature-disabled
limited-feature-class="limited-be"
>
Display User/Group matching
</button>
<be-feature-indicator feature="$ctrl.limitedFeatureId" class="space-left"></be-feature-indicator>
<be-teaser-button
feature-id="$ctrl.limitedFeatureId"
heading="'Display User/Group matching'"
message="'Show the list of users and groups that match the Portainer search configurations.'"
button-text="'Display User/Group matching'"
button-class-name="'!ml-0'"
></be-teaser-button>
</div>
</div>

View File

@@ -40,24 +40,19 @@
<div class="form-group" style="margin-top: 10px">
<div class="col-sm-12">
<button class="label label-default interactive vertical-center" style="border: 0" ng-click="$ctrl.onAddClick()">
<pr-icon icon="'plus-circle'"></pr-icon>
add user search configuration
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()">
<pr-icon icon="'plus'"></pr-icon>
Add user search configuration
</button>
</div>
<div class="col-sm-12" style="margin-top: 10px">
<button
class="btn btm-sm btn-primary"
type="button"
ng-click="$ctrl.search()"
limited-feature-dir="{{ $ctrl.limitedFeatureId }}"
limited-feature-disabled
limited-feature-class="limited-be"
limited-feature-tabindex="-1"
>
Display Users
</button>
<be-feature-indicator feature="$ctrl.limitedFeatureId" class="space-left"></be-feature-indicator>
<be-teaser-button
feature-id="$ctrl.limitedFeatureId"
heading="'Display Users'"
message="'Allows you to display users from your LDAP server.'"
button-text="'Display Users'"
button-class-name="'!ml-0'"
></be-teaser-button>
</div>
</div>

View File

@@ -1,88 +1,78 @@
<rd-widget>
<rd-widget-body>
<div ng-if="$ctrl.index > 0" style="margin-bottom: 10px">
<span class="text-muted small"> Extra search configuration </span>
<button
class="btn btn-sm btn-danger"
type="button"
ng-click="$ctrl.onRemoveClick($ctrl.index)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'trash-2'"></pr-icon>
</button>
<div class="w-full px-5 pt-3">
<div ng-if="$ctrl.index > 0" style="margin-bottom: 10px">
<span class="text-muted small"> Extra search configuration </span>
<button
class="btn btn-sm btn-danger"
type="button"
ng-click="$ctrl.onRemoveClick($ctrl.index)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'trash-2'"></pr-icon>
</button>
</div>
<ldap-settings-dn-builder
label="Group Search Path (optional)"
suffix="{{ $ctrl.domainSuffix }}"
ng-model="$ctrl.config.GroupBaseDN"
on-change="($ctrl.onChangeBaseDN)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-settings-dn-builder>
<div class="form-group">
<label class="col-sm-4 col-md-2 control-label text-left"> Group Base DN </label>
<div class="col-sm-8 col-md-10">
{{ $ctrl.config.GroupBaseDN }}
</div>
</div>
<ldap-settings-dn-builder
label="Group Search Path (optional)"
suffix="{{ $ctrl.domainSuffix }}"
ng-model="$ctrl.config.GroupBaseDN"
on-change="($ctrl.onChangeBaseDN)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-settings-dn-builder>
<div class="form-group">
<label class="col-sm-4 col-md-2 control-label text-left"> Group Base DN </label>
<div class="col-sm-8 col-md-10">
{{ $ctrl.config.GroupBaseDN }}
<div class="form-group">
<div class="col-sm-12 vertical-center" style="margin-bottom: 5px">
<label class="control-label !pt-0 text-left">Groups</label>
<span class="label label-default interactive vertical-center" style="margin-left: 10px" ng-click="$ctrl.addGroup()">
<pr-icon icon="'plus-circle'"></pr-icon>
add another group
</span>
</div>
<div class="col-sm-12" ng-if="$ctrl.groups.length">
<div class="w-full px-5 pt-3">
<div class="form-group no-margin-last-child" ng-repeat="entry in $ctrl.groups">
<div class="col-sm-4">
<select class="form-control" ng-model="entry.type" ng-change="$ctrl.onGroupsChange()" limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-tabindex="-1">
<option value="ou">OU Name</option>
<option value="cn">Folder Name</option>
</select>
</div>
<div class="col-sm-5">
<input
class="form-control"
ng-model="entry.value"
ng-change="$ctrl.onGroupsChange()"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
</div>
<div class="col-sm-3 text-right">
<button
class="btn btn-md btn-danger"
type="button"
ng-click="$ctrl.removeGroup($index)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-12 vertical-center" style="margin-bottom: 5px">
<label class="control-label !pt-0 text-left">Groups</label>
<span class="label label-default interactive vertical-center" style="margin-left: 10px" ng-click="$ctrl.addGroup()">
<pr-icon icon="'plus-circle'"></pr-icon>
add another group
</span>
</div>
<div class="col-sm-12" ng-if="$ctrl.groups.length">
<rd-widget>
<rd-widget-body>
<div class="form-group no-margin-last-child" ng-repeat="entry in $ctrl.groups">
<div class="col-sm-4">
<select
class="form-control"
ng-model="entry.type"
ng-change="$ctrl.onGroupsChange()"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<option value="ou">OU Name</option>
<option value="cn">Folder Name</option>
</select>
</div>
<div class="col-sm-5">
<input
class="form-control"
ng-model="entry.value"
ng-change="$ctrl.onGroupsChange()"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
</div>
<div class="col-sm-3 text-right">
<button
class="btn btn-md btn-danger"
type="button"
ng-click="$ctrl.removeGroup($index)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
</button>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="form-group no-margin-last-child">
<label class="col-sm-4 col-md-2 control-label text-left"> Group Filter </label>
<div class="col-sm-8 col-md-10">
{{ $ctrl.config.GroupFilter }}
</div>
<div class="form-group no-margin-last-child">
<label class="col-sm-4 col-md-2 control-label text-left"> Group Filter </label>
<div class="col-sm-8 col-md-10">
{{ $ctrl.config.GroupFilter }}
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -13,15 +13,9 @@
<div class="form-group" style="margin-top: 10px">
<div class="col-sm-12">
<button
class="label label-default interactive vertical-center"
style="border: 0"
ng-click="$ctrl.onAddClick()"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'plus-circle'"></pr-icon>
add group search configuration
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()" disabled>
<pr-icon icon="'plus'"></pr-icon>
Add group search configuration
</button>
</div>
<div class="col-sm-12" style="margin-top: 10px">

View File

@@ -18,20 +18,7 @@
</div>
<div class="form-group">
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label flex flex-wrap text-left">
LDAP Server
<button
type="button"
class="label label-default interactive no-border vertical-center"
ng-click="$ctrl.addLDAPUrl()"
limited-feature-dir="{{ $ctrl.limitedFeatureId }}"
limited-feature-disabled
limited-feature-class="limited-be"
>
<pr-icon icon="'plus-circle'"></pr-icon>
Add additional server
</button>
</label>
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label flex flex-wrap text-left"> LDAP Server </label>
<div class="col-sm-9 col-lg-10">
<div class="mb-3 flex" ng-repeat="url in $ctrl.settings.URLs track by $index">
<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" required />
@@ -40,6 +27,15 @@
</button>
</div>
</div>
<div class="col-sm-12">
<be-teaser-button
feature-id="$ctrl.limitedFeatureId"
heading="'Add additional server'"
message="'Allows you to add an additional LDAP server.'"
button-text="'Add additional server'"
button-class-name="'!ml-0'"
></be-teaser-button>
</div>
</div>
<div class="form-group">

View File

@@ -49,7 +49,7 @@ export default class LdapSettingsBaseDnBuilderController {
const [, type, value] = match;
ouValues.push({ type, value });
left = left.replace(regex, '');
match = left.match(/(\w+)=(\w+),?/);
match = left.match(regex);
}
return ouValues;
}

View File

@@ -1,9 +1,6 @@
<ng-form limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-class="limited-be" class="ldap-settings-openldap">
<div class="overlay">
<div class="limited-be-link vertical-center"
><be-feature-indicator feature="$ctrl.limitedFeatureId"></be-feature-indicator
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
></div>
<div class="be-indicator-container">
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeatureId"></be-feature-indicator></div>
<div class="limited-be-content">
<div>
<div class="col-sm-12 form-section-title"> Information </div>

View File

@@ -1,4 +1,4 @@
<div class="col-sm-12 form-section-title" ng-style="$ctrl.isLimitedFeatureSelfContained && { 'padding-bottom': '15px' }">
<div class="col-sm-12 form-section-title flex items-center" style="float: initial">
Test login
<be-feature-indicator
ng-if="$ctrl.showBeIndicatorIfNeeded"
@@ -7,52 +7,57 @@
ng-if="$ctrl.isLimitedFeatureSelfContained"
></be-feature-indicator>
</div>
<div class="form-inline">
<div class="form-group" style="margin: 0">
<label for="ldap_test_username" style="font-size: 0.9em; margin-right: 5px"> Username </label>
<input
type="text"
class="form-control"
id="ldap_test_username"
ng-model="$ctrl.username"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
limited-feature-tabindex="-1"
/>
</div>
<div class="form-group no-margin">
<label for="ldap_test_password"> Password </label>
<input
type="password"
class="form-control"
id="ldap_test_password"
ng-model="$ctrl.password"
autocomplete="new-password"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
limited-feature-tabindex="-1"
/>
</div>
<rd-widget>
<rd-widget-body>
<div class="form-inline">
<div class="form-group" style="margin: 0">
<label for="ldap_test_username" style="font-size: 0.9em; margin-right: 5px"> Username </label>
<input
type="text"
class="form-control"
id="ldap_test_username"
ng-model="$ctrl.username"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
limited-feature-tabindex="-1"
/>
</div>
<div class="form-group !ml-0">
<div class="vertical-center">
<button
type="submit"
class="btn btn-primary"
ng-disabled="$ctrl.state.testStatus === $ctrl.TEST_STATUS.LOADING || !$ctrl.username || !$ctrl.password"
ng-click="$ctrl.testLogin($ctrl.username, $ctrl.password)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
limited-feature-tabindex="-1"
>
<span ng-if="$ctrl.state.testStatus !== $ctrl.TEST_STATUS.LOADING">Test</span>
<span ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.LOADING">Testing...</span>
</button>
<pr-icon icon="'check'" class="icon-success" ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.SUCCESS"></pr-icon>
<pr-icon icon="'x'" class="icon-danger" ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.FAILURE"></pr-icon>
<div class="form-group no-margin">
<label for="ldap_test_password"> Password </label>
<input
type="password"
class="form-control"
id="ldap_test_password"
ng-model="$ctrl.password"
autocomplete="new-password"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
limited-feature-tabindex="-1"
/>
</div>
<div class="form-group !ml-0">
<div class="vertical-center">
<button
type="submit"
class="btn btn-primary"
ng-disabled="$ctrl.state.testStatus === $ctrl.TEST_STATUS.LOADING || !$ctrl.username || !$ctrl.password"
ng-click="$ctrl.testLogin($ctrl.username, $ctrl.password)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
limited-feature-tabindex="-1"
>
<span ng-if="$ctrl.state.testStatus !== $ctrl.TEST_STATUS.LOADING">Test</span>
<span ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.LOADING">Testing...</span>
</button>
<pr-icon icon="'check'" class="icon-success" ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.SUCCESS"></pr-icon>
<pr-icon icon="'x'" class="icon-danger" ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.FAILURE"></pr-icon>
</div>
</div>
</div>
</div>
</div>
</rd-widget-body>
</rd-widget>

View File

@@ -1,101 +1,99 @@
<rd-widget>
<rd-widget-body>
<div ng-if="$ctrl.index > 0" style="margin-bottom: 10px">
<span class="text-muted small"> Extra search configuration </span>
<div class="w-full px-5 pt-3">
<div ng-if="$ctrl.index > 0" style="margin-bottom: 10px">
<span class="text-muted small"> Extra search configuration </span>
<button
ng-if="$ctrl.index > 0"
class="btn btn-sm btn-danger"
type="button"
ng-click="$ctrl.onRemoveClick($ctrl.index)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'trash-2'"></pr-icon>
</button>
</div>
<div class="form-group" ng-if="$ctrl.showUsernameFormat">
<div class="col-sm-4" style="margin-bottom: 5px">
<label class="control-label text-left">Username Format</label>
</div>
<div class="col-sm-8">
<div class="input-group">
<div class="input-group-btn">
<button
class="btn btn-primary"
ng-model="$ctrl.config.UserNameAttribute"
uib-btn-radio="'sAMAccountName'"
style="margin-left: 0px"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>username</button
>
<button
class="btn btn-primary"
ng-model="$ctrl.config.UserNameAttribute"
uib-btn-radio="'userPrincipalName'"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>user@domainname</button
>
</div>
</div>
</div>
</div>
<div class="form-group">
<label class="col-sm-4 control-label text-left"> Root Domain </label>
<div class="col-sm-8">
{{ $ctrl.config.BaseDN }}
</div>
</div>
<ldap-settings-dn-builder
ng-model="$ctrl.config.BaseDN"
label="User Search Path (optional)"
suffix="{{ $ctrl.domainSuffix }}"
on-change="($ctrl.onBaseDNChange)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-settings-dn-builder>
<div class="form-group no-margin-last-child">
<div class="col-sm-12" style="margin-bottom: 5px">
<label class="control-label text-left">Allowed Groups (optional)</label>
<button
ng-if="$ctrl.index > 0"
class="btn btn-sm btn-danger"
type="button"
ng-click="$ctrl.onRemoveClick($ctrl.index)"
class="label label-default interactive vertical-center"
style="margin-left: 10px; border: 0"
ng-click="$ctrl.addGroup()"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'trash-2'"></pr-icon>
<pr-icon icon="'plus-circle'"></pr-icon>
add another group
</button>
</div>
<div class="form-group" ng-if="$ctrl.showUsernameFormat">
<div class="col-sm-4" style="margin-bottom: 5px">
<label class="control-label text-left">Username Format</label>
</div>
<div class="col-sm-8">
<div class="input-group">
<div class="input-group-btn">
<button
class="btn btn-primary"
ng-model="$ctrl.config.UserNameAttribute"
uib-btn-radio="'sAMAccountName'"
style="margin-left: 0px"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>username</button
>
<button
class="btn btn-primary"
ng-model="$ctrl.config.UserNameAttribute"
uib-btn-radio="'userPrincipalName'"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>user@domainname</button
>
</div>
</div>
<div class="col-sm-12">
<div ng-repeat="group in $ctrl.groups track by $index" style="margin-bottom: 10px">
<rd-widget>
<rd-widget-body>
<ldap-settings-group-dn-builder
ng-model="group"
index="$index"
suffix="{{ $ctrl.domainSuffix }}"
on-change="($ctrl.onGroupChange)"
on-remove-click="($ctrl.removeGroup)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-settings-group-dn-builder>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>
<div class="form-group">
<label class="col-sm-4 control-label text-left"> Root Domain </label>
<div class="col-sm-8">
{{ $ctrl.config.BaseDN }}
</div>
<div class="form-group">
<label class="col-sm-4 control-label text-left"> User Filter </label>
<div class="col-sm-8">
{{ $ctrl.config.Filter }}
</div>
<ldap-settings-dn-builder
ng-model="$ctrl.config.BaseDN"
label="User Search Path (optional)"
suffix="{{ $ctrl.domainSuffix }}"
on-change="($ctrl.onBaseDNChange)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-settings-dn-builder>
<div class="form-group no-margin-last-child">
<div class="col-sm-12" style="margin-bottom: 5px">
<label class="control-label text-left">Allowed Groups (optional)</label>
<button
type="button"
class="label label-default interactive vertical-center"
style="margin-left: 10px; border: 0"
ng-click="$ctrl.addGroup()"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'plus-circle'"></pr-icon>
add another group
</button>
</div>
<div class="col-sm-12">
<div ng-repeat="group in $ctrl.groups track by $index" style="margin-bottom: 10px">
<rd-widget>
<rd-widget-body>
<ldap-settings-group-dn-builder
ng-model="group"
index="$index"
suffix="{{ $ctrl.domainSuffix }}"
on-change="($ctrl.onGroupChange)"
on-remove-click="($ctrl.removeGroup)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-settings-group-dn-builder>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>
<div class="form-group">
<label class="col-sm-4 control-label text-left"> User Filter </label>
<div class="col-sm-8">
{{ $ctrl.config.Filter }}
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -22,7 +22,7 @@
limited-feature-tabindex="-1"
>
<pr-icon icon="'plus-circle'"></pr-icon>
add user search configuration
Add user search configuration
</button>
</div>
<div class="col-sm-12" style="margin-top: 10px">

View File

@@ -3,7 +3,7 @@
<div class="col-sm-12">
<button
type="button"
ng-class="[$ctrl.className, 'btn btn-primary btn-sm']"
ng-class="[$ctrl.className, 'btn btn-primary btn-sm !ml-0']"
ng-click="$ctrl.onSaveSettings()"
ng-disabled="$ctrl.saveButtonDisabled || $ctrl.saveButtonState"
button-spinner="$ctrl.saveButtonState"

View File

@@ -6,9 +6,7 @@
<div class="widget-icon space-right">
<pr-icon icon="'history'"></pr-icon>
</div>
Activity Logs
<be-feature-indicator feature="$ctrl.feature"></be-feature-indicator>
</div>
<div class="vertical-center">
<datatable-searchbar on-change="($ctrl.onChangeKeyword)" value="$ctrl.keyword"></datatable-searchbar>

View File

@@ -1,11 +1,8 @@
<page-header title="'User Activity'" breadcrumbs="['Activity Logs']" reload="true"> </page-header>
<div class="be-indicator-container limited-be mx-4">
<div class="overlay">
<div class="limited-be-link vertical-center"
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
></div>
<div>
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
<div class="limited-be-content">
<rd-widget>
<rd-widget-body>
@@ -29,18 +26,7 @@
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>
<div class="be-indicator-container limited-be mx-4">
<div class="overlay">
<div class="limited-be-link vertical-center"
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
></div>
<div class="limited-be-content">
<div class="row">
<div class="row mt-5">
<activity-logs-datatable
logs="$ctrl.state.logs"
keyword="$ctrl.state.keyword"

View File

@@ -6,9 +6,7 @@
<div class="widget-icon space-right">
<pr-icon icon="'history'"></pr-icon>
</div>
Authentication Events
<be-feature-indicator feature="$ctrl.feature"></be-feature-indicator>
</div>
<div class="vertical-center">
<datatable-searchbar on-change="($ctrl.onChangeKeyword)"></datatable-searchbar>

View File

@@ -1,11 +1,8 @@
<page-header title="'User Activity'" breadcrumbs="['User authentication activity']" reload="true"> </page-header>
<div class="be-indicator-container limited-be mx-4">
<div class="overlay">
<div class="limited-be-link vertical-center"
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
></div>
<div>
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
<div class="limited-be-content">
<rd-widget>
<rd-widget-body>
@@ -28,18 +25,7 @@
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>
<div class="be-indicator-container limited-be mx-4">
<div class="overlay">
<div class="limited-be-link vertical-center"
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
></div>
<div class="limited-be-content">
<div class="row">
<div class="row mt-5">
<auth-logs-datatable
logs="$ctrl.state.logs"
keyword="$ctrl.state.keyword"

View File

@@ -25,11 +25,10 @@ class LogoutController {
*/
async logoutAsync() {
const error = this.$transition$.params().error;
const performApiLogout = this.$transition$.params().performApiLogout;
const settings = await this.SettingsService.publicSettings();
try {
await this.Authentication.logout(performApiLogout);
await this.Authentication.logout();
} finally {
this.LocalStorage.storeLogoutReason(error);
if (settings.OAuthLogoutURI && this.Authentication.getUserDetails().ID !== 1) {

View File

@@ -72,7 +72,7 @@ angular.module('portainer.app').controller('UserController', [
if (!confirmed) {
return;
}
UserService.updateUser($scope.user.Id, { password: $scope.formValues.newPassword })
UserService.updateUser($scope.user.Id, { newPassword: $scope.formValues.newPassword })
.then(function success() {
Notifications.success('Success', 'Password successfully updated');

View File

@@ -1,5 +1,6 @@
.be-indicator {
border: solid 1px var(--BE-only);
@apply border border-solid border-gray-6;
@apply text-xs text-gray-6;
border-radius: 15px;
padding: 5px 10px;
font-weight: 400;
@@ -9,11 +10,12 @@
}
.be-indicator .be-indicator-icon {
@apply text-black th-highcontrast:text-white th-dark:text-blue-8;
@apply text-inherit;
}
.be-indicator:hover {
@apply no-underline;
@apply underline;
@apply border-blue-9 text-blue-9;
}
.be-indicator:hover .be-indicator-label {
@@ -21,5 +23,5 @@
}
.be-indicator-container {
border: solid 1px var(--BE-only);
@apply relative;
}

View File

@@ -29,16 +29,14 @@ export function BEFeatureIndicator({
}
return (
<a
className={clsx('be-indicator vertical-center', className)}
className={clsx('be-indicator vertical-center text-xs', className)}
href={url}
target="_blank"
rel="noopener noreferrer"
>
{children}
{showIcon && <Icon icon={Briefcase} className="be-indicator-icon mr-1" />}
<span className="be-indicator-label break-words">
Business Edition Feature
</span>
<span className="be-indicator-label break-words">Business Feature</span>
</a>
);
}

View File

@@ -1,16 +1,18 @@
import clsx from 'clsx';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { Tooltip } from '@@/Tip/Tooltip';
import { BEFeatureIndicator } from '.';
import { BEFeatureIndicator } from './BEFeatureIndicator';
export function BEOverlay({
featureId,
children,
className,
}: {
featureId: FeatureId;
children: React.ReactNode;
className?: string;
}) {
const isLimited = isLimitedToBE(featureId);
if (!isLimited) {
@@ -19,13 +21,10 @@ export function BEOverlay({
return (
<div className="be-indicator-container limited-be">
<div className="overlay">
<div className="limited-be-link vertical-center">
<BEFeatureIndicator featureId={FeatureId.CA_FILE} />
<Tooltip message="This feature is currently limited to Business Edition users only. " />
</div>
<div className="limited-be-content">{children}</div>
<div className="limited-be-link vertical-center">
<BEFeatureIndicator featureId={featureId} />
</div>
<div className={clsx('limited-be-content', className)}>{children}</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import { Briefcase } from 'lucide-react';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
@@ -11,7 +11,6 @@ interface Props {
message: string;
buttonText: string;
className?: string;
icon?: ReactNode;
buttonClassName?: string;
}
@@ -21,7 +20,6 @@ export function BETeaserButton({
message,
buttonText,
className,
icon,
buttonClassName,
}: Props) {
return (
@@ -34,9 +32,9 @@ export function BETeaserButton({
<span>
<Button
className={buttonClassName}
icon={icon}
icon={Briefcase}
type="button"
color="warninglight"
color="default"
size="small"
onClick={() => {}}
disabled

View File

@@ -38,12 +38,12 @@
padding-left: 20px;
}
/* used for BE teaser. Dark theme specs defined by EE-5621 */
/* used for BE teaser */
.box-selector-item.limited.business label,
.box-selector-item.limited.business input:checked + label {
@apply border-warning-7 bg-warning-1 text-black;
@apply th-dark:border-blue-8 th-dark:bg-[color:var(--bg-BE-only)] th-dark:text-white;
@apply th-highcontrast:bg-warning-8 th-highcontrast:bg-opacity-10 th-highcontrast:text-white;
@apply border-gray-6 bg-gray-6 bg-opacity-10;
@apply th-dark:border-gray-6 th-dark:bg-gray-6 th-dark:bg-opacity-10;
@apply th-highcontrast:border-gray-6 th-highcontrast:bg-gray-6 th-highcontrast:bg-opacity-10;
filter: none;
}

View File

@@ -9,7 +9,7 @@ import { getFeatureDetails } from '@@/BEFeatureIndicator/utils';
import styles from './BoxSelectorItem.module.css';
import { BoxSelectorOption, Value } from './types';
import { LimitedToBeIndicator } from './LimitedToBeIndicator';
import { LimitedToBeBoxSelectorIndicator } from './LimitedToBeBoxSelectorIndicator';
import { BoxOption } from './BoxOption';
import { LogoIcon } from './LogoIcon';
@@ -40,8 +40,6 @@ export function BoxSelectorItem<T extends Value>({
option.feature
);
const beIndicatorTooltipId = `box-selector-item-${radioName}-${option.id}-limited`;
const ContentBox = slim ? 'div' : Fragment;
return (
@@ -60,13 +58,14 @@ export function BoxSelectorItem<T extends Value>({
checkIcon={checkIcon}
>
{limitedToBE && (
<LimitedToBeIndicator
tooltipId={beIndicatorTooltipId}
<LimitedToBeBoxSelectorIndicator
url={featureUrl}
// show tooltip only for radio type options because be-only checkbox options can't be selected
showTooltip={type === 'radio'}
/>
)}
<div
className={clsx('flex gap-2', {
className={clsx('flex min-w-[140px] gap-2', {
'opacity-30': limitedToBE,
'h-full flex-col justify-start': !slim,
'slim items-center': slim,

View File

@@ -0,0 +1,36 @@
import { Briefcase } from 'lucide-react';
import { Icon } from '@@/Icon';
import { Tooltip } from '@@/Tip/Tooltip';
interface Props {
url?: string;
showTooltip?: boolean;
}
export function LimitedToBeBoxSelectorIndicator({
url,
showTooltip = true,
}: Props) {
return (
<div className="absolute left-0 top-0 w-full">
<div className="mx-auto flex max-w-fit items-center rounded-b-lg border border-t-0 border-solid border-gray-6 bg-transparent px-3 py-1 text-gray-6">
<a
className="inline-flex items-center text-xs text-gray-6"
href={url}
target="_blank"
rel="noreferrer"
>
<Icon icon={Briefcase} className="!mr-1" />
<span>Business Feature</span>
</a>
{showTooltip && (
<Tooltip
size="sm"
message="Select this option to preview this business feature."
/>
)}
</div>
</div>
);
}

View File

@@ -1,37 +0,0 @@
import { HelpCircle } from 'lucide-react';
import clsx from 'clsx';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
interface Props {
tooltipId: string;
url?: string;
}
export function LimitedToBeIndicator({ tooltipId, url }: Props) {
return (
<div className="absolute left-0 top-0 w-full">
<div className="mx-auto flex max-w-fit items-center gap-1 rounded-b-lg bg-warning-4 py-1 px-3 text-sm th-dark:bg-[color:var(--bg-BE-only)]">
<a
className="text-warning-9 th-dark:text-blue-8"
href={url}
target="_blank"
rel="noreferrer"
>
BE Feature
</a>
<TooltipWithChildren
position="bottom"
className={clsx(tooltipId, 'portainer-tooltip')}
heading="Business Edition feature."
message="This feature is currently limited to Business Edition users only."
>
<HelpCircle
className="ml-1 !text-warning-7 th-dark:!text-blue-8"
aria-hidden="true"
/>
</TooltipWithChildren>
</div>
</div>
);
}

View File

@@ -56,7 +56,6 @@ export function UserMenu() {
to="portainer.logout"
label="Log out"
data-cy="userMenu-logOut"
params={{ performApiLogout: true }}
/>
</MenuList>
</Menu>

View File

@@ -1,14 +1,26 @@
import { HelpCircle } from 'lucide-react';
import { ReactNode, useMemo } from 'react';
import sanitize from 'sanitize-html';
import clsx from 'clsx';
import { TooltipWithChildren, Position } from '../TooltipWithChildren';
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
const sizeClasses: Record<Size, string> = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
};
export interface Props {
position?: Position;
message: ReactNode;
className?: string;
setHtmlMessage?: boolean;
size?: Size;
}
export function Tooltip({
@@ -16,6 +28,7 @@ export function Tooltip({
position = 'bottom',
className,
setHtmlMessage,
size = 'md',
}: Props) {
// allow angular views to set html messages for the tooltip
const htmlMessage = useMemo(() => {
@@ -27,14 +40,14 @@ export function Tooltip({
}, [setHtmlMessage, message]);
return (
<TooltipWithChildren
message={htmlMessage || message}
position={position}
className={className}
>
<span className="inline-flex text-base">
<HelpCircle className="lucide ml-1" aria-hidden="true" />
</span>
</TooltipWithChildren>
<span className={clsx('ml-1 inline-flex items-center', sizeClasses[size])}>
<TooltipWithChildren
message={htmlMessage || message}
position={position}
className={className}
>
<HelpCircle className="lucide" aria-hidden="true" />
</TooltipWithChildren>
</span>
);
}

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