diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 841a8b488..8444cd037 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -11,12 +11,6 @@ import ( "github.com/gorilla/mux" ) -func hideFields(settings *portainer.Settings) { - settings.LDAPSettings.Password = "" - settings.OAuthSettings.ClientSecret = "" - settings.OAuthSettings.KubeSecretKey = nil -} - // Handler is the HTTP handler used to handle settings operations. type Handler struct { *mux.Router @@ -33,12 +27,17 @@ func NewHandler(bouncer security.BouncerService) *Handler { Router: mux.NewRouter(), } - h.Handle("/settings", - bouncer.AdminAccess(httperror.LoggerHandler(h.settingsInspect))).Methods(http.MethodGet) - h.Handle("/settings", - bouncer.AdminAccess(httperror.LoggerHandler(h.settingsUpdate))).Methods(http.MethodPut) - h.Handle("/settings/public", - bouncer.PublicAccess(httperror.LoggerHandler(h.settingsPublic))).Methods(http.MethodGet) + adminRouter := h.NewRoute().Subrouter() + adminRouter.Use(bouncer.AdminAccess) + adminRouter.Handle("/settings", httperror.LoggerHandler(h.settingsUpdate)).Methods(http.MethodPut) + + authenticatedRouter := h.NewRoute().Subrouter() + authenticatedRouter.Use(bouncer.AuthenticatedAccess) + authenticatedRouter.Handle("/settings", httperror.LoggerHandler(h.settingsInspect)).Methods(http.MethodGet) + + publicRouter := h.NewRoute().Subrouter() + publicRouter.Use(bouncer.PublicAccess) + publicRouter.Handle("/settings/public", httperror.LoggerHandler(h.settingsPublic)).Methods(http.MethodGet) return h } diff --git a/api/http/handler/settings/settings_inspect.go b/api/http/handler/settings/settings_inspect.go index ccee2cc5e..2386946a5 100644 --- a/api/http/handler/settings/settings_inspect.go +++ b/api/http/handler/settings/settings_inspect.go @@ -3,27 +3,43 @@ package settings import ( "net/http" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/rbacutils" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/utils" httperror "github.com/portainer/portainer/pkg/libhttp/error" - "github.com/portainer/portainer/pkg/libhttp/response" ) -// @id SettingsInspect +// @id settingsInspect // @summary Retrieve Portainer settings -// @description Retrieve Portainer settings. -// @description **Access policy**: administrator +// @description Retrieve settings. Will returns settings based on the user role. +// @description **Access policy**: public // @tags settings -// @security ApiKeyAuth -// @security jwt // @produce json -// @success 200 {object} portainer.Settings "Success" +// @success 200 {object} settingsInspectResponse "Success" // @failure 500 "Server error" // @router /settings [get] func (handler *Handler) settingsInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.DataStore.Settings().Settings() - if err != nil { - return httperror.InternalServerError("Unable to retrieve the settings from the database", err) - } + var roleBasedResponse interface{} + err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + settings, err := tx.Settings().Settings() + if err != nil { + return httperror.InternalServerError("Unable to retrieve the settings from the database", err) + } - hideFields(settings) - return response.JSON(w, settings) + user, err := security.RetrieveUserFromRequest(r, tx) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user details from request", err) + } + + response := buildResponse(settings) + + role := rbacutils.RoleFromUser(user) + + roleBasedResponse = response.ForRole(role) + + return nil + }) + + return utils.TxResponse(w, roleBasedResponse, err) } diff --git a/api/http/handler/settings/settings_inspect_response_helper.go b/api/http/handler/settings/settings_inspect_response_helper.go new file mode 100644 index 000000000..242e3515a --- /dev/null +++ b/api/http/handler/settings/settings_inspect_response_helper.go @@ -0,0 +1,294 @@ +package settings + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/pkg/featureflags" + "golang.org/x/oauth2" +) + +type settingsInspectResponse struct { + adminResponse +} + +type authenticatedResponse struct { + publicSettingsResponse + + // Deployment options for encouraging git ops workflows + GlobalDeploymentOptions portainer.GlobalDeploymentOptions `json:"GlobalDeploymentOptions"` + // Whether edge compute features are enabled + EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"` + // The expiry of a Kubeconfig + KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"` + + // Helm repository URL, defaults to "https://charts.bitnami.com/bitnami" + HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"` + + IsAMTEnabled bool `json:"isAMTEnabled"` + IsFDOEnabled bool `json:"isFDOEnabled"` +} + +type edgeSettings struct { + // The command list interval for edge agent - used in edge async mode (in seconds) + CommandInterval int `json:"CommandInterval" example:"5"` + // The ping interval for edge agent - used in edge async mode (in seconds) + PingInterval int `json:"PingInterval" example:"5"` + // The snapshot interval for edge agent - used in edge async mode (in seconds) + SnapshotInterval int `json:"SnapshotInterval" example:"5"` +} + +type edgeAdminResponse struct { + authenticatedResponse + + Edge edgeSettings + + // TrustOnFirstConnect makes Portainer accepting edge agent connection by default + TrustOnFirstConnect bool `json:"TrustOnFirstConnect" example:"false"` + // EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone + EnforceEdgeID bool `json:"EnforceEdgeID" example:"false"` + + // EdgePortainerURL is the URL that is exposed to edge agents + EdgePortainerURL string `json:"EdgePortainerUrl"` + + // The default check in interval for edge agent (in seconds) + EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval" example:"5"` +} + +type oauthSettings struct { + ClientID string `json:"ClientID"` + AccessTokenURI string `json:"AccessTokenURI"` + AuthorizationURI string `json:"AuthorizationURI"` + ResourceURI string `json:"ResourceURI"` + RedirectURI string `json:"RedirectURI"` + UserIdentifier string `json:"UserIdentifier"` + Scopes string `json:"Scopes"` + OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"` + DefaultTeamID portainer.TeamID `json:"DefaultTeamID"` + SSO bool `json:"SSO"` + LogoutURI string `json:"LogoutURI"` + AuthStyle oauth2.AuthStyle `json:"AuthStyle"` +} + +type ldapSettings struct { + // Enable this option if the server is configured for Anonymous access. When enabled, ReaderDN and Password will not be used + AnonymousMode bool `json:"AnonymousMode" example:"true" validate:"validate_bool"` + // Account that will be used to search for users + ReaderDN string `json:"ReaderDN" example:"cn=readonly-account,dc=ldap,dc=domain,dc=tld" validate:"required_if=AnonymousMode false"` + // URL or IP address of the LDAP server + URL string `json:"URL" example:"myldap.domain.tld:389" validate:"hostname_port"` + TLSConfig portainer.TLSConfiguration `json:"TLSConfig"` + // Whether LDAP connection should use StartTLS + StartTLS bool `json:"StartTLS" example:"true"` + SearchSettings []portainer.LDAPSearchSettings `json:"SearchSettings"` + GroupSearchSettings []portainer.LDAPGroupSearchSettings `json:"GroupSearchSettings"` + // Automatically provision users and assign them to matching LDAP group names + AutoCreateUsers bool `json:"AutoCreateUsers" example:"true"` +} + +type adminResponse struct { + edgeAdminResponse + // A list of label name & value that will be used to hide containers when querying containers + BlackListedLabels []portainer.Pair `json:"BlackListedLabels"` + + LDAPSettings ldapSettings `json:"LDAPSettings"` + OAuthSettings oauthSettings `json:"OAuthSettings"` + InternalAuthSettings portainer.InternalAuthSettings `json:"InternalAuthSettings"` + OpenAMTConfiguration portainer.OpenAMTConfiguration `json:"openAMTConfiguration"` + FDOConfiguration portainer.FDOConfiguration `json:"fdoConfiguration"` + // The interval in which environment(endpoint) snapshots are created + SnapshotInterval string `json:"SnapshotInterval" example:"5m"` + // URL to the templates that will be displayed in the UI when navigating to App Templates + TemplatesURL string `json:"TemplatesURL" example:"https://raw.githubusercontent.com/portainer/templates/v3/templates.json"` + // The duration of a user session + UserSessionTimeout string `json:"UserSessionTimeout" example:"5m"` + // KubectlImage, defaults to portainer/kubectl-shell + KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"` + // Container environment parameter AGENT_SECRET + AgentSecret string `json:"AgentSecret"` +} + +type publicSettingsResponse struct { + // global settings + + // URL to a logo that will be displayed on the login page as well as on top of the sidebar. Will use default Portainer logo when value is empty string + LogoURL string `json:"LogoURL" example:"https://mycompany.mydomain.tld/logo.png"` + // Whether telemetry is enabled + EnableTelemetry bool `json:"EnableTelemetry" example:"true"` + + // login settings: + + // Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth + AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod" example:"1"` + // The URL used for oauth login + OAuthLoginURI string `json:"OAuthLoginURI" example:"https://gitlab.com/oauth"` + // The minimum required length for a password of any user when using internal auth mode + RequiredPasswordLength int `json:"RequiredPasswordLength" example:"1"` + // The URL used for oauth logout + OAuthLogoutURI string `json:"OAuthLogoutURI" example:"https://gitlab.com/oauth/logout"` + // Whether team sync is enabled + TeamSync bool `json:"TeamSync" example:"true"` + // Supported feature flags + Features map[featureflags.Feature]bool `json:"Features"` + + // Deprecated + // please use `GET /api/settings` + GlobalDeploymentOptions portainer.GlobalDeploymentOptions `json:"GlobalDeploymentOptions"` + // Deprecated + // please use `GET /api/settings` + ShowKomposeBuildOption bool `json:"ShowKomposeBuildOption" example:"false"` + // Deprecated + // please use `GET /api/settings` + EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:"true"` + + // Deprecated + // please use `GET /api/settings` + KubeconfigExpiry string `example:"24h" default:"0"` + // Deprecated + // please use `GET /api/settings` + IsFDOEnabled bool + // Deprecated + // please use `GET /api/settings` + IsAMTEnabled bool + + // Deprecated + // please use `GET /api/settings` + Edge struct { + // Deprecated + // please use `GET /api/settings` + PingInterval int `json:"PingInterval" example:"60"` + // Deprecated + // please use `GET /api/settings` + SnapshotInterval int `json:"SnapshotInterval" example:"60"` + // Deprecated + // please use `GET /api/settings` + CommandInterval int `json:"CommandInterval" example:"60"` + // Deprecated + // please use `GET /api/settings` + CheckinInterval int `example:"60"` + } +} + +func (res *settingsInspectResponse) ForRole(role portainer.UserRole) interface{} { + switch role { + case portainer.AdministratorRole: + return res.adminResponse + case portainer.EdgeAdminRole: + return res.edgeAdminResponse + case portainer.StandardUserRole: + return res.authenticatedResponse + default: + return res.publicSettingsResponse + } +} + +func buildResponse(settings *portainer.Settings) settingsInspectResponse { + hideFields(settings) + + return settingsInspectResponse{ + adminResponse: adminResponse{ + edgeAdminResponse: edgeAdminResponse{ + authenticatedResponse: authenticatedResponse{ + publicSettingsResponse: generatePublicSettings(settings), + + GlobalDeploymentOptions: settings.GlobalDeploymentOptions, + EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures, + KubeconfigExpiry: settings.KubeconfigExpiry, + HelmRepositoryURL: settings.HelmRepositoryURL, + IsAMTEnabled: settings.EnableEdgeComputeFeatures && settings.OpenAMTConfiguration.Enabled, + IsFDOEnabled: settings.EnableEdgeComputeFeatures && settings.FDOConfiguration.Enabled, + }, + + Edge: edgeSettings{ + CommandInterval: settings.Edge.CommandInterval, + PingInterval: settings.Edge.PingInterval, + SnapshotInterval: settings.Edge.SnapshotInterval, + }, + TrustOnFirstConnect: settings.TrustOnFirstConnect, + EnforceEdgeID: settings.EnforceEdgeID, + EdgePortainerURL: settings.EdgePortainerURL, + EdgeAgentCheckinInterval: settings.EdgeAgentCheckinInterval, + }, + BlackListedLabels: settings.BlackListedLabels, + LDAPSettings: ldapSettings{ + AnonymousMode: settings.LDAPSettings.AnonymousMode, + ReaderDN: settings.LDAPSettings.ReaderDN, + TLSConfig: settings.LDAPSettings.TLSConfig, + StartTLS: settings.LDAPSettings.StartTLS, + SearchSettings: settings.LDAPSettings.SearchSettings, + GroupSearchSettings: settings.LDAPSettings.GroupSearchSettings, + AutoCreateUsers: settings.LDAPSettings.AutoCreateUsers, + URL: settings.LDAPSettings.URL, + }, + OAuthSettings: oauthSettings{ + ClientID: settings.OAuthSettings.ClientID, + AccessTokenURI: settings.OAuthSettings.AccessTokenURI, + AuthorizationURI: settings.OAuthSettings.AuthorizationURI, + ResourceURI: settings.OAuthSettings.ResourceURI, + RedirectURI: settings.OAuthSettings.RedirectURI, + UserIdentifier: settings.OAuthSettings.UserIdentifier, + Scopes: settings.OAuthSettings.Scopes, + OAuthAutoCreateUsers: settings.OAuthSettings.OAuthAutoCreateUsers, + DefaultTeamID: settings.OAuthSettings.DefaultTeamID, + SSO: settings.OAuthSettings.SSO, + LogoutURI: settings.OAuthSettings.LogoutURI, + AuthStyle: settings.OAuthSettings.AuthStyle, + }, + InternalAuthSettings: settings.InternalAuthSettings, + OpenAMTConfiguration: settings.OpenAMTConfiguration, + FDOConfiguration: settings.FDOConfiguration, + SnapshotInterval: settings.SnapshotInterval, + TemplatesURL: settings.TemplatesURL, + UserSessionTimeout: settings.UserSessionTimeout, + KubectlShellImage: settings.KubectlShellImage, + AgentSecret: settings.AgentSecret, + }, + } +} + +func getTeamSync(settings *portainer.Settings) bool { + if settings.AuthenticationMethod == portainer.AuthenticationLDAP { + return settings.LDAPSettings.GroupSearchSettings != nil && len(settings.LDAPSettings.GroupSearchSettings) > 0 && len(settings.LDAPSettings.GroupSearchSettings[0].GroupBaseDN) > 0 + } + + return false +} + +func generatePublicSettings(appSettings *portainer.Settings) publicSettingsResponse { + publicSettings := publicSettingsResponse{ + LogoURL: appSettings.LogoURL, + AuthenticationMethod: appSettings.AuthenticationMethod, + RequiredPasswordLength: appSettings.InternalAuthSettings.RequiredPasswordLength, + EnableEdgeComputeFeatures: appSettings.EnableEdgeComputeFeatures, + GlobalDeploymentOptions: appSettings.GlobalDeploymentOptions, + ShowKomposeBuildOption: appSettings.ShowKomposeBuildOption, + EnableTelemetry: appSettings.EnableTelemetry, + KubeconfigExpiry: appSettings.KubeconfigExpiry, + Features: featureflags.FeatureFlags(), + IsFDOEnabled: appSettings.EnableEdgeComputeFeatures && appSettings.FDOConfiguration.Enabled, + IsAMTEnabled: appSettings.EnableEdgeComputeFeatures && appSettings.OpenAMTConfiguration.Enabled, + TeamSync: getTeamSync(appSettings), + } + + publicSettings.Edge.PingInterval = appSettings.Edge.PingInterval + publicSettings.Edge.SnapshotInterval = appSettings.Edge.SnapshotInterval + publicSettings.Edge.CommandInterval = appSettings.Edge.CommandInterval + publicSettings.Edge.CheckinInterval = appSettings.EdgeAgentCheckinInterval + + //if OAuth authentication is on, compose the related fields from application settings + if publicSettings.AuthenticationMethod == portainer.AuthenticationOAuth { + publicSettings.OAuthLogoutURI = appSettings.OAuthSettings.LogoutURI + publicSettings.OAuthLoginURI = fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s", + appSettings.OAuthSettings.AuthorizationURI, + appSettings.OAuthSettings.ClientID, + appSettings.OAuthSettings.RedirectURI, + appSettings.OAuthSettings.Scopes) + + //control prompt=login param according to the SSO setting + if !appSettings.OAuthSettings.SSO { + publicSettings.OAuthLoginURI += "&prompt=login" + } + } + + return publicSettings +} diff --git a/api/http/handler/settings/settings_inspect_test.go b/api/http/handler/settings/settings_inspect_test.go new file mode 100644 index 000000000..4accddc9c --- /dev/null +++ b/api/http/handler/settings/settings_inspect_test.go @@ -0,0 +1,170 @@ +package settings + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" +) + +func TestHandler_settingsInspect(t *testing.T) { + t.Run("check that /api/settings returns the right value for admin", func(t *testing.T) { + + user := portainer.User{ + ID: 1, + Username: "admin", + Role: portainer.AdministratorRole, + } + + settings := &portainer.Settings{ + LogoURL: "https://nondefault.com/logo.png", + BlackListedLabels: []portainer.Pair{{Name: "customlabel1", Value: "customvalue1"}}, + AuthenticationMethod: 2, + InternalAuthSettings: portainer.InternalAuthSettings{ + RequiredPasswordLength: 10, + }, + LDAPSettings: portainer.LDAPSettings{ + AnonymousMode: true, + ReaderDN: "readerDN", + Password: "password", + TLSConfig: portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: true, + TLSCACertPath: "/path/to/ca-cert", + TLSCertPath: "/path/to/cert", + TLSKeyPath: "/path/to/key", + }, + StartTLS: true, + SearchSettings: []portainer.LDAPSearchSettings{{ + BaseDN: "baseDN", + Filter: "filter", + UserNameAttribute: "username", + }}, + GroupSearchSettings: []portainer.LDAPGroupSearchSettings{{ + GroupBaseDN: "groupBaseDN", + GroupFilter: "groupFilter", + GroupAttribute: "groupAttribute", + }}, + AutoCreateUsers: true, + URL: "ldap://admin.example.com", + }, + OAuthSettings: portainer.OAuthSettings{ + ClientID: "clientID", + ClientSecret: "clientSecret", + AccessTokenURI: "https://access-token-uri", + AuthorizationURI: "https://authorization-uri", + ResourceURI: "https://resource-uri", + RedirectURI: "https://redirect-uri", + UserIdentifier: "userIdentifier", + Scopes: "scope1 scope2", + OAuthAutoCreateUsers: true, + DefaultTeamID: 1, + SSO: true, + LogoutURI: "https://logout-uri", + KubeSecretKey: []byte("secretKey"), + AuthStyle: 1, + }, + OpenAMTConfiguration: portainer.OpenAMTConfiguration{ + Enabled: true, + MPSServer: "mps-server", + MPSUser: "mps-user", + MPSPassword: "mps-password", + MPSToken: "mps-token", + CertFileName: "cert-filename", + CertFileContent: "cert-file-content", + CertFilePassword: "cert-file-password", + DomainName: "domain-name", + }, + FDOConfiguration: portainer.FDOConfiguration{ + Enabled: true, + OwnerURL: "https://owner-url", + OwnerUsername: "owner-username", + OwnerPassword: "owner-password", + }, + SnapshotInterval: "30m", + TemplatesURL: "https://nondefault.com/templates", + GlobalDeploymentOptions: portainer.GlobalDeploymentOptions{ + HideStacksFunctionality: true, + }, + EnableEdgeComputeFeatures: true, + UserSessionTimeout: "1h", + KubeconfigExpiry: "48h", + EnableTelemetry: true, + HelmRepositoryURL: "https://nondefault.com/helm", + KubectlShellImage: "portainer/kubectl-shell:v2.0.0", + TrustOnFirstConnect: true, + EnforceEdgeID: true, + AgentSecret: "nondefaultsecret", + EdgePortainerURL: "https://edge.nondefault.com", + EdgeAgentCheckinInterval: 20, + Edge: portainer.EdgeSettings{ + CommandInterval: 10, + PingInterval: 10, + SnapshotInterval: 10, + + AsyncMode: true, + }, + } + + // copy settings so we can compare later (since we will change the settings struct in the handler) + dbSettings, err := cloneMyStruct(settings) + assert.NoError(t, err) + + dataStore := testhelpers.NewDatastore( + testhelpers.WithSettingsService(dbSettings), + testhelpers.WithUsers([]portainer.User{user}), + ) + + handler := &Handler{ + DataStore: dataStore, + } + + // Create a mock request + req, err := http.NewRequest("GET", "/settings", nil) + assert.NoError(t, err) + + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role}) + req = req.WithContext(ctx) + + restrictedCtx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: user.ID, IsAdmin: user.Role == portainer.AdministratorRole}) + req = req.WithContext(restrictedCtx) + + // Create a mock response recorder + rr := httptest.NewRecorder() + // Call the handler function + err = handler.settingsInspect(rr, req) + + // Check for any handler errors + assert.Nil(t, err) + + // Check the response status code + assert.Equal(t, http.StatusOK, rr.Code) + + hideFields(settings) + + actualSettings := &portainer.Settings{} + + err = json.Unmarshal(rr.Body.Bytes(), actualSettings) + + assert.EqualExportedValues(t, settings, actualSettings) + }) +} + +func cloneMyStruct[T any](orig *T) (*T, error) { + origJSON, err := json.Marshal(orig) + if err != nil { + return nil, err + } + + clone := new(T) + if err = json.Unmarshal(origJSON, clone); err != nil { + return nil, err + } + + return clone, nil +} diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index fd45748b2..9db5100a9 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -1,60 +1,12 @@ package settings import ( - "fmt" "net/http" - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/pkg/featureflags" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/response" ) -type publicSettingsResponse struct { - // URL to a logo that will be displayed on the login page as well as on top of the sidebar. Will use default Portainer logo when value is empty string - LogoURL string `json:"LogoURL" example:"https://mycompany.mydomain.tld/logo.png"` - // Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth - AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod" example:"1"` - // The minimum required length for a password of any user when using internal auth mode - RequiredPasswordLength int `json:"RequiredPasswordLength" example:"1"` - // Deployment options for encouraging deployment as code - GlobalDeploymentOptions portainer.GlobalDeploymentOptions `json:"GlobalDeploymentOptions"` - // Show the Kompose build option (discontinued in 2.18) - ShowKomposeBuildOption bool `json:"ShowKomposeBuildOption" example:"false"` - // Whether edge compute features are enabled - EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:"true"` - // Supported feature flags - Features map[featureflags.Feature]bool `json:"Features"` - // The URL used for oauth login - OAuthLoginURI string `json:"OAuthLoginURI" example:"https://gitlab.com/oauth"` - // The URL used for oauth logout - OAuthLogoutURI string `json:"OAuthLogoutURI" example:"https://gitlab.com/oauth/logout"` - // Whether telemetry is enabled - EnableTelemetry bool `json:"EnableTelemetry" example:"true"` - // The expiry of a Kubeconfig - KubeconfigExpiry string `example:"24h" default:"0"` - // Whether team sync is enabled - TeamSync bool `json:"TeamSync" example:"true"` - - // Whether FDO is enabled - IsFDOEnabled bool - // Whether AMT is enabled - IsAMTEnabled bool - - Edge struct { - // The ping interval for edge agent - used in edge async mode [seconds] - PingInterval int `json:"PingInterval" example:"60"` - // The snapshot interval for edge agent - used in edge async mode [seconds] - SnapshotInterval int `json:"SnapshotInterval" example:"60"` - // The command list interval for edge agent - used in edge async mode [seconds] - CommandInterval int `json:"CommandInterval" example:"60"` - // The check in interval for edge agent (in seconds) - used in non async mode [seconds] - CheckinInterval int `example:"60"` - } - - IsDockerDesktopExtension bool `json:"IsDockerDesktopExtension" example:"false"` -} - // @id SettingsPublic // @summary Retrieve Portainer public settings // @description Retrieve public settings. Returns a small set of settings that are not reserved to administrators only. @@ -73,47 +25,3 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * publicSettings := generatePublicSettings(settings) return response.JSON(w, publicSettings) } - -func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResponse { - publicSettings := &publicSettingsResponse{ - LogoURL: appSettings.LogoURL, - AuthenticationMethod: appSettings.AuthenticationMethod, - RequiredPasswordLength: appSettings.InternalAuthSettings.RequiredPasswordLength, - EnableEdgeComputeFeatures: appSettings.EnableEdgeComputeFeatures, - GlobalDeploymentOptions: appSettings.GlobalDeploymentOptions, - ShowKomposeBuildOption: appSettings.ShowKomposeBuildOption, - EnableTelemetry: appSettings.EnableTelemetry, - KubeconfigExpiry: appSettings.KubeconfigExpiry, - Features: featureflags.FeatureFlags(), - IsFDOEnabled: appSettings.EnableEdgeComputeFeatures && appSettings.FDOConfiguration.Enabled, - IsAMTEnabled: appSettings.EnableEdgeComputeFeatures && appSettings.OpenAMTConfiguration.Enabled, - } - - publicSettings.Edge.PingInterval = appSettings.Edge.PingInterval - publicSettings.Edge.SnapshotInterval = appSettings.Edge.SnapshotInterval - publicSettings.Edge.CommandInterval = appSettings.Edge.CommandInterval - publicSettings.Edge.CheckinInterval = appSettings.EdgeAgentCheckinInterval - - publicSettings.IsDockerDesktopExtension = appSettings.IsDockerDesktopExtension - - //if OAuth authentication is on, compose the related fields from application settings - if publicSettings.AuthenticationMethod == portainer.AuthenticationOAuth { - publicSettings.OAuthLogoutURI = appSettings.OAuthSettings.LogoutURI - publicSettings.OAuthLoginURI = fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s", - appSettings.OAuthSettings.AuthorizationURI, - appSettings.OAuthSettings.ClientID, - appSettings.OAuthSettings.RedirectURI, - appSettings.OAuthSettings.Scopes) - //control prompt=login param according to the SSO setting - if !appSettings.OAuthSettings.SSO { - publicSettings.OAuthLoginURI += "&prompt=login" - } - } - //if LDAP authentication is on, compose the related fields from application settings - if publicSettings.AuthenticationMethod == portainer.AuthenticationLDAP && appSettings.LDAPSettings.GroupSearchSettings != nil { - if len(appSettings.LDAPSettings.GroupSearchSettings) > 0 { - publicSettings.TeamSync = len(appSettings.LDAPSettings.GroupSearchSettings[0].GroupBaseDN) > 0 - } - } - return publicSettings -} diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index c7a1df833..94e1f73e9 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -143,6 +143,12 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * return response.JSON(w, settings) } +func hideFields(settings *portainer.Settings) { + settings.LDAPSettings.Password = "" + settings.OAuthSettings.ClientSecret = "" + settings.OAuthSettings.KubeSecretKey = nil +} + func (handler *Handler) updateSettings(tx dataservices.DataStoreTx, payload settingsUpdatePayload) (*portainer.Settings, error) { settings, err := tx.Settings().Settings() if err != nil { diff --git a/api/http/rbacutils/rbac_utils.go b/api/http/rbacutils/rbac_utils.go new file mode 100644 index 000000000..015f80491 --- /dev/null +++ b/api/http/rbacutils/rbac_utils.go @@ -0,0 +1,53 @@ +package rbacutils + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/endpointutils" +) + +// IsAdmin checks if user is a Portainer Admin +func IsAdmin(role portainer.UserRole) bool { + return role == portainer.AdministratorRole +} + +// IsAdminOrEdgeAdmin checks if current user is a Portainer Admin, or edge admin of environment +func IsAdminOrEdgeAdmin(role portainer.UserRole, endpoint *portainer.Endpoint) bool { + return IsAdmin(role) || IsEdgeAdmin(role, endpoint) +} + +// IsEdgeAdmin checks if current user is edge admin of environment. +// It doesn't check for portainer admin. +func IsEdgeAdmin(role portainer.UserRole, endpoint *portainer.Endpoint) bool { + return role == portainer.EdgeAdminRole && (endpoint == nil || endpointutils.IsEdgeEndpoint(endpoint)) +} + +// IsAdminOrEndpointAdmin checks if current request is for an admin, edge admin, or an environment(endpoint) admin +// +// EE-6176 TODO later: move this check to RBAC layer performed before in-handler execution (see usage references of this func) +// +// TODO EE-6627: check usage of function +func IsAdminOrEndpointAdmin(user *portainer.User, endpoint *portainer.Endpoint) bool { + if user == nil { + return false + } + return IsAdminOrEdgeAdmin(user.Role, endpoint) || (endpoint != nil && IsEndpointAdmin(user, endpoint.ID)) +} + +// check if user is endpoint admin of endpoint +func IsEndpointAdmin(user *portainer.User, endpointId portainer.EndpointID) bool { + if user == nil { + return false + } + + hasResourceAccess, ok := user.EndpointAuthorizations[endpointId][portainer.EndpointResourcesAccess] + return ok && hasResourceAccess +} + +// RoleFromUser returns the role of the user +func RoleFromUser(user *portainer.User) portainer.UserRole { + if user == nil { + return portainer.UserRole(0) + } + + return user.Role +} diff --git a/api/http/utils/response.go b/api/http/utils/response.go new file mode 100644 index 000000000..1778e04f5 --- /dev/null +++ b/api/http/utils/response.go @@ -0,0 +1,35 @@ +package utils + +import ( + "errors" + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +func TxResponse[T any](w http.ResponseWriter, r T, err error) *httperror.HandlerError { + if err != nil { + var handlerError *httperror.HandlerError + if errors.As(err, &handlerError) { + return handlerError + } + + return httperror.InternalServerError("Unexpected error", err) + } + + return response.JSON(w, r) +} + +func TxEmptyResponse(w http.ResponseWriter, err error) *httperror.HandlerError { + if err != nil { + var handlerError *httperror.HandlerError + if errors.As(err, &handlerError) { + return handlerError + } + + return httperror.InternalServerError("Unexpected error", err) + } + + return response.Empty(w) +} diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index dd33799f0..5bc494d3a 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -146,8 +146,16 @@ type stubUserService struct { users []portainer.User } -func (s *stubUserService) BucketName() string { return "users" } -func (s *stubUserService) Read(ID portainer.UserID) (*portainer.User, error) { return nil, nil } +func (s *stubUserService) BucketName() string { return "users" } +func (s *stubUserService) Read(ID portainer.UserID) (*portainer.User, error) { + for _, user := range s.users { + if user.ID == ID { + return &user, nil + } + } + return nil, errors.ErrObjectNotFound +} + func (s *stubUserService) UserByUsername(username string) (*portainer.User, error) { return nil, nil } func (s *stubUserService) ReadAll() ([]portainer.User, error) { return s.users, nil } func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { diff --git a/api/portainer.go b/api/portainer.go index e0694a6c2..ebf1af938 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -939,6 +939,18 @@ type ( HideStacksFunctionality bool `json:"hideStacksFunctionality" example:"false"` } + EdgeSettings struct { + // The command list interval for edge agent - used in edge async mode (in seconds) + CommandInterval int `json:"CommandInterval" example:"5"` + // The ping interval for edge agent - used in edge async mode (in seconds) + PingInterval int `json:"PingInterval" example:"5"` + // The snapshot interval for edge agent - used in edge async mode (in seconds) + SnapshotInterval int `json:"SnapshotInterval" example:"5"` + + // Deprecated 2.18 + AsyncMode bool + } + // Settings represents the application settings Settings struct { // URL to a logo that will be displayed on the login page as well as on top of the sidebar. Will use default Portainer logo when value is empty string @@ -984,33 +996,28 @@ type ( // EdgePortainerURL is the URL that is exposed to edge agents EdgePortainerURL string `json:"EdgePortainerUrl"` - Edge struct { - // The command list interval for edge agent - used in edge async mode (in seconds) - CommandInterval int `json:"CommandInterval" example:"5"` - // The ping interval for edge agent - used in edge async mode (in seconds) - PingInterval int `json:"PingInterval" example:"5"` - // The snapshot interval for edge agent - used in edge async mode (in seconds) - SnapshotInterval int `json:"SnapshotInterval" example:"5"` - - // Deprecated 2.18 - AsyncMode bool - } + Edge EdgeSettings + IsDockerDesktopExtension bool `json:"IsDockerDesktopExtension,omitempty"` // Deprecated fields - DisplayDonationHeader bool `json:"DisplayDonationHeader,omitempty"` DisplayExternalContributors bool `json:"DisplayExternalContributors,omitempty"` // Deprecated fields v26 - EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures,omitempty"` - AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers,omitempty"` - AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers,omitempty"` - AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers,omitempty"` - AllowHostNamespaceForRegularUsers bool `json:"AllowHostNamespaceForRegularUsers,omitempty"` - AllowStackManagementForRegularUsers bool `json:"AllowStackManagementForRegularUsers,omitempty"` - AllowDeviceMappingForRegularUsers bool `json:"AllowDeviceMappingForRegularUsers,omitempty"` + EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures,omitempty"` + // Deprecated fields v26 + AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers,omitempty"` + // Deprecated fields v26 + AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers,omitempty"` + // Deprecated fields v26 + AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers,omitempty"` + // Deprecated fields v26 + AllowHostNamespaceForRegularUsers bool `json:"AllowHostNamespaceForRegularUsers,omitempty"` + // Deprecated fields v26 + AllowStackManagementForRegularUsers bool `json:"AllowStackManagementForRegularUsers,omitempty"` + // Deprecated fields v26 + AllowDeviceMappingForRegularUsers bool `json:"AllowDeviceMappingForRegularUsers,omitempty"` + // Deprecated fields v26 AllowContainerCapabilitiesForRegularUsers bool `json:"AllowContainerCapabilitiesForRegularUsers,omitempty"` - - IsDockerDesktopExtension bool `json:"IsDockerDesktopExtension,omitempty"` } // SnapshotJob represents a scheduled job that can create environment(endpoint) snapshots @@ -1864,6 +1871,10 @@ const ( AdministratorRole // StandardUserRole represents a regular user role StandardUserRole + // EdgeAdminRole represent a user that has access to resources of all environments + // like AdministratorRole but doesn't have access to Portainer settings + // not used in CE, but added to make code sync easier + EdgeAdminRole ) const ( diff --git a/app/docker/components/imageRegistry/por-image-registry.controller.js b/app/docker/components/imageRegistry/por-image-registry.controller.js index 8d4a9c6ef..b3c935c7f 100644 --- a/app/docker/components/imageRegistry/por-image-registry.controller.js +++ b/app/docker/components/imageRegistry/por-image-registry.controller.js @@ -1,7 +1,8 @@ import angular from 'angular'; import _ from 'lodash-es'; -import { DockerHubViewModel } from 'Portainer/models/dockerhub'; + import { RegistryTypes } from '@/portainer/models/registryTypes'; +import { DockerHubViewModel } from 'Portainer/models/dockerhub'; class porImageRegistryController { /* @ngInject */ diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js index b6270ee57..6c7848f7f 100644 --- a/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js +++ b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js @@ -127,8 +127,8 @@ export default class DockerFeaturesConfigurationController { gpus, }; - const publicSettings = await this.SettingsService.publicSettings(); - const analyticsAllowed = publicSettings.EnableTelemetry; + const appSettings = await this.SettingsService.settings(); + const analyticsAllowed = appSettings.EnableTelemetry; if (analyticsAllowed) { // send analytics if GPU management is changed (with the new state) if (this.initialEnableGPUManagement !== this.state.enableGPUManagement) { diff --git a/app/portainer/components/accessControlForm/porAccessControlForm.html b/app/portainer/components/accessControlForm/porAccessControlForm.html index 8a73a8121..d20223d05 100644 --- a/app/portainer/components/accessControlForm/porAccessControlForm.html +++ b/app/portainer/components/accessControlForm/porAccessControlForm.html @@ -32,7 +32,7 @@ ng-if="$ctrl.formData.AccessControlEnabled && $ctrl.formData.Ownership === $ctrl.RCO.RESTRICTED && ($ctrl.isAdmin || (!$ctrl.isAdmin && $ctrl.availableTeams.length > 1))" >
-