diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index f78c5ecef..467eb99e8 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -176,7 +176,12 @@ func (store *Store) MigrateData() error { CurrentEdition: edition, DB: store.db, DatabaseVersion: version, - VersionService: store.VersionService, + + AuthorizationService: authorization.NewService(store), + EndpointGroupService: store.EndpointGroupService, + EndpointService: store.EndpointService, + ExtensionService: store.ExtensionService, + VersionService: store.VersionService, } migrator := migratoree.NewMigrator(migratorParams) @@ -196,7 +201,12 @@ func (store *Store) MigrateData() error { CurrentEdition: edition, DB: store.db, DatabaseVersion: version, - VersionService: store.VersionService, + + AuthorizationService: authorization.NewService(store), + EndpointGroupService: store.EndpointGroupService, + EndpointService: store.EndpointService, + ExtensionService: store.ExtensionService, + VersionService: store.VersionService, } migrator := migratoree.NewMigrator(migratorParams) diff --git a/api/bolt/init.go b/api/bolt/init.go index b67c39df0..184dd00f1 100644 --- a/api/bolt/init.go +++ b/api/bolt/init.go @@ -2,8 +2,9 @@ package bolt import ( "github.com/gofrs/uuid" - portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/internal/authorization" ) // Init creates the default data set. @@ -99,5 +100,60 @@ func (store *Store) Init() error { } } + roles, err := store.RoleService.Roles() + if err != nil { + return err + } + + if len(roles) == 0 { + environmentAdministratorRole := &portainer.Role{ + Name: "Endpoint administrator", + Description: "Full control of all resources in an endpoint", + Priority: 1, + Authorizations: authorization.DefaultEndpointAuthorizationsForEndpointAdministratorRole(), + } + + err = store.RoleService.CreateRole(environmentAdministratorRole) + if err != nil { + return err + } + + environmentReadOnlyUserRole := &portainer.Role{ + Name: "Helpdesk", + Description: "Read-only access of all resources in an endpoint", + Priority: 2, + Authorizations: authorization.DefaultEndpointAuthorizationsForHelpDeskRole(false), + } + + err = store.RoleService.CreateRole(environmentReadOnlyUserRole) + if err != nil { + return err + } + + standardUserRole := &portainer.Role{ + Name: "Standard user", + Description: "Full control of assigned resources in an endpoint", + Priority: 3, + Authorizations: authorization.DefaultEndpointAuthorizationsForStandardUserRole(false), + } + + err = store.RoleService.CreateRole(standardUserRole) + if err != nil { + return err + } + + readOnlyUserRole := &portainer.Role{ + Name: "Read-only user", + Description: "Read-only access of assigned resources in an endpoint", + Priority: 4, + Authorizations: authorization.DefaultEndpointAuthorizationsForReadOnlyUserRole(false), + } + + err = store.RoleService.CreateRole(readOnlyUserRole) + if err != nil { + return err + } + } + return nil } diff --git a/api/bolt/migratoree/migrate_ce.go b/api/bolt/migratoree/migrate_ce.go index 8e39b6f39..f2502ed6e 100644 --- a/api/bolt/migratoree/migrate_ce.go +++ b/api/bolt/migratoree/migrate_ce.go @@ -6,7 +6,12 @@ import ( // MigrateFromCEdbv25 will migrate the db from latest ce version to latest ee version func (m *Migrator) MigrateFromCEdbv25() error { - err := m.versionService.StoreDBVersion(portainer.DBVersionEE) + err := m.updateAuthorizationsToEE() + if err != nil { + return err + } + + err = m.versionService.StoreDBVersion(portainer.DBVersionEE) if err != nil { return err } @@ -18,3 +23,78 @@ func (m *Migrator) MigrateFromCEdbv25() error { return nil } + +func (m *Migrator) updateAuthorizationsToEE() error { + extensions, err := m.extensionService.Extensions() + for _, extension := range extensions { + if extension.ID == 3 && extension.Enabled { + return nil + } + } + + endpointGroups, err := m.endpointGroupService.EndpointGroups() + if err != nil { + return err + } + + for _, endpointGroup := range endpointGroups { + for key := range endpointGroup.UserAccessPolicies { + updateUserAccessPolicyToReadOnlyRole(endpointGroup.UserAccessPolicies, key) + } + + for key := range endpointGroup.TeamAccessPolicies { + updateTeamAccessPolicyToReadOnlyRole(endpointGroup.TeamAccessPolicies, key) + } + + err := m.endpointGroupService.UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) + if err != nil { + return err + } + } + + endpoints, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + for key := range endpoint.UserAccessPolicies { + updateUserAccessPolicyToReadOnlyRole(endpoint.UserAccessPolicies, key) + } + + for key := range endpoint.TeamAccessPolicies { + updateTeamAccessPolicyToReadOnlyRole(endpoint.TeamAccessPolicies, key) + } + + err := m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return m.authorizationService.UpdateUsersAuthorizations() +} + +func updateUserAccessPolicyToNoRole(policies portainer.UserAccessPolicies, key portainer.UserID) { + tmp := policies[key] + tmp.RoleID = 0 + policies[key] = tmp +} + +func updateTeamAccessPolicyToNoRole(policies portainer.TeamAccessPolicies, key portainer.TeamID) { + tmp := policies[key] + tmp.RoleID = 0 + policies[key] = tmp +} + +func updateUserAccessPolicyToReadOnlyRole(policies portainer.UserAccessPolicies, key portainer.UserID) { + tmp := policies[key] + tmp.RoleID = 4 + policies[key] = tmp +} + +func updateTeamAccessPolicyToReadOnlyRole(policies portainer.TeamAccessPolicies, key portainer.TeamID) { + tmp := policies[key] + tmp.RoleID = 4 + policies[key] = tmp +} diff --git a/api/bolt/migratoree/migrator.go b/api/bolt/migratoree/migrator.go index 7f1b0b6ba..7db2c284f 100644 --- a/api/bolt/migratoree/migrator.go +++ b/api/bolt/migratoree/migrator.go @@ -3,6 +3,10 @@ package migratoree import ( "github.com/boltdb/bolt" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/endpoint" + "github.com/portainer/portainer/api/bolt/endpointgroup" + "github.com/portainer/portainer/api/bolt/extension" + "github.com/portainer/portainer/api/internal/authorization" ) type ( @@ -11,7 +15,12 @@ type ( db *bolt.DB currentDBVersion int currentEdition portainer.SoftwareEdition - versionService portainer.VersionService + + authorizationService *authorization.Service + endpointGroupService *endpointgroup.Service + endpointService *endpoint.Service + extensionService *extension.Service + versionService portainer.VersionService } // Parameters represents the required parameters to create a new Migrator instance. @@ -19,7 +28,12 @@ type ( DB *bolt.DB DatabaseVersion int CurrentEdition portainer.SoftwareEdition - VersionService portainer.VersionService + + AuthorizationService *authorization.Service + EndpointGroupService *endpointgroup.Service + EndpointService *endpoint.Service + ExtensionService *extension.Service + VersionService portainer.VersionService } ) @@ -29,7 +43,12 @@ func NewMigrator(parameters *Parameters) *Migrator { db: parameters.DB, currentDBVersion: parameters.DatabaseVersion, currentEdition: parameters.CurrentEdition, - versionService: parameters.VersionService, + + authorizationService: parameters.AuthorizationService, + endpointGroupService: parameters.EndpointGroupService, + endpointService: parameters.EndpointService, + extensionService: parameters.ExtensionService, + versionService: parameters.VersionService, } } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 5d83916be..9dda153f1 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -17,6 +17,7 @@ import ( "github.com/portainer/portainer/api/git" "github.com/portainer/portainer/api/http" "github.com/portainer/portainer/api/http/client" + "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/snapshot" "github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/kubernetes" @@ -427,9 +428,10 @@ func main() { if len(users) == 0 { log.Println("Created admin user with the given password.") user := &portainer.User{ - Username: "admin", - Role: portainer.AdministratorRole, - Password: adminPasswordHash, + Username: "admin", + Role: portainer.AdministratorRole, + Password: adminPasswordHash, + PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), } err := dataStore.User().CreateUser(user) if err != nil { diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index ad39e93df..52c1982b9 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -13,6 +13,7 @@ import ( "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/internal/authorization" ) type authenticatePayload struct { @@ -78,6 +79,11 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer. log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error()) } + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + return handler.writeToken(w, user) } @@ -97,8 +103,9 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use } user := &portainer.User{ - Username: username, - Role: portainer.StandardUserRole, + Username: username, + Role: portainer.StandardUserRole, + PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), } err = handler.DataStore.User().CreateUser(user) @@ -111,6 +118,11 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error()) } + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + return handler.writeToken(w, user) } diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go index b090cb98f..6f9bc9c66 100644 --- a/api/http/handler/auth/authenticate_oauth.go +++ b/api/http/handler/auth/authenticate_oauth.go @@ -11,6 +11,7 @@ import ( "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/internal/authorization" ) type oauthPayload struct { @@ -74,8 +75,9 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h if user == nil { user = &portainer.User{ - Username: username, - Role: portainer.StandardUserRole, + Username: username, + Role: portainer.StandardUserRole, + PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), } err = handler.DataStore.User().CreateUser(user) @@ -96,6 +98,10 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h } } + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } } return handler.writeToken(w, user) diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index 5ad73712a..a34afea26 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -9,6 +9,7 @@ import ( "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" ) // Handler is the HTTP handler used to handle authentication operations. @@ -21,6 +22,7 @@ type Handler struct { OAuthService portainer.OAuthService ProxyManager *proxy.Manager KubernetesTokenCacheManager *kubernetes.TokenCacheManager + AuthorizationService *authorization.Service } // NewHandler creates a handler to manage authentication operations. diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go index 2b845070c..39ab4ba1e 100644 --- a/api/http/handler/endpointgroups/endpointgroup_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -39,8 +39,10 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} } + updateAuthorizations := false for _, endpoint := range endpoints { if endpoint.GroupID == portainer.EndpointGroupID(endpointGroupID) { + updateAuthorizations = true endpoint.GroupID = portainer.EndpointGroupID(1) err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { @@ -54,6 +56,13 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque } } + if updateAuthorizations { + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + } + for _, tagID := range endpointGroup.TagIDs { tag, err := handler.DataStore.Tag().Tag(tagID) if err != nil { diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go index 047bbb6b4..72cf3b3f6 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update.go +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -92,12 +92,15 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque } } + updateAuthorizations := false if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpointGroup.UserAccessPolicies) { endpointGroup.UserAccessPolicies = payload.UserAccessPolicies + updateAuthorizations = true } if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpointGroup.TeamAccessPolicies) { endpointGroup.TeamAccessPolicies = payload.TeamAccessPolicies + updateAuthorizations = true } err = handler.DataStore.EndpointGroup().UpdateEndpointGroup(endpointGroup.ID, endpointGroup) @@ -105,6 +108,13 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint group changes inside the database", err} } + if updateAuthorizations { + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + } + if tagsChanged { endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { diff --git a/api/http/handler/endpointgroups/handler.go b/api/http/handler/endpointgroups/handler.go index e73828814..f17538b46 100644 --- a/api/http/handler/endpointgroups/handler.go +++ b/api/http/handler/endpointgroups/handler.go @@ -7,12 +7,14 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" ) // Handler is the HTTP handler used to handle endpoint group operations. type Handler struct { *mux.Router - DataStore portainer.DataStore + AuthorizationService *authorization.Service + DataStore portainer.DataStore } // NewHandler creates a handler to manage endpoint group operations. diff --git a/api/http/handler/endpointproxy/proxy_azure.go b/api/http/handler/endpointproxy/proxy_azure.go index 8176edf8a..9984b763f 100644 --- a/api/http/handler/endpointproxy/proxy_azure.go +++ b/api/http/handler/endpointproxy/proxy_azure.go @@ -24,7 +24,7 @@ func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.R return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, false) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go index c4671506d..5b082e419 100644 --- a/api/http/handler/endpointproxy/proxy_docker.go +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -26,7 +26,7 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/endpointproxy/proxy_kubernetes.go b/api/http/handler/endpointproxy/proxy_kubernetes.go index 7a4e9dc01..be6400265 100644 --- a/api/http/handler/endpointproxy/proxy_kubernetes.go +++ b/api/http/handler/endpointproxy/proxy_kubernetes.go @@ -26,7 +26,7 @@ func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *h return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/endpointproxy/proxy_storidge.go b/api/http/handler/endpointproxy/proxy_storidge.go index 6cc59ff17..e44a74abc 100644 --- a/api/http/handler/endpointproxy/proxy_storidge.go +++ b/api/http/handler/endpointproxy/proxy_storidge.go @@ -27,7 +27,7 @@ func (handler *Handler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, false) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 728711d87..ab4e97da8 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -445,6 +445,18 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(endpoint *portainer. return err } + group, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + if len(group.UserAccessPolicies) > 0 || len(group.TeamAccessPolicies) > 0 { + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return err + } + } + for _, tagID := range endpoint.TagIDs { tag, err := handler.DataStore.Tag().Tag(tagID) if err != nil { diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index 875d4153e..b35fc8cf2 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -40,6 +40,13 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * handler.ProxyManager.DeleteEndpointProxy(endpoint) + if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 { + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + } + err = handler.DataStore.EndpointRelation().DeleteEndpointRelation(endpoint.ID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove endpoint relation from the database", err} diff --git a/api/http/handler/endpoints/endpoint_inspect.go b/api/http/handler/endpoints/endpoint_inspect.go index 1411e93cb..1ce758eb9 100644 --- a/api/http/handler/endpoints/endpoint_inspect.go +++ b/api/http/handler/endpoints/endpoint_inspect.go @@ -24,7 +24,7 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, false) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 172c8a45c..19188338c 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -126,11 +126,14 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * endpoint.Kubernetes = *payload.Kubernetes } + updateAuthorizations := false if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpoint.UserAccessPolicies) { + updateAuthorizations = true endpoint.UserAccessPolicies = payload.UserAccessPolicies } if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpoint.TeamAccessPolicies) { + updateAuthorizations = true endpoint.TeamAccessPolicies = payload.TeamAccessPolicies } @@ -223,6 +226,13 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} } + if updateAuthorizations { + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + } + if (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) && (groupIDChanged || tagsChanged) { relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID) if err != nil { diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index c004b5751..8234e3553 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -1,14 +1,14 @@ package endpoints import ( - httperror "github.com/portainer/libhttp/error" - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy" - "github.com/portainer/portainer/api/http/security" - "net/http" "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" ) func hideFields(endpoint *portainer.Endpoint) { @@ -22,6 +22,7 @@ func hideFields(endpoint *portainer.Endpoint) { type Handler struct { *mux.Router requestBouncer *security.RequestBouncer + AuthorizationService *authorization.Service DataStore portainer.DataStore FileService portainer.FileService ProxyManager *proxy.Manager diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 9fa17b842..33e25eba7 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -7,6 +7,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" ) func hideFields(settings *portainer.Settings) { @@ -17,11 +18,12 @@ func hideFields(settings *portainer.Settings) { // Handler is the HTTP handler used to handle settings operations. type Handler struct { *mux.Router - DataStore portainer.DataStore - FileService portainer.FileService - JWTService portainer.JWTService - LDAPService portainer.LDAPService - SnapshotService portainer.SnapshotService + AuthorizationService *authorization.Service + DataStore portainer.DataStore + FileService portainer.FileService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + SnapshotService portainer.SnapshotService } // NewHandler creates a handler to manage settings operations. diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index fbdf47bcf..6a8b47d3b 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -115,8 +115,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.AllowPrivilegedModeForRegularUsers = *payload.AllowPrivilegedModeForRegularUsers } + updateAuthorizations := false if payload.AllowVolumeBrowserForRegularUsers != nil { settings.AllowVolumeBrowserForRegularUsers = *payload.AllowVolumeBrowserForRegularUsers + updateAuthorizations = true } if payload.EnableHostManagementFeatures != nil { @@ -176,9 +178,30 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist settings changes inside the database", err} } + if updateAuthorizations { + err := handler.updateVolumeBrowserSetting(settings) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update RBAC authorizations", err} + } + } + return response.JSON(w, settings) } +func (handler *Handler) updateVolumeBrowserSetting(settings *portainer.Settings) error { + err := handler.AuthorizationService.UpdateVolumeBrowsingAuthorizations(settings.AllowVolumeBrowserForRegularUsers) + if err != nil { + return err + } + + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return err + } + + return nil +} + func (handler *Handler) updateSnapshotInterval(settings *portainer.Settings, snapshotInterval string) error { settings.SnapshotInterval = snapshotInterval diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index c706afbfd..6ea263ae8 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -81,7 +81,9 @@ func (handler *Handler) userCanAccessStack(securityContext *security.RestrictedR func (handler *Handler) userIsAdminOrEndpointAdmin(user *portainer.User, endpointID portainer.EndpointID) (bool, error) { isAdmin := user.Role == portainer.AdministratorRole - return isAdmin, nil + _, endpointResourceAccess := user.EndpointAuthorizations[portainer.EndpointID(endpointID)][portainer.EndpointResourcesAccess] + + return isAdmin || endpointResourceAccess, nil } func (handler *Handler) userCanCreateStack(securityContext *security.RestrictedRequestContext, endpointID portainer.EndpointID) (bool, error) { diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index c9f115ad1..daec00366 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -76,7 +76,7 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index 6866c6809..3c7ea777a 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -65,7 +65,7 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } @@ -114,7 +114,14 @@ func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWrit return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} } - if !securityContext.IsAdmin { + user, err := handler.DataStore.User().User(securityContext.UserID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err} + } + + _, endpointResourceAccess := user.EndpointAuthorizations[portainer.EndpointID(endpointID)][portainer.EndpointResourcesAccess] + + if !securityContext.IsAdmin && !endpointResourceAccess { return &httperror.HandlerError{http.StatusUnauthorized, "Permission denied to delete the stack", httperrors.ErrUnauthorized} } @@ -133,7 +140,7 @@ func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWrit return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index 8024a72f2..e3a111668 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -38,7 +38,7 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go index df9bc279a..3e78f66ec 100644 --- a/api/http/handler/stacks/stack_inspect.go +++ b/api/http/handler/stacks/stack_inspect.go @@ -33,7 +33,7 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index e84f7e5f7..4e52c0e77 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -53,7 +53,7 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go index 298a11d42..80d1f694e 100644 --- a/api/http/handler/stacks/stack_start.go +++ b/api/http/handler/stacks/stack_start.go @@ -40,7 +40,7 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/stacks/stack_stop.go b/api/http/handler/stacks/stack_stop.go index ee2c13f32..0e0703a45 100644 --- a/api/http/handler/stacks/stack_stop.go +++ b/api/http/handler/stacks/stack_stop.go @@ -41,7 +41,7 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index df4178f8f..86a1e65e5 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -72,7 +72,7 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/teammemberships/handler.go b/api/http/handler/teammemberships/handler.go index 4ca0a0fdc..9a77f9dbf 100644 --- a/api/http/handler/teammemberships/handler.go +++ b/api/http/handler/teammemberships/handler.go @@ -1,19 +1,20 @@ package teammemberships import ( - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/security" - "net/http" "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" ) // Handler is the HTTP handler used to handle team membership operations. type Handler struct { *mux.Router - DataStore portainer.DataStore + AuthorizationService *authorization.Service + DataStore portainer.DataStore } // NewHandler creates a handler to manage team membership operations. diff --git a/api/http/handler/teammemberships/teammembership_create.go b/api/http/handler/teammemberships/teammembership_create.go index 361534d07..506608522 100644 --- a/api/http/handler/teammemberships/teammembership_create.go +++ b/api/http/handler/teammemberships/teammembership_create.go @@ -72,5 +72,10 @@ func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Requ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team memberships inside the database", err} } + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + return response.JSON(w, membership) } diff --git a/api/http/handler/teammemberships/teammembership_delete.go b/api/http/handler/teammemberships/teammembership_delete.go index f4e71d3c5..c12892325 100644 --- a/api/http/handler/teammemberships/teammembership_delete.go +++ b/api/http/handler/teammemberships/teammembership_delete.go @@ -40,5 +40,10 @@ func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Requ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the team membership from the database", err} } + err = handler.AuthorizationService.UpdateUsersAuthorizations() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err} + } + return response.Empty(w) } diff --git a/api/http/handler/teams/handler.go b/api/http/handler/teams/handler.go index 789dd8e7e..951019acc 100644 --- a/api/http/handler/teams/handler.go +++ b/api/http/handler/teams/handler.go @@ -7,12 +7,14 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" ) // Handler is the HTTP handler used to handle team operations. type Handler struct { *mux.Router - DataStore portainer.DataStore + AuthorizationService *authorization.Service + DataStore portainer.DataStore } // NewHandler creates a handler to manage team operations. diff --git a/api/http/handler/teams/team_delete.go b/api/http/handler/teams/team_delete.go index b56551a7b..6d3ac534c 100644 --- a/api/http/handler/teams/team_delete.go +++ b/api/http/handler/teams/team_delete.go @@ -34,5 +34,10 @@ func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete associated team memberships from the database", err} } + err = handler.AuthorizationService.RemoveTeamAccessPolicies(portainer.TeamID(teamID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clean-up team access policies", err} + } + return response.Empty(w) } diff --git a/api/http/handler/users/admin_init.go b/api/http/handler/users/admin_init.go index ef6c5425c..d15ed7d7a 100644 --- a/api/http/handler/users/admin_init.go +++ b/api/http/handler/users/admin_init.go @@ -9,6 +9,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/authorization" ) type adminInitPayload struct { @@ -44,8 +45,9 @@ func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httpe } user := &portainer.User{ - Username: payload.Username, - Role: portainer.AdministratorRole, + Username: payload.Username, + Role: portainer.AdministratorRole, + PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), } user.Password, err = handler.CryptoService.Hash(payload.Password) diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index 5ada9b87a..56e75a527 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -2,14 +2,13 @@ package users import ( "errors" - - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/security" - "net/http" "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" ) var ( @@ -27,8 +26,9 @@ func hideFields(user *portainer.User) { // Handler is the HTTP handler used to handle user operations. type Handler struct { *mux.Router - DataStore portainer.DataStore - CryptoService portainer.CryptoService + AuthorizationService *authorization.Service + CryptoService portainer.CryptoService + DataStore portainer.DataStore } // NewHandler creates a handler to manage user operations. diff --git a/api/http/handler/users/user_create.go b/api/http/handler/users/user_create.go index e9e1fa64a..36f9d9039 100644 --- a/api/http/handler/users/user_create.go +++ b/api/http/handler/users/user_create.go @@ -12,6 +12,7 @@ import ( bolterrors "github.com/portainer/portainer/api/bolt/errors" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" ) type userCreatePayload struct { @@ -61,8 +62,9 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http } user = &portainer.User{ - Username: payload.Username, - Role: portainer.UserRole(payload.Role), + Username: payload.Username, + Role: portainer.UserRole(payload.Role), + PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), } settings, err := handler.DataStore.Settings().Settings() diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go index 289b12303..12a1be2c0 100644 --- a/api/http/handler/users/user_delete.go +++ b/api/http/handler/users/user_delete.go @@ -81,5 +81,10 @@ func (handler *Handler) deleteUser(w http.ResponseWriter, user *portainer.User) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err} } + err = handler.AuthorizationService.RemoveUserAccessPolicies(user.ID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clean-up user access policies", err} + } + return response.Empty(w) } diff --git a/api/http/handler/websocket/attach.go b/api/http/handler/websocket/attach.go index ea43cd9cd..6da16659a 100644 --- a/api/http/handler/websocket/attach.go +++ b/api/http/handler/websocket/attach.go @@ -40,7 +40,7 @@ func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/websocket/exec.go b/api/http/handler/websocket/exec.go index 836cf252c..0abbcb263 100644 --- a/api/http/handler/websocket/exec.go +++ b/api/http/handler/websocket/exec.go @@ -47,7 +47,7 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, true) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/handler/websocket/pod.go b/api/http/handler/websocket/pod.go index b90f096b3..103b2b60f 100644 --- a/api/http/handler/websocket/pod.go +++ b/api/http/handler/websocket/pod.go @@ -56,7 +56,7 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } - err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, false) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index bb49bfa4b..84f69af87 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -406,6 +406,11 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r } if tokenData.Role != portainer.AdministratorRole { + user, err := transport.dataStore.User().User(tokenData.ID) + if err != nil { + return nil, err + } + if volumeBrowseRestrictionCheck { settings, err := transport.dataStore.Settings().Settings() if err != nil { @@ -413,10 +418,20 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r } if !settings.AllowVolumeBrowserForRegularUsers { - return responseutils.WriteAccessDeniedResponse() + // Return access denied for all roles except endpoint-administrator + _, userCanBrowse := user.EndpointAuthorizations[transport.endpoint.ID][portainer.OperationDockerAgentBrowseList] + if !userCanBrowse { + return responseutils.WriteAccessDeniedResponse() + } } } + _, endpointResourceAccess := user.EndpointAuthorizations[transport.endpoint.ID][portainer.EndpointResourcesAccess] + + if endpointResourceAccess { + return transport.executeDockerRequest(request) + } + teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) if err != nil { return nil, err @@ -678,5 +693,16 @@ func (transport *Transport) isAdminOrEndpointAdmin(request *http.Request) (bool, return false, err } - return tokenData.Role == portainer.AdministratorRole, nil + if tokenData.Role == portainer.AdministratorRole { + return true, nil + } + + user, err := transport.dataStore.User().User(tokenData.ID) + if err != nil { + return false, err + } + + _, endpointResourceAccess := user.EndpointAuthorizations[portainer.EndpointID(transport.endpoint.ID)][portainer.EndpointResourcesAccess] + + return endpointResourceAccess, nil } diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index 2ba3e8743..00b0d2a87 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -30,7 +30,7 @@ func AuthorizedResourceControlAccess(resourceControl *portainer.ResourceControl, // AuthorizedResourceControlUpdate ensure that the user can update a resource control object. // A non-administrator user cannot create a resource control where: // * the Public flag is set false -// * the AdministatorsOnly flag is set to true +// * the AdministratorsOnly flag is set to true // * he wants to create a resource control without any user/team accesses // * he wants to add more than one user in the user accesses // * he wants to add a user in the user accesses that is not corresponding to its id diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index ac55555d3..80adc2440 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -43,27 +43,20 @@ func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { return h } -// 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. +// AdminAccess is an alias for RestrictedAddress +// It's not removed as it's used across our codebase and removing will cause conflicts with CE func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler { - h = bouncer.mwUpgradeToRestrictedRequest(h) - h = bouncer.mwCheckPortainerAuthorizations(h, true) - h = bouncer.mwAuthenticatedUser(h) - return h + return bouncer.RestrictedAccess(h) } // RestrictedAccess defines a security check for restricted API endpoints. -// Authentication is required to access these endpoints. +// Authentication and authorizations are 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. func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler { h = bouncer.mwUpgradeToRestrictedRequest(h) - h = bouncer.mwCheckPortainerAuthorizations(h, false) + h = bouncer.mwCheckPortainerAuthorizations(h) h = bouncer.mwAuthenticatedUser(h) return h } @@ -81,9 +74,10 @@ func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler // AuthorizedEndpointOperation retrieves the JWT token from the request context and verifies // that the user can access the specified endpoint. +// If the authorizationCheck flag is set, it will also validate that the user can execute the specified operation. // An error is returned when access to the endpoint is denied or if the user do not have the required // authorization to execute the operation. -func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error { +func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endpoint *portainer.Endpoint, authorizationCheck bool) error { tokenData, err := RetrieveTokenData(r) if err != nil { return err @@ -107,6 +101,13 @@ func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endp return httperrors.ErrEndpointAccessDenied } + if authorizationCheck { + err = bouncer.checkEndpointOperationAuthorization(r, endpoint) + if err != nil { + return ErrAuthorizationRequired + } + } + return nil } @@ -128,6 +129,34 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, return nil } +func (bouncer *RequestBouncer) checkEndpointOperationAuthorization(r *http.Request, endpoint *portainer.Endpoint) error { + tokenData, err := RetrieveTokenData(r) + if err != nil { + return err + } + + if tokenData.Role == portainer.AdministratorRole { + return nil + } + + user, err := bouncer.dataStore.User().User(tokenData.ID) + if err != nil { + return err + } + + apiOperation := &portainer.APIOperationAuthorizationRequest{ + Path: r.URL.String(), + Method: r.Method, + Authorizations: user.EndpointAuthorizations[endpoint.ID], + } + + if !authorizedOperation(apiOperation) { + return errors.New("Unauthorized") + } + + return nil +} + // RegistryAccess retrieves the JWT token from the request context and verifies // that the user can access the specified registry. // An error is returned when access is denied. @@ -161,9 +190,7 @@ func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler // mwCheckPortainerAuthorizations will verify that the user has the required authorization to access // a specific API endpoint. -// If the administratorOnly flag is specified, this will prevent non-admin -// users from accessing the endpoint. -func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler, administratorOnly bool) http.Handler { +func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenData, err := RetrieveTokenData(r) if err != nil { @@ -176,12 +203,7 @@ func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler, return } - if administratorOnly { - httperror.WriteError(w, http.StatusForbidden, "Access denied", httperrors.ErrUnauthorized) - return - } - - _, err = bouncer.dataStore.User().User(tokenData.ID) + user, err := bouncer.dataStore.User().User(tokenData.ID) if err != nil && err == bolterrors.ErrObjectNotFound { httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized) return @@ -190,6 +212,17 @@ func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler, return } + apiOperation := &portainer.APIOperationAuthorizationRequest{ + Path: r.URL.String(), + Method: r.Method, + Authorizations: user.PortainerAuthorizations, + } + + if !authorizedOperation(apiOperation) { + httperror.WriteError(w, http.StatusForbidden, "Access denied", ErrAuthorizationRequired) + return + } + next.ServeHTTP(w, r) }) } diff --git a/api/http/security/rbac.go b/api/http/security/rbac.go new file mode 100644 index 000000000..c6c4211af --- /dev/null +++ b/api/http/security/rbac.go @@ -0,0 +1,1347 @@ +package security + +import ( + "net/http" + "regexp" + "strings" + + "github.com/portainer/portainer/api" +) + +// AuthorizedOperation checks if operations is authorized +func authorizedOperation(operation *portainer.APIOperationAuthorizationRequest) bool { + operationAuthorization := getOperationAuthorization(operation.Path, operation.Method) + return operation.Authorizations[operationAuthorization] +} + +var dockerRule = regexp.MustCompile(`/(?P\d+)/docker(?P/.*)`) +var storidgeRule = regexp.MustCompile(`/(?P\d+)/storidge(?P/.*)`) + +//var registryRule = regexp.MustCompile(`/registries/(?P\d+)/v2(?P/.*)?`) + +func extractMatches(regex *regexp.Regexp, str string) map[string]string { + match := regex.FindStringSubmatch(str) + + results := map[string]string{} + for i, name := range match { + results[regex.SubexpNames()[i]] = name + } + return results +} + +func extractResourceAndActionFromURL(routeResource, url string) (string, string) { + routePattern := regexp.MustCompile(`/` + routeResource + `/(?P[^/?]*)/?(?P[^?]*)?(\?.*)?`) + urlComponents := extractMatches(routePattern, url) + + // TODO: optional log statement for debug + //fmt.Printf("[DEBUG] - RBAC | OPERATION: %s | resource: %s | action: %s\n", url, urlComponents["resource"], urlComponents["action"]) + + return urlComponents["resource"], urlComponents["action"] +} + +func getOperationAuthorization(url, method string) portainer.Authorization { + if dockerRule.MatchString(url) { + match := dockerRule.FindStringSubmatch(url) + return getDockerOperationAuthorization(strings.TrimPrefix(url, "/"+match[1]+"/docker"), method) + } else if storidgeRule.MatchString(url) { + return portainer.OperationIntegrationStoridgeAdmin + } + + return getPortainerOperationAuthorization(url, method) +} + +func getPortainerOperationAuthorization(url, method string) portainer.Authorization { + urlParts := strings.Split(url, "/") + baseResource := strings.Split(urlParts[1], "?")[0] + + switch baseResource { + case "dockerhub": + return portainerDockerhubOperationAuthorization(url, method) + case "endpoint_groups": + return portainerEndpointGroupOperationAuthorization(url, method) + case "endpoints": + return portainerEndpointOperationAuthorization(url, method) + case "motd": + return portainer.OperationPortainerMOTD + case "extensions": + return portainerExtensionOperationAuthorization(url, method) + case "registries": + return portainerRegistryOperationAuthorization(url, method) + case "resource_controls": + return portainerResourceControlOperationAuthorization(url, method) + case "roles": + return portainerRoleOperationAuthorization(url, method) + case "schedules": + return portainerScheduleOperationAuthorization(url, method) + case "settings": + return portainerSettingsOperationAuthorization(url, method) + case "stacks": + return portainerStackOperationAuthorization(url, method) + case "tags": + return portainerTagOperationAuthorization(url, method) + case "templates": + return portainerTemplatesOperationAuthorization(url, method) + case "upload": + return portainerUploadOperationAuthorization(url, method) + case "users": + return portainerUserOperationAuthorization(url, method) + case "teams": + return portainerTeamOperationAuthorization(url, method) + case "team_memberships": + return portainerTeamMembershipOperationAuthorization(url, method) + case "websocket": + return portainerWebsocketOperationAuthorization(url, method) + case "webhooks": + return portainerWebhookOperationAuthorization(url, method) + } + + return portainer.OperationPortainerUndefined +} + +func getDockerOperationAuthorization(url, method string) portainer.Authorization { + urlParts := strings.Split(url, "/") + baseResource := strings.Split(urlParts[1], "?")[0] + + switch baseResource { + case "v2": + return getDockerOperationAuthorization(strings.TrimPrefix(url, "/"+baseResource), method) + case "ping": + return portainer.OperationDockerAgentPing + case "agents": + return agentAgentsOperationAuthorization(url, method) + case "browse": + return agentBrowseOperationAuthorization(url, method) + case "host": + return agentHostOperationAuthorization(url, method) + case "containers": + return dockerContainerOperationAuthorization(url, method) + case "images": + return dockerImageOperationAuthorization(url, method) + case "networks": + return dockerNetworkOperationAuthorization(url, method) + case "volumes": + return dockerVolumeOperationAuthorization(url, method) + case "exec": + return dockerExecOperationAuthorization(url, method) + case "swarm": + return dockerSwarmOperationAuthorization(url, method) + case "nodes": + return dockerNodeOperationAuthorization(url, method) + case "services": + return dockerServiceOperationAuthorization(url, method) + case "secrets": + return dockerSecretOperationAuthorization(url, method) + case "configs": + return dockerConfigOperationAuthorization(url, method) + case "tasks": + return dockerTaskOperationAuthorization(url, method) + case "plugins": + return dockerPluginOperationAuthorization(url, method) + case "info": + return portainer.OperationDockerInfo + case "_ping": + return portainer.OperationDockerPing + case "version": + return portainer.OperationDockerVersion + case "events": + return portainer.OperationDockerEvents + case "system/df": + return portainer.OperationDockerSystem + case "session": + return dockerSessionOperationAuthorization(url, method) + case "distribution": + return dockerDistributionOperationAuthorization(url, method) + case "commit": + return dockerCommitOperationAuthorization(url, method) + case "build": + return dockerBuildOperationAuthorization(url, method) + default: + return portainer.OperationDockerUndefined + } +} + +func portainerDockerhubOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("dockerhub", url) + + switch method { + case http.MethodGet: + if resource == "" && action == "" { + return portainer.OperationPortainerDockerHubInspect + } + case http.MethodPut: + if resource == "" && action == "" { + return portainer.OperationPortainerDockerHubUpdate + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerEndpointGroupOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("endpoint_groups", url) + + switch method { + case http.MethodGet: + if resource == "" && action == "" { + return portainer.OperationPortainerEndpointGroupList + } else if resource != "" && action == "" { + return portainer.OperationPortainerEndpointGroupInspect + } + case http.MethodPost: + if resource == "" && action == "" { + return portainer.OperationPortainerEndpointGroupCreate + } + case http.MethodPut: + if resource != "" && action == "" { + return portainer.OperationPortainerEndpointGroupUpdate + } else if action == "access" { + return portainer.OperationPortainerEndpointGroupAccess + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerEndpointGroupDelete + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerEndpointOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("endpoints", url) + + switch method { + case http.MethodGet: + if resource == "" && action == "" { + return portainer.OperationPortainerEndpointList + } else if resource != "" && action == "" { + return portainer.OperationPortainerEndpointInspect + } + case http.MethodPost: + switch action { + case "": + if resource == "" { + return portainer.OperationPortainerEndpointCreate + } else if resource == "snapshot" { + return portainer.OperationPortainerEndpointSnapshots + } + case "extensions": + return portainer.OperationPortainerEndpointExtensionAdd + case "job": + return portainer.OperationPortainerEndpointJob + case "snapshot": + if resource != "" { + return portainer.OperationPortainerEndpointSnapshot + } + } + case http.MethodPut: + if resource != "" && action == "" { + return portainer.OperationPortainerEndpointUpdate + } else if action == "access" { + return portainer.OperationPortainerEndpointUpdateAccess + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerEndpointDelete + } else if strings.HasPrefix(action, "extensions/") { + return portainer.OperationPortainerEndpointExtensionRemove + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerExtensionOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("extensions", url) + + switch method { + case http.MethodGet: + if resource == "" && action == "" { + return portainer.OperationPortainerExtensionList + } else if resource != "" && action == "" { + return portainer.OperationPortainerExtensionInspect + } + case http.MethodPost: + if resource == "" && action == "" { + return portainer.OperationPortainerExtensionCreate + } else if action == "update" { + return portainer.OperationPortainerExtensionUpdate + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerExtensionDelete + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerRegistryOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("registries", url) + + switch method { + case http.MethodGet: + if resource == "" && action == "" { + return portainer.OperationPortainerRegistryList + } else if resource != "" && action == "" { + return portainer.OperationPortainerRegistryInspect + } + case http.MethodPost: + if resource == "" && action == "" { + return portainer.OperationPortainerRegistryCreate + } else if action == "configure" { + return portainer.OperationPortainerRegistryConfigure + } + case http.MethodPut: + if resource != "" && action == "" { + return portainer.OperationPortainerRegistryUpdate + } else if action == "access" { + return portainer.OperationPortainerRegistryUpdateAccess + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerRegistryDelete + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerResourceControlOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("resource_controls", url) + + switch method { + case http.MethodPost: + if resource == "" && action == "" { + return portainer.OperationPortainerResourceControlCreate + } + case http.MethodPut: + if resource != "" && action == "" { + return portainer.OperationPortainerResourceControlUpdate + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerResourceControlDelete + } + } + return portainer.OperationPortainerUndefined +} + +func portainerRoleOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("roles", url) + + switch method { + case http.MethodGet: + if resource == "" && action == "" { + return portainer.OperationPortainerRoleList + } else if resource != "" && action == "" { + return portainer.OperationPortainerRoleInspect + } + case http.MethodPost: + if resource == "" && action == "" { + return portainer.OperationPortainerRoleCreate + } + case http.MethodPut: + if resource != "" && action == "" { + return portainer.OperationPortainerRoleUpdate + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerRoleDelete + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerScheduleOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("schedules", url) + + switch method { + case http.MethodGet: + switch action { + case "": + if resource == "" { + return portainer.OperationPortainerScheduleList + } else { + return portainer.OperationPortainerScheduleInspect + } + case "file": + return portainer.OperationPortainerScheduleFile + case "tasks": + return portainer.OperationPortainerScheduleTasks + } + + case http.MethodPost: + if resource == "" && action == "" { + return portainer.OperationPortainerScheduleCreate + } + case http.MethodPut: + if resource != "" && action == "" { + return portainer.OperationPortainerScheduleUpdate + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerScheduleDelete + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerSettingsOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("settings", url) + + switch method { + case http.MethodGet: + if resource == "" && action == "" { + return portainer.OperationPortainerSettingsInspect + } + case http.MethodPut: + switch action { + case "": + if resource == "" { + return portainer.OperationPortainerSettingsUpdate + } + case "checkLDAP": + return portainer.OperationPortainerSettingsLDAPCheck + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerStackOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("stacks", url) + + switch method { + case http.MethodGet: + switch action { + case "": + if resource == "" { + return portainer.OperationPortainerStackList + } else { + return portainer.OperationPortainerStackInspect + } + case "file": + return portainer.OperationPortainerStackFile + } + + case http.MethodPost: + switch action { + case "": + if resource == "" { + return portainer.OperationPortainerStackCreate + } + case "migrate": + return portainer.OperationPortainerStackMigrate + } + + case http.MethodPut: + if resource != "" && action == "" { + return portainer.OperationPortainerStackUpdate + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerStackDelete + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerTagOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("tags", url) + + switch method { + case http.MethodGet: + if resource == "" && action == "" { + return portainer.OperationPortainerTagList + } + case http.MethodPost: + if resource == "" && action == "" { + return portainer.OperationPortainerTagCreate + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerTagDelete + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerTeamMembershipOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("team_memberships", url) + + switch method { + case http.MethodGet: + if resource == "" && action == "" { + return portainer.OperationPortainerTeamMembershipList + } + case http.MethodPost: + if resource == "" && action == "" { + return portainer.OperationPortainerTeamMembershipCreate + } + case http.MethodPut: + if resource != "" && action == "" { + return portainer.OperationPortainerTeamMembershipUpdate + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerTeamMembershipDelete + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerTeamOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("teams", url) + + switch method { + case http.MethodGet: + switch action { + case "": + if resource == "" { + return portainer.OperationPortainerTeamList + } else { + return portainer.OperationPortainerTeamInspect + } + case "memberships": + return portainer.OperationPortainerTeamMemberships + } + + case http.MethodPost: + if resource == "" && action == "" { + return portainer.OperationPortainerTeamCreate + } + case http.MethodPut: + if resource != "" && action == "" { + return portainer.OperationPortainerTeamUpdate + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerTeamDelete + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerTemplatesOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("templates", url) + + switch method { + case http.MethodGet: + if resource == "" && action == "" { + return portainer.OperationPortainerTemplateList + } else if resource != "" && action == "" { + return portainer.OperationPortainerTemplateInspect + } + case http.MethodPost: + if resource == "" && action == "" { + return portainer.OperationPortainerTemplateCreate + } + case http.MethodPut: + if resource != "" && action == "" { + return portainer.OperationPortainerTemplateUpdate + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerTemplateDelete + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerUploadOperationAuthorization(url, method string) portainer.Authorization { + resource, _ := extractResourceAndActionFromURL("upload", url) + + switch method { + case http.MethodPost: + if resource == "tls" { + return portainer.OperationPortainerUploadTLS + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerUserOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("users", url) + + switch method { + case http.MethodGet: + switch action { + case "": + if resource == "" { + return portainer.OperationPortainerUserList + } else { + return portainer.OperationPortainerUserInspect + } + case "memberships": + return portainer.OperationPortainerUserMemberships + } + + case http.MethodPost: + if resource == "" && action == "" { + return portainer.OperationPortainerUserCreate + } + case http.MethodPut: + if resource != "" && action == "" { + return portainer.OperationPortainerUserUpdate + } else if resource != "" && action == "passwd" { + return portainer.OperationPortainerUserUpdatePassword + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerUserDelete + } + } + + return portainer.OperationPortainerUndefined +} + +func portainerWebsocketOperationAuthorization(url, method string) portainer.Authorization { + resource, _ := extractResourceAndActionFromURL("websocket", url) + + if resource == "exec" || resource == "attach" { + return portainer.OperationPortainerWebsocketExec + } + + return portainer.OperationPortainerUndefined +} + +func portainerWebhookOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("webhooks", url) + + switch method { + case http.MethodGet: + if resource == "" && action == "" { + return portainer.OperationPortainerWebhookList + } + case http.MethodPost: + if resource == "" && action == "" { + return portainer.OperationPortainerWebhookCreate + } + case http.MethodDelete: + if resource != "" && action == "" { + return portainer.OperationPortainerWebhookDelete + } + } + + return portainer.OperationPortainerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/network/network.go#L29 +func dockerNetworkOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("networks", url) + + switch method { + case http.MethodGet: + // GET + //router.NewGetRoute("/networks", r.getNetworksList), + //router.NewGetRoute("/networks/", r.getNetworksList), + //router.NewGetRoute("/networks/{id:.+}", r.getNetwork), + switch action { + case "": + if resource == "" { + return portainer.OperationDockerNetworkList + } else { + return portainer.OperationDockerNetworkInspect + } + } + case http.MethodPost: + //router.NewPostRoute("/networks/create", r.postNetworkCreate), + //router.NewPostRoute("/networks/{id:.*}/connect", r.postNetworkConnect), + //router.NewPostRoute("/networks/{id:.*}/disconnect", r.postNetworkDisconnect), + //router.NewPostRoute("/networks/prune", r.postNetworksPrune), + switch action { + case "": + if resource == "create" { + return portainer.OperationDockerNetworkCreate + } else if resource == "prune" { + return portainer.OperationDockerNetworkPrune + } + case "connect": + return portainer.OperationDockerNetworkConnect + case "disconnect": + return portainer.OperationDockerNetworkDisconnect + } + case http.MethodDelete: + // DELETE + // router.NewDeleteRoute("/networks/{id:.*}", r.deleteNetwork), + if resource != "" && action == "" { + return portainer.OperationDockerNetworkDelete + } + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/volume/volume.go#L25 +func dockerVolumeOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("volumes", url) + + switch method { + case http.MethodGet: + // GET + //router.NewGetRoute("/volumes", r.getVolumesList), + // router.NewGetRoute("/volumes/{name:.*}", r.getVolumeByName), + switch action { + case "": + if resource == "" { + return portainer.OperationDockerVolumeList + } else { + return portainer.OperationDockerVolumeInspect + } + } + case http.MethodPost: + //router.NewPostRoute("/volumes/create", r.postVolumesCreate), + // router.NewPostRoute("/volumes/prune", r.postVolumesPrune), + switch action { + case "": + if resource == "create" { + return portainer.OperationDockerVolumeCreate + } else if resource == "prune" { + return portainer.OperationDockerVolumePrune + } + } + case http.MethodDelete: + // DELETE + //router.NewDeleteRoute("/volumes/{name:.*}", r.deleteVolumes), + if resource != "" && action == "" { + return portainer.OperationDockerVolumeDelete + } + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/container/container.go#L31 +func dockerExecOperationAuthorization(url, method string) portainer.Authorization { + _, action := extractResourceAndActionFromURL("exec", url) + + switch method { + case http.MethodGet: + // GET + // router.NewGetRoute("/exec/{id:.*}/json", r.getExecByID), + if action == "json" { + return portainer.OperationDockerExecInspect + } + case http.MethodPost: + // POST + //router.NewPostRoute("/exec/{name:.*}/start", r.postContainerExecStart), + // router.NewPostRoute("/exec/{name:.*}/resize", r.postContainerExecResize), + if action == "start" { + return portainer.OperationDockerExecStart + } else if action == "resize" { + return portainer.OperationDockerExecResize + } + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/swarm/cluster.go#L25 +func dockerSwarmOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("swarm", url) + + switch method { + case http.MethodGet: + // GET + // router.NewGetRoute("/swarm", sr.inspectCluster), + // router.NewGetRoute("/swarm/unlockkey", sr.getUnlockKey), + switch action { + case "": + if resource == "" { + return portainer.OperationDockerSwarmInspect + } else { + return portainer.OperationDockerSwarmUnlockKey + } + } + case http.MethodPost: + // POST + //router.NewPostRoute("/swarm/init", sr.initCluster), + // router.NewPostRoute("/swarm/join", sr.joinCluster), + // router.NewPostRoute("/swarm/leave", sr.leaveCluster), + // router.NewPostRoute("/swarm/update", sr.updateCluster), + // router.NewPostRoute("/swarm/unlock", sr.unlockCluster), + switch action { + case "": + switch resource { + case "init": + return portainer.OperationDockerSwarmInit + case "join": + return portainer.OperationDockerSwarmJoin + case "leave": + return portainer.OperationDockerSwarmLeave + case "update": + return portainer.OperationDockerSwarmUpdate + case "unlock": + return portainer.OperationDockerSwarmUnlock + } + } + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/swarm/cluster.go#L25 +func dockerNodeOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("nodes", url) + + switch method { + case http.MethodGet: + // GET + //router.NewGetRoute("/nodes", sr.getNodes), + // router.NewGetRoute("/nodes/{id}", sr.getNode), + switch action { + case "": + if resource == "" { + return portainer.OperationDockerNodeList + } else { + return portainer.OperationDockerNodeInspect + } + } + case http.MethodPost: + // POST + // router.NewPostRoute("/nodes/{id}/update", sr.updateNode) + if action == "update" { + return portainer.OperationDockerNodeUpdate + } + case http.MethodDelete: + // DELETE + // router.NewDeleteRoute("/nodes/{id}", sr.removeNode), + if resource != "" { + return portainer.OperationDockerNodeDelete + } + + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/swarm/cluster.go#L25 +func dockerServiceOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("services", url) + + switch method { + case http.MethodGet: + //// GET + //router.NewGetRoute("/services", sr.getServices), + // router.NewGetRoute("/services/{id}", sr.getService), + // router.NewGetRoute("/services/{id}/logs", sr.getServiceLogs), + switch action { + case "": + if resource == "" { + return portainer.OperationDockerServiceList + } else { + return portainer.OperationDockerServiceInspect + } + case "logs": + return portainer.OperationDockerServiceLogs + } + case http.MethodPost: + //// POST + // router.NewPostRoute("/services/create", sr.createService), + // router.NewPostRoute("/services/{id}/update", sr.updateService), + switch action { + case "": + if resource == "create" { + return portainer.OperationDockerServiceCreate + } + case "update": + return portainer.OperationDockerServiceUpdate + } + case http.MethodDelete: + //// DELETE + // router.NewDeleteRoute("/services/{id}", sr.removeService), + if resource != "" && action == "" { + return portainer.OperationDockerServiceDelete + } + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/swarm/cluster.go#L25 +func dockerSecretOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("secrets", url) + + switch method { + case http.MethodGet: + //// GET + //router.NewGetRoute("/secrets", sr.getSecrets), + // router.NewGetRoute("/secrets/{id}", sr.getSecret), + switch action { + case "": + if resource == "" { + return portainer.OperationDockerSecretList + } else { + return portainer.OperationDockerSecretInspect + } + } + case http.MethodPost: + //// POST + // router.NewPostRoute("/secrets/create", sr.createSecret), + // router.NewPostRoute("/secrets/{id}/update", sr.updateSecret), + switch action { + case "": + if resource == "create" { + return portainer.OperationDockerSecretCreate + } + case "update": + return portainer.OperationDockerSecretUpdate + } + case http.MethodDelete: + //// DELETE + // router.NewDeleteRoute("/secrets/{id}", sr.removeSecret), + if resource != "" && action == "" { + return portainer.OperationDockerSecretDelete + } + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/swarm/cluster.go#L25 +func dockerConfigOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("configs", url) + + switch method { + case http.MethodGet: + //// GET + //router.NewGetRoute("/configs", sr.getConfigs), + // router.NewGetRoute("/configs/{id}", sr.getConfig), + switch action { + case "": + if resource == "" { + return portainer.OperationDockerConfigList + } else { + return portainer.OperationDockerConfigInspect + } + } + case http.MethodPost: + //// POST + // router.NewPostRoute("/configs/create", sr.createConfig), + // router.NewPostRoute("/configs/{id}/update", sr.updateConfig), + switch action { + case "": + if resource == "create" { + return portainer.OperationDockerConfigCreate + } + case "update": + return portainer.OperationDockerConfigUpdate + } + case http.MethodDelete: + //// DELETE + // router.NewDeleteRoute("/configs/{id}", sr.removeConfig), + if resource != "" && action == "" { + return portainer.OperationDockerConfigDelete + } + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/swarm/cluster.go#L25 +func dockerTaskOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("tasks", url) + + switch method { + case http.MethodGet: + //// GET + //router.NewGetRoute("/tasks", sr.getTasks), + // router.NewGetRoute("/tasks/{id}", sr.getTask), + // router.NewGetRoute("/tasks/{id}/logs", sr.getTaskLogs), + switch action { + case "": + if resource == "" { + return portainer.OperationDockerTaskList + } else { + return portainer.OperationDockerTaskInspect + } + case "logs": + return portainer.OperationDockerTaskLogs + } + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +//https://github.com/moby/moby/blob/c12f09bf99/api/server/router/plugin/plugin.go#L25 +func dockerPluginOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("plugins", url) + + switch method { + case http.MethodGet: + //// GET + //router.NewGetRoute("/plugins", r.listPlugins), + // router.NewGetRoute("/plugins/{name:.*}/json", r.inspectPlugin), + // router.NewGetRoute("/plugins/privileges", r.getPrivileges), + switch action { + case "": + if resource == "" { + return portainer.OperationDockerPluginList + } else if resource == "privileges" { + return portainer.OperationDockerPluginPrivileges + } + case "json": + return portainer.OperationDockerPluginInspect + } + case http.MethodPost: + //// POST + // router.NewPostRoute("/plugins/pull", r.pullPlugin), + // router.NewPostRoute("/plugins/create", r.createPlugin), + // router.NewPostRoute("/plugins/{name:.*}/enable", r.enablePlugin), + // router.NewPostRoute("/plugins/{name:.*}/disable", r.disablePlugin), + // router.NewPostRoute("/plugins/{name:.*}/push", r.pushPlugin), + // router.NewPostRoute("/plugins/{name:.*}/upgrade", r.upgradePlugin), + // router.NewPostRoute("/plugins/{name:.*}/set", r.setPlugin), + switch action { + case "": + if resource == "pull" { + return portainer.OperationDockerPluginPull + } else if resource == "create" { + return portainer.OperationDockerPluginCreate + } + case "enable": + return portainer.OperationDockerPluginEnable + case "disable": + return portainer.OperationDockerPluginDisable + case "push": + return portainer.OperationDockerPluginPush + case "upgrade": + return portainer.OperationDockerPluginUpgrade + case "set": + return portainer.OperationDockerPluginSet + } + case http.MethodDelete: + //// DELETE + // router.NewDeleteRoute("/plugins/{name:.*}", r.removePlugin), + if resource != "" && action == "" { + return portainer.OperationDockerPluginDelete + } + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/session/session.go +func dockerSessionOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("session", url) + + switch method { + case http.MethodPost: + //// POST + //router.NewPostRoute("/session", r.startSession), + if action == "" && resource == "" { + return portainer.OperationDockerSessionStart + } + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/distribution/distribution.go#L26 +func dockerDistributionOperationAuthorization(url, method string) portainer.Authorization { + _, action := extractResourceAndActionFromURL("distribution", url) + + switch method { + case http.MethodGet: + //// GET + //router.NewGetRoute("/distribution/{name:.*}/json", r.getDistributionInfo), + if action == "json" { + return portainer.OperationDockerDistributionInspect + } + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/container/container.go#L31 +func dockerCommitOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("commit", url) + + switch method { + case http.MethodPost: + //// POST + // router.NewPostRoute("/commit", r.postCommit), + if resource == "" && action == "" { + return portainer.OperationDockerImageCommit + } + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/build/build.go#L32 +func dockerBuildOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("build", url) + + switch method { + case http.MethodPost: + //// POST + // router.NewPostRoute("/build", r.postBuild), + // router.NewPostRoute("/build/prune", r.postPrune), + // router.NewPostRoute("/build/cancel", r.postCancel), + switch action { + case "": + if resource == "" { + return portainer.OperationDockerImageBuild + } else if resource == "prune" { + return portainer.OperationDockerBuildPrune + } else if resource == "cancel" { + return portainer.OperationDockerBuildCancel + } + } + } + + return portainer.OperationDockerUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/image/image.go#L26 +func dockerImageOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("images", url) + + switch method { + case http.MethodGet: + //// GET + //router.NewGetRoute("/images/json", r.getImagesJSON), + // router.NewGetRoute("/images/search", r.getImagesSearch), + // router.NewGetRoute("/images/get", r.getImagesGet), + // router.NewGetRoute("/images/{name:.*}/get", r.getImagesGet), + // router.NewGetRoute("/images/{name:.*}/history", r.getImagesHistory), + // router.NewGetRoute("/images/{name:.*}/json", r.getImagesByName), + switch action { + case "": + if resource == "json" { + return portainer.OperationDockerImageList + } else if resource == "search" { + return portainer.OperationDockerImageSearch + } else if resource == "get" { + return portainer.OperationDockerImageGetAll + } + case "get": + return portainer.OperationDockerImageGet + case "history": + return portainer.OperationDockerImageHistory + case "json": + return portainer.OperationDockerImageInspect + } + case http.MethodPost: + //// POST + // router.NewPostRoute("/images/load", r.postImagesLoad), + // router.NewPostRoute("/images/create", r.postImagesCreate), + // router.NewPostRoute("/images/{name:.*}/push", r.postImagesPush), + // router.NewPostRoute("/images/{name:.*}/tag", r.postImagesTag), + // router.NewPostRoute("/images/prune", r.postImagesPrune), + switch action { + case "": + if resource == "load" { + return portainer.OperationDockerImageLoad + } else if resource == "create" { + return portainer.OperationDockerImageCreate + } else if resource == "prune" { + return portainer.OperationDockerImagePrune + } + case "push": + return portainer.OperationDockerImagePush + case "tag": + return portainer.OperationDockerImageTag + } + case http.MethodDelete: + //// DELETE + // router.NewDeleteRoute("/images/{name:.*}", r.deleteImages) + return portainer.OperationDockerImageDelete + } + + return portainer.OperationDockerUndefined +} + +func agentAgentsOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("agents", url) + + switch method { + case http.MethodGet: + if action == "" && resource == "" { + return portainer.OperationDockerAgentList + } + } + + return portainer.OperationDockerAgentUndefined +} + +func agentBrowseOperationAuthorization(url, method string) portainer.Authorization { + resource, _ := extractResourceAndActionFromURL("browse", url) + + switch method { + case http.MethodGet: + switch resource { + case "ls": + return portainer.OperationDockerAgentBrowseList + case "get": + return portainer.OperationDockerAgentBrowseGet + } + case http.MethodDelete: + if resource == "delete" { + return portainer.OperationDockerAgentBrowseDelete + } + case http.MethodPut: + if resource == "rename" { + return portainer.OperationDockerAgentBrowseRename + } + case http.MethodPost: + if resource == "put" { + return portainer.OperationDockerAgentBrowsePut + } + + } + + return portainer.OperationDockerAgentUndefined +} + +func agentHostOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("host", url) + + switch method { + case http.MethodGet: + if action == "" && resource == "info" { + return portainer.OperationDockerAgentHostInfo + } + } + + return portainer.OperationDockerAgentUndefined +} + +// Based on the routes available at +// https://github.com/moby/moby/blob/c12f09bf99/api/server/router/container/container.go#L31 +func dockerContainerOperationAuthorization(url, method string) portainer.Authorization { + resource, action := extractResourceAndActionFromURL("containers", url) + + switch method { + case http.MethodHead: + //// HEAD + //router.NewHeadRoute("/containers/{name:.*}/archive", r.headContainersArchive), + if action == "archive" { + return portainer.OperationDockerContainerArchiveInfo + } + case http.MethodGet: + //// GET + // router.NewGetRoute("/containers/json", r.getContainersJSON), + // router.NewGetRoute("/containers/{name:.*}/export", r.getContainersExport), + // router.NewGetRoute("/containers/{name:.*}/changes", r.getContainersChanges), + // router.NewGetRoute("/containers/{name:.*}/json", r.getContainersByName), + // router.NewGetRoute("/containers/{name:.*}/top", r.getContainersTop), + // router.NewGetRoute("/containers/{name:.*}/logs", r.getContainersLogs), + // router.NewGetRoute("/containers/{name:.*}/stats", r.getContainersStats), + // router.NewGetRoute("/containers/{name:.*}/attach/ws", r.wsContainersAttach), + // router.NewGetRoute("/exec/{id:.*}/json", r.getExecByID), + // router.NewGetRoute("/containers/{name:.*}/archive", r.getContainersArchive), + switch action { + case "": + if resource == "json" { + return portainer.OperationDockerContainerList + } + case "export": + return portainer.OperationDockerContainerExport + case "changes": + return portainer.OperationDockerContainerChanges + case "json": + return portainer.OperationDockerContainerInspect + case "top": + return portainer.OperationDockerContainerTop + case "logs": + return portainer.OperationDockerContainerLogs + case "stats": + return portainer.OperationDockerContainerStats + case "attach/ws": + return portainer.OperationDockerContainerAttachWebsocket + case "archive": + return portainer.OperationDockerContainerArchive + } + case http.MethodPost: + //// POST + // router.NewPostRoute("/containers/create", r.postContainersCreate), + // router.NewPostRoute("/containers/{name:.*}/kill", r.postContainersKill), + // router.NewPostRoute("/containers/{name:.*}/pause", r.postContainersPause), + // router.NewPostRoute("/containers/{name:.*}/unpause", r.postContainersUnpause), + // router.NewPostRoute("/containers/{name:.*}/restart", r.postContainersRestart), + // router.NewPostRoute("/containers/{name:.*}/start", r.postContainersStart), + // router.NewPostRoute("/containers/{name:.*}/stop", r.postContainersStop), + // router.NewPostRoute("/containers/{name:.*}/wait", r.postContainersWait), + // router.NewPostRoute("/containers/{name:.*}/resize", r.postContainersResize), + // router.NewPostRoute("/containers/{name:.*}/attach", r.postContainersAttach), + // router.NewPostRoute("/containers/{name:.*}/copy", r.postContainersCopy), // Deprecated since 1.8, Errors out since 1.12 + // router.NewPostRoute("/containers/{name:.*}/exec", r.postContainerExecCreate), + // router.NewPostRoute("/exec/{name:.*}/start", r.postContainerExecStart), + // router.NewPostRoute("/exec/{name:.*}/resize", r.postContainerExecResize), + // router.NewPostRoute("/containers/{name:.*}/rename", r.postContainerRename), + // router.NewPostRoute("/containers/{name:.*}/update", r.postContainerUpdate), + // router.NewPostRoute("/containers/prune", r.postContainersPrune), + // router.NewPostRoute("/commit", r.postCommit), + switch action { + case "": + if resource == "create" { + return portainer.OperationDockerContainerCreate + } else if resource == "prune" { + return portainer.OperationDockerContainerPrune + } + case "kill": + return portainer.OperationDockerContainerKill + case "pause": + return portainer.OperationDockerContainerPause + case "unpause": + return portainer.OperationDockerContainerUnpause + case "restart": + return portainer.OperationDockerContainerRestart + case "start": + return portainer.OperationDockerContainerStart + case "stop": + return portainer.OperationDockerContainerStop + case "wait": + return portainer.OperationDockerContainerWait + case "resize": + return portainer.OperationDockerContainerResize + case "attach": + return portainer.OperationDockerContainerAttach + case "exec": + return portainer.OperationDockerContainerExec + case "rename": + return portainer.OperationDockerContainerRename + case "update": + return portainer.OperationDockerContainerUpdate + } + case http.MethodPut: + //// PUT + // router.NewPutRoute("/containers/{name:.*}/archive", r.putContainersArchive), + if action == "archive" { + return portainer.OperationDockerContainerPutContainerArchive + } + case http.MethodDelete: + //// DELETE + // router.NewDeleteRoute("/containers/{name:.*}", r.deleteContainers), + if resource != "" && action == "" { + return portainer.OperationDockerContainerDelete + } + } + + return portainer.OperationDockerUndefined +} diff --git a/api/http/server.go b/api/http/server.go index 8f83529f1..b7f58690e 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -5,7 +5,7 @@ import ( "path/filepath" "time" - portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/http/handler" @@ -39,6 +39,7 @@ import ( "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/kubernetes/cli" ) @@ -70,6 +71,7 @@ type Server struct { // Start starts the HTTP server func (server *Server) Start() error { + authorizationService := authorization.NewService(server.DataStore) kubernetesTokenCacheManager := kubernetes.NewTokenCacheManager() proxyManager := proxy.NewManager(server.DataStore, server.SignatureService, server.ReverseTunnelService, server.DockerClientFactory, server.KubernetesClientFactory, kubernetesTokenCacheManager) @@ -78,6 +80,7 @@ func (server *Server) Start() error { rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) var authHandler = auth.NewHandler(requestBouncer, rateLimiter) + authHandler.AuthorizationService = authorizationService authHandler.DataStore = server.DataStore authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService @@ -114,6 +117,7 @@ func (server *Server) Start() error { edgeTemplatesHandler.DataStore = server.DataStore var endpointHandler = endpoints.NewHandler(requestBouncer) + endpointHandler.AuthorizationService = authorizationService endpointHandler.DataStore = server.DataStore endpointHandler.FileService = server.FileService endpointHandler.ProxyManager = proxyManager @@ -127,6 +131,7 @@ func (server *Server) Start() error { endpointEdgeHandler.ReverseTunnelService = server.ReverseTunnelService var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) + endpointGroupHandler.AuthorizationService = authorizationService endpointGroupHandler.DataStore = server.DataStore var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) @@ -147,6 +152,7 @@ func (server *Server) Start() error { resourceControlHandler.DataStore = server.DataStore var settingsHandler = settings.NewHandler(requestBouncer) + settingsHandler.AuthorizationService = authorizationService settingsHandler.DataStore = server.DataStore settingsHandler.FileService = server.FileService settingsHandler.JWTService = server.JWTService @@ -165,9 +171,11 @@ func (server *Server) Start() error { tagHandler.DataStore = server.DataStore var teamHandler = teams.NewHandler(requestBouncer) + teamHandler.AuthorizationService = authorizationService teamHandler.DataStore = server.DataStore var teamMembershipHandler = teammemberships.NewHandler(requestBouncer) + teamMembershipHandler.AuthorizationService = authorizationService teamMembershipHandler.DataStore = server.DataStore var statusHandler = status.NewHandler(requestBouncer, server.Status) @@ -181,6 +189,7 @@ func (server *Server) Start() error { uploadHandler.FileService = server.FileService var userHandler = users.NewHandler(requestBouncer, rateLimiter) + userHandler.AuthorizationService = authorizationService userHandler.DataStore = server.DataStore userHandler.CryptoService = server.CryptoService diff --git a/api/internal/authorization/access_control.go b/api/internal/authorization/access_control.go index 0e2d91ab7..a552af49f 100644 --- a/api/internal/authorization/access_control.go +++ b/api/internal/authorization/access_control.go @@ -123,6 +123,12 @@ func FilterAuthorizedStacks(stacks []portainer.Stack, user *portainer.User, user authorizedStacks := make([]portainer.Stack, 0) for _, stack := range stacks { + _, isEndpointAdmin := user.EndpointAuthorizations[stack.EndpointID][portainer.EndpointResourcesAccess] + if isEndpointAdmin { + authorizedStacks = append(authorizedStacks, stack) + continue + } + if stack.ResourceControl != nil && UserCanAccessResource(user.ID, userTeamIDs, stack.ResourceControl) { authorizedStacks = append(authorizedStacks, stack) } diff --git a/api/internal/authorization/authorizations.go b/api/internal/authorization/authorizations.go index 31916cd31..20b5da48c 100644 --- a/api/internal/authorization/authorizations.go +++ b/api/internal/authorization/authorizations.go @@ -424,6 +424,182 @@ func DefaultPortainerAuthorizations() portainer.Authorizations { } } +// UpdateVolumeBrowsingAuthorizations will update all the volume browsing authorizations for each role (except endpoint administrator) +// based on the specified removeAuthorizations parameter. If removeAuthorizations is set to true, all +// the authorizations will be dropped for the each role. If removeAuthorizations is set to false, the authorizations +// will be reset based for each role. +func (service Service) UpdateVolumeBrowsingAuthorizations(remove bool) error { + roles, err := service.dataStore.Role().Roles() + if err != nil { + return err + } + + for _, role := range roles { + // all roles except endpoint administrator + if role.ID != portainer.RoleID(1) { + updateRoleVolumeBrowsingAuthorizations(&role, remove) + + err := service.dataStore.Role().UpdateRole(role.ID, &role) + if err != nil { + return err + } + } + } + + return nil +} + +func updateRoleVolumeBrowsingAuthorizations(role *portainer.Role, removeAuthorizations bool) { + if !removeAuthorizations { + delete(role.Authorizations, portainer.OperationDockerAgentBrowseDelete) + delete(role.Authorizations, portainer.OperationDockerAgentBrowseGet) + delete(role.Authorizations, portainer.OperationDockerAgentBrowseList) + delete(role.Authorizations, portainer.OperationDockerAgentBrowsePut) + delete(role.Authorizations, portainer.OperationDockerAgentBrowseRename) + return + } + + role.Authorizations[portainer.OperationDockerAgentBrowseGet] = true + role.Authorizations[portainer.OperationDockerAgentBrowseList] = true + + // Standard-user + if role.ID == portainer.RoleID(3) { + role.Authorizations[portainer.OperationDockerAgentBrowseDelete] = true + role.Authorizations[portainer.OperationDockerAgentBrowsePut] = true + role.Authorizations[portainer.OperationDockerAgentBrowseRename] = true + } +} + +// RemoveTeamAccessPolicies will remove all existing access policies associated to the specified team +func (service *Service) RemoveTeamAccessPolicies(teamID portainer.TeamID) error { + endpoints, err := service.dataStore.Endpoint().Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + for policyTeamID := range endpoint.TeamAccessPolicies { + if policyTeamID == teamID { + delete(endpoint.TeamAccessPolicies, policyTeamID) + + err := service.dataStore.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + + break + } + } + } + + endpointGroups, err := service.dataStore.EndpointGroup().EndpointGroups() + if err != nil { + return err + } + + for _, endpointGroup := range endpointGroups { + for policyTeamID := range endpointGroup.TeamAccessPolicies { + if policyTeamID == teamID { + delete(endpointGroup.TeamAccessPolicies, policyTeamID) + + err := service.dataStore.EndpointGroup().UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) + if err != nil { + return err + } + + break + } + } + } + + registries, err := service.dataStore.Registry().Registries() + if err != nil { + return err + } + + for _, registry := range registries { + for policyTeamID := range registry.TeamAccessPolicies { + if policyTeamID == teamID { + delete(registry.TeamAccessPolicies, policyTeamID) + + err := service.dataStore.Registry().UpdateRegistry(registry.ID, ®istry) + if err != nil { + return err + } + + break + } + } + } + + return service.UpdateUsersAuthorizations() +} + +// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user +func (service *Service) RemoveUserAccessPolicies(userID portainer.UserID) error { + endpoints, err := service.dataStore.Endpoint().Endpoints() + if err != nil { + return err + } + + for _, endpoint := range endpoints { + for policyUserID := range endpoint.UserAccessPolicies { + if policyUserID == userID { + delete(endpoint.UserAccessPolicies, policyUserID) + + err := service.dataStore.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + + break + } + } + } + + endpointGroups, err := service.dataStore.EndpointGroup().EndpointGroups() + if err != nil { + return err + } + + for _, endpointGroup := range endpointGroups { + for policyUserID := range endpointGroup.UserAccessPolicies { + if policyUserID == userID { + delete(endpointGroup.UserAccessPolicies, policyUserID) + + err := service.dataStore.EndpointGroup().UpdateEndpointGroup(endpointGroup.ID, &endpointGroup) + if err != nil { + return err + } + + break + } + } + } + + registries, err := service.dataStore.Registry().Registries() + if err != nil { + return err + } + + for _, registry := range registries { + for policyUserID := range registry.UserAccessPolicies { + if policyUserID == userID { + delete(registry.UserAccessPolicies, policyUserID) + + err := service.dataStore.Registry().UpdateRegistry(registry.ID, ®istry) + if err != nil { + return err + } + + break + } + } + } + + return nil +} + // UpdateUsersAuthorizations will trigger an update of the authorizations for all the users. func (service *Service) UpdateUsersAuthorizations() error { users, err := service.dataStore.User().Users() diff --git a/api/portainer.go b/api/portainer.go index 265874998..3e9f45109 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -14,6 +14,13 @@ type ( // AgentPlatform represents a platform type for an Agent AgentPlatform int + // APIOperationAuthorizationRequest represent an request for the authorization to execute an API operation + APIOperationAuthorizationRequest struct { + Path string + Method string + Authorizations Authorizations + } + // AuthenticationMethod represents the authentication method used to authenticate a user AuthenticationMethod int @@ -729,13 +736,10 @@ type ( // User represents a user account User struct { - ID UserID `json:"Id"` - Username string `json:"Username"` - Password string `json:"Password,omitempty"` - Role UserRole `json:"Role"` - - // Deprecated fields - // Deprecated in DBVersion == 25 + ID UserID `json:"Id"` + Username string `json:"Username"` + Password string `json:"Password,omitempty"` + Role UserRole `json:"Role"` PortainerAuthorizations Authorizations `json:"PortainerAuthorizations"` EndpointAuthorizations EndpointAuthorizations `json:"EndpointAuthorizations"` } diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index a59371819..b6638d051 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -645,9 +645,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [ HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); $scope.isAdmin = Authentication.isAdmin(); - $scope.showDeviceMapping = await shouldShowDevices(); - $scope.areContainerCapabilitiesEnabled = await checkIfContainerCapabilitiesEnabled(); - $scope.isAdminOrEndpointAdmin = Authentication.isAdmin(); + $scope.showDeviceMapping = shouldShowDevices(); + $scope.areContainerCapabilitiesEnabled = checkIfContainerCapabilitiesEnabled(); Volume.query( {}, @@ -932,16 +931,20 @@ angular.module('portainer.docker').controller('CreateContainerController', [ } } - async function shouldShowDevices() { + function shouldShowDevices() { const { allowDeviceMappingForRegularUsers } = $scope.applicationState.application; - return allowDeviceMappingForRegularUsers || Authentication.isAdmin(); + return allowDeviceMappingForRegularUsers || checkIfAdminOrEndpointAdmin(); } - async function checkIfContainerCapabilitiesEnabled() { + function checkIfContainerCapabilitiesEnabled() { const { allowContainerCapabilitiesForRegularUsers } = $scope.applicationState.application; - return allowContainerCapabilitiesForRegularUsers || Authentication.isAdmin(); + return allowContainerCapabilitiesForRegularUsers || checkIfAdminOrEndpointAdmin(); + } + + function checkIfAdminOrEndpointAdmin() { + return Authentication.isAdmin() || Authentication.hasAuthorizations(['EndpointResourcesAccess']); } initView(); diff --git a/app/docker/views/dashboard/dashboardController.js b/app/docker/views/dashboard/dashboardController.js index dfcac9981..5c12e1343 100644 --- a/app/docker/views/dashboard/dashboardController.js +++ b/app/docker/views/dashboard/dashboardController.js @@ -91,7 +91,7 @@ angular.module('portainer.docker').controller('DashboardController', [ const isAdmin = Authentication.isAdmin(); const { allowStackManagementForRegularUsers } = $scope.applicationState.application; - return isAdmin || allowStackManagementForRegularUsers; + return isAdmin || allowStackManagementForRegularUsers || Authentication.hasAuthorizations(['EndpointResourcesAccess']); } initView(); diff --git a/app/docker/views/services/create/createServiceController.js b/app/docker/views/services/create/createServiceController.js index 2edddb658..bba6a269e 100644 --- a/app/docker/views/services/create/createServiceController.js +++ b/app/docker/views/services/create/createServiceController.js @@ -589,8 +589,7 @@ angular.module('portainer.docker').controller('CreateServiceController', [ const settings = await SettingsService.publicSettings(); const { AllowBindMountsForRegularUsers } = settings; - - return isAdmin || AllowBindMountsForRegularUsers; + return isAdmin || AllowBindMountsForRegularUsers || Authentication.hasAuthorizations(['EndpointResourcesAccess']); } }, ]); diff --git a/app/portainer/__module.js b/app/portainer/__module.js index bc55146b5..ca271f2d2 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -1,4 +1,5 @@ import _ from 'lodash-es'; +import './rbac'; import './registry-management'; @@ -17,7 +18,7 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat return await Authentication.init(); } -angular.module('portainer.app', ['portainer.oauth', 'portainer.registrymanagement']).config([ +angular.module('portainer.app', ['portainer.oauth', 'portainer.rbac', 'portainer.registrymanagement']).config([ '$stateRegistryProvider', function ($stateRegistryProvider) { 'use strict'; diff --git a/app/portainer/components/access-datatable/accessDatatable.html b/app/portainer/components/access-datatable/accessDatatable.html index 7dc4540d3..2151b8cd6 100644 --- a/app/portainer/components/access-datatable/accessDatatable.html +++ b/app/portainer/components/access-datatable/accessDatatable.html @@ -10,6 +10,15 @@ + - +
+ +
+ +
+
@@ -57,6 +64,9 @@ title-icon="fa-user-lock" table-key="{{ 'access_' + ctrl.entityType }}" order-by="Name" + is-update-enabled="ctrl.entityType !== 'registry'" + show-roles="ctrl.entityType !== 'registry'" + roles="ctrl.roles" inherit-from="ctrl.inheritFrom" dataset="ctrl.authorizedUsersAndTeams" update-action="ctrl.updateAction" diff --git a/app/portainer/components/accessManagement/porAccessManagementController.js b/app/portainer/components/accessManagement/porAccessManagementController.js index 460c302d9..bc0c408e8 100644 --- a/app/portainer/components/accessManagement/porAccessManagementController.js +++ b/app/portainer/components/accessManagement/porAccessManagementController.js @@ -4,9 +4,8 @@ import angular from 'angular'; class PorAccessManagementController { /* @ngInject */ - constructor(Notifications, AccessService) { - this.Notifications = Notifications; - this.AccessService = AccessService; + constructor(Notifications, AccessService, RoleService) { + Object.assign(this, { Notifications, AccessService, RoleService }); this.unauthorizeAccess = this.unauthorizeAccess.bind(this); this.updateAction = this.updateAction.bind(this); @@ -29,10 +28,11 @@ class PorAccessManagementController { const entity = this.accessControlledEntity; const oldUserAccessPolicies = entity.UserAccessPolicies; const oldTeamAccessPolicies = entity.TeamAccessPolicies; + const selectedRoleId = this.formValues.selectedRole.Id; const selectedUserAccesses = _.filter(this.formValues.multiselectOutput, (access) => access.Type === 'user'); const selectedTeamAccesses = _.filter(this.formValues.multiselectOutput, (access) => access.Type === 'team'); - const accessPolicies = this.AccessService.generateAccessPolicies(oldUserAccessPolicies, oldTeamAccessPolicies, selectedUserAccesses, selectedTeamAccesses, 0); + const accessPolicies = this.AccessService.generateAccessPolicies(oldUserAccessPolicies, oldTeamAccessPolicies, selectedUserAccesses, selectedTeamAccesses, selectedRoleId); this.accessControlledEntity.UserAccessPolicies = accessPolicies.userAccessPolicies; this.accessControlledEntity.TeamAccessPolicies = accessPolicies.teamAccessPolicies; this.updateAccess(); @@ -54,6 +54,11 @@ class PorAccessManagementController { const entity = this.accessControlledEntity; const parent = this.inheritFrom; + this.roles = await this.RoleService.roles(); + this.formValues = { + selectedRole: this.roles[0], + }; + const data = await this.AccessService.accesses(entity, parent, this.roles); this.availableUsersAndTeams = _.orderBy(data.availableUsersAndTeams, 'Name', 'asc'); this.authorizedUsersAndTeams = data.authorizedUsersAndTeams; diff --git a/app/portainer/rbac/components/access-viewer/accessViewer.html b/app/portainer/rbac/components/access-viewer/accessViewer.html new file mode 100644 index 000000000..0a933f8fe --- /dev/null +++ b/app/portainer/rbac/components/access-viewer/accessViewer.html @@ -0,0 +1,38 @@ +
+ + + +
+
+ User +
+
+
+ + No user available + + + + {{ $select.selected.Username }} + + + {{ item.Username }} + + +
+
+ +
+ Access +
+
+
+ + Effective role for each endpoint will be displayed for the selected user +
+
+ +
+
+
+
diff --git a/app/portainer/rbac/components/access-viewer/accessViewer.js b/app/portainer/rbac/components/access-viewer/accessViewer.js new file mode 100644 index 000000000..0b4dd9c05 --- /dev/null +++ b/app/portainer/rbac/components/access-viewer/accessViewer.js @@ -0,0 +1,5 @@ +angular.module('portainer.rbac').component('accessViewer', { + templateUrl: './accessViewer.html', + controller: 'AccessViewerController', + controllerAs: 'ctrl', +}); diff --git a/app/portainer/rbac/components/access-viewer/accessViewerController.js b/app/portainer/rbac/components/access-viewer/accessViewerController.js new file mode 100644 index 000000000..7dab19b39 --- /dev/null +++ b/app/portainer/rbac/components/access-viewer/accessViewerController.js @@ -0,0 +1,122 @@ +import _ from 'lodash-es'; +import angular from 'angular'; + +import AccessViewerPolicyModel from '../../models/access'; + +class AccessViewerController { + /* @ngInject */ + constructor(Notifications, RoleService, UserService, EndpointService, GroupService, TeamService, TeamMembershipService) { + this.Notifications = Notifications; + this.RoleService = RoleService; + this.UserService = UserService; + this.EndpointService = EndpointService; + this.GroupService = GroupService; + this.TeamService = TeamService; + this.TeamMembershipService = TeamMembershipService; + } + + onUserSelect() { + this.userRoles = []; + const userRoles = {}; + const user = this.selectedUser; + const userMemberships = _.filter(this.teamMemberships, { UserId: user.Id }); + + for (const [, endpoint] of _.entries(this.endpoints)) { + let role = this.getRoleFromUserEndpointPolicy(user, endpoint); + if (role) { + userRoles[endpoint.Id] = role; + continue; + } + + role = this.getRoleFromUserEndpointGroupPolicy(user, endpoint); + if (role) { + userRoles[endpoint.Id] = role; + continue; + } + + role = this.getRoleFromTeamEndpointPolicies(userMemberships, endpoint); + if (role) { + userRoles[endpoint.Id] = role; + continue; + } + + role = this.getRoleFromTeamEndpointGroupPolicies(userMemberships, endpoint); + if (role) { + userRoles[endpoint.Id] = role; + } + } + + this.userRoles = _.values(userRoles); + } + + findLowestRole(policies) { + return _.first(_.orderBy(policies, 'RoleId', 'desc')); + } + + getRoleFromUserEndpointPolicy(user, endpoint) { + const policyRoles = []; + const policy = endpoint.UserAccessPolicies[user.Id]; + if (policy) { + const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, null, null); + policyRoles.push(accessPolicy); + } + return this.findLowestRole(policyRoles); + } + + getRoleFromUserEndpointGroupPolicy(user, endpoint) { + const policyRoles = []; + const policy = this.groupUserAccessPolicies[endpoint.GroupId][user.Id]; + if (policy) { + const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, this.groups[endpoint.GroupId], null); + policyRoles.push(accessPolicy); + } + return this.findLowestRole(policyRoles); + } + + getRoleFromTeamEndpointPolicies(memberships, endpoint) { + const policyRoles = []; + for (const membership of memberships) { + const policy = endpoint.TeamAccessPolicies[membership.TeamId]; + if (policy) { + const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, null, this.teams[membership.TeamId]); + policyRoles.push(accessPolicy); + } + } + return this.findLowestRole(policyRoles); + } + + getRoleFromTeamEndpointGroupPolicies(memberships, endpoint) { + const policyRoles = []; + for (const membership of memberships) { + const policy = this.groupTeamAccessPolicies[endpoint.GroupId][membership.TeamId]; + if (policy) { + const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, this.groups[endpoint.GroupId], this.teams[membership.TeamId]); + policyRoles.push(accessPolicy); + } + } + return this.findLowestRole(policyRoles); + } + + async $onInit() { + try { + this.users = await this.UserService.users(); + this.endpoints = _.keyBy((await this.EndpointService.endpoints()).value, 'Id'); + const groups = await this.GroupService.groups(); + this.groupUserAccessPolicies = {}; + this.groupTeamAccessPolicies = {}; + _.forEach(groups, (group) => { + this.groupUserAccessPolicies[group.Id] = group.UserAccessPolicies; + this.groupTeamAccessPolicies[group.Id] = group.TeamAccessPolicies; + }); + this.groups = _.keyBy(groups, 'Id'); + this.roles = _.keyBy(await this.RoleService.roles(), 'Id'); + this.teams = _.keyBy(await this.TeamService.teams(), 'Id'); + this.teamMemberships = await this.TeamMembershipService.memberships(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve accesses'); + } + } +} + +export default AccessViewerController; +angular.module('portainer.app').controller('AccessViewerController', AccessViewerController); diff --git a/app/portainer/rbac/components/access-viewer/datatable/accessViewerDatatable.html b/app/portainer/rbac/components/access-viewer/datatable/accessViewerDatatable.html new file mode 100644 index 000000000..9d91cd098 --- /dev/null +++ b/app/portainer/rbac/components/access-viewer/datatable/accessViewerDatatable.html @@ -0,0 +1,73 @@ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + Endpoint + + + + + + Role + + + + Access origin
{{ item.EndpointName }}{{ item.RoleName }}{{ item.TeamName ? 'Team' : 'User' }} {{ item.TeamName }} access defined on {{ item.AccessLocation }} + {{ item.GroupName }} + Manage access + + Manage access + +
Select a user to show associated access and role
The selected user does not have access to any endpoint(s)
+
+ +
diff --git a/app/portainer/rbac/components/access-viewer/datatable/accessViewerDatatable.js b/app/portainer/rbac/components/access-viewer/datatable/accessViewerDatatable.js new file mode 100644 index 000000000..3feb24627 --- /dev/null +++ b/app/portainer/rbac/components/access-viewer/datatable/accessViewerDatatable.js @@ -0,0 +1,11 @@ +angular.module('portainer.rbac').component('accessViewerDatatable', { + templateUrl: './accessViewerDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + tableKey: '@', + orderBy: '@', + dataset: '<', + }, +}); diff --git a/app/portainer/rbac/components/roles-datatable/rolesDatatable.html b/app/portainer/rbac/components/roles-datatable/rolesDatatable.html new file mode 100644 index 000000000..078ea19d0 --- /dev/null +++ b/app/portainer/rbac/components/roles-datatable/rolesDatatable.html @@ -0,0 +1,78 @@ +
+ + +
+
{{ $ctrl.titleText }}
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Description + + + +
{{ item.Name }}{{ item.Description }}
Loading...
No role available.
+
+ +
+
+
diff --git a/app/portainer/rbac/components/roles-datatable/rolesDatatable.js b/app/portainer/rbac/components/roles-datatable/rolesDatatable.js new file mode 100644 index 000000000..b8f55c641 --- /dev/null +++ b/app/portainer/rbac/components/roles-datatable/rolesDatatable.js @@ -0,0 +1,12 @@ +angular.module('portainer.rbac').component('rolesDatatable', { + templateUrl: './rolesDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + }, +}); diff --git a/app/portainer/rbac/directives/authorization.js b/app/portainer/rbac/directives/authorization.js new file mode 100644 index 000000000..24e2f1b95 --- /dev/null +++ b/app/portainer/rbac/directives/authorization.js @@ -0,0 +1,30 @@ +angular.module('portainer.rbac').directive('authorization', [ + 'Authentication', + '$async', + function (Authentication, $async) { + async function linkAsync(scope, elem, attrs) { + elem.hide(); + + var authorizations = attrs.authorization.split(','); + for (var i = 0; i < authorizations.length; i++) { + authorizations[i] = authorizations[i].trim(); + } + + var hasAuthorizations = Authentication.hasAuthorizations(authorizations); + + if (hasAuthorizations) { + elem.show(); + } else if (!hasAuthorizations && elem[0].tagName === 'A') { + elem.show(); + elem.addClass('portainer-disabled-link'); + } + } + + return { + restrict: 'A', + link: function (scope, elem, attrs) { + return $async(linkAsync, scope, elem, attrs); + }, + }; + }, +]); diff --git a/app/portainer/rbac/directives/disable-authorization.js b/app/portainer/rbac/directives/disable-authorization.js new file mode 100644 index 000000000..9c2c7f01a --- /dev/null +++ b/app/portainer/rbac/directives/disable-authorization.js @@ -0,0 +1,26 @@ +angular.module('portainer.rbac').directive('disableAuthorization', [ + 'Authentication', + '$async', + function (Authentication, $async) { + async function linkAsync(scope, elem, attrs) { + var authorizations = attrs.disableAuthorization.split(','); + for (var i = 0; i < authorizations.length; i++) { + authorizations[i] = authorizations[i].trim(); + } + + if (!Authentication.hasAuthorizations(authorizations)) { + elem.attr('disabled', true); + if (elem.is('Slider')) { + elem.css('pointer-events', 'none'); + } + } + } + + return { + restrict: 'A', + link: function (scope, elem, attrs) { + return $async(linkAsync, scope, elem, attrs); + }, + }; + }, +]); diff --git a/app/portainer/rbac/index.js b/app/portainer/rbac/index.js new file mode 100644 index 000000000..1bd6f011e --- /dev/null +++ b/app/portainer/rbac/index.js @@ -0,0 +1,23 @@ +angular + .module('portainer.rbac', ['ngResource']) + .constant('API_ENDPOINT_ROLES', 'api/roles') + .config([ + '$stateRegistryProvider', + function ($stateRegistryProvider) { + 'use strict'; + + var roles = { + name: 'portainer.roles', + url: '/roles', + views: { + 'content@': { + templateUrl: './views/roles/roles.html', + controller: 'RolesController', + controllerAs: 'ctrl', + }, + }, + }; + + $stateRegistryProvider.register(roles); + }, + ]); diff --git a/app/portainer/rbac/models/access.js b/app/portainer/rbac/models/access.js new file mode 100644 index 000000000..6326352f8 --- /dev/null +++ b/app/portainer/rbac/models/access.js @@ -0,0 +1,15 @@ +export default function AccessViewerPolicyModel(policy, endpoint, roles, group, team) { + this.EndpointId = endpoint.Id; + this.EndpointName = endpoint.Name; + this.RoleId = policy.RoleId; + this.RoleName = roles[policy.RoleId].Name; + if (group) { + this.GroupId = group.Id; + this.GroupName = group.Name; + } + if (team) { + this.TeamId = team.Id; + this.TeamName = team.Name; + } + this.AccessLocation = group ? 'endpoint group' : 'endpoint'; +} diff --git a/app/portainer/rbac/models/role.js b/app/portainer/rbac/models/role.js new file mode 100644 index 000000000..349bc0946 --- /dev/null +++ b/app/portainer/rbac/models/role.js @@ -0,0 +1,6 @@ +export function RoleViewModel(data) { + this.ID = data.Id; + this.Name = data.Name; + this.Description = data.Description; + this.Authorizations = data.Authorizations; +} diff --git a/app/portainer/rbac/rest/role.js b/app/portainer/rbac/rest/role.js new file mode 100644 index 000000000..bca280af3 --- /dev/null +++ b/app/portainer/rbac/rest/role.js @@ -0,0 +1,18 @@ +angular.module('portainer.app').factory('Roles', [ + '$resource', + 'API_ENDPOINT_ROLES', + function RolesFactory($resource, API_ENDPOINT_ROLES) { + 'use strict'; + return $resource( + API_ENDPOINT_ROLES + '/:id', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + } + ); + }, +]); diff --git a/app/portainer/rbac/services/roleService.js b/app/portainer/rbac/services/roleService.js new file mode 100644 index 000000000..646204afb --- /dev/null +++ b/app/portainer/rbac/services/roleService.js @@ -0,0 +1,35 @@ +import { RoleViewModel } from '../models/role'; + +angular.module('portainer.rbac').factory('RoleService', [ + '$q', + 'Roles', + function RoleService($q, Roles) { + 'use strict'; + var service = {}; + + service.role = function (roleId) { + var deferred = $q.defer(); + + Roles.get({ id: roleId }) + .$promise.then(function success(data) { + var role = new RoleViewModel(data); + deferred.resolve(role); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve role', err: err }); + }); + + return deferred.promise; + }; + + service.roles = function () { + return Roles.query({}).$promise; + }; + + service.deleteRole = function (roleId) { + return Roles.remove({ id: roleId }).$promise; + }; + + return service; + }, +]); diff --git a/app/portainer/rbac/views/roles/roles.html b/app/portainer/rbac/views/roles/roles.html new file mode 100644 index 000000000..fad73d985 --- /dev/null +++ b/app/portainer/rbac/views/roles/roles.html @@ -0,0 +1,18 @@ + + + + + + + Role management + + +
+
+ +
+
+ +
+ +
diff --git a/app/portainer/rbac/views/roles/rolesController.js b/app/portainer/rbac/views/roles/rolesController.js new file mode 100644 index 000000000..5074c27e2 --- /dev/null +++ b/app/portainer/rbac/views/roles/rolesController.js @@ -0,0 +1,21 @@ +import angular from 'angular'; + +class RolesController { + /* @ngInject */ + constructor(Notifications, RoleService) { + this.Notifications = Notifications; + this.RoleService = RoleService; + } + + async $onInit() { + this.roles = []; + + try { + this.roles = await this.RoleService.roles(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve roles'); + } + } +} +export default RolesController; +angular.module('portainer.rbac').controller('RolesController', RolesController); diff --git a/app/portainer/services/api/accessService.js b/app/portainer/services/api/accessService.js index d09f4bdfe..2c9acd0dd 100644 --- a/app/portainer/services/api/accessService.js +++ b/app/portainer/services/api/accessService.js @@ -11,7 +11,15 @@ angular.module('portainer.app').factory('AccessService', [ 'use strict'; var service = {}; - function _mapAccessData(accesses, authorizedPolicies, inheritedPolicies) { + function _getRole(roles, roleId) { + if (roles.length) { + const role = _.find(roles, (role) => role.Id === roleId); + return role ? role : { Id: 0, Name: '-' }; + } + return { Id: 0, Name: '-' }; + } + + function _mapAccessData(accesses, authorizedPolicies, inheritedPolicies, roles) { var availableAccesses = []; var authorizedAccesses = []; @@ -22,11 +30,14 @@ angular.module('portainer.app').factory('AccessService', [ const inherited = inheritedPolicies && inheritedPolicies[access.Id]; if (authorized && inherited) { + access.Role = _getRole(roles, authorizedPolicies[access.Id].RoleId); access.Override = true; authorizedAccesses.push(access); } else if (authorized && !inherited) { + access.Role = _getRole(roles, authorizedPolicies[access.Id].RoleId); authorizedAccesses.push(access); } else if (!authorized && inherited) { + access.Role = _getRole(roles, inheritedPolicies[access.Id].RoleId); access.Inherited = true; authorizedAccesses.push(access); availableAccesses.push(access); @@ -41,7 +52,7 @@ angular.module('portainer.app').factory('AccessService', [ }; } - function getAccesses(authorizedUserPolicies, authorizedTeamPolicies, inheritedUserPolicies, inheritedTeamPolicies) { + function getAccesses(authorizedUserPolicies, authorizedTeamPolicies, inheritedUserPolicies, inheritedTeamPolicies, roles) { var deferred = $q.defer(); $q.all({ @@ -56,8 +67,8 @@ angular.module('portainer.app').factory('AccessService', [ return new TeamAccessViewModel(team); }); - var userAccessData = _mapAccessData(userAccesses, authorizedUserPolicies, inheritedUserPolicies); - var teamAccessData = _mapAccessData(teamAccesses, authorizedTeamPolicies, inheritedTeamPolicies); + var userAccessData = _mapAccessData(userAccesses, authorizedUserPolicies, inheritedUserPolicies, roles); + var teamAccessData = _mapAccessData(teamAccesses, authorizedTeamPolicies, inheritedTeamPolicies, roles); var accessData = { availableUsersAndTeams: userAccessData.available.concat(teamAccessData.available), @@ -73,7 +84,7 @@ angular.module('portainer.app').factory('AccessService', [ return deferred.promise; } - async function accessesAsync(entity, parent) { + async function accessesAsync(entity, parent, roles) { try { if (!entity) { throw { msg: 'Unable to retrieve accesses' }; @@ -90,14 +101,14 @@ angular.module('portainer.app').factory('AccessService', [ if (parent && !parent.TeamAccessPolicies) { parent.TeamAccessPolicies = {}; } - return await getAccesses(entity.UserAccessPolicies, entity.TeamAccessPolicies, parent ? parent.UserAccessPolicies : {}, parent ? parent.TeamAccessPolicies : {}); + return await getAccesses(entity.UserAccessPolicies, entity.TeamAccessPolicies, parent ? parent.UserAccessPolicies : {}, parent ? parent.TeamAccessPolicies : {}, roles); } catch (err) { throw err; } } - function accesses(entity, parent) { - return $async(accessesAsync, entity, parent); + function accesses(entity, parent, roles) { + return $async(accessesAsync, entity, parent, roles); } service.accesses = accesses; diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index 80638cf72..c82d710ea 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -7,7 +7,8 @@ angular.module('portainer.app').factory('Authentication', [ 'LocalStorage', 'StateManager', 'EndpointProvider', - function AuthenticationFactory($async, $state, Auth, OAuth, jwtHelper, LocalStorage, StateManager, EndpointProvider) { + 'UserService', + function AuthenticationFactory($async, $state, Auth, OAuth, jwtHelper, LocalStorage, StateManager, EndpointProvider, UserService) { 'use strict'; var service = {}; @@ -20,6 +21,7 @@ angular.module('portainer.app').factory('Authentication', [ service.isAuthenticated = isAuthenticated; service.getUserDetails = getUserDetails; service.isAdmin = isAdmin; + service.hasAuthorizations = hasAuthorizations; async function initAsync() { try { @@ -79,12 +81,19 @@ angular.module('portainer.app').factory('Authentication', [ return user; } + async function retrievePermissions() { + const data = await UserService.user(user.ID); + user.endpointAuthorizations = data.EndpointAuthorizations; + user.portainerAuthorizations = data.PortainerAuthorizations; + } + async function setUser(jwt) { LocalStorage.storeJWT(jwt); var tokenPayload = jwtHelper.decodeToken(jwt); user.username = tokenPayload.username; user.ID = tokenPayload.id; user.role = tokenPayload.role; + await retrievePermissions(); } function isAdmin() { @@ -94,6 +103,18 @@ angular.module('portainer.app').factory('Authentication', [ return false; } + function hasAuthorizations(authorizations) { + const endpointId = EndpointProvider.endpointID(); + if (isAdmin()) { + return true; + } + if (!user.endpointAuthorizations || !user.endpointAuthorizations[endpointId]) { + return false; + } + const userEndpointAuthorizations = user.endpointAuthorizations[endpointId]; + return authorizations.some((authorization) => userEndpointAuthorizations[authorization]); + } + return service; }, ]); diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index 01008afb3..335bdc5d6 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -94,7 +94,10 @@
diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index 578e4dd58..b3270ce9f 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -120,11 +120,29 @@ ($state.current.name === 'portainer.users' || $state.current.name === 'portainer.users.user' || $state.current.name === 'portainer.teams' || - $state.current.name === 'portainer.teams.team') + $state.current.name === 'portainer.teams.team' || + $state.current.name === 'portainer.roles' || + $state.current.name === 'portainer.roles.role' || + $state.current.name === 'portainer.roles.new') " > Teams
+