diff --git a/api/bolt/data_migration.go b/api/bolt/data_migration.go new file mode 100644 index 000000000..2cc094e24 --- /dev/null +++ b/api/bolt/data_migration.go @@ -0,0 +1,76 @@ +package bolt + +import ( + "github.com/boltdb/bolt" + "github.com/portainer/portainer" +) + +type Migrator struct { + UserService *UserService + EndpointService *EndpointService + ResourceControlService *ResourceControlService + VersionService *VersionService + CurrentDBVersion int + store *Store +} + +func NewMigrator(store *Store, version int) *Migrator { + return &Migrator{ + UserService: store.UserService, + EndpointService: store.EndpointService, + ResourceControlService: store.ResourceControlService, + VersionService: store.VersionService, + CurrentDBVersion: version, + store: store, + } +} + +func (m *Migrator) Migrate() error { + + // Portainer < 1.12 + if m.CurrentDBVersion == 0 { + err := m.updateAdminUser() + if err != nil { + return err + } + } + + err := m.VersionService.StoreDBVersion(portainer.DBVersion) + if err != nil { + return err + } + return nil +} + +func (m *Migrator) updateAdminUser() error { + u, err := m.UserService.UserByUsername("admin") + if err == nil { + admin := &portainer.User{ + Username: "admin", + Password: u.Password, + Role: portainer.AdministratorRole, + } + err = m.UserService.CreateUser(admin) + if err != nil { + return err + } + err = m.removeLegacyAdminUser() + if err != nil { + return err + } + } else if err != nil && err != portainer.ErrUserNotFound { + return err + } + return nil +} + +func (m *Migrator) removeLegacyAdminUser() error { + return m.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + err := bucket.Delete([]byte("admin")) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index d0321db21..79e859a5f 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -1,9 +1,12 @@ package bolt import ( + "log" + "os" "time" "github.com/boltdb/bolt" + "github.com/portainer/portainer" ) // Store defines the implementation of portainer.DataStore using @@ -13,29 +16,49 @@ type Store struct { Path string // Services - UserService *UserService - EndpointService *EndpointService + UserService *UserService + EndpointService *EndpointService + ResourceControlService *ResourceControlService + VersionService *VersionService - db *bolt.DB + db *bolt.DB + checkForDataMigration bool } const ( - databaseFileName = "portainer.db" - userBucketName = "users" - endpointBucketName = "endpoints" - activeEndpointBucketName = "activeEndpoint" + databaseFileName = "portainer.db" + versionBucketName = "version" + userBucketName = "users" + endpointBucketName = "endpoints" + containerResourceControlBucketName = "containerResourceControl" + serviceResourceControlBucketName = "serviceResourceControl" + volumeResourceControlBucketName = "volumeResourceControl" ) // NewStore initializes a new Store and the associated services -func NewStore(storePath string) *Store { +func NewStore(storePath string) (*Store, error) { store := &Store{ - Path: storePath, - UserService: &UserService{}, - EndpointService: &EndpointService{}, + Path: storePath, + UserService: &UserService{}, + EndpointService: &EndpointService{}, + ResourceControlService: &ResourceControlService{}, + VersionService: &VersionService{}, } store.UserService.store = store store.EndpointService.store = store - return store + store.ResourceControlService.store = store + store.VersionService.store = store + + _, err := os.Stat(storePath) + if err != nil && os.IsNotExist(err) { + store.checkForDataMigration = false + } else if err != nil { + return nil, err + } else { + store.checkForDataMigration = true + } + + return store, nil } // Open opens and initializes the BoltDB database. @@ -47,7 +70,11 @@ func (store *Store) Open() error { } store.db = db return db.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists([]byte(userBucketName)) + _, err := tx.CreateBucketIfNotExists([]byte(versionBucketName)) + if err != nil { + return err + } + _, err = tx.CreateBucketIfNotExists([]byte(userBucketName)) if err != nil { return err } @@ -55,7 +82,15 @@ func (store *Store) Open() error { if err != nil { return err } - _, err = tx.CreateBucketIfNotExists([]byte(activeEndpointBucketName)) + _, err = tx.CreateBucketIfNotExists([]byte(containerResourceControlBucketName)) + if err != nil { + return err + } + _, err = tx.CreateBucketIfNotExists([]byte(serviceResourceControlBucketName)) + if err != nil { + return err + } + _, err = tx.CreateBucketIfNotExists([]byte(volumeResourceControlBucketName)) if err != nil { return err } @@ -70,3 +105,32 @@ func (store *Store) Close() error { } return nil } + +// MigrateData automatically migrate the data based on the DBVersion. +func (store *Store) MigrateData() error { + if !store.checkForDataMigration { + err := store.VersionService.StoreDBVersion(portainer.DBVersion) + if err != nil { + return err + } + return nil + } + + version, err := store.VersionService.DBVersion() + if err == portainer.ErrDBVersionNotFound { + version = 0 + } else if err != nil { + return err + } + + if version < portainer.DBVersion { + log.Printf("Migrating database from version %v to %v.\n", version, portainer.DBVersion) + migrator := NewMigrator(store, version) + err = migrator.Migrate() + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/endpoint_service.go b/api/bolt/endpoint_service.go index cb8349076..d7c230e9a 100644 --- a/api/bolt/endpoint_service.go +++ b/api/bolt/endpoint_service.go @@ -12,10 +12,6 @@ type EndpointService struct { store *Store } -const ( - activeEndpointID = 0 -) - // Endpoint returns an endpoint by ID. func (service *EndpointService) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) { var data []byte @@ -138,62 +134,6 @@ func (service *EndpointService) DeleteEndpoint(ID portainer.EndpointID) error { }) } -// GetActive returns the active endpoint. -func (service *EndpointService) GetActive() (*portainer.Endpoint, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(activeEndpointBucketName)) - value := bucket.Get(internal.Itob(activeEndpointID)) - if value == nil { - return portainer.ErrEndpointNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var endpoint portainer.Endpoint - err = internal.UnmarshalEndpoint(data, &endpoint) - if err != nil { - return nil, err - } - return &endpoint, nil -} - -// SetActive saves an endpoint as active. -func (service *EndpointService) SetActive(endpoint *portainer.Endpoint) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(activeEndpointBucketName)) - - data, err := internal.MarshalEndpoint(endpoint) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(activeEndpointID), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteActive deletes the active endpoint. -func (service *EndpointService) DeleteActive() error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(activeEndpointBucketName)) - err := bucket.Delete(internal.Itob(activeEndpointID)) - if err != nil { - return err - } - return nil - }) -} - func marshalAndStoreEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error { data, err := internal.MarshalEndpoint(endpoint) if err != nil { @@ -210,6 +150,5 @@ func marshalAndStoreEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) func storeNewEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error { id, _ := bucket.NextSequence() endpoint.ID = portainer.EndpointID(id) - return marshalAndStoreEndpoint(endpoint, bucket) } diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go index e9d6416eb..351592963 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/internal.go @@ -27,6 +27,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error { return json.Unmarshal(data, endpoint) } +// MarshalResourceControl encodes a resource control object to binary format. +func MarshalResourceControl(rc *portainer.ResourceControl) ([]byte, error) { + return json.Marshal(rc) +} + +// UnmarshalResourceControl decodes a resource control object from a binary data. +func UnmarshalResourceControl(data []byte, rc *portainer.ResourceControl) error { + return json.Unmarshal(data, rc) +} + // Itob returns an 8-byte big endian representation of v. // This function is typically used for encoding integer IDs to byte slices // so that they can be used as BoltDB keys. diff --git a/api/bolt/resourcecontrol_service.go b/api/bolt/resourcecontrol_service.go new file mode 100644 index 000000000..07b174616 --- /dev/null +++ b/api/bolt/resourcecontrol_service.go @@ -0,0 +1,110 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// ResourceControlService represents a service for managing resource controls. +type ResourceControlService struct { + store *Store +} + +func getBucketNameByResourceControlType(rcType portainer.ResourceControlType) string { + bucketName := containerResourceControlBucketName + if rcType == portainer.ServiceResourceControl { + bucketName = serviceResourceControlBucketName + } else if rcType == portainer.VolumeResourceControl { + bucketName = volumeResourceControlBucketName + } + return bucketName +} + +// ResourceControl returns a resource control object by resource ID +func (service *ResourceControlService) ResourceControl(resourceID string, rcType portainer.ResourceControlType) (*portainer.ResourceControl, error) { + var data []byte + bucketName := getBucketNameByResourceControlType(rcType) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + value := bucket.Get([]byte(resourceID)) + if value == nil { + return nil + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + if data == nil { + return nil, nil + } + + var rc portainer.ResourceControl + err = internal.UnmarshalResourceControl(data, &rc) + if err != nil { + return nil, err + } + return &rc, nil +} + +// ResourceControls returns all resource control objects +func (service *ResourceControlService) ResourceControls(rcType portainer.ResourceControlType) ([]portainer.ResourceControl, error) { + var rcs = make([]portainer.ResourceControl, 0) + bucketName := getBucketNameByResourceControlType(rcType) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var rc portainer.ResourceControl + err := internal.UnmarshalResourceControl(v, &rc) + if err != nil { + return err + } + rcs = append(rcs, rc) + } + + return nil + }) + if err != nil { + return nil, err + } + + return rcs, nil +} + +// CreateResourceControl creates a new resource control +func (service *ResourceControlService) CreateResourceControl(resourceID string, rc *portainer.ResourceControl, rcType portainer.ResourceControlType) error { + bucketName := getBucketNameByResourceControlType(rcType) + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + data, err := internal.MarshalResourceControl(rc) + if err != nil { + return err + } + + err = bucket.Put([]byte(resourceID), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteResourceControl deletes a resource control object by resource ID +func (service *ResourceControlService) DeleteResourceControl(resourceID string, rcType portainer.ResourceControlType) error { + bucketName := getBucketNameByResourceControlType(rcType) + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + err := bucket.Delete([]byte(resourceID)) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/bolt/user_service.go b/api/bolt/user_service.go index 0171c3e33..1e1c68f40 100644 --- a/api/bolt/user_service.go +++ b/api/bolt/user_service.go @@ -12,12 +12,12 @@ type UserService struct { store *Store } -// User returns a user by username. -func (service *UserService) User(username string) (*portainer.User, error) { +// User returns a user by ID +func (service *UserService) User(ID portainer.UserID) (*portainer.User, error) { var data []byte err := service.store.db.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(userBucketName)) - value := bucket.Get([]byte(username)) + value := bucket.Get(internal.Itob(int(ID))) if value == nil { return portainer.ErrUserNotFound } @@ -38,8 +38,88 @@ func (service *UserService) User(username string) (*portainer.User, error) { return &user, nil } +// UserByUsername returns a user by username. +func (service *UserService) UserByUsername(username string) (*portainer.User, error) { + var user *portainer.User + + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var u portainer.User + err := internal.UnmarshalUser(v, &u) + if err != nil { + return err + } + if u.Username == username { + user = &u + } + } + + if user == nil { + return portainer.ErrUserNotFound + } + return nil + }) + if err != nil { + return nil, err + } + return user, nil +} + +// Users return an array containing all the users. +func (service *UserService) Users() ([]portainer.User, error) { + var users = make([]portainer.User, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var user portainer.User + err := internal.UnmarshalUser(v, &user) + if err != nil { + return err + } + users = append(users, user) + } + + return nil + }) + if err != nil { + return nil, err + } + + return users, nil +} + +// UsersByRole return an array containing all the users with the specified role. +func (service *UserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { + var users = make([]portainer.User, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var user portainer.User + err := internal.UnmarshalUser(v, &user) + if err != nil { + return err + } + if user.Role == role { + users = append(users, user) + } + } + return nil + }) + if err != nil { + return nil, err + } + + return users, nil +} + // UpdateUser saves a user. -func (service *UserService) UpdateUser(user *portainer.User) error { +func (service *UserService) UpdateUser(ID portainer.UserID, user *portainer.User) error { data, err := internal.MarshalUser(user) if err != nil { return err @@ -47,7 +127,41 @@ func (service *UserService) UpdateUser(user *portainer.User) error { return service.store.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(userBucketName)) - err = bucket.Put([]byte(user.Username), data) + err = bucket.Put(internal.Itob(int(ID)), data) + + if err != nil { + return err + } + return nil + }) +} + +// CreateUser creates a new user. +func (service *UserService) CreateUser(user *portainer.User) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + + id, _ := bucket.NextSequence() + user.ID = portainer.UserID(id) + + data, err := internal.MarshalUser(user) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(user.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteUser deletes a user. +func (service *UserService) DeleteUser(ID portainer.UserID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) if err != nil { return err } diff --git a/api/bolt/version_service.go b/api/bolt/version_service.go new file mode 100644 index 000000000..2da511437 --- /dev/null +++ b/api/bolt/version_service.go @@ -0,0 +1,58 @@ +package bolt + +import ( + "strconv" + + "github.com/portainer/portainer" + + "github.com/boltdb/bolt" +) + +// EndpointService represents a service for managing users. +type VersionService struct { + store *Store +} + +const ( + DBVersionKey = "DB_VERSION" +) + +// DBVersion the stored database version. +func (service *VersionService) DBVersion() (int, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(versionBucketName)) + value := bucket.Get([]byte(DBVersionKey)) + if value == nil { + return portainer.ErrDBVersionNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return 0, err + } + + dbVersion, err := strconv.Atoi(string(data)) + if err != nil { + return 0, err + } + + return dbVersion, nil +} + +// StoreDBVersion store the database version. +func (service *VersionService) StoreDBVersion(version int) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(versionBucketName)) + + data := []byte(strconv.Itoa(version)) + err := bucket.Put([]byte(DBVersionKey), data) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 12b778c11..6ebdf1ee1 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -36,8 +36,17 @@ func initFileService(dataStorePath string) portainer.FileService { } func initStore(dataStorePath string) *bolt.Store { - var store = bolt.NewStore(dataStorePath) - err := store.Open() + store, err := bolt.NewStore(dataStorePath) + if err != nil { + log.Fatal(err) + } + + err = store.Open() + if err != nil { + log.Fatal(err) + } + + err = store.MigrateData() if err != nil { log.Fatal(err) } @@ -91,31 +100,6 @@ func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService return &endpoints[0] } -func initActiveEndpoint(endpointService portainer.EndpointService, flags *portainer.CLIFlags) *portainer.Endpoint { - activeEndpoint, err := endpointService.GetActive() - if err == portainer.ErrEndpointNotFound { - if *flags.Endpoint != "" { - activeEndpoint = &portainer.Endpoint{ - Name: "primary", - URL: *flags.Endpoint, - TLS: *flags.TLSVerify, - TLSCACertPath: *flags.TLSCacert, - TLSCertPath: *flags.TLSCert, - TLSKeyPath: *flags.TLSKey, - } - err = endpointService.CreateEndpoint(activeEndpoint) - if err != nil { - log.Fatal(err) - } - } else if *flags.ExternalEndpoints != "" { - activeEndpoint = retrieveFirstEndpointFromDatabase(endpointService) - } - } else if err != nil { - log.Fatal(err) - } - return activeEndpoint -} - func main() { flags := initCLI() @@ -132,21 +116,43 @@ func main() { settings := initSettings(authorizeEndpointMgmt, flags) - activeEndpoint := initActiveEndpoint(store.EndpointService, flags) + if *flags.Endpoint != "" { + var endpoints []portainer.Endpoint + endpoints, err := store.EndpointService.Endpoints() + if err != nil { + log.Fatal(err) + } + if len(endpoints) == 0 { + endpoint := &portainer.Endpoint{ + Name: "primary", + URL: *flags.Endpoint, + TLS: *flags.TLSVerify, + TLSCACertPath: *flags.TLSCacert, + TLSCertPath: *flags.TLSCert, + TLSKeyPath: *flags.TLSKey, + } + err = store.EndpointService.CreateEndpoint(endpoint) + if err != nil { + log.Fatal(err) + } + } else { + log.Println("Instance already has defined endpoints. Skipping the endpoint defined via CLI.") + } + } var server portainer.Server = &http.Server{ - BindAddress: *flags.Addr, - AssetsPath: *flags.Assets, - Settings: settings, - TemplatesURL: *flags.Templates, - AuthDisabled: *flags.NoAuth, - EndpointManagement: authorizeEndpointMgmt, - UserService: store.UserService, - EndpointService: store.EndpointService, - CryptoService: cryptoService, - JWTService: jwtService, - FileService: fileService, - ActiveEndpoint: activeEndpoint, + BindAddress: *flags.Addr, + AssetsPath: *flags.Assets, + Settings: settings, + TemplatesURL: *flags.Templates, + AuthDisabled: *flags.NoAuth, + EndpointManagement: authorizeEndpointMgmt, + UserService: store.UserService, + EndpointService: store.EndpointService, + ResourceControlService: store.ResourceControlService, + CryptoService: cryptoService, + JWTService: jwtService, + FileService: fileService, } log.Printf("Starting Portainer on %s", *flags.Addr) diff --git a/api/cron/endpoint_sync.go b/api/cron/endpoint_sync.go index 9dcd4e290..3401c6893 100644 --- a/api/cron/endpoint_sync.go +++ b/api/cron/endpoint_sync.go @@ -66,7 +66,10 @@ func endpointExists(endpoint *portainer.Endpoint, endpoints []portainer.Endpoint func mergeEndpointIfRequired(original, updated *portainer.Endpoint) *portainer.Endpoint { var endpoint *portainer.Endpoint - if original.URL != updated.URL || original.TLS != updated.TLS { + if original.URL != updated.URL || original.TLS != updated.TLS || + (updated.TLS && original.TLSCACertPath != updated.TLSCACertPath) || + (updated.TLS && original.TLSCertPath != updated.TLSCertPath) || + (updated.TLS && original.TLSKeyPath != updated.TLSKeyPath) { endpoint = original endpoint.URL = updated.URL if updated.TLS { diff --git a/api/errors.go b/api/errors.go index 48ea6ab75..14dbc3156 100644 --- a/api/errors.go +++ b/api/errors.go @@ -2,19 +2,26 @@ package portainer // General errors. const ( - ErrUnauthorized = Error("Unauthorized") + ErrUnauthorized = Error("Unauthorized") + ErrResourceAccessDenied = Error("Access denied to resource") ) // User errors. const ( ErrUserNotFound = Error("User not found") + ErrUserAlreadyExists = Error("User already exists") ErrAdminAlreadyInitialized = Error("Admin user already initialized") ) // Endpoint errors. const ( - ErrEndpointNotFound = Error("Endpoint not found") - ErrNoActiveEndpoint = Error("Undefined Docker endpoint") + ErrEndpointNotFound = Error("Endpoint not found") + ErrEndpointAccessDenied = Error("Access denied to endpoint") +) + +// Version errors. +const ( + ErrDBVersionNotFound = Error("DB version not found") ) // Crypto errors. @@ -24,8 +31,9 @@ const ( // JWT errors. const ( - ErrSecretGeneration = Error("Unable to generate secret key") - ErrInvalidJWTToken = Error("Invalid JWT token") + ErrSecretGeneration = Error("Unable to generate secret key") + ErrInvalidJWTToken = Error("Invalid JWT token") + ErrMissingContextData = Error("Unable to find JWT data in request context") ) // File errors. diff --git a/api/http/auth_handler.go b/api/http/auth_handler.go index b4c1af789..0eb0e7559 100644 --- a/api/http/auth_handler.go +++ b/api/http/auth_handler.go @@ -33,12 +33,14 @@ const ( ) // NewAuthHandler returns a new instance of AuthHandler. -func NewAuthHandler() *AuthHandler { +func NewAuthHandler(mw *middleWareService) *AuthHandler { h := &AuthHandler{ Router: mux.NewRouter(), Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.HandleFunc("/auth", h.handlePostAuth) + h.Handle("/auth", + mw.public(http.HandlerFunc(h.handlePostAuth))) + return h } @@ -68,7 +70,7 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques var username = req.Username var password = req.Password - u, err := handler.UserService.User(username) + u, err := handler.UserService.UserByUsername(username) if err == portainer.ErrUserNotFound { Error(w, err, http.StatusNotFound, handler.Logger) return @@ -84,7 +86,9 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques } tokenData := &portainer.TokenData{ - username, + ID: u.ID, + Username: u.Username, + Role: u.Role, } token, err := handler.JWTService.GenerateToken(tokenData) if err != nil { diff --git a/api/http/docker_handler.go b/api/http/docker_handler.go index 9cffa4b26..5a8668a3a 100644 --- a/api/http/docker_handler.go +++ b/api/http/docker_handler.go @@ -1,16 +1,14 @@ package http import ( + "strconv" + "github.com/portainer/portainer" - "io" "log" - "net" "net/http" - "net/http/httputil" "net/url" "os" - "strings" "github.com/gorilla/mux" ) @@ -18,142 +16,95 @@ import ( // DockerHandler represents an HTTP API handler for proxying requests to the Docker API. type DockerHandler struct { *mux.Router - Logger *log.Logger - middleWareService *middleWareService - proxy http.Handler + Logger *log.Logger + EndpointService portainer.EndpointService + ProxyFactory ProxyFactory + proxies map[portainer.EndpointID]http.Handler } // NewDockerHandler returns a new instance of DockerHandler. -func NewDockerHandler(middleWareService *middleWareService) *DockerHandler { +func NewDockerHandler(mw *middleWareService, resourceControlService portainer.ResourceControlService) *DockerHandler { h := &DockerHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - middleWareService: middleWareService, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + ProxyFactory: ProxyFactory{ + ResourceControlService: resourceControlService, + }, + proxies: make(map[portainer.EndpointID]http.Handler), } - h.PathPrefix("/").Handler(middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.proxyRequestsToDockerAPI(w, r) - }))) + h.PathPrefix("/{id}/").Handler( + mw.authenticated(http.HandlerFunc(h.proxyRequestsToDockerAPI))) return h } +func checkEndpointAccessControl(endpoint *portainer.Endpoint, userID portainer.UserID) bool { + for _, authorizedUserID := range endpoint.AuthorizedUsers { + if authorizedUserID == userID { + return true + } + } + return false +} + func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) { - if handler.proxy != nil { - handler.proxy.ServeHTTP(w, r) - } else { - Error(w, portainer.ErrNoActiveEndpoint, http.StatusNotFound, handler.Logger) - } -} + vars := mux.Vars(r) + id := vars["id"] -func (handler *DockerHandler) setupProxy(endpoint *portainer.Endpoint) error { - var proxy http.Handler - endpointURL, err := url.Parse(endpoint.URL) + parsedID, err := strconv.Atoi(id) if err != nil { - return err + Error(w, err, http.StatusBadRequest, handler.Logger) + return } - if endpointURL.Scheme == "tcp" { - if endpoint.TLS { - proxy, err = newHTTPSProxy(endpointURL, endpoint) - if err != nil { - return err - } - } else { - proxy = newHTTPProxy(endpointURL) - } - } else { - // Assume unix:// scheme - proxy = newSocketProxy(endpointURL.Path) - } - handler.proxy = proxy - return nil -} -// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go -// included here for use in NewSingleHostReverseProxyWithHostHeader -// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go -func singleJoiningSlash(a, b string) string { - aslash := strings.HasSuffix(a, "/") - bslash := strings.HasPrefix(b, "/") - switch { - case aslash && bslash: - return a + b[1:] - case !aslash && !bslash: - return a + "/" + b + endpointID := portainer.EndpointID(parsedID) + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return } - return a + b -} -// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy -// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host -// HTTP header, which NewSingleHostReverseProxy deliberately preserves -func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { - targetQuery := target.RawQuery - director := func(req *http.Request) { - req.URL.Scheme = target.Scheme - req.URL.Host = target.Host - req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path) - req.Host = req.URL.Host - if targetQuery == "" || req.URL.RawQuery == "" { - req.URL.RawQuery = targetQuery + req.URL.RawQuery - } else { - req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery - } - if _, ok := req.Header["User-Agent"]; !ok { - // explicitly disable User-Agent so it's not set to default value - req.Header.Set("User-Agent", "") + tokenData, err := extractTokenDataFromRequestContext(r) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + } + if tokenData.Role != portainer.AdministratorRole && !checkEndpointAccessControl(endpoint, tokenData.ID) { + Error(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + proxy := handler.proxies[endpointID] + if proxy == nil { + proxy, err = handler.createAndRegisterEndpointProxy(endpoint) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return } } - return &httputil.ReverseProxy{Director: director} + http.StripPrefix("/"+id, proxy).ServeHTTP(w, r) } -func newHTTPProxy(u *url.URL) http.Handler { - u.Scheme = "http" - return NewSingleHostReverseProxyWithHostHeader(u) -} +func (handler *DockerHandler) createAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + var proxy http.Handler -func newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { - u.Scheme = "https" - proxy := NewSingleHostReverseProxyWithHostHeader(u) - config, err := createTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath) + endpointURL, err := url.Parse(endpoint.URL) if err != nil { return nil, err } - proxy.Transport = &http.Transport{ - TLSClientConfig: config, + + if endpointURL.Scheme == "tcp" { + if endpoint.TLS { + proxy, err = handler.ProxyFactory.newHTTPSProxy(endpointURL, endpoint) + if err != nil { + return nil, err + } + } else { + proxy = handler.ProxyFactory.newHTTPProxy(endpointURL) + } + } else { + // Assume unix:// scheme + proxy = handler.ProxyFactory.newSocketProxy(endpointURL.Path) } + + handler.proxies[endpoint.ID] = proxy return proxy, nil } - -func newSocketProxy(path string) http.Handler { - return &unixSocketHandler{path} -} - -// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket -type unixSocketHandler struct { - path string -} - -func (h *unixSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - conn, err := net.Dial("unix", h.path) - if err != nil { - Error(w, err, http.StatusInternalServerError, nil) - return - } - c := httputil.NewClientConn(conn, nil) - defer c.Close() - - res, err := c.Do(r) - if err != nil { - Error(w, err, http.StatusInternalServerError, nil) - return - } - defer res.Body.Close() - - for k, vv := range res.Header { - for _, v := range vv { - w.Header().Add(k, v) - } - } - if _, err := io.Copy(w, res.Body); err != nil { - Error(w, err, http.StatusInternalServerError, nil) - } -} diff --git a/api/http/docker_proxy.go b/api/http/docker_proxy.go new file mode 100644 index 000000000..f4644dd36 --- /dev/null +++ b/api/http/docker_proxy.go @@ -0,0 +1,121 @@ +package http + +import ( + "io" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/portainer/portainer" +) + +// ProxyFactory is a factory to create reverse proxies to Docker endpoints +type ProxyFactory struct { + ResourceControlService portainer.ResourceControlService +} + +// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go +// included here for use in NewSingleHostReverseProxyWithHostHeader +// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go +func singleJoiningSlash(a, b string) string { + aslash := strings.HasSuffix(a, "/") + bslash := strings.HasPrefix(b, "/") + switch { + case aslash && bslash: + return a + b[1:] + case !aslash && !bslash: + return a + "/" + b + } + return a + b +} + +// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy +// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host +// HTTP header, which NewSingleHostReverseProxy deliberately preserves. +// It also adds an extra Transport to the proxy to allow Portainer to rewrite the responses. +func (factory *ProxyFactory) newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { + targetQuery := target.RawQuery + director := func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path) + req.Host = req.URL.Host + if targetQuery == "" || req.URL.RawQuery == "" { + req.URL.RawQuery = targetQuery + req.URL.RawQuery + } else { + req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery + } + if _, ok := req.Header["User-Agent"]; !ok { + // explicitly disable User-Agent so it's not set to default value + req.Header.Set("User-Agent", "") + } + } + transport := &proxyTransport{ + ResourceControlService: factory.ResourceControlService, + transport: &http.Transport{}, + } + return &httputil.ReverseProxy{Director: director, Transport: transport} +} + +func (factory *ProxyFactory) newHTTPProxy(u *url.URL) http.Handler { + u.Scheme = "http" + return factory.newSingleHostReverseProxyWithHostHeader(u) +} + +func (factory *ProxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { + u.Scheme = "https" + proxy := factory.newSingleHostReverseProxyWithHostHeader(u) + config, err := createTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath) + if err != nil { + return nil, err + } + + proxy.Transport.(*proxyTransport).transport.TLSClientConfig = config + return proxy, nil +} + +func (factory *ProxyFactory) newSocketProxy(path string) http.Handler { + return &unixSocketHandler{path, &proxyTransport{ + ResourceControlService: factory.ResourceControlService, + }} +} + +// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket +type unixSocketHandler struct { + path string + transport *proxyTransport +} + +func (h *unixSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, err := net.Dial("unix", h.path) + if err != nil { + Error(w, err, http.StatusInternalServerError, nil) + return + } + c := httputil.NewClientConn(conn, nil) + defer c.Close() + + res, err := c.Do(r) + if err != nil { + Error(w, err, http.StatusInternalServerError, nil) + return + } + defer res.Body.Close() + + err = h.transport.proxyDockerRequests(r, res) + if err != nil { + Error(w, err, http.StatusInternalServerError, nil) + return + } + + for k, vv := range res.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + if _, err := io.Copy(w, res.Body); err != nil { + Error(w, err, http.StatusInternalServerError, nil) + } +} diff --git a/api/http/endpoint_handler.go b/api/http/endpoint_handler.go index 7c7d0f930..5fac67276 100644 --- a/api/http/endpoint_handler.go +++ b/api/http/endpoint_handler.go @@ -20,8 +20,7 @@ type EndpointHandler struct { authorizeEndpointManagement bool EndpointService portainer.EndpointService FileService portainer.FileService - server *Server - middleWareService *middleWareService + // server *Server } const ( @@ -31,30 +30,24 @@ const ( ) // NewEndpointHandler returns a new instance of EndpointHandler. -func NewEndpointHandler(middleWareService *middleWareService) *EndpointHandler { +func NewEndpointHandler(mw *middleWareService) *EndpointHandler { h := &EndpointHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - middleWareService: middleWareService, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.Handle("/endpoints", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePostEndpoints(w, r) - }))).Methods(http.MethodPost) - h.Handle("/endpoints", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handleGetEndpoints(w, r) - }))).Methods(http.MethodGet) - h.Handle("/endpoints/{id}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handleGetEndpoint(w, r) - }))).Methods(http.MethodGet) - h.Handle("/endpoints/{id}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePutEndpoint(w, r) - }))).Methods(http.MethodPut) - h.Handle("/endpoints/{id}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handleDeleteEndpoint(w, r) - }))).Methods(http.MethodDelete) - h.Handle("/endpoints/{id}/active", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePostEndpoint(w, r) - }))).Methods(http.MethodPost) + h.Handle("/endpoints", + mw.administrator(http.HandlerFunc(h.handlePostEndpoints))).Methods(http.MethodPost) + h.Handle("/endpoints", + mw.authenticated(http.HandlerFunc(h.handleGetEndpoints))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", + mw.administrator(http.HandlerFunc(h.handleGetEndpoint))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", + mw.administrator(http.HandlerFunc(h.handlePutEndpoint))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}/access", + mw.administrator(http.HandlerFunc(h.handlePutEndpointAccess))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}", + mw.administrator(http.HandlerFunc(h.handleDeleteEndpoint))).Methods(http.MethodDelete) + return h } @@ -65,12 +58,35 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt Error(w, err, http.StatusInternalServerError, handler.Logger) return } - encodeJSON(w, endpoints, handler.Logger) + + tokenData, err := extractTokenDataFromRequestContext(r) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + } + if tokenData == nil { + Error(w, portainer.ErrInvalidJWTToken, http.StatusBadRequest, handler.Logger) + return + } + + var allowedEndpoints []portainer.Endpoint + if tokenData.Role != portainer.AdministratorRole { + allowedEndpoints = make([]portainer.Endpoint, 0) + for _, endpoint := range endpoints { + for _, authorizedUserID := range endpoint.AuthorizedUsers { + if authorizedUserID == tokenData.ID { + allowedEndpoints = append(allowedEndpoints, endpoint) + break + } + } + } + } else { + allowedEndpoints = endpoints + } + + encodeJSON(w, allowedEndpoints, handler.Logger) } // handlePostEndpoints handles POST requests on /endpoints -// if the active URL parameter is specified, will also define the new endpoint as the active endpoint. -// /endpoints(?active=true|false) func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) @@ -90,9 +106,10 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht } endpoint := &portainer.Endpoint{ - Name: req.Name, - URL: req.URL, - TLS: req.TLS, + Name: req.Name, + URL: req.URL, + TLS: req.TLS, + AuthorizedUsers: []portainer.UserID{}, } err = handler.EndpointService.CreateEndpoint(endpoint) @@ -115,22 +132,6 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht } } - activeEndpointParameter := r.FormValue("active") - if activeEndpointParameter != "" { - active, err := strconv.ParseBool(activeEndpointParameter) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - if active == true { - err = handler.server.updateActiveEndpoint(endpoint) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - } - encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger) } @@ -145,7 +146,6 @@ type postEndpointsResponse struct { } // handleGetEndpoint handles GET requests on /endpoints/:id -// GET /endpoints/0 returns active endpoint func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] @@ -156,48 +156,6 @@ func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http return } - var endpoint *portainer.Endpoint - if id == "0" { - endpoint, err = handler.EndpointService.GetActive() - if err == portainer.ErrEndpointNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if handler.server.ActiveEndpoint == nil { - err = handler.server.updateActiveEndpoint(endpoint) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - } else { - endpoint, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - encodeJSON(w, endpoint, handler.Logger) -} - -// handlePostEndpoint handles POST requests on /endpoints/:id/active -func (handler *EndpointHandler) handlePostEndpoint(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err == portainer.ErrEndpointNotFound { Error(w, err, http.StatusNotFound, handler.Logger) @@ -207,12 +165,58 @@ func (handler *EndpointHandler) handlePostEndpoint(w http.ResponseWriter, r *htt return } - err = handler.server.updateActiveEndpoint(endpoint) + encodeJSON(w, endpoint, handler.Logger) +} + +// handlePutEndpointAccess handles PUT requests on /endpoints/:id/access +func (handler *EndpointHandler) handlePutEndpointAccess(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putEndpointAccessRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + authorizedUserIDs := []portainer.UserID{} + for _, value := range req.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + endpoint.AuthorizedUsers = authorizedUserIDs + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) + return } } +type putEndpointAccessRequest struct { + AuthorizedUsers []int `valid:"required"` +} + // handlePutEndpoint handles PUT requests on /endpoints/:id func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { @@ -241,14 +245,25 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http return } - endpoint := &portainer.Endpoint{ - ID: portainer.EndpointID(endpointID), - Name: req.Name, - URL: req.URL, - TLS: req.TLS, + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if req.Name != "" { + endpoint.Name = req.Name + } + + if req.URL != "" { + endpoint.URL = req.URL } if req.TLS { + endpoint.TLS = true caCertPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCA) endpoint.TLSCACertPath = caCertPath certPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCert) @@ -256,6 +271,10 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http keyPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileKey) endpoint.TLSKeyPath = keyPath } else { + endpoint.TLS = false + endpoint.TLSCACertPath = "" + endpoint.TLSCertPath = "" + endpoint.TLSKeyPath = "" err = handler.FileService.DeleteTLSFiles(endpoint.ID) if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) @@ -271,13 +290,12 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http } type putEndpointsRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - TLS bool + Name string `valid:"-"` + URL string `valid:"-"` + TLS bool `valid:"-"` } // handleDeleteEndpoint handles DELETE requests on /endpoints/:id -// DELETE /endpoints/0 deletes the active endpoint func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) @@ -293,13 +311,7 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h return } - var endpoint *portainer.Endpoint - if id == "0" { - endpoint, err = handler.EndpointService.GetActive() - endpointID = int(endpoint.ID) - } else { - endpoint, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - } + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err == portainer.ErrEndpointNotFound { Error(w, err, http.StatusNotFound, handler.Logger) @@ -314,13 +326,6 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h Error(w, err, http.StatusInternalServerError, handler.Logger) return } - if id == "0" { - err = handler.EndpointService.DeleteActive() - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } if endpoint.TLS { err = handler.FileService.DeleteTLSFiles(portainer.EndpointID(endpointID)) diff --git a/api/http/handler.go b/api/http/handler.go index efce098b5..7a27925c7 100644 --- a/api/http/handler.go +++ b/api/http/handler.go @@ -27,6 +27,10 @@ const ( ErrInvalidJSON = portainer.Error("Invalid JSON") // ErrInvalidRequestFormat defines an error raised when the format of the data sent in a request is not valid ErrInvalidRequestFormat = portainer.Error("Invalid request data format") + // ErrInvalidQueryFormat defines an error raised when the data sent in the query or the URL is invalid + ErrInvalidQueryFormat = portainer.Error("Invalid query format") + // ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse + ErrEmptyResponseBody = portainer.Error("Empty response body") ) // ServeHTTP delegates a request to the appropriate subhandler. diff --git a/api/http/middleware.go b/api/http/middleware.go index 891e7c610..4221a61f4 100644 --- a/api/http/middleware.go +++ b/api/http/middleware.go @@ -1,33 +1,61 @@ package http import ( + "context" + "github.com/portainer/portainer" "net/http" "strings" ) -// Service represents a service to manage HTTP middlewares -type middleWareService struct { - jwtService portainer.JWTService - authDisabled bool -} - -func addMiddleware(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler { - for _, mw := range middleware { - h = mw(h) +type ( + // middleWareService represents a service to manage HTTP middlewares + middleWareService struct { + jwtService portainer.JWTService + authDisabled bool } + contextKey int +) + +const ( + contextAuthenticationKey contextKey = iota +) + +func extractTokenDataFromRequestContext(request *http.Request) (*portainer.TokenData, error) { + contextData := request.Context().Value(contextAuthenticationKey) + if contextData == nil { + return nil, portainer.ErrMissingContextData + } + + tokenData := contextData.(*portainer.TokenData) + return tokenData, nil +} + +// public defines a chain of middleware for public endpoints (no authentication required) +func (service *middleWareService) public(h http.Handler) http.Handler { + h = mwSecureHeaders(h) return h } -func (service *middleWareService) addMiddleWares(h http.Handler) http.Handler { - h = service.middleWareSecureHeaders(h) - h = service.middleWareAuthenticate(h) +// authenticated defines a chain of middleware for private endpoints (authentication required) +func (service *middleWareService) authenticated(h http.Handler) http.Handler { + h = service.mwCheckAuthentication(h) + h = mwSecureHeaders(h) return h } -// middleWareAuthenticate provides secure headers middleware for handlers -func (*middleWareService) middleWareSecureHeaders(next http.Handler) http.Handler { +// administrator defines a chain of middleware for private administrator restricted endpoints +// (authentication and role admin required) +func (service *middleWareService) administrator(h http.Handler) http.Handler { + h = mwCheckAdministratorRole(h) + h = service.mwCheckAuthentication(h) + h = mwSecureHeaders(h) + return h +} + +// mwSecureHeaders provides secure headers middleware for handlers +func mwSecureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("X-Content-Type-Options", "nosniff") w.Header().Add("X-Frame-Options", "DENY") @@ -35,9 +63,28 @@ func (*middleWareService) middleWareSecureHeaders(next http.Handler) http.Handle }) } -// middleWareAuthenticate provides Authentication middleware for handlers -func (service *middleWareService) middleWareAuthenticate(next http.Handler) http.Handler { +// mwCheckAdministratorRole check the role of the user associated to the request +func mwCheckAdministratorRole(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenData, err := extractTokenDataFromRequestContext(r) + if err != nil { + Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + return + } + + if tokenData.Role != portainer.AdministratorRole { + Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + return + } + + next.ServeHTTP(w, r) + }) +} + +// mwCheckAuthentication provides Authentication middleware for handlers +func (service *middleWareService) mwCheckAuthentication(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var tokenData *portainer.TokenData if !service.authDisabled { var token string @@ -53,14 +100,20 @@ func (service *middleWareService) middleWareAuthenticate(next http.Handler) http return } - err := service.jwtService.VerifyToken(token) + var err error + tokenData, err = service.jwtService.ParseAndVerifyToken(token) if err != nil { Error(w, err, http.StatusUnauthorized, nil) return } + } else { + tokenData = &portainer.TokenData{ + Role: portainer.AdministratorRole, + } } - next.ServeHTTP(w, r) + ctx := context.WithValue(r.Context(), contextAuthenticationKey, tokenData) + next.ServeHTTP(w, r.WithContext(ctx)) return }) } diff --git a/api/http/proxy_transport.go b/api/http/proxy_transport.go new file mode 100644 index 000000000..34130979a --- /dev/null +++ b/api/http/proxy_transport.go @@ -0,0 +1,664 @@ +package http + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "path" + "strconv" + "strings" + + "github.com/portainer/portainer" +) + +type ( + proxyTransport struct { + transport *http.Transport + ResourceControlService portainer.ResourceControlService + } + resourceControlMetadata struct { + OwnerID portainer.UserID `json:"OwnerId"` + } +) + +func (p *proxyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + response, err := p.transport.RoundTrip(req) + if err != nil { + return response, err + } + + err = p.proxyDockerRequests(req, response) + return response, err +} + +func (p *proxyTransport) proxyDockerRequests(request *http.Request, response *http.Response) error { + path := request.URL.Path + + if strings.HasPrefix(path, "/containers") { + return p.handleContainerRequests(request, response) + } else if strings.HasPrefix(path, "/services") { + return p.handleServiceRequests(request, response) + } else if strings.HasPrefix(path, "/volumes") { + return p.handleVolumeRequests(request, response) + } + + return nil +} + +func (p *proxyTransport) handleContainerRequests(request *http.Request, response *http.Response) error { + requestPath := request.URL.Path + + tokenData, err := extractTokenDataFromRequestContext(request) + if err != nil { + return err + } + + if requestPath == "/containers/prune" && tokenData.Role != portainer.AdministratorRole { + return writeAccessDeniedResponse(response) + } + if requestPath == "/containers/json" { + if tokenData.Role == portainer.AdministratorRole { + return p.decorateContainerResponse(response) + } + return p.proxyContainerResponseWithResourceControl(response, tokenData.ID) + } + // /containers/{id}/action + if match, _ := path.Match("/containers/*/*", requestPath); match { + if tokenData.Role != portainer.AdministratorRole { + resourceID := path.Base(path.Dir(requestPath)) + return p.proxyContainerResponseWithAccessControl(response, tokenData.ID, resourceID) + } + } + + return nil +} + +func (p *proxyTransport) handleServiceRequests(request *http.Request, response *http.Response) error { + requestPath := request.URL.Path + + tokenData, err := extractTokenDataFromRequestContext(request) + if err != nil { + return err + } + + if requestPath == "/services" { + if tokenData.Role == portainer.AdministratorRole { + return p.decorateServiceResponse(response) + } + return p.proxyServiceResponseWithResourceControl(response, tokenData.ID) + } + // /services/{id} + if match, _ := path.Match("/services/*", requestPath); match { + if tokenData.Role != portainer.AdministratorRole { + resourceID := path.Base(requestPath) + return p.proxyServiceResponseWithAccessControl(response, tokenData.ID, resourceID) + } + } + // /services/{id}/action + if match, _ := path.Match("/services/*/*", requestPath); match { + if tokenData.Role != portainer.AdministratorRole { + resourceID := path.Base(path.Dir(requestPath)) + return p.proxyServiceResponseWithAccessControl(response, tokenData.ID, resourceID) + } + } + + return nil +} + +func (p *proxyTransport) handleVolumeRequests(request *http.Request, response *http.Response) error { + requestPath := request.URL.Path + + tokenData, err := extractTokenDataFromRequestContext(request) + if err != nil { + return err + } + + if requestPath == "/volumes" { + if tokenData.Role == portainer.AdministratorRole { + return p.decorateVolumeResponse(response) + } + return p.proxyVolumeResponseWithResourceControl(response, tokenData.ID) + } + if requestPath == "/volumes/prune" && tokenData.Role != portainer.AdministratorRole { + return writeAccessDeniedResponse(response) + } + // /volumes/{name} + if match, _ := path.Match("/volumes/*", requestPath); match { + if tokenData.Role != portainer.AdministratorRole { + resourceID := path.Base(requestPath) + return p.proxyVolumeResponseWithAccessControl(response, tokenData.ID, resourceID) + } + } + return nil +} + +func (p *proxyTransport) proxyContainerResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error { + rcs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl) + if err != nil { + return err + } + + userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs) + if err != nil { + return err + } + + if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) { + return writeAccessDeniedResponse(response) + } + + return nil +} + +func (p *proxyTransport) proxyServiceResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error { + rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) + if err != nil { + return err + } + + userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs) + if err != nil { + return err + } + + if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) { + return writeAccessDeniedResponse(response) + } + return nil +} + +func (p *proxyTransport) proxyVolumeResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error { + rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl) + if err != nil { + return err + } + + userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs) + if err != nil { + return err + } + + if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) { + return writeAccessDeniedResponse(response) + } + return nil +} + +func (p *proxyTransport) decorateContainerResponse(response *http.Response) error { + responseData, err := getResponseData(response) + if err != nil { + return err + } + + containers, err := p.decorateContainers(responseData) + if err != nil { + return err + } + + err = rewriteContainerResponse(response, containers) + if err != nil { + return err + } + + return nil +} + +func (p *proxyTransport) proxyContainerResponseWithResourceControl(response *http.Response, userID portainer.UserID) error { + responseData, err := getResponseData(response) + if err != nil { + return err + } + + containers, err := p.filterContainers(userID, responseData) + if err != nil { + return err + } + + err = rewriteContainerResponse(response, containers) + if err != nil { + return err + } + + return nil +} + +func (p *proxyTransport) decorateServiceResponse(response *http.Response) error { + responseData, err := getResponseData(response) + if err != nil { + return err + } + + services, err := p.decorateServices(responseData) + if err != nil { + return err + } + + err = rewriteServiceResponse(response, services) + if err != nil { + return err + } + + return nil +} + +func (p *proxyTransport) proxyServiceResponseWithResourceControl(response *http.Response, userID portainer.UserID) error { + responseData, err := getResponseData(response) + if err != nil { + return err + } + + volumes, err := p.filterServices(userID, responseData) + if err != nil { + return err + } + + err = rewriteServiceResponse(response, volumes) + if err != nil { + return err + } + + return nil +} + +func (p *proxyTransport) decorateVolumeResponse(response *http.Response) error { + responseData, err := getResponseData(response) + if err != nil { + return err + } + + volumes, err := p.decorateVolumes(responseData) + if err != nil { + return err + } + + err = rewriteVolumeResponse(response, volumes) + if err != nil { + return err + } + + return nil +} + +func (p *proxyTransport) proxyVolumeResponseWithResourceControl(response *http.Response, userID portainer.UserID) error { + responseData, err := getResponseData(response) + if err != nil { + return err + } + + volumes, err := p.filterVolumes(userID, responseData) + if err != nil { + return err + } + + err = rewriteVolumeResponse(response, volumes) + if err != nil { + return err + } + + return nil +} + +func (p *proxyTransport) decorateContainers(responseData interface{}) ([]interface{}, error) { + responseDataArray := responseData.([]interface{}) + + containerRCs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl) + if err != nil { + return nil, err + } + + serviceRCs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) + if err != nil { + return nil, err + } + + decoratedResources := make([]interface{}, 0) + + for _, container := range responseDataArray { + jsonObject := container.(map[string]interface{}) + containerID := jsonObject["Id"].(string) + containerRC := getRCByResourceID(containerID, containerRCs) + if containerRC != nil { + decoratedObject := decorateWithResourceControlMetadata(jsonObject, containerRC.OwnerID) + decoratedResources = append(decoratedResources, decoratedObject) + continue + } + + containerLabels := jsonObject["Labels"] + if containerLabels != nil { + jsonLabels := containerLabels.(map[string]interface{}) + serviceID := jsonLabels["com.docker.swarm.service.id"] + if serviceID != nil { + serviceRC := getRCByResourceID(serviceID.(string), serviceRCs) + if serviceRC != nil { + decoratedObject := decorateWithResourceControlMetadata(jsonObject, serviceRC.OwnerID) + decoratedResources = append(decoratedResources, decoratedObject) + continue + } + } + } + decoratedResources = append(decoratedResources, container) + } + + return decoratedResources, nil +} + +func (p *proxyTransport) filterContainers(userID portainer.UserID, responseData interface{}) ([]interface{}, error) { + responseDataArray := responseData.([]interface{}) + + containerRCs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl) + if err != nil { + return nil, err + } + + serviceRCs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) + if err != nil { + return nil, err + } + + userOwnedContainerIDs, err := getResourceIDsOwnedByUser(userID, containerRCs) + if err != nil { + return nil, err + } + + userOwnedServiceIDs, err := getResourceIDsOwnedByUser(userID, serviceRCs) + if err != nil { + return nil, err + } + + publicContainers := getPublicContainers(responseDataArray, containerRCs, serviceRCs) + + filteredResources := make([]interface{}, 0) + + for _, container := range responseDataArray { + jsonObject := container.(map[string]interface{}) + containerID := jsonObject["Id"].(string) + if isStringInArray(containerID, userOwnedContainerIDs) { + decoratedObject := decorateWithResourceControlMetadata(jsonObject, userID) + filteredResources = append(filteredResources, decoratedObject) + continue + } + + containerLabels := jsonObject["Labels"] + if containerLabels != nil { + jsonLabels := containerLabels.(map[string]interface{}) + serviceID := jsonLabels["com.docker.swarm.service.id"] + if serviceID != nil && isStringInArray(serviceID.(string), userOwnedServiceIDs) { + decoratedObject := decorateWithResourceControlMetadata(jsonObject, userID) + filteredResources = append(filteredResources, decoratedObject) + } + } + } + + filteredResources = append(filteredResources, publicContainers...) + return filteredResources, nil +} + +func decorateWithResourceControlMetadata(object map[string]interface{}, userID portainer.UserID) map[string]interface{} { + metadata := make(map[string]interface{}) + metadata["ResourceControl"] = resourceControlMetadata{ + OwnerID: userID, + } + object["Portainer"] = metadata + return object +} + +func (p *proxyTransport) decorateServices(responseData interface{}) ([]interface{}, error) { + responseDataArray := responseData.([]interface{}) + + rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) + if err != nil { + return nil, err + } + + decoratedResources := make([]interface{}, 0) + + for _, service := range responseDataArray { + jsonResource := service.(map[string]interface{}) + resourceID := jsonResource["ID"].(string) + serviceRC := getRCByResourceID(resourceID, rcs) + if serviceRC != nil { + decoratedObject := decorateWithResourceControlMetadata(jsonResource, serviceRC.OwnerID) + decoratedResources = append(decoratedResources, decoratedObject) + continue + } + decoratedResources = append(decoratedResources, service) + } + + return decoratedResources, nil +} + +func (p *proxyTransport) filterServices(userID portainer.UserID, responseData interface{}) ([]interface{}, error) { + responseDataArray := responseData.([]interface{}) + + rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) + if err != nil { + return nil, err + } + + userOwnedServiceIDs, err := getResourceIDsOwnedByUser(userID, rcs) + if err != nil { + return nil, err + } + + publicServices := getPublicResources(responseDataArray, rcs, "ID") + + filteredResources := make([]interface{}, 0) + + for _, res := range responseDataArray { + jsonResource := res.(map[string]interface{}) + resourceID := jsonResource["ID"].(string) + if isStringInArray(resourceID, userOwnedServiceIDs) { + decoratedObject := decorateWithResourceControlMetadata(jsonResource, userID) + filteredResources = append(filteredResources, decoratedObject) + } + } + + filteredResources = append(filteredResources, publicServices...) + return filteredResources, nil +} + +func (p *proxyTransport) decorateVolumes(responseData interface{}) ([]interface{}, error) { + var responseDataArray []interface{} + jsonObject := responseData.(map[string]interface{}) + if jsonObject["Volumes"] != nil { + responseDataArray = jsonObject["Volumes"].([]interface{}) + } + + rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl) + if err != nil { + return nil, err + } + + decoratedResources := make([]interface{}, 0) + + for _, volume := range responseDataArray { + jsonResource := volume.(map[string]interface{}) + resourceID := jsonResource["Name"].(string) + volumeRC := getRCByResourceID(resourceID, rcs) + if volumeRC != nil { + decoratedObject := decorateWithResourceControlMetadata(jsonResource, volumeRC.OwnerID) + decoratedResources = append(decoratedResources, decoratedObject) + continue + } + decoratedResources = append(decoratedResources, volume) + } + + return decoratedResources, nil +} + +func (p *proxyTransport) filterVolumes(userID portainer.UserID, responseData interface{}) ([]interface{}, error) { + var responseDataArray []interface{} + jsonObject := responseData.(map[string]interface{}) + if jsonObject["Volumes"] != nil { + responseDataArray = jsonObject["Volumes"].([]interface{}) + } + + rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl) + if err != nil { + return nil, err + } + + userOwnedVolumeIDs, err := getResourceIDsOwnedByUser(userID, rcs) + if err != nil { + return nil, err + } + + publicVolumes := getPublicResources(responseDataArray, rcs, "Name") + + filteredResources := make([]interface{}, 0) + + for _, res := range responseDataArray { + jsonResource := res.(map[string]interface{}) + resourceID := jsonResource["Name"].(string) + if isStringInArray(resourceID, userOwnedVolumeIDs) { + decoratedObject := decorateWithResourceControlMetadata(jsonResource, userID) + filteredResources = append(filteredResources, decoratedObject) + } + } + + filteredResources = append(filteredResources, publicVolumes...) + return filteredResources, nil +} + +func getResourceIDsOwnedByUser(userID portainer.UserID, rcs []portainer.ResourceControl) ([]string, error) { + ownedResources := make([]string, 0) + for _, rc := range rcs { + if rc.OwnerID == userID { + ownedResources = append(ownedResources, rc.ResourceID) + } + } + return ownedResources, nil +} + +func getOwnedServiceContainers(responseData []interface{}, serviceRCs []portainer.ResourceControl) []interface{} { + ownedContainers := make([]interface{}, 0) + for _, res := range responseData { + jsonResource := res.(map[string]map[string]interface{}) + swarmServiceID := jsonResource["Labels"]["com.docker.swarm.service.id"] + if swarmServiceID != nil { + resourceID := swarmServiceID.(string) + if isResourceIDInRCs(resourceID, serviceRCs) { + ownedContainers = append(ownedContainers, res) + } + } + } + return ownedContainers +} + +func getPublicContainers(responseData []interface{}, containerRCs []portainer.ResourceControl, serviceRCs []portainer.ResourceControl) []interface{} { + publicContainers := make([]interface{}, 0) + for _, container := range responseData { + jsonObject := container.(map[string]interface{}) + containerID := jsonObject["Id"].(string) + if !isResourceIDInRCs(containerID, containerRCs) { + containerLabels := jsonObject["Labels"] + if containerLabels != nil { + jsonLabels := containerLabels.(map[string]interface{}) + serviceID := jsonLabels["com.docker.swarm.service.id"] + if serviceID == nil { + publicContainers = append(publicContainers, container) + } else if serviceID != nil && !isResourceIDInRCs(serviceID.(string), serviceRCs) { + publicContainers = append(publicContainers, container) + } + } else { + publicContainers = append(publicContainers, container) + } + } + } + + return publicContainers +} + +func getPublicResources(responseData []interface{}, rcs []portainer.ResourceControl, resourceIDKey string) []interface{} { + publicResources := make([]interface{}, 0) + for _, res := range responseData { + jsonResource := res.(map[string]interface{}) + resourceID := jsonResource[resourceIDKey].(string) + if !isResourceIDInRCs(resourceID, rcs) { + publicResources = append(publicResources, res) + } + } + return publicResources +} + +func isStringInArray(target string, array []string) bool { + for _, element := range array { + if element == target { + return true + } + } + return false +} + +func isResourceIDInRCs(resourceID string, rcs []portainer.ResourceControl) bool { + for _, rc := range rcs { + if resourceID == rc.ResourceID { + return true + } + } + return false +} + +func getRCByResourceID(resourceID string, rcs []portainer.ResourceControl) *portainer.ResourceControl { + for _, rc := range rcs { + if resourceID == rc.ResourceID { + return &rc + } + } + return nil +} + +func getResponseData(response *http.Response) (interface{}, error) { + var data interface{} + if response.Body != nil { + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, err + } + + err = response.Body.Close() + if err != nil { + return nil, err + } + + err = json.Unmarshal(body, &data) + if err != nil { + return nil, err + } + + return data, nil + } + return nil, ErrEmptyResponseBody +} + +func writeAccessDeniedResponse(response *http.Response) error { + return rewriteResponse(response, portainer.ErrResourceAccessDenied, 403) +} + +func rewriteContainerResponse(response *http.Response, responseData interface{}) error { + return rewriteResponse(response, responseData, 200) +} + +func rewriteServiceResponse(response *http.Response, responseData interface{}) error { + return rewriteResponse(response, responseData, 200) +} + +func rewriteVolumeResponse(response *http.Response, responseData interface{}) error { + data := map[string]interface{}{} + data["Volumes"] = responseData + return rewriteResponse(response, data, 200) +} + +func rewriteResponse(response *http.Response, newContent interface{}, statusCode int) error { + jsonData, err := json.Marshal(newContent) + if err != nil { + return err + } + body := ioutil.NopCloser(bytes.NewReader(jsonData)) + response.StatusCode = statusCode + response.Body = body + response.ContentLength = int64(len(jsonData)) + response.Header.Set("Content-Length", strconv.Itoa(len(jsonData))) + return nil +} diff --git a/api/http/server.go b/api/http/server.go index dc09db888..046e58ee9 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -8,35 +8,19 @@ import ( // Server implements the portainer.Server interface type Server struct { - BindAddress string - AssetsPath string - AuthDisabled bool - EndpointManagement bool - UserService portainer.UserService - EndpointService portainer.EndpointService - CryptoService portainer.CryptoService - JWTService portainer.JWTService - FileService portainer.FileService - Settings *portainer.Settings - TemplatesURL string - ActiveEndpoint *portainer.Endpoint - Handler *Handler -} - -func (server *Server) updateActiveEndpoint(endpoint *portainer.Endpoint) error { - if endpoint != nil { - server.ActiveEndpoint = endpoint - server.Handler.WebSocketHandler.endpoint = endpoint - err := server.Handler.DockerHandler.setupProxy(endpoint) - if err != nil { - return err - } - err = server.EndpointService.SetActive(endpoint) - if err != nil { - return err - } - } - return nil + BindAddress string + AssetsPath string + AuthDisabled bool + EndpointManagement bool + UserService portainer.UserService + EndpointService portainer.EndpointService + ResourceControlService portainer.ResourceControlService + CryptoService portainer.CryptoService + JWTService portainer.JWTService + FileService portainer.FileService + Settings *portainer.Settings + TemplatesURL string + Handler *Handler } // Start starts the HTTP server @@ -46,7 +30,7 @@ func (server *Server) Start() error { authDisabled: server.AuthDisabled, } - var authHandler = NewAuthHandler() + var authHandler = NewAuthHandler(middleWareService) authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService @@ -54,18 +38,19 @@ func (server *Server) Start() error { var userHandler = NewUserHandler(middleWareService) userHandler.UserService = server.UserService userHandler.CryptoService = server.CryptoService + userHandler.ResourceControlService = server.ResourceControlService var settingsHandler = NewSettingsHandler(middleWareService) settingsHandler.settings = server.Settings var templatesHandler = NewTemplatesHandler(middleWareService) templatesHandler.templatesURL = server.TemplatesURL - var dockerHandler = NewDockerHandler(middleWareService) + var dockerHandler = NewDockerHandler(middleWareService, server.ResourceControlService) + dockerHandler.EndpointService = server.EndpointService var websocketHandler = NewWebSocketHandler() - // EndpointHandler requires a reference to the server to be able to update the active endpoint. + websocketHandler.EndpointService = server.EndpointService var endpointHandler = NewEndpointHandler(middleWareService) endpointHandler.authorizeEndpointManagement = server.EndpointManagement endpointHandler.EndpointService = server.EndpointService endpointHandler.FileService = server.FileService - endpointHandler.server = server var uploadHandler = NewUploadHandler(middleWareService) uploadHandler.FileService = server.FileService var fileHandler = newFileHandler(server.AssetsPath) @@ -81,10 +66,6 @@ func (server *Server) Start() error { FileHandler: fileHandler, UploadHandler: uploadHandler, } - err := server.updateActiveEndpoint(server.ActiveEndpoint) - if err != nil { - return err - } return http.ListenAndServe(server.BindAddress, server.Handler) } diff --git a/api/http/settings_handler.go b/api/http/settings_handler.go index fab77aaa7..db426c071 100644 --- a/api/http/settings_handler.go +++ b/api/http/settings_handler.go @@ -13,19 +13,19 @@ import ( // SettingsHandler represents an HTTP API handler for managing settings. type SettingsHandler struct { *mux.Router - Logger *log.Logger - middleWareService *middleWareService - settings *portainer.Settings + Logger *log.Logger + settings *portainer.Settings } // NewSettingsHandler returns a new instance of SettingsHandler. -func NewSettingsHandler(middleWareService *middleWareService) *SettingsHandler { +func NewSettingsHandler(mw *middleWareService) *SettingsHandler { h := &SettingsHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - middleWareService: middleWareService, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.HandleFunc("/settings", h.handleGetSettings) + h.Handle("/settings", + mw.public(http.HandlerFunc(h.handleGetSettings))) + return h } diff --git a/api/http/templates_handler.go b/api/http/templates_handler.go index a72a6a4c1..510605021 100644 --- a/api/http/templates_handler.go +++ b/api/http/templates_handler.go @@ -12,21 +12,18 @@ import ( // TemplatesHandler represents an HTTP API handler for managing templates. type TemplatesHandler struct { *mux.Router - Logger *log.Logger - middleWareService *middleWareService - templatesURL string + Logger *log.Logger + templatesURL string } // NewTemplatesHandler returns a new instance of TemplatesHandler. -func NewTemplatesHandler(middleWareService *middleWareService) *TemplatesHandler { +func NewTemplatesHandler(mw *middleWareService) *TemplatesHandler { h := &TemplatesHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - middleWareService: middleWareService, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.Handle("/templates", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handleGetTemplates(w, r) - }))) + h.Handle("/templates", + mw.authenticated(http.HandlerFunc(h.handleGetTemplates))) return h } diff --git a/api/http/upload_handler.go b/api/http/upload_handler.go index a0d53c36b..a89bf03a4 100644 --- a/api/http/upload_handler.go +++ b/api/http/upload_handler.go @@ -14,21 +14,18 @@ import ( // UploadHandler represents an HTTP API handler for managing file uploads. type UploadHandler struct { *mux.Router - Logger *log.Logger - FileService portainer.FileService - middleWareService *middleWareService + Logger *log.Logger + FileService portainer.FileService } // NewUploadHandler returns a new instance of UploadHandler. -func NewUploadHandler(middleWareService *middleWareService) *UploadHandler { +func NewUploadHandler(mw *middleWareService) *UploadHandler { h := &UploadHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - middleWareService: middleWareService, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.Handle("/upload/tls/{endpointID}/{certificate:(?:ca|cert|key)}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePostUploadTLS(w, r) - }))) + h.Handle("/upload/tls/{endpointID}/{certificate:(?:ca|cert|key)}", + mw.authenticated(http.HandlerFunc(h.handlePostUploadTLS))) return h } diff --git a/api/http/user_handler.go b/api/http/user_handler.go index 38243b0a6..b1ba8f684 100644 --- a/api/http/user_handler.go +++ b/api/http/user_handler.go @@ -1,6 +1,8 @@ package http import ( + "strconv" + "github.com/portainer/portainer" "encoding/json" @@ -15,43 +17,44 @@ import ( // UserHandler represents an HTTP API handler for managing users. type UserHandler struct { *mux.Router - Logger *log.Logger - UserService portainer.UserService - CryptoService portainer.CryptoService - middleWareService *middleWareService + Logger *log.Logger + UserService portainer.UserService + ResourceControlService portainer.ResourceControlService + CryptoService portainer.CryptoService } // NewUserHandler returns a new instance of UserHandler. -func NewUserHandler(middleWareService *middleWareService) *UserHandler { +func NewUserHandler(mw *middleWareService) *UserHandler { h := &UserHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - middleWareService: middleWareService, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.Handle("/users", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePostUsers(w, r) - }))) - h.Handle("/users/{username}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handleGetUser(w, r) - }))).Methods(http.MethodGet) - h.Handle("/users/{username}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePutUser(w, r) - }))).Methods(http.MethodPut) - h.Handle("/users/{username}/passwd", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePostUserPasswd(w, r) - }))) - h.HandleFunc("/users/admin/check", h.handleGetAdminCheck) - h.HandleFunc("/users/admin/init", h.handlePostAdminInit) + h.Handle("/users", + mw.administrator(http.HandlerFunc(h.handlePostUsers))).Methods(http.MethodPost) + h.Handle("/users", + mw.administrator(http.HandlerFunc(h.handleGetUsers))).Methods(http.MethodGet) + h.Handle("/users/{id}", + mw.administrator(http.HandlerFunc(h.handleGetUser))).Methods(http.MethodGet) + h.Handle("/users/{id}", + mw.authenticated(http.HandlerFunc(h.handlePutUser))).Methods(http.MethodPut) + h.Handle("/users/{id}", + mw.administrator(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete) + h.Handle("/users/{id}/passwd", + mw.authenticated(http.HandlerFunc(h.handlePostUserPasswd))) + h.Handle("/users/{userId}/resources/{resourceType}", + mw.authenticated(http.HandlerFunc(h.handlePostUserResource))).Methods(http.MethodPost) + h.Handle("/users/{userId}/resources/{resourceType}/{resourceId}", + mw.authenticated(http.HandlerFunc(h.handleDeleteUserResource))).Methods(http.MethodDelete) + h.Handle("/users/admin/check", + mw.public(http.HandlerFunc(h.handleGetAdminCheck))) + h.Handle("/users/admin/init", + mw.public(http.HandlerFunc(h.handlePostAdminInit))) + return h } // handlePostUsers handles POST requests on /users func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - handleNotAllowed(w, []string{http.MethodPost}) - return - } - var req postUsersRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) @@ -64,8 +67,26 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque return } - user := &portainer.User{ + var role portainer.UserRole + if req.Role == 1 { + role = portainer.AdministratorRole + } else { + role = portainer.StandardUserRole + } + + user, err := handler.UserService.UserByUsername(req.Username) + if err != nil && err != portainer.ErrUserNotFound { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if user != nil { + Error(w, portainer.ErrUserAlreadyExists, http.StatusConflict, handler.Logger) + return + } + + user = &portainer.User{ Username: req.Username, + Role: role, } user.Password, err = handler.CryptoService.Hash(req.Password) if err != nil { @@ -73,7 +94,7 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque return } - err = handler.UserService.UpdateUser(user) + err = handler.UserService.CreateUser(user) if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) return @@ -83,9 +104,24 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque type postUsersRequest struct { Username string `valid:"alphanum,required"` Password string `valid:"required"` + Role int `valid:"required"` } -// handlePostUserPasswd handles POST requests on /users/:username/passwd +// handleGetUsers handles GET requests on /users +func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) { + users, err := handler.UserService.Users() + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + for i := range users { + users[i].Password = "" + } + encodeJSON(w, users, handler.Logger) +} + +// handlePostUserPasswd handles POST requests on /users/:id/passwd func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { handleNotAllowed(w, []string{http.MethodPost}) @@ -93,15 +129,21 @@ func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http. } vars := mux.Vars(r) - username := vars["username"] + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } var req postUserPasswdRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) return } - _, err := govalidator.ValidateStruct(req) + _, err = govalidator.ValidateStruct(req) if err != nil { Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return @@ -109,7 +151,7 @@ func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http. var password = req.Password - u, err := handler.UserService.User(username) + u, err := handler.UserService.User(portainer.UserID(userID)) if err == portainer.ErrUserNotFound { Error(w, err, http.StatusNotFound, handler.Logger) return @@ -135,12 +177,18 @@ type postUserPasswdResponse struct { Valid bool `json:"valid"` } -// handleGetUser handles GET requests on /users/:username +// handleGetUser handles GET requests on /users/:id func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) - username := vars["username"] + id := vars["id"] - user, err := handler.UserService.User(username) + userID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + user, err := handler.UserService.User(portainer.UserID(userID)) if err == portainer.ErrUserNotFound { Error(w, err, http.StatusNotFound, handler.Logger) return @@ -153,30 +201,74 @@ func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request encodeJSON(w, &user, handler.Logger) } -// handlePutUser handles PUT requests on /users/:username +// handlePutUser handles PUT requests on /users/:id func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + tokenData, err := extractTokenDataFromRequestContext(r) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + Error(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) + return + } + var req putUserRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) return } - _, err := govalidator.ValidateStruct(req) + _, err = govalidator.ValidateStruct(req) if err != nil { Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return } - user := &portainer.User{ - Username: req.Username, - } - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + if req.Password == "" && req.Role == 0 { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return } - err = handler.UserService.UpdateUser(user) + user, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if req.Password != "" { + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + } + + if req.Role != 0 { + if tokenData.Role != portainer.AdministratorRole { + Error(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) + return + } + if req.Role == 1 { + user.Role = portainer.AdministratorRole + } else { + user.Role = portainer.StandardUserRole + } + } + + err = handler.UserService.UpdateUser(user.ID, user) if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) return @@ -184,8 +276,8 @@ func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request } type putUserRequest struct { - Username string `valid:"alphanum,required"` - Password string `valid:"required"` + Password string `valid:"-"` + Role int `valid:"-"` } // handlePostAdminInit handles GET requests on /users/admin/check @@ -195,17 +287,15 @@ func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.R return } - user, err := handler.UserService.User("admin") - if err == portainer.ErrUserNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { + users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) + if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) return } - - user.Password = "" - encodeJSON(w, &user, handler.Logger) + if len(users) == 0 { + Error(w, portainer.ErrUserNotFound, http.StatusNotFound, handler.Logger) + return + } } // handlePostAdminInit handles POST requests on /users/admin/init @@ -227,10 +317,11 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R return } - user, err := handler.UserService.User("admin") + user, err := handler.UserService.UserByUsername("admin") if err == portainer.ErrUserNotFound { user := &portainer.User{ Username: "admin", + Role: portainer.AdministratorRole, } user.Password, err = handler.CryptoService.Hash(req.Password) if err != nil { @@ -238,7 +329,7 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R return } - err = handler.UserService.UpdateUser(user) + err = handler.UserService.CreateUser(user) if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) return @@ -256,3 +347,134 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R type postAdminInitRequest struct { Password string `valid:"required"` } + +// handleDeleteUser handles DELETE requests on /users/:id +func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + _, err = handler.UserService.User(portainer.UserID(userID)) + + if err == portainer.ErrUserNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.UserService.DeleteUser(portainer.UserID(userID)) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +// handlePostUserResource handles POST requests on /users/:userId/resources/:resourceType +func (handler *UserHandler) handlePostUserResource(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userId"] + resourceType := vars["resourceType"] + + uid, err := strconv.Atoi(userID) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var rcType portainer.ResourceControlType + if resourceType == "container" { + rcType = portainer.ContainerResourceControl + } else if resourceType == "service" { + rcType = portainer.ServiceResourceControl + } else if resourceType == "volume" { + rcType = portainer.VolumeResourceControl + } else { + Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) + return + } + + tokenData, err := extractTokenDataFromRequestContext(r) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + } + if tokenData.ID != portainer.UserID(uid) { + Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + var req postUserResourceRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + resource := portainer.ResourceControl{ + OwnerID: portainer.UserID(uid), + ResourceID: req.ResourceID, + AccessLevel: portainer.RestrictedResourceAccessLevel, + } + + err = handler.ResourceControlService.CreateResourceControl(req.ResourceID, &resource, rcType) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } +} + +type postUserResourceRequest struct { + ResourceID string `valid:"required"` +} + +// handleDeleteUserResource handles DELETE requests on /users/:userId/resources/:resourceType/:resourceId +func (handler *UserHandler) handleDeleteUserResource(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userId"] + resourceID := vars["resourceId"] + resourceType := vars["resourceType"] + + uid, err := strconv.Atoi(userID) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var rcType portainer.ResourceControlType + if resourceType == "container" { + rcType = portainer.ContainerResourceControl + } else if resourceType == "service" { + rcType = portainer.ServiceResourceControl + } else if resourceType == "volume" { + rcType = portainer.VolumeResourceControl + } else { + Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) + return + } + + tokenData, err := extractTokenDataFromRequestContext(r) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + } + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(uid) { + Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + err = handler.ResourceControlService.DeleteResourceControl(resourceID, rcType) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} diff --git a/api/http/websocket_handler.go b/api/http/websocket_handler.go index c217404a0..126fcbed0 100644 --- a/api/http/websocket_handler.go +++ b/api/http/websocket_handler.go @@ -1,8 +1,6 @@ package http import ( - "github.com/portainer/portainer" - "bytes" "crypto/tls" "encoding/json" @@ -14,18 +12,19 @@ import ( "net/http/httputil" "net/url" "os" + "strconv" "time" "github.com/gorilla/mux" + "github.com/portainer/portainer" "golang.org/x/net/websocket" ) // WebSocketHandler represents an HTTP API handler for proxying requests to a web socket. type WebSocketHandler struct { *mux.Router - Logger *log.Logger - middleWareService *middleWareService - endpoint *portainer.Endpoint + Logger *log.Logger + EndpointService portainer.EndpointService } // NewWebSocketHandler returns a new instance of WebSocketHandler. @@ -41,34 +40,47 @@ func NewWebSocketHandler() *WebSocketHandler { func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { qry := ws.Request().URL.Query() execID := qry.Get("id") + edpID := qry.Get("endpointId") - // Should not be managed here - endpoint, err := url.Parse(handler.endpoint.URL) + parsedID, err := strconv.Atoi(edpID) if err != nil { - log.Fatalf("Unable to parse endpoint URL: %s", err) + log.Printf("Unable to parse endpoint ID: %s", err) + return + } + + endpointID := portainer.EndpointID(parsedID) + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + log.Printf("Unable to retrieve endpoint: %s", err) + return + } + + endpointURL, err := url.Parse(endpoint.URL) + if err != nil { + log.Printf("Unable to parse endpoint URL: %s", err) return } var host string - if endpoint.Scheme == "tcp" { - host = endpoint.Host - } else if endpoint.Scheme == "unix" { - host = endpoint.Path + if endpointURL.Scheme == "tcp" { + host = endpointURL.Host + } else if endpointURL.Scheme == "unix" { + host = endpointURL.Path } // Should not be managed here var tlsConfig *tls.Config - if handler.endpoint.TLS { - tlsConfig, err = createTLSConfiguration(handler.endpoint.TLSCACertPath, - handler.endpoint.TLSCertPath, - handler.endpoint.TLSKeyPath) + if endpoint.TLS { + tlsConfig, err = createTLSConfiguration(endpoint.TLSCACertPath, + endpoint.TLSCertPath, + endpoint.TLSKeyPath) if err != nil { log.Fatalf("Unable to create TLS configuration: %s", err) return } } - if err := hijack(host, endpoint.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil { + if err := hijack(host, endpointURL.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil { log.Fatalf("error during hijack: %s", err) return } diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go index 0971ee5f7..9d14be4bc 100644 --- a/api/jwt/jwt.go +++ b/api/jwt/jwt.go @@ -4,9 +4,10 @@ import ( "github.com/portainer/portainer" "fmt" + "time" + "github.com/dgrijalva/jwt-go" "github.com/gorilla/securecookie" - "time" ) // Service represents a service for managing JWT tokens. @@ -15,7 +16,9 @@ type Service struct { } type claims struct { + UserID int `json:"id"` Username string `json:"username"` + Role int `json:"role"` jwt.StandardClaims } @@ -35,7 +38,9 @@ func NewService() (*Service, error) { func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) { expireToken := time.Now().Add(time.Hour * 8).Unix() cl := claims{ + int(data.ID), data.Username, + int(data.Role), jwt.StandardClaims{ ExpiresAt: expireToken, }, @@ -50,17 +55,25 @@ func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) return signedToken, nil } -// VerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid. -func (service *Service) VerifyToken(token string) error { - parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { +// ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid. +func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, error) { + parsedToken, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) return nil, msg } return service.secret, nil }) - if err != nil || parsedToken == nil || !parsedToken.Valid { - return portainer.ErrInvalidJWTToken + if err == nil && parsedToken != nil { + if cl, ok := parsedToken.Claims.(*claims); ok && parsedToken.Valid { + tokenData := &portainer.TokenData{ + ID: portainer.UserID(cl.UserID), + Username: cl.Username, + Role: portainer.UserRole(cl.Role), + } + return tokenData, nil + } } - return nil + + return nil, portainer.ErrInvalidJWTToken } diff --git a/api/portainer.go b/api/portainer.go index 5297d50c0..501b6501c 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -41,13 +41,24 @@ type ( // User represent a user account. User struct { - Username string `json:"Username"` - Password string `json:"Password,omitempty"` + ID UserID `json:"Id"` + Username string `json:"Username"` + Password string `json:"Password,omitempty"` + Role UserRole `json:"Role"` } + // UserID represents a user identifier + UserID int + + // UserRole represents the role of a user. It can be either an administrator + // or a regular user. + UserRole int + // TokenData represents the data embedded in a JWT token. TokenData struct { + ID UserID Username string + Role UserRole } // EndpointID represents an endpoint identifier. @@ -56,15 +67,31 @@ type ( // Endpoint represents a Docker endpoint with all the info required // to connect to it. Endpoint struct { - ID EndpointID `json:"Id"` - Name string `json:"Name"` - URL string `json:"URL"` - TLS bool `json:"TLS"` - TLSCACertPath string `json:"TLSCACert,omitempty"` - TLSCertPath string `json:"TLSCert,omitempty"` - TLSKeyPath string `json:"TLSKey,omitempty"` + ID EndpointID `json:"Id"` + Name string `json:"Name"` + URL string `json:"URL"` + TLS bool `json:"TLS"` + TLSCACertPath string `json:"TLSCACert,omitempty"` + TLSCertPath string `json:"TLSCert,omitempty"` + TLSKeyPath string `json:"TLSKey,omitempty"` + AuthorizedUsers []UserID `json:"AuthorizedUsers"` } + // ResourceControl represent a reference to a Docker resource with specific controls + ResourceControl struct { + OwnerID UserID `json:"OwnerId"` + ResourceID string `json:"ResourceId"` + AccessLevel ResourceAccessLevel `json:"AccessLevel"` + } + + // ResourceControlType represents a type of resource control. + // Can be one of: container, service or volume. + ResourceControlType int + + // ResourceAccessLevel represents the level of control associated to a resource for a specific owner. + // Can be one of: full, restricted, limited. + ResourceAccessLevel int + // TLSFileType represents a type of TLS file required to connect to a Docker endpoint. // It can be either a TLS CA file, a TLS certificate file or a TLS key file. TLSFileType int @@ -79,32 +106,49 @@ type ( DataStore interface { Open() error Close() error + MigrateData() error } - // Server defines the interface to serve the data. + // Server defines the interface to serve the API. Server interface { Start() error } - // UserService represents a service for managing users. + // UserService represents a service for managing user data. UserService interface { - User(username string) (*User, error) - UpdateUser(user *User) error + User(ID UserID) (*User, error) + UserByUsername(username string) (*User, error) + Users() ([]User, error) + UsersByRole(role UserRole) ([]User, error) + CreateUser(user *User) error + UpdateUser(ID UserID, user *User) error + DeleteUser(ID UserID) error } - // EndpointService represents a service for managing endpoints. + // EndpointService represents a service for managing endpoint data. EndpointService interface { Endpoint(ID EndpointID) (*Endpoint, error) Endpoints() ([]Endpoint, error) CreateEndpoint(endpoint *Endpoint) error UpdateEndpoint(ID EndpointID, endpoint *Endpoint) error DeleteEndpoint(ID EndpointID) error - GetActive() (*Endpoint, error) - SetActive(endpoint *Endpoint) error - DeleteActive() error Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error } + // VersionService represents a service for managing version data. + VersionService interface { + DBVersion() (int, error) + StoreDBVersion(version int) error + } + + // ResourceControlService represents a service for managing resource control data. + ResourceControlService interface { + ResourceControl(resourceID string, rcType ResourceControlType) (*ResourceControl, error) + ResourceControls(rcType ResourceControlType) ([]ResourceControl, error) + CreateResourceControl(resourceID string, rc *ResourceControl, rcType ResourceControlType) error + DeleteResourceControl(resourceID string, rcType ResourceControlType) error + } + // CryptoService represents a service for encrypting/hashing data. CryptoService interface { Hash(data string) (string, error) @@ -114,7 +158,7 @@ type ( // JWTService represents a service for managing JWT tokens. JWTService interface { GenerateToken(data *TokenData) (string, error) - VerifyToken(token string) error + ParseAndVerifyToken(token string) (*TokenData, error) } // FileService represents a service for managing files. @@ -131,8 +175,10 @@ type ( ) const ( - // APIVersion is the version number of portainer API. + // APIVersion is the version number of Portainer API. APIVersion = "1.11.4" + // DBVersion is the version number of Portainer database. + DBVersion = 1 ) const ( @@ -143,3 +189,27 @@ const ( // TLSFileKey represents a TLS key file. TLSFileKey ) + +const ( + _ UserRole = iota + // AdministratorRole represents an administrator user role + AdministratorRole + // StandardUserRole represents a regular user role + StandardUserRole +) + +const ( + _ ResourceControlType = iota + // ContainerResourceControl represents a resource control for a container + ContainerResourceControl + // ServiceResourceControl represents a resource control for a service + ServiceResourceControl + // VolumeResourceControl represents a resource control for a volume + VolumeResourceControl +) + +const ( + _ ResourceAccessLevel = iota + // RestrictedResourceAccessLevel represents a restricted access level on a resource (private ownership) + RestrictedResourceAccessLevel +) diff --git a/app/app.js b/app/app.js index 10d2d4243..c30203af3 100644 --- a/app/app.js +++ b/app/app.js @@ -30,6 +30,7 @@ angular.module('portainer', [ 'createVolume', 'docker', 'endpoint', + 'endpointAccess', 'endpointInit', 'endpoints', 'events', @@ -47,6 +48,8 @@ angular.module('portainer', [ 'swarm', 'task', 'templates', + 'user', + 'users', 'volumes']) .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider) { 'use strict'; @@ -292,6 +295,19 @@ angular.module('portainer', [ } } }) + .state('endpoint.access', { + url: '^/endpoints/:id/access', + views: { + "content@": { + templateUrl: 'app/components/endpointAccess/endpointAccess.html', + controller: 'EndpointAccessController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('endpointInit', { url: '/init/endpoint', views: { @@ -457,6 +473,32 @@ angular.module('portainer', [ } } }) + .state('users', { + url: '/users/', + views: { + "content@": { + templateUrl: 'app/components/users/users.html', + controller: 'UsersController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('user', { + url: '^/users/:id', + views: { + "content@": { + templateUrl: 'app/components/user/user.html', + controller: 'UserController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('swarm', { url: '/swarm/', views: { @@ -488,7 +530,8 @@ angular.module('portainer', [ }; }); }]) - .run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'Messages', 'Analytics', function ($rootScope, $state, Authentication, authManager, StateManager, Messages, Analytics) { + .run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Messages', 'Analytics', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Messages, Analytics) { + EndpointProvider.initialize(); StateManager.initialize().then(function success(state) { if (state.application.authentication) { authManager.checkAuthOnRefresh(); diff --git a/app/components/auth/authController.js b/app/components/auth/authController.js index b46e8537a..7d5026039 100644 --- a/app/components/auth/authController.js +++ b/app/components/auth/authController.js @@ -1,6 +1,6 @@ angular.module('auth', []) -.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Config', 'Authentication', 'Users', 'EndpointService', 'StateManager', 'Messages', -function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Authentication, Users, EndpointService, StateManager, Messages) { +.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Config', 'Authentication', 'Users', 'EndpointService', 'StateManager', 'EndpointProvider', 'Messages', +function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Authentication, Users, EndpointService, StateManager, EndpointProvider, Messages) { $scope.authData = { username: 'admin', @@ -14,18 +14,34 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au }; if (!$scope.applicationState.application.authentication) { - EndpointService.getActive().then(function success(data) { - StateManager.updateEndpointState(true) - .then(function success() { - $state.go('dashboard'); - }, function error(err) { - Messages.error("Failure", err, 'Unable to connect to the Docker endpoint'); - }); - }, function error(err) { - if (err.status === 404) { + EndpointService.endpoints() + .then(function success(data) { + if (data.length > 0) { + endpointID = EndpointProvider.endpointID(); + if (!endpointID) { + endpointID = data[0].Id; + EndpointProvider.setEndpointID(endpointID); + } + StateManager.updateEndpointState(true) + .then(function success() { + $state.go('dashboard'); + }, function error(err) { + Messages.error("Failure", err, 'Unable to connect to the Docker endpoint'); + }); + } + else { $state.go('endpointInit'); + } + }, function error(err) { + Messages.error("Failure", err, 'Unable to retrieve endpoints'); + }); + } else { + Users.checkAdminUser({}, function () {}, + function (e) { + if (e.status === 404) { + $scope.initPassword = true; } else { - Messages.error("Failure", err, 'Unable to verify Docker endpoint existence'); + Messages.error("Failure", e, 'Unable to verify administrator account existence'); } }); } @@ -47,15 +63,6 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au $scope.logo = c.logo; }); - Users.checkAdminUser({}, function (d) {}, - function (e) { - if (e.status === 404) { - $scope.initPassword = true; - } else { - Messages.error("Failure", e, 'Unable to verify administrator account existence'); - } - }); - $scope.createAdminUser = function() { var password = $sanitize($scope.initPasswordData.password); Users.initAdminUser({password: password}, function (d) { @@ -75,23 +82,33 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au $scope.authenticationError = false; var username = $sanitize($scope.authData.username); var password = $sanitize($scope.authData.password); - Authentication.login(username, password).then(function success() { - EndpointService.getActive().then(function success(data) { + Authentication.login(username, password) + .then(function success(data) { + return EndpointService.endpoints(); + }) + .then(function success(data) { + var userDetails = Authentication.getUserDetails(); + if (data.length > 0) { + endpointID = EndpointProvider.endpointID(); + if (!endpointID) { + endpointID = data[0].Id; + EndpointProvider.setEndpointID(endpointID); + } StateManager.updateEndpointState(true) .then(function success() { $state.go('dashboard'); }, function error(err) { Messages.error("Failure", err, 'Unable to connect to the Docker endpoint'); }); - }, function error(err) { - if (err.status === 404) { - $state.go('endpointInit'); - } else { - Messages.error("Failure", err, 'Unable to verify Docker endpoint existence'); - } - }); - }, function error() { - $scope.authData.error = 'Invalid credentials'; + } + else if (data.length === 0 && userDetails.role === 1) { + $state.go('endpointInit'); + } else if (data.length === 0 && userDetails.role === 2) { + $scope.authData.error = 'User not allowed. Please contact your administrator.'; + } + }) + .catch(function error(err) { + $scope.authData.error = 'Authentication error'; }); }; }]); diff --git a/app/components/containerConsole/containerConsoleController.js b/app/components/containerConsole/containerConsoleController.js index d81bff349..4ef8cc3d6 100644 --- a/app/components/containerConsole/containerConsoleController.js +++ b/app/components/containerConsole/containerConsoleController.js @@ -1,6 +1,6 @@ angular.module('containerConsole', []) -.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Settings', 'Container', 'Image', 'Exec', '$timeout', 'Messages', -function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Messages) { +.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Settings', 'Container', 'Image', 'Exec', '$timeout', 'EndpointProvider', 'Messages', +function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, EndpointProvider, Messages) { $scope.state = {}; $scope.state.loaded = false; $scope.state.connected = false; @@ -55,7 +55,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Mess } else { var execId = d.Id; resizeTTY(execId, termHeight, termWidth); - var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId; + var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID(); if (url.indexOf('https') > -1) { url = url.replace('https://', 'wss://'); } else { diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index 1da516551..5849e8ac2 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -90,6 +90,13 @@ + + + Ownership + + + + @@ -107,12 +114,43 @@ - + + + + + Public service + + + Public + + + + + + Private service + + + Private + Switch to public + + + + + + Private service (owner: {{ container.Owner }}) + + + Private (owner: {{ container.Owner }}) + Switch to public + + + - Loading... + Loading... - No containers available. + No containers available. diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index 93089e3da..eea0140e0 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -1,6 +1,6 @@ angular.module('containers', []) -.controller('ContainersController', ['$scope', '$filter', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Messages', 'Config', 'Pagination', 'EntityListService', -function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages, Config, Pagination, EntityListService) { + .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Messages', 'Config', 'Pagination', 'EntityListService', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', + function ($q, $scope, $filter, Container, ContainerHelper, Info, Settings, Messages, Config, Pagination, EntityListService, ModalService, Authentication, ResourceControlService, UserService) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('containers'); $scope.state.displayAll = Settings.displayAll; @@ -17,8 +17,51 @@ function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages, Pagination.setPaginationCount('containers', $scope.state.pagination_count); }; + function removeContainerResourceControl(container) { + volumeResourceControlQueries = []; + angular.forEach(container.Mounts, function (volume) { + volumeResourceControlQueries.push(ResourceControlService.removeVolumeResourceControl(container.Metadata.ResourceControl.OwnerId, volume.Name)); + }); + + $q.all(volumeResourceControlQueries) + .then(function success() { + return ResourceControlService.removeContainerResourceControl(container.Metadata.ResourceControl.OwnerId, container.Id); + }) + .then(function success() { + delete container.Metadata.ResourceControl; + Messages.send('Ownership changed to public', container.Id); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to change container ownership"); + }); + } + + $scope.switchOwnership = function(container) { + ModalService.confirmContainerOwnershipChange(function (confirmed) { + if(!confirmed) { return; } + removeContainerResourceControl(container); + }); + }; + + function mapUsersToContainers(users) { + angular.forEach($scope.containers, function (container) { + if (container.Metadata) { + var containerRC = container.Metadata.ResourceControl; + if (containerRC && containerRC.OwnerId != $scope.user.ID) { + angular.forEach(users, function (user) { + if (containerRC.OwnerId === user.Id) { + container.Owner = user.Username; + } + }); + } + } + }); + } + var update = function (data) { $('#loadContainersSpinner').show(); + var userDetails = Authentication.getUserDetails(); + $scope.user = userDetails; $scope.state.selectedItemCount = 0; Container.query(data, function (d) { var containers = d; @@ -41,7 +84,20 @@ function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages, } return model; }); - $('#loadContainersSpinner').hide(); + if (userDetails.role === 1) { + UserService.users() + .then(function success(data) { + mapUsersToContainers(data); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to retrieve users"); + }) + .finally(function final() { + $('#loadContainersSpinner').hide(); + }); + } else { + $('#loadContainersSpinner').hide(); + } }, function (e) { $('#loadContainersSpinner').hide(); Messages.error("Failure", e, "Unable to retrieve containers"); @@ -77,7 +133,17 @@ function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages, Messages.send("Error", d.message); } else { - Messages.send("Container " + msg, c.Id); + if (c.Metadata && c.Metadata.ResourceControl) { + ResourceControlService.removeContainerResourceControl(c.Metadata.ResourceControl.OwnerId, c.Id) + .then(function success() { + Messages.send("Container " + msg, c.Id); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to remove container ownership"); + }); + } else { + Messages.send("Container " + msg, c.Id); + } } complete(); }, function (e) { diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index af8b928d6..7ca69caa0 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -1,8 +1,11 @@ +// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. +// See app/components/templates/templatesController.js as a reference. angular.module('createContainer', []) -.controller('CreateContainerController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'Messages', -function ($scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, Messages) { +.controller('CreateContainerController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Messages', +function ($scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Messages) { $scope.formValues = { + Ownership: $scope.applicationState.application.authentication ? 'private' : '', alwaysPull: true, Console: 'none', Volumes: [], @@ -116,26 +119,40 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai }); }); - // TODO: centralize, already present in templatesController + function startContainer(containerID) { + Container.start({id: containerID}, {}, function (cd) { + if (cd.message) { + $('#createContainerSpinner').hide(); + Messages.error('Error', {}, cd.message); + } else { + $('#createContainerSpinner').hide(); + Messages.send('Container Started', containerID); + $state.go('containers', {}, {reload: true}); + } + }, function (e) { + $('#createContainerSpinner').hide(); + Messages.error("Failure", e, 'Unable to start container'); + }); + } + function createContainer(config) { Container.create(config, function (d) { if (d.message) { $('#createContainerSpinner').hide(); Messages.error('Error', {}, d.message); } else { - Container.start({id: d.Id}, {}, function (cd) { - if (cd.message) { + if ($scope.formValues.Ownership === 'private') { + ResourceControlService.setContainerResourceControl(Authentication.getUserDetails().ID, d.Id) + .then(function success() { + startContainer(d.Id); + }) + .catch(function error(err) { $('#createContainerSpinner').hide(); - Messages.error('Error', {}, cd.message); - } else { - $('#createContainerSpinner').hide(); - Messages.send('Container Started', d.Id); - $state.go('containers', {}, {reload: true}); - } - }, function (e) { - $('#createContainerSpinner').hide(); - Messages.error("Failure", e, 'Unable to start container'); - }); + Messages.error("Failure", err, 'Unable to apply resource control on container'); + }); + } else { + startContainer(d.Id); + } } }, function (e) { $('#createContainerSpinner').hide(); @@ -143,7 +160,6 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai }); } - // TODO: centralize, already present in templatesController function pullImageAndCreateContainer(config) { Image.create($scope.imageConfig, function (data) { createContainer(config); @@ -242,7 +258,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai networkMode += ':' + containerName; } config.HostConfig.NetworkMode = networkMode; - + $scope.formValues.ExtraHosts.forEach(function (v) { if (v.value) { config.HostConfig.ExtraHosts.push(v.value); diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index 47c92b9ed..8302d29a5 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -100,6 +100,26 @@ + +
+
+ +
+ + +
+
+
+ @@ -333,7 +353,7 @@ - + diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index 8497463ab..ed3d3dce5 100644 --- a/app/components/createService/createServiceController.js +++ b/app/components/createService/createServiceController.js @@ -1,8 +1,11 @@ +// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. +// See app/components/templates/templatesController.js as a reference. angular.module('createService', []) -.controller('CreateServiceController', ['$scope', '$state', 'Service', 'Volume', 'Network', 'ImageHelper', 'Messages', -function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) { +.controller('CreateServiceController', ['$scope', '$state', 'Service', 'Volume', 'Network', 'ImageHelper', 'Authentication', 'ResourceControlService', 'Messages', +function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, ResourceControlService, Messages) { $scope.formValues = { + Ownership: $scope.applicationState.application.authentication ? 'private' : '', Name: '', Image: '', Registry: '', @@ -205,9 +208,22 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) { function createNewService(config) { Service.create(config, function (d) { - $('#createServiceSpinner').hide(); - Messages.send('Service created', d.ID); - $state.go('services', {}, {reload: true}); + if ($scope.formValues.Ownership === 'private') { + ResourceControlService.setServiceResourceControl(Authentication.getUserDetails().ID, d.ID) + .then(function success() { + $('#createServiceSpinner').hide(); + Messages.send('Service created', d.ID); + $state.go('services', {}, {reload: true}); + }) + .catch(function error(err) { + $('#createContainerSpinner').hide(); + Messages.error("Failure", err, 'Unable to apply resource control on service'); + }); + } else { + $('#createServiceSpinner').hide(); + Messages.send('Service created', d.ID); + $state.go('services', {}, {reload: true}); + } }, function (e) { $('#createServiceSpinner').hide(); Messages.error("Failure", e, 'Unable to create service'); diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index c38c606e4..87853badb 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -87,6 +87,26 @@ + +
+
+ +
+ + +
+
+
+ diff --git a/app/components/createVolume/createVolumeController.js b/app/components/createVolume/createVolumeController.js index def0b5dcc..61874d1a7 100644 --- a/app/components/createVolume/createVolumeController.js +++ b/app/components/createVolume/createVolumeController.js @@ -1,8 +1,9 @@ angular.module('createVolume', []) -.controller('CreateVolumeController', ['$scope', '$state', 'Volume', 'Messages', -function ($scope, $state, Volume, Messages) { +.controller('CreateVolumeController', ['$scope', '$state', 'Volume', 'ResourceControlService', 'Authentication', 'Messages', +function ($scope, $state, Volume, ResourceControlService, Authentication, Messages) { $scope.formValues = { + Ownership: $scope.applicationState.application.authentication ? 'private' : '', DriverOptions: [] }; @@ -25,9 +26,22 @@ function ($scope, $state, Volume, Messages) { $('#createVolumeSpinner').hide(); Messages.error('Unable to create volume', {}, d.message); } else { - Messages.send("Volume created", d.Name); - $('#createVolumeSpinner').hide(); - $state.go('volumes', {}, {reload: true}); + if ($scope.formValues.Ownership === 'private') { + ResourceControlService.setVolumeResourceControl(Authentication.getUserDetails().ID, d.Name) + .then(function success() { + Messages.send("Volume created", d.Name); + $('#createVolumeSpinner').hide(); + $state.go('volumes', {}, {reload: true}); + }) + .catch(function error(err) { + $('#createVolumeSpinner').hide(); + Messages.error("Failure", err, 'Unable to apply resource control on volume'); + }); + } else { + Messages.send("Volume created", d.Name); + $('#createVolumeSpinner').hide(); + $state.go('volumes', {}, {reload: true}); + } } }, function (e) { $('#createVolumeSpinner').hide(); diff --git a/app/components/createVolume/createvolume.html b/app/components/createVolume/createvolume.html index 5213f4dd6..e55d30a4b 100644 --- a/app/components/createVolume/createvolume.html +++ b/app/components/createVolume/createvolume.html @@ -55,6 +55,26 @@ + +
+
+ +
+ + +
+
+
+ diff --git a/app/components/endpoint/endpointController.js b/app/components/endpoint/endpointController.js index e6e4eb526..b221a4f14 100644 --- a/app/components/endpoint/endpointController.js +++ b/app/components/endpoint/endpointController.js @@ -18,14 +18,18 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Messages) { $scope.updateEndpoint = function() { var ID = $scope.endpoint.Id; - var name = $scope.endpoint.Name; - var URL = $scope.endpoint.URL; - var TLS = $scope.endpoint.TLS; - var TLSCACert = $scope.formValues.TLSCACert !== $scope.endpoint.TLSCACert ? $scope.formValues.TLSCACert : null; - var TLSCert = $scope.formValues.TLSCert !== $scope.endpoint.TLSCert ? $scope.formValues.TLSCert : null; - var TLSKey = $scope.formValues.TLSKey !== $scope.endpoint.TLSKey ? $scope.formValues.TLSKey : null; - var type = $scope.endpointType; - EndpointService.updateEndpoint(ID, name, URL, TLS, TLSCACert, TLSCert, TLSKey, type).then(function success(data) { + var endpointParams = { + name: $scope.endpoint.Name, + URL: $scope.endpoint.URL, + TLS: $scope.endpoint.TLS, + TLSCACert: $scope.formValues.TLSCACert !== $scope.endpoint.TLSCACert ? $scope.formValues.TLSCACert : null, + TLSCert: $scope.formValues.TLSCert !== $scope.endpoint.TLSCert ? $scope.formValues.TLSCert : null, + TLSKey: $scope.formValues.TLSKey !== $scope.endpoint.TLSKey ? $scope.formValues.TLSKey : null, + type: $scope.endpointType + }; + + EndpointService.updateEndpoint(ID, endpointParams) + .then(function success(data) { Messages.send("Endpoint updated", $scope.endpoint.Name); $state.go('endpoints'); }, function error(err) { diff --git a/app/components/endpointAccess/endpointAccess.html b/app/components/endpointAccess/endpointAccess.html new file mode 100644 index 000000000..042280431 --- /dev/null +++ b/app/components/endpointAccess/endpointAccess.html @@ -0,0 +1,177 @@ + + + + + + Endpoints > {{ endpoint.Name }} > Access management + + + +
+
+ + + + + + + + + + + + + + + + + +
Name + {{ endpoint.Name }} +
URL + {{ endpoint.URL | stripprotocol }} +
+ + You can select which user can access this endpoint by moving them to the authorized users table. Simply click + on a user entry to move it from one table to the other. + +
+
+
+
+
+ +
+
+ + +
+ Items per page: + +
+
+ +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Role + + + +
{{ user.Username }} + {{ user.RoleName }} + +
Loading...
No users.
+
+ +
+
+
+
+
+
+ + +
+ Items per page: + +
+
+ +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Role + + + +
{{ user.Username }} + {{ user.RoleName }} + +
Loading...
No authorized users.
+
+ +
+
+
+
+
+
diff --git a/app/components/endpointAccess/endpointAccessController.js b/app/components/endpointAccess/endpointAccessController.js new file mode 100644 index 000000000..44ebb9c2c --- /dev/null +++ b/app/components/endpointAccess/endpointAccessController.js @@ -0,0 +1,148 @@ +angular.module('endpointAccess', []) +.controller('EndpointAccessController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'UserService', 'Pagination', 'Messages', +function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserService, Pagination, Messages) { + + $scope.state = { + pagination_count_users: Pagination.getPaginationCount('endpoint_access_users'), + pagination_count_authorizedUsers: Pagination.getPaginationCount('endpoint_access_authorizedUsers') + }; + + $scope.sortTypeUsers = 'Username'; + $scope.sortReverseUsers = true; + + $scope.orderUsers = function(sortType) { + $scope.sortReverseUsers = ($scope.sortTypeUsers === sortType) ? !$scope.sortReverseUsers : false; + $scope.sortTypeUsers = sortType; + }; + + $scope.changePaginationCountUsers = function() { + Pagination.setPaginationCount('endpoint_access_users', $scope.state.pagination_count_users); + }; + + $scope.sortTypeAuthorizedUsers = 'Username'; + $scope.sortReverseAuthorizedUsers = true; + + $scope.orderAuthorizedUsers = function(sortType) { + $scope.sortReverseAuthorizedUsers = ($scope.sortTypeAuthorizedUsers === sortType) ? !$scope.sortReverseAuthorizedUsers : false; + $scope.sortTypeAuthorizedUsers = sortType; + }; + + $scope.changePaginationCountAuthorizedUsers = function() { + Pagination.setPaginationCount('endpoint_access_authorizedUsers', $scope.state.pagination_count_authorizedUsers); + }; + + $scope.authorizeAllUsers = function() { + var authorizedUserIDs = []; + angular.forEach($scope.authorizedUsers, function (user) { + authorizedUserIDs.push(user.Id); + }); + angular.forEach($scope.users, function (user) { + authorizedUserIDs.push(user.Id); + }); + EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs) + .then(function success(data) { + $scope.authorizedUsers = $scope.authorizedUsers.concat($scope.users); + $scope.users = []; + Messages.send('Access granted for all users'); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to update endpoint permissions"); + }); + }; + + $scope.unauthorizeAllUsers = function() { + EndpointService.updateAuthorizedUsers($stateParams.id, []) + .then(function success(data) { + $scope.users = $scope.users.concat($scope.authorizedUsers); + $scope.authorizedUsers = []; + Messages.send('Access removed for all users'); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to update endpoint permissions"); + }); + }; + + $scope.authorizeUser = function(user) { + var authorizedUserIDs = []; + angular.forEach($scope.authorizedUsers, function (u) { + authorizedUserIDs.push(u.Id); + }); + authorizedUserIDs.push(user.Id); + EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs) + .then(function success(data) { + removeUserFromArray(user.Id, $scope.users); + $scope.authorizedUsers.push(user); + Messages.send('Access granted for user', user.Username); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to update endpoint permissions"); + }); + }; + + $scope.unauthorizeUser = function(user) { + var authorizedUserIDs = $scope.authorizedUsers.filter(function (u) { + if (u.Id !== user.Id) { + return u; + } + }).map(function (u) { + return u.Id; + }); + EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs) + .then(function success(data) { + removeUserFromArray(user.Id, $scope.authorizedUsers); + $scope.users.push(user); + Messages.send('Access removed for user', user.Username); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to update endpoint permissions"); + }); + }; + + function getEndpointAndUsers(endpointID) { + $('#loadingViewSpinner').show(); + $q.all({ + endpoint: EndpointService.endpoint($stateParams.id), + users: UserService.users(), + }) + .then(function success(data) { + $scope.endpoint = data.endpoint; + $scope.users = data.users.filter(function (user) { + if (user.Role !== 1) { + return user; + } + }).map(function (user) { + return new UserViewModel(user); + }); + $scope.authorizedUsers = []; + angular.forEach($scope.endpoint.AuthorizedUsers, function(userID) { + for (var i = 0, l = $scope.users.length; i < l; i++) { + if ($scope.users[i].Id === userID) { + $scope.authorizedUsers.push($scope.users[i]); + $scope.users.splice(i, 1); + return; + } + } + }); + }) + .catch(function error(err) { + $scope.templates = []; + $scope.users = []; + $scope.authorizedUsers = []; + Messages.error("Failure", err, "Unable to retrieve endpoint details"); + }) + .finally(function final(){ + $('#loadingViewSpinner').hide(); + }); + } + + function removeUserFromArray(id, users) { + for (var i = 0, l = users.length; i < l; i++) { + if (users[i].Id === id) { + users.splice(i, 1); + return; + } + } + } + + getEndpointAndUsers($stateParams.id); +}]); diff --git a/app/components/endpointInit/endpointInitController.js b/app/components/endpointInit/endpointInitController.js index b6aa99cdd..911f6c994 100644 --- a/app/components/endpointInit/endpointInitController.js +++ b/app/components/endpointInit/endpointInitController.js @@ -1,6 +1,6 @@ angular.module('endpointInit', []) -.controller('EndpointInitController', ['$scope', '$state', 'EndpointService', 'StateManager', 'Messages', -function ($scope, $state, EndpointService, StateManager, Messages) { +.controller('EndpointInitController', ['$scope', '$state', 'EndpointService', 'StateManager', 'EndpointProvider', 'Messages', +function ($scope, $state, EndpointService, StateManager, EndpointProvider, Messages) { $scope.state = { error: '', uploadInProgress: false @@ -29,20 +29,28 @@ function ($scope, $state, EndpointService, StateManager, Messages) { var name = "local"; var URL = "unix:///var/run/docker.sock"; var TLS = false; - EndpointService.createLocalEndpoint(name, URL, TLS, true).then(function success(data) { - StateManager.updateEndpointState(false) - .then(function success() { + + EndpointService.createLocalEndpoint(name, URL, TLS, true) + .then( + function success(data) { + var endpointID = data.Id; + EndpointProvider.setEndpointID(endpointID); + StateManager.updateEndpointState(false).then( + function success() { $state.go('dashboard'); - }, function error(err) { - EndpointService.deleteEndpoint(0) + }, + function error(err) { + EndpointService.deleteEndpoint(endpointID) .then(function success() { - $('#initEndpointSpinner').hide(); $scope.state.error = 'Unable to connect to the Docker endpoint'; }); }); - }, function error(err) { - $('#initEndpointSpinner').hide(); + }, + function error() { $scope.state.error = 'Unable to create endpoint'; + }) + .finally(function final() { + $('#initEndpointSpinner').hide(); }); }; @@ -57,11 +65,13 @@ function ($scope, $state, EndpointService, StateManager, Messages) { var TLSKeyFile = $scope.formValues.TLSKey; EndpointService.createRemoteEndpoint(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, TLS ? false : true) .then(function success(data) { + var endpointID = data.Id; + EndpointProvider.setEndpointID(endpointID); StateManager.updateEndpointState(false) .then(function success() { $state.go('dashboard'); }, function error(err) { - EndpointService.deleteEndpoint(0) + EndpointService.deleteEndpoint(endpointID) .then(function success() { $('#initEndpointSpinner').hide(); $scope.state.error = 'Unable to connect to the Docker endpoint'; diff --git a/app/components/endpoints/endpoints.html b/app/components/endpoints/endpoints.html index 41501eaa5..d9cb053ae 100644 --- a/app/components/endpoints/endpoints.html +++ b/app/components/endpoints/endpoints.html @@ -14,7 +14,7 @@ - Portainer has been started using the --external-endpoints flag. Endpoint management via the UI is disabled. + Portainer has been started using the --external-endpoints flag. Endpoint management via the UI is disabled. You can still manage endpoint access. @@ -152,14 +152,7 @@ - - - TLS - - - - - + @@ -167,13 +160,17 @@ {{ endpoint.Name }} {{ endpoint.URL | stripprotocol }} - - - - Edit + + + + Edit + + + You cannot edit the active endpoint + - - You cannot edit the active endpoint + + Manage access diff --git a/app/components/endpoints/endpointsController.js b/app/components/endpoints/endpointsController.js index 879ecbc1e..5fb787ccc 100644 --- a/app/components/endpoints/endpointsController.js +++ b/app/components/endpoints/endpointsController.js @@ -1,6 +1,6 @@ angular.module('endpoints', []) -.controller('EndpointsController', ['$scope', '$state', 'EndpointService', 'Messages', 'Pagination', -function ($scope, $state, EndpointService, Messages, Pagination) { +.controller('EndpointsController', ['$scope', '$state', 'EndpointService', 'EndpointProvider', 'Messages', 'Pagination', +function ($scope, $state, EndpointService, EndpointProvider, Messages, Pagination) { $scope.state = { error: '', uploadInProgress: false, @@ -84,19 +84,17 @@ function ($scope, $state, EndpointService, Messages, Pagination) { function fetchEndpoints() { $('#loadEndpointsSpinner').show(); - EndpointService.endpoints().then(function success(data) { + EndpointService.endpoints() + .then(function success(data) { $scope.endpoints = data; - EndpointService.getActive().then(function success(data) { - $scope.activeEndpoint = data; - $('#loadEndpointsSpinner').hide(); - }, function error(err) { - $('#loadEndpointsSpinner').hide(); - Messages.error("Failure", err, "Unable to retrieve active endpoint"); - }); - }, function error(err) { - $('#loadEndpointsSpinner').hide(); + $scope.activeEndpointID = EndpointProvider.endpointID(); + }) + .catch(function error(err) { Messages.error("Failure", err, "Unable to retrieve endpoints"); $scope.endpoints = []; + }) + .finally(function final() { + $('#loadEndpointsSpinner').hide(); }); } diff --git a/app/components/images/imagesController.js b/app/components/images/imagesController.js index e3bf84066..44752330e 100644 --- a/app/components/images/imagesController.js +++ b/app/components/images/imagesController.js @@ -60,21 +60,9 @@ function ($scope, $state, Config, Image, ImageHelper, Messages, Pagination, Moda }; $scope.confirmRemovalAction = function (force) { - ModalService.confirm({ - title: "Are you sure?", - message: "Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.", - buttons: { - confirm: { - label: 'Remove the image', - }, - cancel: { - label: 'Cancel' - } - }, - callback: function (confirmed) { - if(!confirmed) { return; } - $scope.removeAction(force); - } + ModalService.confirmImageForceRemoval(function (confirmed) { + if(!confirmed) { return; } + $scope.removeAction(force); }); }; diff --git a/app/components/services/services.html b/app/components/services/services.html index 0e958cecc..df4b69047 100644 --- a/app/components/services/services.html +++ b/app/components/services/services.html @@ -58,6 +58,13 @@ + + + Ownership + + + + @@ -76,12 +83,33 @@ + + + + + Private + + + Private (owner: {{ service.Owner }}) + + Switch to public + + + + Private + Switch to public + + + + Public + + - Loading... + Loading... - No services available. + No services available. diff --git a/app/components/services/servicesController.js b/app/components/services/servicesController.js index 1fb5c1dbb..850d5009f 100644 --- a/app/components/services/servicesController.js +++ b/app/components/services/servicesController.js @@ -1,12 +1,40 @@ angular.module('services', []) -.controller('ServicesController', ['$scope', '$stateParams', '$state', 'Service', 'ServiceHelper', 'Messages', 'Pagination', -function ($scope, $stateParams, $state, Service, ServiceHelper, Messages, Pagination) { +.controller('ServicesController', ['$q', '$scope', '$stateParams', '$state', 'Service', 'ServiceHelper', 'Messages', 'Pagination', 'Authentication', 'UserService', 'ModalService', 'ResourceControlService', +function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Messages, Pagination, Authentication, UserService, ModalService, ResourceControlService) { $scope.state = {}; $scope.state.selectedItemCount = 0; $scope.state.pagination_count = Pagination.getPaginationCount('services'); $scope.sortType = 'Name'; $scope.sortReverse = false; + function removeServiceResourceControl(service) { + volumeResourceControlQueries = []; + angular.forEach(service.Mounts, function (mount) { + if (mount.Type === 'volume') { + volumeResourceControlQueries.push(ResourceControlService.removeVolumeResourceControl(service.Metadata.ResourceControl.OwnerId, mount.Source)); + } + }); + + $q.all(volumeResourceControlQueries) + .then(function success() { + return ResourceControlService.removeServiceResourceControl(service.Metadata.ResourceControl.OwnerId, service.Id); + }) + .then(function success() { + delete service.Metadata.ResourceControl; + Messages.send('Ownership changed to public', service.Id); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to change service ownership"); + }); + } + + $scope.switchOwnership = function(volume) { + ModalService.confirmServiceOwnershipChange(function (confirmed) { + if(!confirmed) { return; } + removeServiceResourceControl(volume); + }); + }; + $scope.changePaginationCount = function() { Pagination.setPaginationCount('services', $scope.state.pagination_count); }; @@ -57,9 +85,21 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Messages, Pagina $('#loadServicesSpinner').hide(); Messages.error("Unable to remove service", {}, d[0].message); } else { - Messages.send("Service deleted", service.Id); - var index = $scope.services.indexOf(service); - $scope.services.splice(index, 1); + if (service.Metadata && service.Metadata.ResourceControl) { + ResourceControlService.removeServiceResourceControl(service.Metadata.ResourceControl.OwnerId, service.Id) + .then(function success() { + Messages.send("Service deleted", service.Id); + var index = $scope.services.indexOf(service); + $scope.services.splice(index, 1); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to remove service ownership"); + }); + } else { + Messages.send("Service deleted", service.Id); + var index = $scope.services.indexOf(service); + $scope.services.splice(index, 1); + } } complete(); }, function (e) { @@ -70,12 +110,42 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Messages, Pagina }); }; + function mapUsersToServices(users) { + angular.forEach($scope.services, function (service) { + if (service.Metadata) { + var serviceRC = service.Metadata.ResourceControl; + if (serviceRC && serviceRC.OwnerId != $scope.user.ID) { + angular.forEach(users, function (user) { + if (serviceRC.OwnerId === user.Id) { + service.Owner = user.Username; + } + }); + } + } + }); + } + function fetchServices() { $('#loadServicesSpinner').show(); + var userDetails = Authentication.getUserDetails(); + $scope.user = userDetails; + Service.query({}, function (d) { $scope.services = d.map(function (service) { return new ServiceViewModel(service); }); + if (userDetails.role === 1) { + UserService.users() + .then(function success(data) { + mapUsersToServices(data); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to retrieve users"); + }) + .finally(function final() { + $('#loadServicesSpinner').hide(); + }); + } $('#loadServicesSpinner').hide(); }, function(e) { $('#loadServicesSpinner').hide(); diff --git a/app/components/settings/settingsController.js b/app/components/settings/settingsController.js index d79dcc2e0..eb20ec9bb 100644 --- a/app/components/settings/settingsController.js +++ b/app/components/settings/settingsController.js @@ -1,6 +1,6 @@ angular.module('settings', []) -.controller('SettingsController', ['$scope', '$state', '$sanitize', 'Users', 'Messages', -function ($scope, $state, $sanitize, Users, Messages) { +.controller('SettingsController', ['$scope', '$state', '$sanitize', 'Authentication', 'UserService', 'Messages', +function ($scope, $state, $sanitize, Authentication, UserService, Messages) { $scope.formValues = { currentPassword: '', newPassword: '', @@ -9,22 +9,21 @@ function ($scope, $state, $sanitize, Users, Messages) { $scope.updatePassword = function() { $scope.invalidPassword = false; - $scope.error = false; + var userID = Authentication.getUserDetails().ID; var currentPassword = $sanitize($scope.formValues.currentPassword); - Users.checkPassword({ username: $scope.username, password: currentPassword }, function (d) { - if (d.valid) { - var newPassword = $sanitize($scope.formValues.newPassword); - Users.update({ username: $scope.username, password: newPassword }, function (d) { - Messages.send("Success", "Password successfully updated"); - $state.reload(); - }, function (e) { - Messages.error("Failure", e, "Unable to update password"); - }); - } else { + var newPassword = $sanitize($scope.formValues.newPassword); + + UserService.updateUserPassword(userID, currentPassword, newPassword) + .then(function success() { + Messages.send("Success", "Password successfully updated"); + $state.reload(); + }) + .catch(function error(err) { + if (err.invalidPassword) { $scope.invalidPassword = true; + } else { + Messages.error("Failure", err, err.msg); } - }, function (e) { - Messages.error("Failure", e, "Unable to check password validity"); }); }; }]); diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html index 1d29b8f8d..5515fa69e 100644 --- a/app/components/sidebar/sidebar.html +++ b/app/components/sidebar/sidebar.html @@ -50,7 +50,10 @@ - + diff --git a/app/components/sidebar/sidebarController.js b/app/components/sidebar/sidebarController.js index 152aa2c3e..9b040af7f 100644 --- a/app/components/sidebar/sidebarController.js +++ b/app/components/sidebar/sidebarController.js @@ -1,40 +1,41 @@ angular.module('sidebar', []) -.controller('SidebarController', ['$scope', '$state', 'Settings', 'Config', 'EndpointService', 'StateManager', 'Messages', -function ($scope, $state, Settings, Config, EndpointService, StateManager, Messages) { +.controller('SidebarController', ['$scope', '$state', 'Settings', 'Config', 'EndpointService', 'StateManager', 'EndpointProvider', 'Messages', 'Authentication', +function ($scope, $state, Settings, Config, EndpointService, StateManager, EndpointProvider, Messages, Authentication) { Config.$promise.then(function (c) { $scope.logo = c.logo; }); $scope.uiVersion = Settings.uiVersion; + $scope.userRole = Authentication.getUserDetails().role; $scope.switchEndpoint = function(endpoint) { - EndpointService.setActive(endpoint.Id) - .then(function success(data) { + var activeEndpointID = EndpointProvider.endpointID(); + EndpointProvider.setEndpointID(endpoint.Id); + StateManager.updateEndpointState(true) + .then(function success() { + $state.go('dashboard'); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to connect to the Docker endpoint"); + EndpointProvider.setEndpointID(activeEndpointID); StateManager.updateEndpointState(true) - .then(function success() { - $state.go('dashboard'); - }, function error(err) { - Messages.error("Failure", err, "Unable to connect to the Docker endpoint"); - }); - }, function error(err) { - Messages.error("Failure", err, "Unable to switch to new endpoint"); + .then(function success() {}); }); }; function fetchEndpoints() { - EndpointService.endpoints().then(function success(data) { + EndpointService.endpoints() + .then(function success(data) { $scope.endpoints = data; - EndpointService.getActive().then(function success(data) { - angular.forEach($scope.endpoints, function (endpoint) { - if (endpoint.Id === data.Id) { - $scope.activeEndpoint = endpoint; - } - }); - }, function error(err) { - Messages.error("Failure", err, "Unable to retrieve active endpoint"); + var activeEndpointID = EndpointProvider.endpointID(); + angular.forEach($scope.endpoints, function (endpoint) { + if (endpoint.Id === activeEndpointID) { + $scope.activeEndpoint = endpoint; + } }); - }, function error(err) { + }) + .catch(function error(err) { $scope.endpoints = []; }); } diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index 0e2982a46..513b55580 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -39,6 +39,7 @@ +
@@ -51,6 +52,21 @@
+ + +
+
+ +
+ + +
+
+
+
diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 015dc5198..a56996ec0 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -1,12 +1,13 @@ angular.module('templates', []) -.controller('TemplatesController', ['$scope', '$q', '$state', '$anchorScroll', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Messages', 'Pagination', -function ($scope, $q, $state, $anchorScroll, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Messages, Pagination) { +.controller('TemplatesController', ['$scope', '$q', '$state', '$anchorScroll', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Messages', 'Pagination', 'ResourceControlService', 'Authentication', +function ($scope, $q, $state, $anchorScroll, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Messages, Pagination, ResourceControlService, Authentication) { $scope.state = { selectedTemplate: null, showAdvancedOptions: false, pagination_count: Pagination.getPaginationCount('templates') }; $scope.formValues = { + Ownership: $scope.applicationState.application.authentication ? 'private' : '', network: "", name: "", }; @@ -36,18 +37,30 @@ function ($scope, $q, $state, $anchorScroll, Config, ContainerService, Container var template = $scope.state.selectedTemplate; var templateConfiguration = createTemplateConfiguration(template); var generatedVolumeCount = TemplateHelper.determineRequiredGeneratedVolumeCount(template.Volumes); - VolumeService.createXAutoGeneratedLocalVolumes(generatedVolumeCount) .then(function success(data) { + var volumeResourceControlQueries = []; + if ($scope.formValues.Ownership === 'private') { + angular.forEach(data, function (volume) { + volumeResourceControlQueries.push(ResourceControlService.setVolumeResourceControl(Authentication.getUserDetails().ID, volume.Name)); + }); + } TemplateService.updateContainerConfigurationWithVolumes(templateConfiguration.container, template, data); - return ImageService.pullImage(templateConfiguration.image); + return $q.all(volumeResourceControlQueries).then(ImageService.pullImage(templateConfiguration.image)); }) .then(function success(data) { return ContainerService.createAndStartContainer(templateConfiguration.container); }) .then(function success(data) { Messages.send('Container Started', data.Id); - $state.go('containers', {}, {reload: true}); + if ($scope.formValues.Ownership === 'private') { + ResourceControlService.setContainerResourceControl(Authentication.getUserDetails().ID, data.Id) + .then(function success(data) { + $state.go('containers', {}, {reload: true}); + }); + } else { + $state.go('containers', {}, {reload: true}); + } }) .catch(function error(err) { Messages.error('Failure', err, err.msg); diff --git a/app/components/user/user.html b/app/components/user/user.html new file mode 100644 index 000000000..8182ff84f --- /dev/null +++ b/app/components/user/user.html @@ -0,0 +1,88 @@ + + + + + + Users > {{ user.Username }} + + + +
+
+ + + + + + + + + + + + + +
Name + {{ user.Username }} + +
Permissions +
+ + +
+
+
+
+
+
+ +
+
+ + + +
+ +
+ +
+
+ + +
+
+
+ + +
+ +
+
+ + + +
+
+
+ +
+
+ +
+
+

+ {{ state.updatePasswordError }} +

+
+
+
+
+
+
+
diff --git a/app/components/user/userController.js b/app/components/user/userController.js new file mode 100644 index 000000000..eca3c5293 --- /dev/null +++ b/app/components/user/userController.js @@ -0,0 +1,85 @@ +angular.module('user', []) +.controller('UserController', ['$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Messages', +function ($scope, $state, $stateParams, UserService, ModalService, Messages) { + + $scope.state = { + updatePasswordError: '', + }; + + $scope.formValues = { + newPassword: '', + confirmPassword: '' + }; + + $scope.deleteUser = function() { + ModalService.confirmDeletion( + 'Do you want to delete this user? This user will not be able to login into Portainer anymore.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + deleteUser(); + } + ); + }; + + $scope.updatePermissions = function() { + $('#loadingViewSpinner').show(); + UserService.updateUser($scope.user.Id, undefined, $scope.user.RoleId) + .then(function success(data) { + var newRole = $scope.user.RoleId === 1 ? 'administrator' : 'user'; + Messages.send('Permissions successfully updated', $scope.user.Username + ' is now ' + newRole); + $state.reload(); + }) + .catch(function error(err) { + Messages.error("Failure", err, 'Unable to update user permissions'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + $scope.updatePassword = function() { + $('#loadingViewSpinner').show(); + UserService.updateUser($scope.user.Id, $scope.formValues.newPassword, undefined) + .then(function success(data) { + Messages.send('Password successfully updated'); + $state.reload(); + }) + .catch(function error(err) { + $scope.state.updatePasswordError = 'Unable to update password'; + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + function deleteUser() { + $('#loadingViewSpinner').show(); + UserService.deleteUser($scope.user.Id) + .then(function success(data) { + Messages.send('User successfully deleted', $scope.user.Username); + $state.go('users'); + }) + .catch(function error(err) { + Messages.error("Failure", err, 'Unable to remove user'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + function getUser() { + $('#loadingViewSpinner').show(); + UserService.user($stateParams.id) + .then(function success(data) { + $scope.user = new UserViewModel(data); + }) + .catch(function error(err) { + Messages.error("Failure", err, 'Unable to retrieve user information'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + getUser(); +}]); diff --git a/app/components/users/users.html b/app/components/users/users.html new file mode 100644 index 000000000..799f70552 --- /dev/null +++ b/app/components/users/users.html @@ -0,0 +1,159 @@ + + + + + + + + User management + + +
+
+ + + + +
+ +
+ +
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+
+ + +
+ +
+
+ + + +
+
+
+ + +
+ +
+
+ + +
+
+
+ +
+
+ + + + {{ state.userCreationError }} + +
+
+
+
+
+
+
+ +
+
+ + +
+ Items per page: + +
+
+ +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Name + + + + + + Role + + + +
{{ user.Username }} + {{ user.RoleName }} + + + Edit +
Loading...
No users available.
+
+ +
+
+
+ +
+
diff --git a/app/components/users/usersController.js b/app/components/users/usersController.js new file mode 100644 index 000000000..5fd53e84e --- /dev/null +++ b/app/components/users/usersController.js @@ -0,0 +1,132 @@ +angular.module('users', []) +.controller('UsersController', ['$scope', '$state', 'UserService', 'ModalService', 'Messages', 'Pagination', +function ($scope, $state, UserService, ModalService, Messages, Pagination) { + $scope.state = { + userCreationError: '', + selectedItemCount: 0, + validUsername: false, + pagination_count: Pagination.getPaginationCount('users') + }; + $scope.sortType = 'RoleName'; + $scope.sortReverse = false; + + $scope.formValues = { + Username: '', + Password: '', + ConfirmPassword: '', + Role: 2, + }; + + $scope.order = function(sortType) { + $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; + + $scope.changePaginationCount = function() { + Pagination.setPaginationCount('endpoints', $scope.state.pagination_count); + }; + + $scope.selectItems = function (allSelected) { + angular.forEach($scope.state.filteredUsers, function (user) { + if (user.Checked !== allSelected) { + user.Checked = allSelected; + $scope.selectItem(user); + } + }); + }; + + $scope.selectItem = function (item) { + if (item.Checked) { + $scope.state.selectedItemCount++; + } else { + $scope.state.selectedItemCount--; + } + }; + + $scope.checkUsernameValidity = function() { + var valid = true; + for (var i = 0; i < $scope.users.length; i++) { + if ($scope.formValues.Username === $scope.users[i].Username) { + valid = false; + break; + } + } + $scope.state.validUsername = valid; + $scope.state.userCreationError = valid ? '' : 'Username already taken'; + }; + + $scope.addUser = function() { + $scope.state.userCreationError = ''; + var username = $scope.formValues.Username; + var password = $scope.formValues.Password; + var role = $scope.formValues.Role; + UserService.createUser(username, password, role) + .then(function success(data) { + Messages.send("User created", username); + $state.reload(); + }) + .catch(function error(err) { + $scope.state.userCreationError = err.msg; + }) + .finally(function final() { + + }); + }; + + function deleteSelectedUsers() { + $('#loadUsersSpinner').show(); + var counter = 0; + var complete = function () { + counter = counter - 1; + if (counter === 0) { + $('#loadUsersSpinner').hide(); + } + }; + angular.forEach($scope.users, function (user) { + if (user.Checked) { + counter = counter + 1; + UserService.deleteUser(user.Id) + .then(function success(data) { + var index = $scope.users.indexOf(user); + $scope.users.splice(index, 1); + Messages.send('User successfully deleted', user.Username); + }) + .catch(function error(err) { + Messages.error("Failure", err, 'Unable to remove user'); + }) + .finally(function final() { + complete(); + }); + } + }); + } + + $scope.removeAction = function () { + ModalService.confirmDeletion( + 'Do you want to delete the selected users? They will not be able to login into Portainer anymore.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + deleteSelectedUsers(); + } + ); + }; + + function fetchUsers() { + $('#loadUsersSpinner').show(); + UserService.users() + .then(function success(data) { + $scope.users = data.map(function(user) { + return new UserViewModel(user); + }); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to retrieve users"); + $scope.users = []; + }) + .finally(function final() { + $('#loadUsersSpinner').hide(); + }); + } + + fetchUsers(); +}]); diff --git a/app/components/volumes/volumes.html b/app/components/volumes/volumes.html index 530e24a7b..24a3cd550 100644 --- a/app/components/volumes/volumes.html +++ b/app/components/volumes/volumes.html @@ -60,6 +60,13 @@ + + + Ownership + + + + @@ -68,12 +75,33 @@ {{ volume.Name|truncate:50 }} {{ volume.Driver }} {{ volume.Mountpoint }} + + + + + Private + + + Private (owner: {{ volume.Owner }}) + + Switch to public + + + + Private + Switch to public + + + + Public + + - Loading... + Loading... - No volumes available. + No volumes available. diff --git a/app/components/volumes/volumesController.js b/app/components/volumes/volumesController.js index 6bbc9c27c..e020f075b 100644 --- a/app/components/volumes/volumesController.js +++ b/app/components/volumes/volumesController.js @@ -1,6 +1,6 @@ angular.module('volumes', []) -.controller('VolumesController', ['$scope', '$state', 'Volume', 'Messages', 'Pagination', -function ($scope, $state, Volume, Messages, Pagination) { +.controller('VolumesController', ['$scope', '$state', 'Volume', 'Messages', 'Pagination', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', +function ($scope, $state, Volume, Messages, Pagination, ModalService, Authentication, ResourceControlService, UserService) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('volumes'); $scope.state.selectedItemCount = 0; @@ -10,6 +10,24 @@ function ($scope, $state, Volume, Messages, Pagination) { Name: '' }; + function removeVolumeResourceControl(volume) { + ResourceControlService.removeVolumeResourceControl(volume.Metadata.ResourceControl.OwnerId, volume.Name) + .then(function success() { + delete volume.Metadata.ResourceControl; + Messages.send('Ownership changed to public', volume.Name); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to change volume ownership"); + }); + } + + $scope.switchOwnership = function(volume) { + ModalService.confirmVolumeOwnershipChange(function (confirmed) { + if(!confirmed) { return; } + removeVolumeResourceControl(volume); + }); + }; + $scope.changePaginationCount = function() { Pagination.setPaginationCount('volumes', $scope.state.pagination_count); }; @@ -52,9 +70,21 @@ function ($scope, $state, Volume, Messages, Pagination) { if (d.message) { Messages.error("Unable to remove volume", {}, d.message); } else { - Messages.send("Volume deleted", volume.Name); - var index = $scope.volumes.indexOf(volume); - $scope.volumes.splice(index, 1); + if (volume.Metadata && volume.Metadata.ResourceControl) { + ResourceControlService.removeVolumeResourceControl(volume.Metadata.ResourceControl.OwnerId, volume.Name) + .then(function success() { + Messages.send("Volume deleted", volume.Name); + var index = $scope.volumes.indexOf(volume); + $scope.volumes.splice(index, 1); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to remove volume ownership"); + }); + } else { + Messages.send("Volume deleted", volume.Name); + var index = $scope.volumes.indexOf(volume); + $scope.volumes.splice(index, 1); + } } complete(); }, function (e) { @@ -65,11 +95,45 @@ function ($scope, $state, Volume, Messages, Pagination) { }); }; + function mapUsersToVolumes(users) { + angular.forEach($scope.volumes, function (volume) { + if (volume.Metadata) { + var volumeRC = volume.Metadata.ResourceControl; + if (volumeRC && volumeRC.OwnerId != $scope.user.ID) { + angular.forEach(users, function (user) { + if (volumeRC.OwnerId === user.Id) { + volume.Owner = user.Username; + } + }); + } + } + }); + } + function fetchVolumes() { $('#loadVolumesSpinner').show(); + var userDetails = Authentication.getUserDetails(); + $scope.user = userDetails; + Volume.query({}, function (d) { - $scope.volumes = d.Volumes || []; - $('#loadVolumesSpinner').hide(); + var volumes = d.Volumes || []; + $scope.volumes = volumes.map(function (v) { + return new VolumeViewModel(v); + }); + if (userDetails.role === 1) { + UserService.users() + .then(function success(data) { + mapUsersToVolumes(data); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to retrieve users"); + }) + .finally(function final() { + $('#loadVolumesSpinner').hide(); + }); + } else { + $('#loadVolumesSpinner').hide(); + } }, function (e) { $('#loadVolumesSpinner').hide(); Messages.error("Failure", e, "Unable to retrieve volumes"); diff --git a/app/directives/header-content.js b/app/directives/header-content.js index 173a2331b..e372e28a4 100644 --- a/app/directives/header-content.js +++ b/app/directives/header-content.js @@ -5,7 +5,7 @@ angular requires: '^rdHeader', transclude: true, link: function (scope, iElement, iAttrs) { - scope.username = Authentication.getCredentials().username; + scope.username = Authentication.getUserDetails().username; }, template: '', restrict: 'E' diff --git a/app/directives/header-title.js b/app/directives/header-title.js index 1c03c5a12..3001d4f34 100644 --- a/app/directives/header-title.js +++ b/app/directives/header-title.js @@ -7,7 +7,7 @@ angular title: '@' }, link: function (scope, iElement, iAttrs) { - scope.username = Authentication.getCredentials().username; + scope.username = Authentication.getUserDetails().username; }, transclude: true, template: '
{{title}} {{username}}
', diff --git a/app/directives/tooltip.js b/app/directives/tooltip.js new file mode 100644 index 000000000..2293fbf72 --- /dev/null +++ b/app/directives/tooltip.js @@ -0,0 +1,13 @@ +angular +.module('portainer') +.directive('portainerTooltip', [function portainerTooltip() { + var directive = { + scope: { + message: '@', + position: '@' + }, + template: '', + restrict: 'E' + }; + return directive; +}]); diff --git a/app/directives/widget-header.js b/app/directives/widget-header.js index 4106fbc79..9e047aaa1 100644 --- a/app/directives/widget-header.js +++ b/app/directives/widget-header.js @@ -5,10 +5,11 @@ angular requires: '^rdWidget', scope: { title: '@', - icon: '@' + icon: '@', + classes: '@?' }, transclude: true, - template: '
{{title}}
', + template: '
{{title}}
', restrict: 'E' }; return directive; diff --git a/app/models/container.js b/app/models/container.js index 2df1912a6..bb4a183f8 100644 --- a/app/models/container.js +++ b/app/models/container.js @@ -10,11 +10,21 @@ function ContainerViewModel(data) { this.Image = data.Image; this.Command = data.Command; this.Checked = false; + this.Labels = data.Labels; this.Ports = []; + this.Mounts = data.Mounts; for (var i = 0; i < data.Ports.length; ++i) { var p = data.Ports[i]; if (p.PublicPort) { this.Ports.push({ host: p.IP, private: p.PrivatePort, public: p.PublicPort }); } } + if (data.Portainer) { + this.Metadata = {}; + if (data.Portainer.ResourceControl) { + this.Metadata.ResourceControl = { + OwnerId: data.Portainer.ResourceControl.OwnerId + }; + } + } } diff --git a/app/models/service.js b/app/models/service.js index ab182761d..b312e2d2c 100644 --- a/app/models/service.js +++ b/app/models/service.js @@ -17,6 +17,10 @@ function ServiceViewModel(data) { if (data.Spec.TaskTemplate.ContainerSpec.Env) { this.Env = data.Spec.TaskTemplate.ContainerSpec.Env; } + this.Mounts = []; + if (data.Spec.TaskTemplate.ContainerSpec.Mounts) { + this.Mounts = data.Spec.TaskTemplate.ContainerSpec.Mounts; + } if (data.Endpoint.Ports) { this.Ports = data.Endpoint.Ports; } @@ -33,4 +37,13 @@ function ServiceViewModel(data) { this.Checked = false; this.Scale = false; this.EditName = false; + + if (data.Portainer) { + this.Metadata = {}; + if (data.Portainer.ResourceControl) { + this.Metadata.ResourceControl = { + OwnerId: data.Portainer.ResourceControl.OwnerId + }; + } + } } diff --git a/app/models/user.js b/app/models/user.js new file mode 100644 index 000000000..7b021284b --- /dev/null +++ b/app/models/user.js @@ -0,0 +1,11 @@ +function UserViewModel(data) { + this.Id = data.Id; + this.Username = data.Username; + this.RoleId = data.Role; + if (data.Role === 1) { + this.RoleName = "administrator"; + } else { + this.RoleName = "user"; + } + this.Checked = false; +} diff --git a/app/models/volume.js b/app/models/volume.js new file mode 100644 index 000000000..f46356ce5 --- /dev/null +++ b/app/models/volume.js @@ -0,0 +1,14 @@ +function VolumeViewModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Driver = data.Driver; + this.Mountpoint = data.Mountpoint; + if (data.Portainer) { + this.Metadata = {}; + if (data.Portainer.ResourceControl) { + this.Metadata.ResourceControl = { + OwnerId: data.Portainer.ResourceControl.OwnerId + }; + } + } +} diff --git a/app/rest/container.js b/app/rest/container.js index a3e4d3874..4130a5592 100644 --- a/app/rest/container.js +++ b/app/rest/container.js @@ -1,9 +1,11 @@ angular.module('portainer.rest') -.factory('Container', ['$resource', 'Settings', function ContainerFactory($resource, Settings) { +.factory('Container', ['$resource', 'Settings', 'EndpointProvider', function ContainerFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/containers/:id/:action', { - name: '@name' - }, { + return $resource(Settings.url + '/:endpointId/containers/:id/:action', { + name: '@name', + endpointId: EndpointProvider.endpointID + }, + { query: {method: 'GET', params: {all: 0, action: 'json', filters: '@filters' }, isArray: true}, get: {method: 'GET', params: {action: 'json'}}, stop: {method: 'POST', params: {id: '@id', t: 5, action: 'stop'}}, @@ -11,7 +13,6 @@ angular.module('portainer.rest') kill: {method: 'POST', params: {id: '@id', action: 'kill'}}, pause: {method: 'POST', params: {id: '@id', action: 'pause'}}, unpause: {method: 'POST', params: {id: '@id', action: 'unpause'}}, - changes: {method: 'GET', params: {action: 'changes'}, isArray: true}, stats: {method: 'GET', params: {id: '@id', stream: false, action: 'stats'}, timeout: 5000}, start: { method: 'POST', params: {id: '@id', action: 'start'}, diff --git a/app/rest/containerCommit.js b/app/rest/containerCommit.js index 3fdb0c83d..a3e13331a 100644 --- a/app/rest/containerCommit.js +++ b/app/rest/containerCommit.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('ContainerCommit', ['$resource', 'Settings', function ContainerCommitFactory($resource, Settings) { +.factory('ContainerCommit', ['$resource', 'Settings', 'EndpointProvider', function ContainerCommitFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/commit', {}, { + return $resource(Settings.url + '/:endpointId/commit', { + endpointId: EndpointProvider.endpointID + }, + { commit: {method: 'POST', params: {container: '@id', repo: '@repo', tag: '@tag'}} }); }]); diff --git a/app/rest/containerLogs.js b/app/rest/containerLogs.js index a9c07f583..cc4f8408e 100644 --- a/app/rest/containerLogs.js +++ b/app/rest/containerLogs.js @@ -1,11 +1,11 @@ angular.module('portainer.rest') -.factory('ContainerLogs', ['$http', 'Settings', function ContainerLogsFactory($http, Settings) { +.factory('ContainerLogs', ['$http', 'Settings', 'EndpointProvider', function ContainerLogsFactory($http, Settings, EndpointProvider) { 'use strict'; return { get: function (id, params, callback) { $http({ method: 'GET', - url: Settings.url + '/containers/' + id + '/logs', + url: Settings.url + '/' + EndpointProvider.endpointID() + '/containers/' + id + '/logs', params: { 'stdout': params.stdout || 0, 'stderr': params.stderr || 0, diff --git a/app/rest/containerTop.js b/app/rest/containerTop.js index 7515452a8..050f5653f 100644 --- a/app/rest/containerTop.js +++ b/app/rest/containerTop.js @@ -1,11 +1,11 @@ angular.module('portainer.rest') -.factory('ContainerTop', ['$http', 'Settings', function ($http, Settings) { +.factory('ContainerTop', ['$http', 'Settings', 'EndpointProvider', function ($http, Settings, EndpointProvider) { 'use strict'; return { get: function (id, params, callback, errorCallback) { $http({ method: 'GET', - url: Settings.url + '/containers/' + id + '/top', + url: Settings.url + '/' + EndpointProvider.endpointID() + '/containers/' + id + '/top', params: { ps_args: params.ps_args } diff --git a/app/rest/endpoint.js b/app/rest/endpoint.js index d5eb432a3..5919c0b61 100644 --- a/app/rest/endpoint.js +++ b/app/rest/endpoint.js @@ -6,8 +6,7 @@ angular.module('portainer.rest') query: { method: 'GET', isArray: true }, get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, + updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } }, remove: { method: 'DELETE', params: { id: '@id'} }, - getActiveEndpoint: { method: 'GET', params: { id: '0' } }, - setActiveEndpoint: { method: 'POST', params: { id: '@id', action: 'active' } } }); }]); diff --git a/app/rest/event.js b/app/rest/event.js index 9dbb80e3b..ddc5aa436 100644 --- a/app/rest/event.js +++ b/app/rest/event.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Events', ['$resource', 'Settings', function EventFactory($resource, Settings) { +.factory('Events', ['$resource', 'Settings', 'EndpointProvider', function EventFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/events', {}, { + return $resource(Settings.url + '/:endpointId/events', { + endpointId: EndpointProvider.endpointID + }, + { query: { method: 'GET', params: {since: '@since', until: '@until'}, isArray: true, transformResponse: jsonObjectsToArrayHandler diff --git a/app/rest/exec.js b/app/rest/exec.js index 25ed67b47..c354090ba 100644 --- a/app/rest/exec.js +++ b/app/rest/exec.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Exec', ['$resource', 'Settings', function ExecFactory($resource, Settings) { +.factory('Exec', ['$resource', 'Settings', 'EndpointProvider', function ExecFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/exec/:id/:action', {}, { + return $resource(Settings.url + '/:endpointId/exec/:id/:action', { + endpointId: EndpointProvider.endpointID + }, + { resize: { method: 'POST', params: {id: '@id', action: 'resize', h: '@height', w: '@width'}, transformResponse: genericHandler diff --git a/app/rest/image.js b/app/rest/image.js index 8a436b45a..41a76c803 100644 --- a/app/rest/image.js +++ b/app/rest/image.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Image', ['$resource', 'Settings', function ImageFactory($resource, Settings) { +.factory('Image', ['$resource', 'Settings', 'EndpointProvider', function ImageFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/images/:id/:action', {}, { + return $resource(Settings.url + '/:endpointId/images/:id/:action', { + endpointId: EndpointProvider.endpointID + }, + { query: {method: 'GET', params: {all: 0, action: 'json'}, isArray: true}, get: {method: 'GET', params: {action: 'json'}}, search: {method: 'GET', params: {action: 'search'}}, diff --git a/app/rest/info.js b/app/rest/info.js index d138a341a..007a4444a 100644 --- a/app/rest/info.js +++ b/app/rest/info.js @@ -1,5 +1,7 @@ angular.module('portainer.rest') -.factory('Info', ['$resource', 'Settings', function InfoFactory($resource, Settings) { +.factory('Info', ['$resource', 'Settings', 'EndpointProvider', function InfoFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/info', {}); + return $resource(Settings.url + '/:endpointId/info', { + endpointId: EndpointProvider.endpointID + }); }]); diff --git a/app/rest/network.js b/app/rest/network.js index 793463d12..030c23753 100644 --- a/app/rest/network.js +++ b/app/rest/network.js @@ -1,7 +1,11 @@ angular.module('portainer.rest') -.factory('Network', ['$resource', 'Settings', function NetworkFactory($resource, Settings) { +.factory('Network', ['$resource', 'Settings', 'EndpointProvider', function NetworkFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/networks/:id/:action', {id: '@id'}, { + return $resource(Settings.url + '/:endpointId/networks/:id/:action', { + id: '@id', + endpointId: EndpointProvider.endpointID + }, + { query: {method: 'GET', isArray: true}, get: {method: 'GET'}, create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler}, diff --git a/app/rest/node.js b/app/rest/node.js index e1896059a..6d300f3fe 100644 --- a/app/rest/node.js +++ b/app/rest/node.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Node', ['$resource', 'Settings', function NodeFactory($resource, Settings) { +.factory('Node', ['$resource', 'Settings', 'EndpointProvider', function NodeFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/nodes/:id/:action', {}, { + return $resource(Settings.url + '/:endpointId/nodes/:id/:action', { + endpointId: EndpointProvider.endpointID + }, + { query: {method: 'GET', isArray: true}, get: {method: 'GET', params: {id: '@id'}}, update: { method: 'POST', params: {id: '@id', action: 'update', version: '@version'} }, diff --git a/app/rest/resourceControl.js b/app/rest/resourceControl.js new file mode 100644 index 000000000..7734429e6 --- /dev/null +++ b/app/rest/resourceControl.js @@ -0,0 +1,8 @@ +angular.module('portainer.rest') +.factory('ResourceControl', ['$resource', 'USERS_ENDPOINT', function ResourceControlFactory($resource, USERS_ENDPOINT) { + 'use strict'; + return $resource(USERS_ENDPOINT + '/:userId/resources/:resourceType/:resourceId', {}, { + create: { method: 'POST', params: { userId: '@userId', resourceType: '@resourceType' } }, + remove: { method: 'DELETE', params: { userId: '@userId', resourceId: '@resourceId', resourceType: '@resourceType' } }, + }); +}]); diff --git a/app/rest/service.js b/app/rest/service.js index 904998438..2a5ca1f30 100644 --- a/app/rest/service.js +++ b/app/rest/service.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Service', ['$resource', 'Settings', function ServiceFactory($resource, Settings) { +.factory('Service', ['$resource', 'Settings', 'EndpointProvider', function ServiceFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/services/:id/:action', {}, { + return $resource(Settings.url + '/:endpointId/services/:id/:action', { + endpointId: EndpointProvider.endpointID + }, + { get: { method: 'GET', params: {id: '@id'} }, query: { method: 'GET', isArray: true }, create: { method: 'POST', params: {action: 'create'} }, diff --git a/app/rest/swarm.js b/app/rest/swarm.js index e0cc40e6b..134827a0f 100644 --- a/app/rest/swarm.js +++ b/app/rest/swarm.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Swarm', ['$resource', 'Settings', function SwarmFactory($resource, Settings) { +.factory('Swarm', ['$resource', 'Settings', 'EndpointProvider', function SwarmFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/swarm', {}, { + return $resource(Settings.url + '/:endpointId/swarm', { + endpointId: EndpointProvider.endpointID + }, + { get: {method: 'GET'} }); }]); diff --git a/app/rest/task.js b/app/rest/task.js index 91a8c2a93..2fe03b502 100644 --- a/app/rest/task.js +++ b/app/rest/task.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Task', ['$resource', 'Settings', function TaskFactory($resource, Settings) { +.factory('Task', ['$resource', 'Settings', 'EndpointProvider', function TaskFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/tasks/:id', {}, { + return $resource(Settings.url + '/:endpointId/tasks/:id', { + endpointId: EndpointProvider.endpointID + }, + { get: { method: 'GET', params: {id: '@id'} }, query: { method: 'GET', isArray: true, params: {filters: '@filters'} } }); diff --git a/app/rest/user.js b/app/rest/user.js index a67d040a9..cc55f448d 100644 --- a/app/rest/user.js +++ b/app/rest/user.js @@ -1,12 +1,15 @@ angular.module('portainer.rest') .factory('Users', ['$resource', 'USERS_ENDPOINT', function UsersFactory($resource, USERS_ENDPOINT) { 'use strict'; - return $resource(USERS_ENDPOINT + '/:username/:action', {}, { + return $resource(USERS_ENDPOINT + '/:id/:action', {}, { create: { method: 'POST' }, - get: { method: 'GET', params: { username: '@username' } }, - update: { method: 'PUT', params: { username: '@username' } }, - checkPassword: { method: 'POST', params: { username: '@username', action: 'passwd' } }, - checkAdminUser: { method: 'GET', params: { username: 'admin', action: 'check' } }, - initAdminUser: { method: 'POST', params: { username: 'admin', action: 'init' } } + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id'} }, + // RPCs should be moved to a specific endpoint + checkPassword: { method: 'POST', params: { id: '@id', action: 'passwd' } }, + checkAdminUser: { method: 'GET', params: { id: 'admin', action: 'check' }, isArray: true }, + initAdminUser: { method: 'POST', params: { id: 'admin', action: 'init' } } }); }]); diff --git a/app/rest/version.js b/app/rest/version.js index 29ff766f0..d6bd6fb37 100644 --- a/app/rest/version.js +++ b/app/rest/version.js @@ -1,5 +1,7 @@ angular.module('portainer.rest') -.factory('Version', ['$resource', 'Settings', function VersionFactory($resource, Settings) { +.factory('Version', ['$resource', 'Settings', 'EndpointProvider', function VersionFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/version', {}); + return $resource(Settings.url + '/:endpointId/version', { + endpointId: EndpointProvider.endpointID + }); }]); diff --git a/app/rest/volume.js b/app/rest/volume.js index dd3f94ad0..132fdec01 100644 --- a/app/rest/volume.js +++ b/app/rest/volume.js @@ -1,7 +1,12 @@ angular.module('portainer.rest') -.factory('Volume', ['$resource', 'Settings', function VolumeFactory($resource, Settings) { +.factory('Volume', ['$resource', 'Settings', 'EndpointProvider', function VolumeFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/volumes/:name/:action', {name: '@name'}, { + return $resource(Settings.url + '/:endpointId/volumes/:name/:action', + { + name: '@name', + endpointId: EndpointProvider.endpointID + }, + { query: {method: 'GET'}, get: {method: 'GET'}, create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler}, diff --git a/app/services/authentication.js b/app/services/authentication.js index d9f58b844..9fee8e7de 100644 --- a/app/services/authentication.js +++ b/app/services/authentication.js @@ -1,14 +1,16 @@ angular.module('portainer.services') -.factory('Authentication', ['$q', 'Auth', 'jwtHelper', 'LocalStorage', 'StateManager', function AuthenticationFactory($q, Auth, jwtHelper, LocalStorage, StateManager) { +.factory('Authentication', ['$q', 'Auth', 'jwtHelper', 'LocalStorage', 'StateManager', 'EndpointProvider', function AuthenticationFactory($q, Auth, jwtHelper, LocalStorage, StateManager, EndpointProvider) { 'use strict'; - var credentials = {}; + var user = {}; return { init: function() { var jwt = LocalStorage.getJWT(); if (jwt) { var tokenPayload = jwtHelper.decodeToken(jwt); - credentials.username = tokenPayload.username; + user.username = tokenPayload.username; + user.ID = tokenPayload.id; + user.role = tokenPayload.role; } }, login: function(username, password) { @@ -16,7 +18,10 @@ angular.module('portainer.services') Auth.login({username: username, password: password}).$promise .then(function(data) { LocalStorage.storeJWT(data.jwt); - credentials.username = username; + var tokenPayload = jwtHelper.decodeToken(data.jwt); + user.username = username; + user.ID = tokenPayload.id; + user.role = tokenPayload.role; resolve(); }, function() { reject(); @@ -25,14 +30,15 @@ angular.module('portainer.services') }, logout: function() { StateManager.clean(); + EndpointProvider.clean(); LocalStorage.clean(); }, isAuthenticated: function() { var jwt = LocalStorage.getJWT(); return jwt && !jwtHelper.isTokenExpired(jwt); }, - getCredentials: function() { - return credentials; + getUserDetails: function() { + return user; } }; }]); diff --git a/app/services/endpointProvider.js b/app/services/endpointProvider.js new file mode 100644 index 000000000..e6d43f604 --- /dev/null +++ b/app/services/endpointProvider.js @@ -0,0 +1,23 @@ +angular.module('portainer.services') +.factory('EndpointProvider', ['LocalStorage', function EndpointProviderFactory(LocalStorage) { + 'use strict'; + var endpoint = {}; + var service = {}; + service.initialize = function() { + var endpointID = LocalStorage.getEndpointID(); + if (endpointID) { + endpoint.ID = endpointID; + } + }; + service.clean = function() { + endpoint = {}; + }; + service.endpointID = function() { + return endpoint.ID; + }; + service.setEndpointID = function(id) { + endpoint.ID = id; + LocalStorage.storeEndpointID(id); + }; + return service; +}]); diff --git a/app/services/endpointService.js b/app/services/endpointService.js index 511004b90..9531cceff 100644 --- a/app/services/endpointService.js +++ b/app/services/endpointService.js @@ -1,84 +1,92 @@ angular.module('portainer.services') .factory('EndpointService', ['$q', 'Endpoints', 'FileUploadService', function EndpointServiceFactory($q, Endpoints, FileUploadService) { 'use strict'; - return { - getActive: function() { - return Endpoints.getActiveEndpoint().$promise; - }, - setActive: function(endpointID) { - return Endpoints.setActiveEndpoint({id: endpointID}).$promise; - }, - endpoint: function(endpointID) { - return Endpoints.get({id: endpointID}).$promise; - }, - endpoints: function() { - return Endpoints.query({}).$promise; - }, - updateEndpoint: function(ID, name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, type) { - var endpoint = { - id: ID, - Name: name, - URL: type === 'local' ? ("unix://" + URL) : ("tcp://" + URL), - TLS: TLS - }; - var deferred = $q.defer(); - Endpoints.update({}, endpoint, function success(data) { - FileUploadService.uploadTLSFilesForEndpoint(ID, TLSCAFile, TLSCertFile, TLSKeyFile).then(function success(data) { + var service = {}; + + service.endpoint = function(endpointID) { + return Endpoints.get({id: endpointID}).$promise; + }; + + service.endpoints = function() { + return Endpoints.query({}).$promise; + }; + + service.updateAuthorizedUsers = function(id, authorizedUserIDs) { + return Endpoints.updateAccess({id: id}, {authorizedUsers: authorizedUserIDs}).$promise; + }; + + service.updateEndpoint = function(id, endpointParams) { + var query = { + name: endpointParams.name, + TLS: endpointParams.TLS, + authorizedUsers: endpointParams.authorizedUsers + }; + if (endpointParams.type && endpointParams.URL) { + query.URL = endpointParams.type === 'local' ? ("unix://" + endpointParams.URL) : ("tcp://" + endpointParams.URL); + } + var deferred = $q.defer(); + Endpoints.update({id: id}, query).$promise + .then(function success() { + return FileUploadService.uploadTLSFilesForEndpoint(id, endpointParams.TLSCAFile, endpointParams.TLSCertFile, endpointParams.TLSKeyFile); + }) + .then(function success(data) { + deferred.notify({upload: false}); + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.notify({upload: false}); + deferred.reject({msg: 'Unable to update endpoint', err: err}); + }); + return deferred.promise; + }; + + service.deleteEndpoint = function(endpointID) { + return Endpoints.remove({id: endpointID}).$promise; + }; + + service.createLocalEndpoint = function(name, URL, TLS, active) { + var endpoint = { + Name: "local", + URL: "unix:///var/run/docker.sock", + TLS: false + }; + return Endpoints.create({}, endpoint).$promise; + }; + + service.createRemoteEndpoint = function(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, active) { + var endpoint = { + Name: name, + URL: 'tcp://' + URL, + TLS: TLS + }; + var deferred = $q.defer(); + Endpoints.create({active: active}, endpoint, function success(data) { + var endpointID = data.Id; + if (TLS) { + deferred.notify({upload: true}); + FileUploadService.uploadTLSFilesForEndpoint(endpointID, TLSCAFile, TLSCertFile, TLSKeyFile).then(function success(data) { deferred.notify({upload: false}); - deferred.resolve(data); + if (active) { + Endpoints.setActiveEndpoint({}, {id: endpointID}, function success(data) { + deferred.resolve(data); + }, function error(err) { + deferred.reject({msg: 'Unable to create endpoint', err: err}); + }); + } else { + deferred.resolve(data); + } }, function error(err) { deferred.notify({upload: false}); deferred.reject({msg: 'Unable to upload TLS certs', err: err}); }); - }, function error(err) { - deferred.reject({msg: 'Unable to update endpoint', err: err}); - }); - return deferred.promise; - }, - deleteEndpoint: function(endpointID) { - return Endpoints.remove({id: endpointID}).$promise; - }, - createLocalEndpoint: function(name, URL, TLS, active) { - var endpoint = { - Name: "local", - URL: "unix:///var/run/docker.sock", - TLS: false - }; - return Endpoints.create({active: active}, endpoint).$promise; - }, - createRemoteEndpoint: function(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, active) { - var endpoint = { - Name: name, - URL: 'tcp://' + URL, - TLS: TLS - }; - var deferred = $q.defer(); - Endpoints.create({active: active}, endpoint, function success(data) { - var endpointID = data.Id; - if (TLS) { - deferred.notify({upload: true}); - FileUploadService.uploadTLSFilesForEndpoint(endpointID, TLSCAFile, TLSCertFile, TLSKeyFile).then(function success(data) { - deferred.notify({upload: false}); - if (active) { - Endpoints.setActiveEndpoint({}, {id: endpointID}, function success(data) { - deferred.resolve(data); - }, function error(err) { - deferred.reject({msg: 'Unable to create endpoint', err: err}); - }); - } else { - deferred.resolve(data); - } - }, function error(err) { - deferred.notify({upload: false}); - deferred.reject({msg: 'Unable to upload TLS certs', err: err}); - }); - } else { - deferred.resolve(data); - } - }, function error(err) { - deferred.reject({msg: 'Unable to create endpoint', err: err}); - }); - return deferred.promise; - } + } else { + deferred.resolve(data); + } + }, function error(err) { + deferred.reject({msg: 'Unable to create endpoint', err: err}); + }); + return deferred.promise; }; + + return service; }]); diff --git a/app/services/fileUpload.js b/app/services/fileUpload.js index 89eddafc2..7811afed4 100644 --- a/app/services/fileUpload.js +++ b/app/services/fileUpload.js @@ -19,15 +19,15 @@ angular.module('portainer.services') var deferred = $q.defer(); var queue = []; - if (TLSCAFile !== null) { + if (TLSCAFile) { var uploadTLSCA = uploadFile('api/upload/tls/' + endpointID + '/ca', TLSCAFile); queue.push(uploadTLSCA); } - if (TLSCertFile !== null) { + if (TLSCertFile) { var uploadTLSCert = uploadFile('api/upload/tls/' + endpointID + '/cert', TLSCertFile); queue.push(uploadTLSCert); } - if (TLSKeyFile !== null) { + if (TLSKeyFile) { var uploadTLSKey = uploadFile('api/upload/tls/' + endpointID + '/key', TLSKeyFile); queue.push(uploadTLSKey); } diff --git a/app/services/localStorage.js b/app/services/localStorage.js index 5d8e413fd..acc6fe378 100644 --- a/app/services/localStorage.js +++ b/app/services/localStorage.js @@ -2,6 +2,12 @@ angular.module('portainer.services') .factory('LocalStorage', ['localStorageService', function LocalStorageFactory(localStorageService) { 'use strict'; return { + storeEndpointID: function(id) { + localStorageService.set('ENDPOINT_ID', id); + }, + getEndpointID: function() { + return localStorageService.get('ENDPOINT_ID'); + }, storeEndpointState: function(state) { localStorageService.set('ENDPOINT_STATE', state); }, diff --git a/app/services/modalService.js b/app/services/modalService.js index d7cd5d3c6..b9d9453bb 100644 --- a/app/services/modalService.js +++ b/app/services/modalService.js @@ -2,6 +2,7 @@ angular.module('portainer.services') .factory('ModalService', [function ModalServiceFactory() { 'use strict'; var service = {}; + service.confirm = function(options){ var box = bootbox.confirm({ title: options.title, @@ -9,11 +10,11 @@ angular.module('portainer.services') buttons: { confirm: { label: options.buttons.confirm.label, - className: 'btn-danger' + className: options.buttons.confirm.className }, - cancel: { - label: options.buttons.cancel.label - } + cancel: { + label: options.buttons.cancel && options.buttons.cancel.label ? options.buttons.cancel.label : 'Cancel' + } }, callback: options.callback }); @@ -24,5 +25,62 @@ angular.module('portainer.services') } }); }; + + service.confirmOwnershipChange = function(callback, msg) { + service.confirm({ + title: 'Are you sure ?', + message: msg, + buttons: { + confirm: { + label: 'Change ownership', + className: 'btn-primary' + } + }, + callback: callback, + }); + }; + + service.confirmContainerOwnershipChange = function(callback) { + var msg = 'You can change the ownership of a container one way only. You will not be able to make this container private again. Changing ownership on this container will also change the ownership on any attached volume.'; + service.confirmOwnershipChange(callback, msg); + }; + + service.confirmServiceOwnershipChange = function(callback) { + var msg = 'You can change the ownership of a service one way only. You will not be able to make this service private again. Changing ownership on this service will also change the ownership on any attached volume.'; + service.confirmOwnershipChange(callback, msg); + }; + + service.confirmVolumeOwnershipChange = function(callback) { + var msg = 'You can change the ownership of a volume one way only. You will not be able to make this volume private again.'; + service.confirmOwnershipChange(callback, msg); + }; + + service.confirmImageForceRemoval = function(callback) { + service.confirm({ + title: "Are you sure?", + message: "Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.", + buttons: { + confirm: { + label: 'Remove the image', + className: 'btn-danger' + } + }, + callback: callback, + }); + }; + + service.confirmDeletion = function(message, callback) { + service.confirm({ + title: 'Are you sure ?', + message: message, + buttons: { + confirm: { + label: 'Delete', + className: 'btn-danger' + } + }, + callback: callback, + }); + }; return service; }]); diff --git a/app/services/resourceControlService.js b/app/services/resourceControlService.js new file mode 100644 index 000000000..3b30c2680 --- /dev/null +++ b/app/services/resourceControlService.js @@ -0,0 +1,31 @@ +angular.module('portainer.services') +.factory('ResourceControlService', ['$q', 'ResourceControl', function ResourceControlServiceFactory($q, ResourceControl) { + 'use strict'; + var service = {}; + + service.setContainerResourceControl = function(userID, resourceID) { + return ResourceControl.create({ userId: userID, resourceType: 'container' }, { ResourceID: resourceID }).$promise; + }; + + service.removeContainerResourceControl = function(userID, resourceID) { + return ResourceControl.remove({ userId: userID, resourceId: resourceID, resourceType: 'container' }).$promise; + }; + + service.setServiceResourceControl = function(userID, resourceID) { + return ResourceControl.create({ userId: userID, resourceType: 'service' }, { ResourceID: resourceID }).$promise; + }; + + service.removeServiceResourceControl = function(userID, resourceID) { + return ResourceControl.remove({ userId: userID, resourceId: resourceID, resourceType: 'service' }).$promise; + }; + + service.setVolumeResourceControl = function(userID, resourceID) { + return ResourceControl.create({ userId: userID, resourceType: 'volume' }, { ResourceID: resourceID }).$promise; + }; + + service.removeVolumeResourceControl = function(userID, resourceID) { + return ResourceControl.remove({ userId: userID, resourceId: resourceID, resourceType: 'volume' }).$promise; + }; + + return service; +}]); diff --git a/app/services/userService.js b/app/services/userService.js new file mode 100644 index 000000000..a836d298d --- /dev/null +++ b/app/services/userService.js @@ -0,0 +1,48 @@ +angular.module('portainer.services') +.factory('UserService', ['$q', 'Users', function UserServiceFactory($q, Users) { + 'use strict'; + var service = {}; + service.users = function() { + return Users.query({}).$promise; + }; + + service.user = function(id) { + return Users.get({id: id}).$promise; + }; + + service.createUser = function(username, password, role) { + return Users.create({}, {username: username, password: password, role: role}).$promise; + }; + + service.deleteUser = function(id) { + return Users.remove({id: id}).$promise; + }; + + service.updateUser = function(id, password, role) { + var query = { + password: password, + role: role + }; + return Users.update({id: id}, query).$promise; + }; + + service.updateUserPassword = function(id, currentPassword, newPassword) { + var deferred = $q.defer(); + Users.checkPassword({id: id}, {password: currentPassword}).$promise + .then(function success(data) { + if (!data.valid) { + deferred.reject({invalidPassword: true}); + } + return service.updateUser(id, newPassword, undefined); + }) + .then(function success(data) { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to update user password', err: err}); + }); + return deferred.promise; + }; + + return service; +}]); diff --git a/assets/css/app.css b/assets/css/app.css index 09a7b23b6..6e3b3db30 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -84,6 +84,26 @@ a[ng-click]{ color: #23ae89; } +.tooltip.portainer-tooltip .tooltip-inner { + font-family: Montserrat; + background-color: #ffffff; + padding: .833em 1em; + color: #333333; + border: 1px solid #d4d4d5; + border-radius: .14285714rem; + box-shadow: 0 2px 4px 0 rgba(34,36,38,.12),0 2px 10px 0 rgba(34,36,38,.15); +} + +.tooltip.portainer-tooltip .tooltip-arrow { + display: none; +} + +.fa.tooltip-icon { + margin-left: 5px; + font-size: 1.3em; + color: #337ab7; +} + .fa.red-icon { color: #ae2323; } @@ -272,3 +292,40 @@ ul.sidebar .sidebar-list a.active { border-left: 3px solid #fff; background: #2d3e63; } + +@media (min-width: 768px) { + .pull-sm-left { + float: left !important; + } + .pull-sm-right { + float: right !important; + } + .pull-sm-none { + float: none !important; + } +} +@media (min-width: 992px) { + .pull-md-left { + float: left !important; + } + .pull-md-right { + float: right !important; + } + .pull-md-none { + float: none !important; + } +} +@media (min-width: 1200px) { + .pull-lg-left { + float: left !important; + } + .pull-lg-right { + float: right !important; + } + .pull-lg-none { + float: none !important; + } +} +.pull-none { + float: none !important; +}