From c09ee2c2d313a46c45f77cd581ad021062491737 Mon Sep 17 00:00:00 2001 From: Software Developer <7852635+dsuhinin@users.noreply.github.com> Date: Thu, 28 Mar 2024 10:11:55 +0100 Subject: [PATCH] Integrate auth logic into mlflow, aim, admin, chooser parts (#1051) Integrate auth logic into `mlflow`, `aim`, `admin`, `chooser` parts. --- pkg/api/admin/controller/controller.go | 15 ----- pkg/api/admin/controller/namespace.go | 33 ----------- pkg/api/admin/routes.go | 27 --------- pkg/api/aim2/routes.go | 14 ++++- pkg/api/mlflow/config/auth/auth.go | 20 +++++-- pkg/api/mlflow/config/auth/auth_test.go | 21 +++++-- .../mlflow/config/auth/config_loader_test.go | 29 +++++++++- pkg/api/mlflow/routes.go | 18 ++++-- pkg/common/api/error.go | 1 - pkg/common/db/models/user_permission.go | 43 ++++++++++---- pkg/common/middleware/authorization.go | 31 +++------- pkg/server/server.go | 54 +++++------------ pkg/ui/admin/controller/controller.go | 2 +- pkg/ui/admin/middleware/authorization.go | 28 +++++++++ pkg/ui/admin/routes.go | 22 ++++++- .../admin/service/namespace/service.go | 0 .../admin/service/namespace/service_test.go | 0 .../admin/service/namespace/validator.go | 0 .../admin/service/namespace/validator_test.go | 0 .../chooser}/api/response/namespace.go | 2 - pkg/ui/chooser/controller/controller.go | 2 +- pkg/ui/chooser/controller/namespaces.go | 29 +++++++++- pkg/ui/chooser/embed/index.html | 6 +- pkg/ui/chooser/middleware/authorization.go | 58 +++++++++++++++++++ pkg/ui/chooser/response/namespace.go | 35 +++++++++++ pkg/ui/chooser/routes.go | 32 +++++++--- pkg/ui/chooser/service/namespace/helpers.go | 21 +++++++ pkg/ui/chooser/service/namespace/service.go | 52 +++++++++++++++++ .../golang/auth/user_auth_from_config_test.go | 1 + .../namespace => chooser}/get_current_test.go | 14 ++++- .../{admin/namespace => chooser}/list_test.go | 6 +- tests/integration/golang/helpers/client.go | 5 ++ tests/integration/golang/helpers/test.go | 4 ++ 33 files changed, 438 insertions(+), 187 deletions(-) delete mode 100644 pkg/api/admin/controller/controller.go delete mode 100644 pkg/api/admin/controller/namespace.go delete mode 100644 pkg/api/admin/routes.go create mode 100644 pkg/ui/admin/middleware/authorization.go rename pkg/{api => ui}/admin/service/namespace/service.go (100%) rename pkg/{api => ui}/admin/service/namespace/service_test.go (100%) rename pkg/{api => ui}/admin/service/namespace/validator.go (100%) rename pkg/{api => ui}/admin/service/namespace/validator_test.go (100%) rename pkg/{api/admin => ui/chooser}/api/response/namespace.go (99%) create mode 100644 pkg/ui/chooser/middleware/authorization.go create mode 100644 pkg/ui/chooser/response/namespace.go create mode 100644 pkg/ui/chooser/service/namespace/helpers.go create mode 100644 pkg/ui/chooser/service/namespace/service.go rename tests/integration/golang/{admin/namespace => chooser}/get_current_test.go (80%) rename tests/integration/golang/{admin/namespace => chooser}/list_test.go (90%) diff --git a/pkg/api/admin/controller/controller.go b/pkg/api/admin/controller/controller.go deleted file mode 100644 index a717a00ac..000000000 --- a/pkg/api/admin/controller/controller.go +++ /dev/null @@ -1,15 +0,0 @@ -package controller - -import "github.com/G-Research/fasttrackml/pkg/api/admin/service/namespace" - -// Controller contains all the request handler functions for the admin api. -type Controller struct { - namespaceService *namespace.Service -} - -// NewController creates new Controller instance. -func NewController(namespaceService *namespace.Service) *Controller { - return &Controller{ - namespaceService: namespaceService, - } -} diff --git a/pkg/api/admin/controller/namespace.go b/pkg/api/admin/controller/namespace.go deleted file mode 100644 index 9c2a3692e..000000000 --- a/pkg/api/admin/controller/namespace.go +++ /dev/null @@ -1,33 +0,0 @@ -package controller - -import ( - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/log" - - "github.com/G-Research/fasttrackml/pkg/api/admin/api/response" - "github.com/G-Research/fasttrackml/pkg/common/middleware" -) - -// ListNamespaces handles `GET /namespaces/list` endpoint. -func (c Controller) ListNamespaces(ctx *fiber.Ctx) error { - namespaces, err := c.namespaceService.ListNamespaces(ctx.Context()) - if err != nil { - return err - } - resp := response.NewListNamespacesResponse(namespaces) - log.Debugf("namespacesList response: %#v", resp) - - return ctx.JSON(resp) -} - -// GetCurrentNamespace handles `GET /namespaces/current` endpoint. -func (c Controller) GetCurrentNamespace(ctx *fiber.Ctx) error { - ns, err := middleware.GetNamespaceFromContext(ctx.Context()) - if err != nil { - return err - } - resp := response.NewGetCurrentNamespaceResponse(ns) - log.Debugf("currentNamespace response: %#v", resp) - - return ctx.JSON(ns) -} diff --git a/pkg/api/admin/routes.go b/pkg/api/admin/routes.go deleted file mode 100644 index 82cd0eb95..000000000 --- a/pkg/api/admin/routes.go +++ /dev/null @@ -1,27 +0,0 @@ -package admin - -import ( - "github.com/gofiber/fiber/v2" - - "github.com/G-Research/fasttrackml/pkg/api/admin/controller" -) - -// Router represents `admin` router. -type Router struct { - controller *controller.Controller -} - -// NewRouter creates new instance of `admin` router. -func NewRouter(controller *controller.Controller) *Router { - return &Router{ - controller: controller, - } -} - -// Init makes initialization of all `admin` routes. -func (r Router) Init(fr fiber.Router) { - mainGroup := fr.Group("admin") - namespaces := mainGroup.Group("namespaces") - namespaces.Get("/list", r.controller.ListNamespaces) - namespaces.Get("/current", r.controller.GetCurrentNamespace) -} diff --git a/pkg/api/aim2/routes.go b/pkg/api/aim2/routes.go index bd4e21e73..d68e30e87 100644 --- a/pkg/api/aim2/routes.go +++ b/pkg/api/aim2/routes.go @@ -3,23 +3,35 @@ package aim2 import ( "github.com/gofiber/fiber/v2" + mlflowConfig "github.com/G-Research/fasttrackml/pkg/api/mlflow/config" + "github.com/G-Research/fasttrackml/pkg/common/middleware" + "github.com/G-Research/fasttrackml/pkg/api/aim2/controller" ) // Router represents `mlflow` router. type Router struct { + config *mlflowConfig.ServiceConfig controller *controller.Controller } // NewRouter creates new instance of `mlflow` router. -func NewRouter(controller *controller.Controller) *Router { +func NewRouter(config *mlflowConfig.ServiceConfig, controller *controller.Controller) *Router { return &Router{ + config: config, controller: controller, } } func (r Router) Init(server fiber.Router) { mainGroup := server.Group("/aim/api") + // apply global auth middlewares. + switch { + case r.config.Auth.IsAuthTypeUser(): + mainGroup.Use(middleware.NewUserMiddleware(r.config.Auth.AuthParsedUserPermissions)) + } + + // setup related routes. apps := mainGroup.Group("apps") apps.Get("/", r.controller.GetApps) apps.Post("/", r.controller.CreateApp) diff --git a/pkg/api/mlflow/config/auth/auth.go b/pkg/api/mlflow/config/auth/auth.go index 15f44afed..2bf3ad465 100644 --- a/pkg/api/mlflow/config/auth/auth.go +++ b/pkg/api/mlflow/config/auth/auth.go @@ -1,5 +1,11 @@ package auth +import ( + "github.com/rotisserie/eris" + + "github.com/G-Research/fasttrackml/pkg/common/db/models" +) + // supported list of authentication types. const ( TypeOIDC string = "oidc" @@ -7,10 +13,11 @@ const ( ) type Config struct { - AuthType string - AuthUsername string - AuthPassword string - AuthUsersConfig string + AuthType string + AuthUsername string + AuthPassword string + AuthUsersConfig string + AuthParsedUserPermissions *models.UserPermissions } // IsAuthTypeOIDC makes check that current auth is TypeOIDC. @@ -33,6 +40,11 @@ func (c *Config) NormalizeConfiguration() error { switch { case c.AuthUsersConfig != "": c.AuthType = TypeUser + parsedUserPermissions, err := Load(c.AuthUsersConfig) + if err != nil { + return eris.Wrapf(err, "error loading auth user configuration from file: %s", c.AuthUsersConfig) + } + c.AuthParsedUserPermissions = parsedUserPermissions } return nil } diff --git a/pkg/api/mlflow/config/auth/auth_test.go b/pkg/api/mlflow/config/auth/auth_test.go index 1313c5ba8..f0da80fd9 100644 --- a/pkg/api/mlflow/config/auth/auth_test.go +++ b/pkg/api/mlflow/config/auth/auth_test.go @@ -1,6 +1,8 @@ package auth import ( + "fmt" + "os" "testing" "github.com/stretchr/testify/assert" @@ -9,13 +11,21 @@ import ( func TestConfig_NormalizeConfiguration(t *testing.T) { tests := []struct { name string - config *Config + init func() *Config configType string }{ { name: "TestAuthTypeUser", - config: &Config{ - AuthUsersConfig: "/path/to/file", + init: func() *Config { + configPath := fmt.Sprintf("%s/configuration.yml", t.TempDir()) + // #nosec G304 + f, err := os.Create(configPath) + assert.Nil(t, err) + assert.Nil(t, f.Close()) + + return &Config{ + AuthUsersConfig: configPath, + } }, configType: TypeUser, }, @@ -23,8 +33,9 @@ func TestConfig_NormalizeConfiguration(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Nil(t, tt.config.NormalizeConfiguration()) - assert.Equal(t, tt.configType, tt.config.AuthType) + config := tt.init() + assert.Nil(t, config.NormalizeConfiguration()) + assert.Equal(t, tt.configType, config.AuthType) }) } } diff --git a/pkg/api/mlflow/config/auth/config_loader_test.go b/pkg/api/mlflow/config/auth/config_loader_test.go index 124ac71e2..846c742ed 100644 --- a/pkg/api/mlflow/config/auth/config_loader_test.go +++ b/pkg/api/mlflow/config/auth/config_loader_test.go @@ -160,6 +160,24 @@ func TestUserPermissions_HasAccess_Ok(t *testing.T) { }, }), }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authToken := tt.permissions.ValidateAuthToken(tt.token) + assert.NotNil(t, authToken) + assert.True(t, authToken.HasUserAccess(tt.namespace)) + }) + } +} + +func TestUserPermissions_HasAdminAccess_Ok(t *testing.T) { + tests := []struct { + name string + token string + namespace string + permissions *models.UserPermissions + }{ { name: "TestUserPermissionsUserHasAdminRole", token: "token", @@ -174,7 +192,9 @@ func TestUserPermissions_HasAccess_Ok(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.True(t, tt.permissions.HasAccess(tt.namespace, tt.token)) + authToken := tt.permissions.ValidateAuthToken(tt.token) + assert.NotNil(t, authToken) + assert.True(t, authToken.HasAdminAccess()) }) } } @@ -212,7 +232,12 @@ func TestUserPermissions_HasAccess_Error(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.False(t, tt.permissions.HasAccess(tt.namespace, tt.token)) + authToken := tt.permissions.ValidateAuthToken(tt.token) + if authToken != nil { + assert.False(t, authToken.HasUserAccess(tt.namespace)) + } else { + assert.Nil(t, authToken) + } }) } } diff --git a/pkg/api/mlflow/routes.go b/pkg/api/mlflow/routes.go index fa89b59cc..38cdafbb8 100644 --- a/pkg/api/mlflow/routes.go +++ b/pkg/api/mlflow/routes.go @@ -3,8 +3,10 @@ package mlflow import ( "github.com/gofiber/fiber/v2" + mlflowConfig "github.com/G-Research/fasttrackml/pkg/api/mlflow/config" "github.com/G-Research/fasttrackml/pkg/api/mlflow/controller" "github.com/G-Research/fasttrackml/pkg/common/api" + "github.com/G-Research/fasttrackml/pkg/common/middleware" ) // List of route prefixes. @@ -58,13 +60,15 @@ const ( // Router represents `mlflow` router. type Router struct { + config *mlflowConfig.ServiceConfig prefixList []string controller *controller.Controller } // NewRouter creates new instance of `mlflow` router. -func NewRouter(controller *controller.Controller) *Router { +func NewRouter(config *mlflowConfig.ServiceConfig, controller *controller.Controller) *Router { return &Router{ + config: config, prefixList: []string{ "/api/2.0/mlflow/", "/ajax-api/2.0/mlflow/", @@ -74,10 +78,16 @@ func NewRouter(controller *controller.Controller) *Router { } // Init makes initialization of all `mlflow` routes. -func (r Router) Init(server fiber.Router) { +func (r Router) Init(router fiber.Router) { for _, prefix := range r.prefixList { - mainGroup := server.Group(prefix) - + mainGroup := router.Group(prefix) + // apply global auth middlewares. + switch { + case r.config.Auth.IsAuthTypeUser(): + mainGroup.Use(middleware.NewUserMiddleware(r.config.Auth.AuthParsedUserPermissions)) + } + + // setup related routes. artifacts := mainGroup.Group(ArtifactsRoutePrefix) artifacts.Get(ArtifactsGetRoute, r.controller.GetArtifact) artifacts.Get(ArtifactsListRoute, r.controller.ListArtifacts) diff --git a/pkg/common/api/error.go b/pkg/common/api/error.go index 0ba876d87..7aadf80e2 100644 --- a/pkg/common/api/error.go +++ b/pkg/common/api/error.go @@ -27,7 +27,6 @@ type ErrorCode string const ( ErrorCodeInternalError = "INTERNAL_ERROR" - ErrorAccessForbiddenError = "FORBIDDEN_ERROR" ErrorCodeTemporarilyUnavailable = "TEMPORARILY_UNAVAILABLE" ErrorCodeBadRequest = "BAD_REQUEST" ErrorCodeInvalidParameterValue = "INVALID_PARAMETER_VALUE" diff --git a/pkg/common/db/models/user_permission.go b/pkg/common/db/models/user_permission.go index b4feed10b..8028fdccd 100644 --- a/pkg/common/db/models/user_permission.go +++ b/pkg/common/db/models/user_permission.go @@ -2,6 +2,32 @@ package models import "fmt" +// BasicAuthToken represents object to store auth information related to Basic Auth. +type BasicAuthToken struct { + roles map[string]struct{} +} + +// HasAdminAccess makes check that user has admin permissions to access to the requested resource. +func (p BasicAuthToken) HasAdminAccess() bool { + if _, ok := p.roles["admin"]; ok { + return true + } + return false +} + +// HasUserAccess makes check that user has permission to access to the requested namespace. +func (p BasicAuthToken) HasUserAccess(namespace string) bool { + if _, ok := p.roles[fmt.Sprintf("ns:%s", namespace)]; !ok { + return ok + } + return true +} + +// GetRoles returns User roles assigned to current Auth token. +func (p BasicAuthToken) GetRoles() map[string]struct{} { + return p.roles +} + // UserPermissions represents model to store user permissions data. type UserPermissions struct { data map[string]map[string]struct{} @@ -19,23 +45,18 @@ func (p UserPermissions) GetData() map[string]map[string]struct{} { return p.data } -// HasAccess makes check that user has permission to access to the requested namespace. -func (p UserPermissions) HasAccess(namespace string, authToken string) bool { +// ValidateAuthToken makes basic validation of auth token. +func (p UserPermissions) ValidateAuthToken(authToken string) *BasicAuthToken { if authToken == "" { - return false + return nil } roles, ok := p.data[authToken] if !ok { - return ok + return nil } - if _, ok := roles["admin"]; ok { - return true + return &BasicAuthToken{ + roles: roles, } - - if _, ok := roles[fmt.Sprintf("ns:%s", namespace)]; !ok { - return ok - } - return true } diff --git a/pkg/common/middleware/authorization.go b/pkg/common/middleware/authorization.go index ed64803b1..dadbf33bf 100644 --- a/pkg/common/middleware/authorization.go +++ b/pkg/common/middleware/authorization.go @@ -21,8 +21,13 @@ func NewUserMiddleware(userPermissions *models.UserPermissions) fiber.Handler { log.Debugf("checking access permission to %s namespace", namespace.Code) // check that user has permissions to access to the requested namespace. - authToken := strings.Replace(ctx.Get(fiber.HeaderAuthorization), "Basic ", "", 1) - if !userPermissions.HasAccess(namespace.Code, authToken) { + authToken := userPermissions.ValidateAuthToken( + strings.Replace(ctx.Get(fiber.HeaderAuthorization), "Basic ", "", 1), + ) + if authToken != nil && authToken.HasAdminAccess() { + return ctx.Next() + } + if authToken == nil || !authToken.HasUserAccess(namespace.Code) { return ctx.Status( http.StatusNotFound, ).JSON( @@ -33,25 +38,3 @@ func NewUserMiddleware(userPermissions *models.UserPermissions) fiber.Handler { return ctx.Next() } } - -// NewOIDCMiddleware creates new OIDC based Middleware instance. -func NewOIDCMiddleware() fiber.Handler { - return func(ctx *fiber.Ctx) (err error) { - namespace, err := GetNamespaceFromContext(ctx.Context()) - if err != nil { - return api.NewInternalError("error getting namespace from context") - } - log.Debugf("checking access permission to %s namespace", namespace.Code) - - authToken := strings.Replace(ctx.Get(fiber.HeaderAuthorization), "Bearer ", "", 1) - if authToken == "" { - return ctx.Status( - http.StatusBadRequest, - ).JSON( - api.NewBadRequestError("authorization header is empty or incorrect"), - ) - } - - return ctx.Next() - } -} diff --git a/pkg/server/server.go b/pkg/server/server.go index ebe2fc0f0..0edb05a92 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -16,9 +16,6 @@ import ( "github.com/rotisserie/eris" log "github.com/sirupsen/logrus" - adminAPI "github.com/G-Research/fasttrackml/pkg/api/admin" - adminAPIController "github.com/G-Research/fasttrackml/pkg/api/admin/controller" - "github.com/G-Research/fasttrackml/pkg/api/admin/service/namespace" aimAPI "github.com/G-Research/fasttrackml/pkg/api/aim" aim2API "github.com/G-Research/fasttrackml/pkg/api/aim2" aim2Controller "github.com/G-Research/fasttrackml/pkg/api/aim2/controller" @@ -31,7 +28,6 @@ import ( aimTagService "github.com/G-Research/fasttrackml/pkg/api/aim2/services/tag" mlflowAPI "github.com/G-Research/fasttrackml/pkg/api/mlflow" mlflowConfig "github.com/G-Research/fasttrackml/pkg/api/mlflow/config" - "github.com/G-Research/fasttrackml/pkg/api/mlflow/config/auth" mlflowController "github.com/G-Research/fasttrackml/pkg/api/mlflow/controller" "github.com/G-Research/fasttrackml/pkg/api/mlflow/dao" mlflowRepositories "github.com/G-Research/fasttrackml/pkg/api/mlflow/dao/repositories" @@ -46,9 +42,11 @@ import ( "github.com/G-Research/fasttrackml/pkg/database" adminUI "github.com/G-Research/fasttrackml/pkg/ui/admin" adminUIController "github.com/G-Research/fasttrackml/pkg/ui/admin/controller" + adminUINamespaceService "github.com/G-Research/fasttrackml/pkg/ui/admin/service/namespace" aimUI "github.com/G-Research/fasttrackml/pkg/ui/aim" "github.com/G-Research/fasttrackml/pkg/ui/chooser" chooserController "github.com/G-Research/fasttrackml/pkg/ui/chooser/controller" + chooserNamespaceService "github.com/G-Research/fasttrackml/pkg/ui/chooser/service/namespace" mlflowUI "github.com/G-Research/fasttrackml/pkg/ui/mlflow" "github.com/G-Research/fasttrackml/pkg/version" ) @@ -206,22 +204,6 @@ func createApp( })) } - // attach auth middleware based on provided configuration of auth type. - switch { - case config.Auth.IsAuthTypeUser(): - log.Info("Auth - enabling user auth configuration from file") - userPermissions, err := auth.Load(config.Auth.AuthUsersConfig) - if err != nil { - return nil, eris.Wrapf( - err, "error loading user configuration from file: %s", config.Auth.AuthUsersConfig, - ) - } - app.Use(middleware.NewUserMiddleware(userPermissions)) - case config.Auth.IsAuthTypeOIDC(): - log.Info("Auth - enabling OIDC user auth") - app.Use(middleware.NewOIDCMiddleware()) - } - app.Use(compress.New(compress.Config{ Next: func(c *fiber.Ctx) bool { // This is a little brittle, maybe there is a better way? @@ -252,6 +234,7 @@ func createApp( // init `aim` api refactored routes. log.Info("Using refactored aim service") aim2API.NewRouter( + config, aim2Controller.NewController( aimTagService.NewService( aimRepositories.NewTagRepository(db.GormDB()), @@ -286,6 +269,7 @@ func createApp( // init `mlflow` api and ui routes. // TODO:DSuhinin right now it might look scary. we prettify it a bit later. mlflowAPI.NewRouter( + config, mlflowController.NewController( mlflowRunService.NewService( mlflowRepositories.NewTagRepository(db.GormDB()), @@ -313,38 +297,32 @@ func createApp( mlflowUI.AddRoutes(app) aimUI.AddRoutes(app) - // init `admin` api routes. - adminAPI.NewRouter( - adminAPIController.NewController( - namespace.NewService( - config, - namespaceRepository, - mlflowRepositories.NewExperimentRepository(db.GormDB()), - ), - ), - ).Init(app) - // init `admin` UI routes. - adminUI.NewRouter( + if err := adminUI.NewRouter( + config, adminUIController.NewController( - namespace.NewService( + adminUINamespaceService.NewService( config, namespaceRepository, mlflowRepositories.NewExperimentRepository(db.GormDB()), ), ), - ).Init(app) + ).Init(app); err != nil { + return nil, eris.Wrap(err, "error initializing admin routes") + } // init `chooser` ui routes. - chooser.NewRouter( + if err := chooser.NewRouter( + config, chooserController.NewController( - namespace.NewService( + chooserNamespaceService.NewService( config, namespaceRepository, - mlflowRepositories.NewExperimentRepository(db.GormDB()), ), ), - ).AddRoutes(app) + ).Init(app); err != nil { + return nil, eris.Wrap(err, "error initializing chooser routes") + } return app, nil } diff --git a/pkg/ui/admin/controller/controller.go b/pkg/ui/admin/controller/controller.go index 41c8b0910..452bb9b92 100644 --- a/pkg/ui/admin/controller/controller.go +++ b/pkg/ui/admin/controller/controller.go @@ -1,6 +1,6 @@ package controller -import "github.com/G-Research/fasttrackml/pkg/api/admin/service/namespace" +import "github.com/G-Research/fasttrackml/pkg/ui/admin/service/namespace" // Controller contains all the request handler functions for the admin ui. type Controller struct { diff --git a/pkg/ui/admin/middleware/authorization.go b/pkg/ui/admin/middleware/authorization.go new file mode 100644 index 000000000..569178a16 --- /dev/null +++ b/pkg/ui/admin/middleware/authorization.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gofiber/fiber/v2" + + "github.com/G-Research/fasttrackml/pkg/common/db/models" +) + +// NewAdminUserMiddleware creates new User based Middleware instance. +func NewAdminUserMiddleware(userPermissions *models.UserPermissions) fiber.Handler { + return func(ctx *fiber.Ctx) (err error) { + authToken := userPermissions.ValidateAuthToken( + strings.Replace(ctx.Get(fiber.HeaderAuthorization), "Basic ", "", 1), + ) + if authToken == nil || !authToken.HasAdminAccess() { + return ctx.Status( + http.StatusNotFound, + ).SendString( + "unable to find requested resource", + ) + } + + return ctx.Next() + } +} diff --git a/pkg/ui/admin/routes.go b/pkg/ui/admin/routes.go index cf8dbcfee..5f8ec1878 100644 --- a/pkg/ui/admin/routes.go +++ b/pkg/ui/admin/routes.go @@ -9,8 +9,11 @@ import ( "github.com/gofiber/fiber/v2/middleware/etag" "github.com/gofiber/fiber/v2/middleware/filesystem" "github.com/gofiber/template/html/v2" + "github.com/rotisserie/eris" + mlflowConfig "github.com/G-Research/fasttrackml/pkg/api/mlflow/config" "github.com/G-Research/fasttrackml/pkg/ui/admin/controller" + "github.com/G-Research/fasttrackml/pkg/ui/admin/middleware" ) //go:embed embed/* @@ -18,20 +21,25 @@ var content embed.FS // Router represents `admin` router. type Router struct { + config *mlflowConfig.ServiceConfig controller *controller.Controller } // NewRouter creates new instance of `admin` router. -func NewRouter(controller *controller.Controller) *Router { +func NewRouter(config *mlflowConfig.ServiceConfig, controller *controller.Controller) *Router { return &Router{ + config: config, controller: controller, } } // Init makes initialization of all `admin` routes. -func (r Router) Init(router fiber.Router) { +func (r Router) Init(router fiber.Router) error { //nolint:errcheck - sub, _ := fs.Sub(content, "embed") + sub, err := fs.Sub(content, "embed") + if err != nil { + return eris.Wrap(err, "error mounting `embed` directory") + } // engine and app for template rendering engine := html.NewFileSystem(http.FS(sub), ".html") @@ -43,6 +51,12 @@ func (r Router) Init(router fiber.Router) { // specific routes namespaces := app.Group("namespaces") + // apply global auth middlewares. + switch { + case r.config.Auth.IsAuthTypeUser(): + namespaces.Use(middleware.NewAdminUserMiddleware(r.config.Auth.AuthParsedUserPermissions)) + } + namespaces.Get("/", r.controller.GetNamespaces) namespaces.Post("/", r.controller.CreateNamespace) namespaces.Get("/new", r.controller.NewNamespace) @@ -54,4 +68,6 @@ func (r Router) Init(router fiber.Router) { app.Use("/", etag.New(), filesystem.New(filesystem.Config{ Root: http.FS(sub), })) + + return nil } diff --git a/pkg/api/admin/service/namespace/service.go b/pkg/ui/admin/service/namespace/service.go similarity index 100% rename from pkg/api/admin/service/namespace/service.go rename to pkg/ui/admin/service/namespace/service.go diff --git a/pkg/api/admin/service/namespace/service_test.go b/pkg/ui/admin/service/namespace/service_test.go similarity index 100% rename from pkg/api/admin/service/namespace/service_test.go rename to pkg/ui/admin/service/namespace/service_test.go diff --git a/pkg/api/admin/service/namespace/validator.go b/pkg/ui/admin/service/namespace/validator.go similarity index 100% rename from pkg/api/admin/service/namespace/validator.go rename to pkg/ui/admin/service/namespace/validator.go diff --git a/pkg/api/admin/service/namespace/validator_test.go b/pkg/ui/admin/service/namespace/validator_test.go similarity index 100% rename from pkg/api/admin/service/namespace/validator_test.go rename to pkg/ui/admin/service/namespace/validator_test.go diff --git a/pkg/api/admin/api/response/namespace.go b/pkg/ui/chooser/api/response/namespace.go similarity index 99% rename from pkg/api/admin/api/response/namespace.go rename to pkg/ui/chooser/api/response/namespace.go index 7ff462a5a..aa3b208d7 100644 --- a/pkg/api/admin/api/response/namespace.go +++ b/pkg/ui/chooser/api/response/namespace.go @@ -17,11 +17,9 @@ func NewListNamespacesResponse( namespaces []models.Namespace, ) *ListNamespaces { response := ListNamespaces(make([]Namespace, len(namespaces))) - for i := range namespaces { response[i] = *NewGetCurrentNamespaceResponse(&namespaces[i]) } - return &response } diff --git a/pkg/ui/chooser/controller/controller.go b/pkg/ui/chooser/controller/controller.go index bbc2088f7..5fbb2e86c 100644 --- a/pkg/ui/chooser/controller/controller.go +++ b/pkg/ui/chooser/controller/controller.go @@ -1,6 +1,6 @@ package controller -import "github.com/G-Research/fasttrackml/pkg/api/admin/service/namespace" +import "github.com/G-Research/fasttrackml/pkg/ui/chooser/service/namespace" // Controller handles all the input HTTP requests. type Controller struct { diff --git a/pkg/ui/chooser/controller/namespaces.go b/pkg/ui/chooser/controller/namespaces.go index 564f2baf7..fb84a308e 100644 --- a/pkg/ui/chooser/controller/namespaces.go +++ b/pkg/ui/chooser/controller/namespaces.go @@ -2,13 +2,15 @@ package controller import ( "github.com/gofiber/fiber/v2" + log "github.com/sirupsen/logrus" "github.com/G-Research/fasttrackml/pkg/common/middleware" + "github.com/G-Research/fasttrackml/pkg/ui/chooser/api/response" ) // GetNamespaces renders the index view func (c Controller) GetNamespaces(ctx *fiber.Ctx) error { - namespaces, err := c.namespaceService.ListNamespaces(ctx.Context()) + namespaces, isAdmin, err := c.namespaceService.ListNamespaces(ctx.Context()) if err != nil { return err } @@ -17,7 +19,32 @@ func (c Controller) GetNamespaces(ctx *fiber.Ctx) error { return err } return ctx.Render("index", fiber.Map{ + "IsAdmin": isAdmin, "Namespaces": namespaces, "CurrentNamespace": ns, }) } + +// ListNamespaces handles `GET /namespaces/list` endpoint. +func (c Controller) ListNamespaces(ctx *fiber.Ctx) error { + namespaces, _, err := c.namespaceService.ListNamespaces(ctx.Context()) + if err != nil { + return err + } + resp := response.NewListNamespacesResponse(namespaces) + log.Debugf("namespacesList response: %#v", resp) + + return ctx.JSON(resp) +} + +// GetCurrentNamespace handles `GET /namespaces/current` endpoint. +func (c Controller) GetCurrentNamespace(ctx *fiber.Ctx) error { + ns, err := middleware.GetNamespaceFromContext(ctx.Context()) + if err != nil { + return err + } + resp := response.NewGetCurrentNamespaceResponse(ns) + log.Debugf("currentNamespace response: %#v", resp) + + return ctx.JSON(ns) +} diff --git a/pkg/ui/chooser/embed/index.html b/pkg/ui/chooser/embed/index.html index 614742bad..09a2d146b 100644 --- a/pkg/ui/chooser/embed/index.html +++ b/pkg/ui/chooser/embed/index.html @@ -109,7 +109,11 @@ -

Manage namespaces

+ {{ if .IsAdmin }} +

+ Manage namespaces +

+ {{ end }} diff --git a/pkg/ui/chooser/middleware/authorization.go b/pkg/ui/chooser/middleware/authorization.go new file mode 100644 index 000000000..ff7621401 --- /dev/null +++ b/pkg/ui/chooser/middleware/authorization.go @@ -0,0 +1,58 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/rotisserie/eris" + log "github.com/sirupsen/logrus" + + "github.com/G-Research/fasttrackml/pkg/common/api" + "github.com/G-Research/fasttrackml/pkg/common/db/models" + "github.com/G-Research/fasttrackml/pkg/common/middleware" +) + +// nolint:gosec +const ( + authTokenContextKey = "basic_auth_token" +) + +// NewUserMiddleware creates new User based Middleware instance. +func NewUserMiddleware(userPermissions *models.UserPermissions) fiber.Handler { + return func(ctx *fiber.Ctx) (err error) { + namespace, err := middleware.GetNamespaceFromContext(ctx.Context()) + if err != nil { + return api.NewInternalError("error getting namespace from context") + } + log.Debugf("checking access permission to %s namespace", namespace.Code) + + authToken := userPermissions.ValidateAuthToken( + strings.Replace(ctx.Get(fiber.HeaderAuthorization), "Basic ", "", 1), + ) + if authToken != nil && authToken.HasAdminAccess() { + ctx.Locals(authTokenContextKey, authToken) + return ctx.Next() + } + if authToken == nil || !authToken.HasUserAccess(namespace.Code) { + return ctx.Status( + http.StatusNotFound, + ).SendString( + "unable to find requested resource", + ) + } + + ctx.Locals(authTokenContextKey, authToken) + return ctx.Next() + } +} + +// GetAuthTokenFromContext returns Basic Auth Token from the context. +func GetAuthTokenFromContext(ctx context.Context) (*models.BasicAuthToken, error) { + authToken, ok := ctx.Value(authTokenContextKey).(*models.BasicAuthToken) + if !ok { + return nil, eris.New("error getting auth token from context") + } + return authToken, nil +} diff --git a/pkg/ui/chooser/response/namespace.go b/pkg/ui/chooser/response/namespace.go new file mode 100644 index 000000000..aa3b208d7 --- /dev/null +++ b/pkg/ui/chooser/response/namespace.go @@ -0,0 +1,35 @@ +package response + +import "github.com/G-Research/fasttrackml/pkg/api/mlflow/dao/models" + +// Namespace is the response struct for the GetCurrentNamespace endpoint. +type Namespace struct { + ID uint `json:"id"` + Code string `json:"code"` + Description string `json:"description"` +} + +// ListNamespaces is the response struct for the ListNamespaces endpoint (slice of Namespace). +type ListNamespaces []Namespace + +// NewListNamespacesResponse creates new instance of ListNamespaces. +func NewListNamespacesResponse( + namespaces []models.Namespace, +) *ListNamespaces { + response := ListNamespaces(make([]Namespace, len(namespaces))) + for i := range namespaces { + response[i] = *NewGetCurrentNamespaceResponse(&namespaces[i]) + } + return &response +} + +// NewGetCurrentNamespaceResponse creates new instance of Namespace. +func NewGetCurrentNamespaceResponse( + namespace *models.Namespace, +) *Namespace { + return &Namespace{ + ID: namespace.ID, + Code: namespace.Code, + Description: namespace.Description, + } +} diff --git a/pkg/ui/chooser/routes.go b/pkg/ui/chooser/routes.go index d3896ab72..7dc040a49 100644 --- a/pkg/ui/chooser/routes.go +++ b/pkg/ui/chooser/routes.go @@ -9,8 +9,11 @@ import ( "github.com/gofiber/fiber/v2/middleware/etag" "github.com/gofiber/fiber/v2/middleware/filesystem" "github.com/gofiber/template/html/v2" + "github.com/rotisserie/eris" + mlflowConfig "github.com/G-Research/fasttrackml/pkg/api/mlflow/config" "github.com/G-Research/fasttrackml/pkg/ui/chooser/controller" + "github.com/G-Research/fasttrackml/pkg/ui/chooser/middleware" ) //go:embed embed @@ -18,22 +21,27 @@ var content embed.FS // Router represents `chooser` router. type Router struct { + config *mlflowConfig.ServiceConfig controller *controller.Controller } // NewRouter creates new instance of `chooser` router. -func NewRouter(controller *controller.Controller) *Router { +func NewRouter(config *mlflowConfig.ServiceConfig, controller *controller.Controller) *Router { return &Router{ + config: config, controller: controller, } } -// AddRoutes adds all the `chooser` routes -func (r Router) AddRoutes(fr fiber.Router) { +// Init adds all the `chooser` routes +func (r Router) Init(router fiber.Router) error { //nolint:errcheck - sub, _ := fs.Sub(content, "embed") + sub, err := fs.Sub(content, "embed") + if err != nil { + return eris.Wrap(err, "error mounting `embed` directory") + } - fr.Use("/static/chooser/", etag.New(), filesystem.New(filesystem.Config{ + router.Use("/static/chooser/", etag.New(), filesystem.New(filesystem.Config{ Root: http.FS(sub), })) @@ -41,8 +49,18 @@ func (r Router) AddRoutes(fr fiber.Router) { app := fiber.New(fiber.Config{ Views: html.NewFileSystem(http.FS(sub), ".html"), }) - fr.Mount("/", app) + router.Mount("/", app) + + // apply global auth middlewares. + switch { + case r.config.Auth.IsAuthTypeUser(): + app.Use(middleware.NewUserMiddleware(r.config.Auth.AuthParsedUserPermissions)) + } - // specific routes + // setup related routes. app.Get("/", r.controller.GetNamespaces) + app.Get("/chooser/namespaces", r.controller.ListNamespaces) + app.Get("/chooser/namespaces/current", r.controller.GetCurrentNamespace) + + return nil } diff --git a/pkg/ui/chooser/service/namespace/helpers.go b/pkg/ui/chooser/service/namespace/helpers.go new file mode 100644 index 000000000..ad8baea6a --- /dev/null +++ b/pkg/ui/chooser/service/namespace/helpers.go @@ -0,0 +1,21 @@ +package namespace + +import ( + "fmt" + + "github.com/G-Research/fasttrackml/pkg/api/mlflow/dao/models" +) + +// FilterNamespacesByUserRoles filter namespaces by provided user roles. +func FilterNamespacesByUserRoles( + roles map[string]struct{}, + namespaces []models.Namespace, +) []models.Namespace { + var filteredPermissions []models.Namespace + for _, namespace := range namespaces { + if _, ok := roles[fmt.Sprintf("ns:%s", namespace.Code)]; ok { + filteredPermissions = append(filteredPermissions, namespace) + } + } + return filteredPermissions +} diff --git a/pkg/ui/chooser/service/namespace/service.go b/pkg/ui/chooser/service/namespace/service.go new file mode 100644 index 000000000..881340ce8 --- /dev/null +++ b/pkg/ui/chooser/service/namespace/service.go @@ -0,0 +1,52 @@ +package namespace + +import ( + "context" + + "github.com/rotisserie/eris" + + "github.com/G-Research/fasttrackml/pkg/api/mlflow/config" + "github.com/G-Research/fasttrackml/pkg/api/mlflow/dao/models" + "github.com/G-Research/fasttrackml/pkg/api/mlflow/dao/repositories" + "github.com/G-Research/fasttrackml/pkg/ui/chooser/middleware" +) + +// Service provides service layer to work with `namespace` business logic. +type Service struct { + config *config.ServiceConfig + namespaceRepository repositories.NamespaceRepositoryProvider +} + +// NewService creates new Service instance. +func NewService( + config *config.ServiceConfig, + namespaceRepository repositories.NamespaceRepositoryProvider, +) *Service { + return &Service{ + config: config, + namespaceRepository: namespaceRepository, + } +} + +// ListNamespaces returns all namespaces. +func (s Service) ListNamespaces(ctx context.Context) ([]models.Namespace, bool, error) { + namespaces, err := s.namespaceRepository.List(ctx) + if err != nil { + return nil, false, eris.Wrap(err, "error listing namespaces") + } + + switch { + case s.config.Auth.IsAuthTypeUser(): + authToken, err := middleware.GetAuthTokenFromContext(ctx) + if err != nil { + return nil, false, err + } + // if auth token is not admin auth token, then we have to filter namespaces + // and show only those which belong to current user, otherwise just show everything. + if !authToken.HasAdminAccess() { + return FilterNamespacesByUserRoles(authToken.GetRoles(), namespaces), false, nil + } + } + + return namespaces, true, nil +} diff --git a/tests/integration/golang/auth/user_auth_from_config_test.go b/tests/integration/golang/auth/user_auth_from_config_test.go index 3e169d0f7..ac6d655e2 100644 --- a/tests/integration/golang/auth/user_auth_from_config_test.go +++ b/tests/integration/golang/auth/user_auth_from_config_test.go @@ -73,6 +73,7 @@ func TestUserAuthFromConfigTestSuite(t *testing.T) { AuthUsersConfig: configPath, }, } + assert.Nil(t, testSuite.Config.Validate()) suite.Run(t, testSuite) } diff --git a/tests/integration/golang/admin/namespace/get_current_test.go b/tests/integration/golang/chooser/get_current_test.go similarity index 80% rename from tests/integration/golang/admin/namespace/get_current_test.go rename to tests/integration/golang/chooser/get_current_test.go index eb539752f..56e7bc3f5 100644 --- a/tests/integration/golang/admin/namespace/get_current_test.go +++ b/tests/integration/golang/chooser/get_current_test.go @@ -1,4 +1,4 @@ -package namespace +package chooser import ( "context" @@ -6,9 +6,9 @@ import ( "github.com/stretchr/testify/suite" - "github.com/G-Research/fasttrackml/pkg/api/admin/api/response" "github.com/G-Research/fasttrackml/pkg/api/mlflow/common" "github.com/G-Research/fasttrackml/pkg/api/mlflow/dao/models" + "github.com/G-Research/fasttrackml/pkg/ui/chooser/api/response" "github.com/G-Research/fasttrackml/tests/integration/golang/helpers" ) @@ -30,7 +30,15 @@ func (s *GetCurrentNamespacesTestSuite) Test_Ok() { s.Require().Nil(err) var resp response.Namespace - s.Require().Nil(s.AdminClient().WithNamespace(namespace.Code).WithResponse(&resp).DoRequest("/namespaces/current")) + s.Require().Nil( + s.ChooserClient().WithNamespace( + namespace.Code, + ).WithResponse( + &resp, + ).DoRequest( + "/namespaces/current", + ), + ) s.Equal(namespace.ID, resp.ID) s.Equal(namespace.Code, resp.Code) diff --git a/tests/integration/golang/admin/namespace/list_test.go b/tests/integration/golang/chooser/list_test.go similarity index 90% rename from tests/integration/golang/admin/namespace/list_test.go rename to tests/integration/golang/chooser/list_test.go index 475f76945..5eb406e31 100644 --- a/tests/integration/golang/admin/namespace/list_test.go +++ b/tests/integration/golang/chooser/list_test.go @@ -1,4 +1,4 @@ -package namespace +package chooser import ( "context" @@ -7,9 +7,9 @@ import ( "github.com/stretchr/testify/suite" - "github.com/G-Research/fasttrackml/pkg/api/admin/api/response" "github.com/G-Research/fasttrackml/pkg/api/mlflow/common" "github.com/G-Research/fasttrackml/pkg/api/mlflow/dao/models" + "github.com/G-Research/fasttrackml/pkg/ui/chooser/api/response" "github.com/G-Research/fasttrackml/tests/integration/golang/helpers" ) @@ -40,7 +40,7 @@ func (s *ListNamespacesTestSuite) Test_Ok() { } var resp response.ListNamespaces - s.Require().Nil(s.AdminClient().WithResponse(&resp).DoRequest("/namespaces/list")) + s.Require().Nil(s.ChooserClient().WithResponse(&resp).DoRequest("/namespaces")) s.Require().Equal(len(namespaces), len(resp)) for _, actualNamespace := range resp { diff --git a/tests/integration/golang/helpers/client.go b/tests/integration/golang/helpers/client.go index 2c2cf82d7..0ac0ac321 100644 --- a/tests/integration/golang/helpers/client.go +++ b/tests/integration/golang/helpers/client.go @@ -70,6 +70,11 @@ func NewAdminApiClient(server server.Server) *HttpClient { return NewClient(server, "/admin") } +// NewChooserApiClient creates new HTTP client for the chooser api +func NewChooserApiClient(server server.Server) *HttpClient { + return NewClient(server, "/chooser") +} + // WithMethod sets the HTTP method. func (c *HttpClient) WithMethod(method string) *HttpClient { c.method = method diff --git a/tests/integration/golang/helpers/test.go b/tests/integration/golang/helpers/test.go index 5e338fb74..b76a8dfbb 100644 --- a/tests/integration/golang/helpers/test.go +++ b/tests/integration/golang/helpers/test.go @@ -26,6 +26,7 @@ type BaseTestSuite struct { AIMClient func() *HttpClient MlflowClient func() *HttpClient AdminClient func() *HttpClient + ChooserClient func() *HttpClient AppFixtures *fixtures.AppFixtures RunFixtures *fixtures.RunFixtures TagFixtures *fixtures.TagFixtures @@ -147,6 +148,9 @@ func (s *BaseTestSuite) startServer() { s.AdminClient = func() *HttpClient { return NewAdminApiClient(s.server) } + s.ChooserClient = func() *HttpClient { + return NewChooserApiClient(s.server) + } } func (s *BaseTestSuite) stopServer() {