From fce41f8076471db9eadfc5f400651c05febfb9d0 Mon Sep 17 00:00:00 2001 From: Guiheux Steven Date: Mon, 21 Mar 2022 11:55:07 +0100 Subject: [PATCH] feat: add rbac middleware system (#6103) --- engine/api/api_routes.go | 4 +- engine/api/rbac/dao_rbac.go | 100 ++++++++++ engine/api/rbac/dao_rbac_global.go | 94 +++++++++ engine/api/rbac/dao_rbac_project.go | 95 +++++++++ engine/api/rbac/dao_rbac_project_group.go | 64 ++++++ engine/api/rbac/dao_rbac_project_project.go | 104 ++++++++++ engine/api/rbac/dao_rbac_project_user.go | 50 +++++ engine/api/rbac/dao_rbac_test.go | 205 ++++++++++++++++++++ engine/api/rbac/gorp_model.go | 128 ++++++++++++ engine/api/rbac/loader.go | 96 +++++++++ engine/api/rbac/rbac.go | 113 +++++++++++ engine/api/rbac/rule_project.go | 30 +++ engine/api/router.go | 17 +- engine/api/router_middleware_rbac.go | 24 +++ engine/api/router_util.go | 22 ++- engine/api/user/gorp_model.go | 2 + engine/api/v2_project.go | 28 +++ engine/api/v2_project_test.go | 30 +++ engine/service/http.go | 4 + engine/sql/api/236_rbac.sql | 106 ++++++++++ sdk/doc/route.go | 2 +- sdk/log/fields.go | 20 +- sdk/project.go | 5 + sdk/rbac.go | 37 ++++ sdk/rbac_global.go | 33 ++++ sdk/rbac_global_test.go | 38 ++++ sdk/rbac_project.go | 48 +++++ sdk/rbac_project_test.go | 68 +++++++ sdk/reflect.go | 9 + 29 files changed, 1556 insertions(+), 20 deletions(-) create mode 100644 engine/api/rbac/dao_rbac.go create mode 100644 engine/api/rbac/dao_rbac_global.go create mode 100644 engine/api/rbac/dao_rbac_project.go create mode 100644 engine/api/rbac/dao_rbac_project_group.go create mode 100644 engine/api/rbac/dao_rbac_project_project.go create mode 100644 engine/api/rbac/dao_rbac_project_user.go create mode 100644 engine/api/rbac/dao_rbac_test.go create mode 100644 engine/api/rbac/gorp_model.go create mode 100644 engine/api/rbac/loader.go create mode 100644 engine/api/rbac/rbac.go create mode 100644 engine/api/rbac/rule_project.go create mode 100644 engine/api/router_middleware_rbac.go create mode 100644 engine/api/v2_project.go create mode 100644 engine/api/v2_project_test.go create mode 100644 engine/sql/api/236_rbac.sql create mode 100644 sdk/rbac.go create mode 100644 sdk/rbac_global.go create mode 100644 sdk/rbac_global_test.go create mode 100644 sdk/rbac_project.go create mode 100644 sdk/rbac_project_test.go diff --git a/engine/api/api_routes.go b/engine/api/api_routes.go index 618694678e..57862e7828 100644 --- a/engine/api/api_routes.go +++ b/engine/api/api_routes.go @@ -29,7 +29,7 @@ func (api *API) InitRouter() { api.Router.SetHeaderFunc = service.DefaultHeaders api.Router.Middlewares = append(api.Router.Middlewares, api.tracingMiddleware, api.jwtMiddleware) api.Router.DefaultAuthMiddleware = api.authMiddleware - api.Router.PostAuthMiddlewares = append(api.Router.PostAuthMiddlewares, api.xsrfMiddleware, api.maintenanceMiddleware) + api.Router.PostAuthMiddlewares = append(api.Router.PostAuthMiddlewares, api.xsrfMiddleware, api.maintenanceMiddleware, api.rbacMiddleware) api.Router.PostMiddlewares = append(api.Router.PostMiddlewares, service.TracingPostMiddleware) r := api.Router @@ -449,6 +449,8 @@ func (api *API) InitRouter() { r.Handle("/template/{groupName}/{templateSlug}/instance/{instanceID}", Scope(sdk.AuthConsumerScopeTemplate), r.DELETE(api.deleteTemplateInstanceHandler)) r.Handle("/template/{groupName}/{templateSlug}/usage", Scope(sdk.AuthConsumerScopeTemplate), r.GET(api.getTemplateUsageHandler)) + r.Handle("/v2/project/{projectKey}/vcs", nil, r.POSTv2(api.postVCSOnProjectHandler)) + //Not Found handler r.Mux.NotFoundHandler = http.HandlerFunc(r.NotFoundHandler) diff --git a/engine/api/rbac/dao_rbac.go b/engine/api/rbac/dao_rbac.go new file mode 100644 index 0000000000..72ba97ddd9 --- /dev/null +++ b/engine/api/rbac/dao_rbac.go @@ -0,0 +1,100 @@ +package rbac + +import ( + "context" + "time" + + "github.com/go-gorp/gorp" + "github.com/rockbears/log" + + "github.com/ovh/cds/engine/api/database/gorpmapping" + "github.com/ovh/cds/engine/gorpmapper" + "github.com/ovh/cds/sdk" +) + +func LoadRbacByName(ctx context.Context, db gorp.SqlExecutor, name string, opts ...LoadOptionFunc) (sdk.RBAC, error) { + query := `SELECT * FROM rbac WHERE name = $1` + return get(ctx, db, gorpmapping.NewQuery(query).Args(name), opts...) +} + +// Insert a RBAC permission in database +func Insert(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rb *sdk.RBAC) error { + if err := sdk.IsValidRbac(rb); err != nil { + return err + } + if rb.UUID == "" { + rb.UUID = sdk.UUID() + } + if rb.Created.IsZero() { + rb.Created = time.Now() + } + rb.LastModified = time.Now() + dbRb := rbac{RBAC: *rb} + if err := gorpmapping.InsertAndSign(ctx, db, &dbRb); err != nil { + return err + } + + for i := range rb.Globals { + dbRbGlobal := rbacGlobal{ + RbacUUID: dbRb.UUID, + RBACGlobal: rb.Globals[i], + } + if err := insertRbacGlobal(ctx, db, &dbRbGlobal); err != nil { + return err + } + } + for i := range rb.Projects { + dbRbProject := rbacProject{ + RbacUUID: dbRb.UUID, + RBACProject: rb.Projects[i], + } + if err := insertRbacProject(ctx, db, &dbRbProject); err != nil { + return err + } + } + *rb = dbRb.RBAC + return nil +} + +func Update(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rb *sdk.RBAC) error { + if err := Delete(ctx, db, *rb); err != nil { + return err + } + return Insert(ctx, db, rb) +} + +func Delete(_ context.Context, db gorpmapper.SqlExecutorWithTx, rb sdk.RBAC) error { + dbRb := rbac{RBAC: rb} + if err := gorpmapping.Delete(db, &dbRb); err != nil { + return err + } + return nil +} + +func get(ctx context.Context, db gorp.SqlExecutor, q gorpmapping.Query, opts ...LoadOptionFunc) (sdk.RBAC, error) { + var r sdk.RBAC + var rbacDB rbac + found, err := gorpmapping.Get(ctx, db, q, &rbacDB) + if err != nil { + return r, err + } + if !found { + return r, sdk.WithStack(sdk.ErrNotFound) + } + + isValid, err := gorpmapping.CheckSignature(rbacDB, rbacDB.Signature) + if err != nil { + return r, sdk.WrapError(err, "error when checking signature for rbac %s", rbacDB.UUID) + } + if !isValid { + log.Error(ctx, "rbac.get> rbac %s (%s) data corrupted", rbacDB.Name, rbacDB.UUID) + return r, sdk.WithStack(sdk.ErrNotFound) + } + for _, f := range opts { + if err := f(ctx, db, &rbacDB); err != nil { + return r, err + } + } + r = rbacDB.RBAC + return r, nil +} diff --git a/engine/api/rbac/dao_rbac_global.go b/engine/api/rbac/dao_rbac_global.go new file mode 100644 index 0000000000..85cccd4eb0 --- /dev/null +++ b/engine/api/rbac/dao_rbac_global.go @@ -0,0 +1,94 @@ +package rbac + +import ( + "context" + + "github.com/go-gorp/gorp" + "github.com/rockbears/log" + + "github.com/ovh/cds/engine/api/database/gorpmapping" + "github.com/ovh/cds/engine/gorpmapper" + "github.com/ovh/cds/sdk" +) + +func insertRbacGlobal(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rg *rbacGlobal) error { + if err := gorpmapping.InsertAndSign(ctx, db, rg); err != nil { + return err + } + + for _, userID := range rg.RBACUsersIDs { + if err := insertRbacGlobalUser(ctx, db, rg.ID, userID); err != nil { + return err + } + } + for _, groupID := range rg.RBACGroupsIDs { + if err := insertRbacGlobalGroup(ctx, db, rg.ID, groupID); err != nil { + return err + } + } + return nil +} + +func insertRbacGlobalUser(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rbacGlobalID int64, userID string) error { + rgu := rbacGlobalUser{ + RbacGlobalID: rbacGlobalID, + RbacGlobalUserID: userID, + } + if err := gorpmapping.InsertAndSign(ctx, db, &rgu); err != nil { + return err + } + return nil +} + +func insertRbacGlobalGroup(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rbacGlobalID int64, groupID int64) error { + rgu := rbacGlobalGroup{ + RbacGlobalID: rbacGlobalID, + RbacGlobalGroupID: groupID, + } + if err := gorpmapping.InsertAndSign(ctx, db, &rgu); err != nil { + return err + } + return nil +} + +func getAllRBACGlobalUsers(ctx context.Context, db gorp.SqlExecutor, rbacGlobal *rbacGlobal) error { + q := gorpmapping.NewQuery("SELECT * FROM rbac_global_users WHERE rbac_global_id = $1").Args(rbacGlobal.ID) + var rbacUserIDS []rbacGlobalUser + if err := gorpmapping.GetAll(ctx, db, q, &rbacUserIDS); err != nil { + return err + } + rbacGlobal.RBACGlobal.RBACUsersIDs = make([]string, 0, len(rbacUserIDS)) + for _, rbacUsers := range rbacUserIDS { + isValid, err := gorpmapping.CheckSignature(rbacUsers, rbacUsers.Signature) + if err != nil { + return sdk.WrapError(err, "error when checking signature for rbac_global_users %d", rbacUsers.ID) + } + if !isValid { + log.Error(ctx, "rbac.getAllRBACGlobalUsers> rbac_global_users %d data corrupted", rbacUsers.ID) + continue + } + rbacGlobal.RBACGlobal.RBACUsersIDs = append(rbacGlobal.RBACGlobal.RBACUsersIDs, rbacUsers.RbacGlobalUserID) + } + return nil +} + +func getAllRBACGlobalGroups(ctx context.Context, db gorp.SqlExecutor, rbacGlobal *rbacGlobal) error { + q := gorpmapping.NewQuery("SELECT * FROM rbac_global_groups WHERE rbac_global_id = $1").Args(rbacGlobal.ID) + var rbacGroupIDs []rbacGlobalGroup + if err := gorpmapping.GetAll(ctx, db, q, &rbacGroupIDs); err != nil { + return err + } + rbacGlobal.RBACGlobal.RBACGroupsIDs = make([]int64, 0, len(rbacGroupIDs)) + for _, rbacGroups := range rbacGroupIDs { + isValid, err := gorpmapping.CheckSignature(rbacGroups, rbacGroups.Signature) + if err != nil { + return sdk.WrapError(err, "error when checking signature for rbac_global_groups %d", rbacGroups.ID) + } + if !isValid { + log.Error(ctx, "rbac.getAllRBACGlobalGroups> rbac_global_groups %d data corrupted", rbacGroups.ID) + continue + } + rbacGlobal.RBACGlobal.RBACGroupsIDs = append(rbacGlobal.RBACGlobal.RBACGroupsIDs, rbacGroups.RbacGlobalGroupID) + } + return nil +} diff --git a/engine/api/rbac/dao_rbac_project.go b/engine/api/rbac/dao_rbac_project.go new file mode 100644 index 0000000000..778c2b361c --- /dev/null +++ b/engine/api/rbac/dao_rbac_project.go @@ -0,0 +1,95 @@ +package rbac + +import ( + "context" + + "github.com/go-gorp/gorp" + "github.com/lib/pq" + "github.com/rockbears/log" + + "github.com/ovh/cds/engine/api/database/gorpmapping" + "github.com/ovh/cds/engine/gorpmapper" + "github.com/ovh/cds/sdk" +) + +func insertRbacProject(ctx context.Context, db gorpmapper.SqlExecutorWithTx, dbRP *rbacProject) error { + if err := gorpmapping.InsertAndSign(ctx, db, dbRP); err != nil { + return err + } + + for _, rbProjectID := range dbRP.RBACProjectsIDs { + if err := insertRbacProjectIdentifiers(ctx, db, dbRP.ID, rbProjectID); err != nil { + return err + } + } + for _, rbUserID := range dbRP.RBACUsersIDs { + if err := insertRbacProjectUser(ctx, db, dbRP.ID, rbUserID); err != nil { + return err + } + } + for _, rbGroupID := range dbRP.RBACGroupsIDs { + if err := insertRbacProjectGroup(ctx, db, dbRP.ID, rbGroupID); err != nil { + return err + } + } + return nil +} + +func insertRbacProjectIdentifiers(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rbacParentID int64, projectID int64) error { + identifier := rbacProjectIdentifiers{ + RbacProjectID: rbacParentID, + ProjectID: projectID, + } + if err := gorpmapping.InsertAndSign(ctx, db, &identifier); err != nil { + return err + } + return nil +} + +func insertRbacProjectUser(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rbacProjectID int64, userID string) error { + rgu := rbacProjectUser{ + RbacProjectID: rbacProjectID, + RbacProjectUserID: userID, + } + if err := gorpmapping.InsertAndSign(ctx, db, &rgu); err != nil { + return err + } + return nil +} + +func insertRbacProjectGroup(ctx context.Context, db gorpmapper.SqlExecutorWithTx, rbacProjectID int64, groupID int64) error { + rgu := rbacProjectGroup{ + RbacProjectID: rbacProjectID, + RbacProjectGroupID: groupID, + } + if err := gorpmapping.InsertAndSign(ctx, db, &rgu); err != nil { + return err + } + return nil +} + +func getAllRbacProjects(ctx context.Context, db gorp.SqlExecutor, q gorpmapping.Query) ([]rbacProject, error) { + var rbacProjects []rbacProject + if err := gorpmapping.GetAll(ctx, db, q, &rbacProjects); err != nil { + return nil, err + } + + projectsFiltered := make([]rbacProject, 0, len(rbacProjects)) + for _, projectDatas := range rbacProjects { + isValid, err := gorpmapping.CheckSignature(projectDatas, projectDatas.Signature) + if err != nil { + return nil, sdk.WrapError(err, "error when checking signature for rbac_project %d", projectDatas.ID) + } + if !isValid { + log.Error(ctx, "rbac.getAllRbacProjects> rbac_project %d data corrupted", projectDatas.ID) + continue + } + projectsFiltered = append(projectsFiltered, projectDatas) + } + return projectsFiltered, nil +} + +func loadRbacProjectsByRoleAndIDs(ctx context.Context, db gorp.SqlExecutor, role string, rbacProjectIDs []int64) ([]rbacProject, error) { + q := gorpmapping.NewQuery(`SELECT * from rbac_project WHERE role = $1 AND id = ANY($2)`).Args(role, pq.Int64Array(rbacProjectIDs)) + return getAllRbacProjects(ctx, db, q) +} diff --git a/engine/api/rbac/dao_rbac_project_group.go b/engine/api/rbac/dao_rbac_project_group.go new file mode 100644 index 0000000000..a125032f28 --- /dev/null +++ b/engine/api/rbac/dao_rbac_project_group.go @@ -0,0 +1,64 @@ +package rbac + +import ( + "context" + + "github.com/go-gorp/gorp" + "github.com/lib/pq" + "github.com/rockbears/log" + + "github.com/ovh/cds/engine/api/database/gorpmapping" + "github.com/ovh/cds/engine/api/group" + "github.com/ovh/cds/sdk" +) + +func loadRbacProjectGroupsByUserID(ctx context.Context, db gorp.SqlExecutor, userID string) ([]rbacProjectGroup, error) { + groups, err := group.LoadAllByUserID(ctx, db, userID) + if err != nil { + return nil, err + } + groupIDs := make([]int64, 0, len(groups)) + for _, g := range groups { + groupIDs = append(groupIDs, g.ID) + } + return loadRbacProjectGroupsByGroupIDs(ctx, db, groupIDs) +} + +func loadRbacProjectGroupsByGroupIDs(ctx context.Context, db gorp.SqlExecutor, groupIDs []int64) ([]rbacProjectGroup, error) { + q := gorpmapping.NewQuery("SELECT * FROM rbac_project_groups WHERE group_id = ANY ($1)").Args(pq.Int64Array(groupIDs)) + return getAllRBACProjectGroups(ctx, db, q) +} + +func loadRBACProjectGroups(ctx context.Context, db gorp.SqlExecutor, rbacProject *rbacProject) error { + q := gorpmapping.NewQuery("SELECT * FROM rbac_project_groups WHERE rbac_project_id = $1").Args(rbacProject.ID) + rbacProjectGroups, err := getAllRBACProjectGroups(ctx, db, q) + if err != nil { + return err + } + rbacProject.RBACProject.RBACGroupsIDs = make([]int64, 0, len(rbacProjectGroups)) + for _, g := range rbacProjectGroups { + rbacProject.RBACProject.RBACGroupsIDs = append(rbacProject.RBACProject.RBACGroupsIDs, g.RbacProjectGroupID) + } + return nil +} + +func getAllRBACProjectGroups(ctx context.Context, db gorp.SqlExecutor, q gorpmapping.Query) ([]rbacProjectGroup, error) { + var rbacGroupIDs []rbacProjectGroup + if err := gorpmapping.GetAll(ctx, db, q, &rbacGroupIDs); err != nil { + return nil, err + } + + groupsFiltered := make([]rbacProjectGroup, 0, len(rbacGroupIDs)) + for _, rbacGroups := range rbacGroupIDs { + isValid, err := gorpmapping.CheckSignature(rbacGroups, rbacGroups.Signature) + if err != nil { + return nil, sdk.WrapError(err, "error when checking signature for rbac_project_groups %d", rbacGroups.ID) + } + if !isValid { + log.Error(ctx, "rbac.getAllRBACProjectGroups> rbac_project_groups %d data corrupted", rbacGroups.ID) + continue + } + groupsFiltered = append(groupsFiltered, rbacGroups) + } + return groupsFiltered, nil +} diff --git a/engine/api/rbac/dao_rbac_project_project.go b/engine/api/rbac/dao_rbac_project_project.go new file mode 100644 index 0000000000..e0bf2e5205 --- /dev/null +++ b/engine/api/rbac/dao_rbac_project_project.go @@ -0,0 +1,104 @@ +package rbac + +import ( + "context" + "github.com/lib/pq" + + "github.com/go-gorp/gorp" + "github.com/rockbears/log" + + "github.com/ovh/cds/engine/api/database/gorpmapping" + "github.com/ovh/cds/sdk" +) + +func getAllRBACProjectIdentifiers(ctx context.Context, db gorp.SqlExecutor, q gorpmapping.Query) ([]rbacProjectIdentifiers, error) { + var rbacProjectIdentifier []rbacProjectIdentifiers + if err := gorpmapping.GetAll(ctx, db, q, &rbacProjectIdentifier); err != nil { + return nil, err + } + rbacProjectIdentifierFiltered := make([]rbacProjectIdentifiers, 0, len(rbacProjectIdentifier)) + for _, projectDatas := range rbacProjectIdentifier { + isValid, err := gorpmapping.CheckSignature(projectDatas, projectDatas.Signature) + if err != nil { + return nil, sdk.WrapError(err, "error when checking signature for rbac_project_projects %d", projectDatas.ID) + } + if !isValid { + log.Error(ctx, "rbac.getAllRBACProjectIdentifiers> rbac_project_projects %d data corrupted", projectDatas.ID) + continue + } + rbacProjectIdentifierFiltered = append(rbacProjectIdentifierFiltered, projectDatas) + } + return rbacProjectIdentifierFiltered, nil +} + +func loadRBACProjectIdentifiers(ctx context.Context, db gorp.SqlExecutor, rbacProject *rbacProject) error { + q := gorpmapping.NewQuery("SELECT * FROM rbac_project_projects WHERE rbac_project_id = $1").Args(rbacProject.ID) + rbacProjectIdentifiers, err := getAllRBACProjectIdentifiers(ctx, db, q) + if err != nil { + return err + } + rbacProject.RBACProject.RBACProjectsIDs = make([]int64, 0, len(rbacProjectIdentifiers)) + for _, projectDatas := range rbacProjectIdentifiers { + rbacProject.RBACProject.RBACProjectsIDs = append(rbacProject.RBACProject.RBACProjectsIDs, projectDatas.ProjectID) + } + return nil +} + +func loadRRBACProjectIdentifiers(ctx context.Context, db gorp.SqlExecutor, rbacProjectIDs []int64) ([]rbacProjectIdentifiers, error) { + query := gorpmapping.NewQuery(`SELECT * FROM rbac_project_projects WHERE rbac_project_id = ANY($1)`).Args(pq.Int64Array(rbacProjectIDs)) + return getAllRBACProjectIdentifiers(ctx, db, query) +} + +func LoadProjectIDsByRoleAndUserID(ctx context.Context, db gorp.SqlExecutor, role string, userID string) ([]int64, error) { + // Get rbac_project_groups + rbacProjectGroups, err := loadRbacProjectGroupsByUserID(ctx, db, userID) + if err != nil { + return nil, err + } + // Get rbac_project_users + rbacProjectUsers, err := loadRbacProjectUsersByUserID(ctx, db, userID) + if err != nil { + return nil, err + } + + // Deduplicate rbac_project.id + mapRbacProjectID := make(map[int64]struct{}) + rbacProjectIDs := make([]int64, 0) + for _, rpg := range rbacProjectGroups { + mapRbacProjectID[rpg.RbacProjectID] = struct{}{} + rbacProjectIDs = append(rbacProjectIDs, rpg.RbacProjectID) + } + for _, rpu := range rbacProjectUsers { + if _, has := mapRbacProjectID[rpu.RbacProjectID]; !has { + mapRbacProjectID[rpu.RbacProjectID] = struct{}{} + rbacProjectIDs = append(rbacProjectIDs, rpu.RbacProjectID) + } + } + + // Get rbac_project + rbacProjects, err := loadRbacProjectsByRoleAndIDs(ctx, db, role, rbacProjectIDs) + if err != nil { + return nil, err + } + + // Get rbac_project_projects + rbacProjectIDs = make([]int64, 0, len(rbacProjects)) + for _, rp := range rbacProjects { + rbacProjectIDs = append(rbacProjectIDs, rp.ID) + } + rbacProjectsIdentifiers, err := loadRRBACProjectIdentifiers(ctx, db, rbacProjectIDs) + if err != nil { + return nil, err + } + + // Deduplicate project ID + projectIDs := make([]int64, 0) + projectMap := make(map[int64]struct{}) + for _, rpi := range rbacProjectsIdentifiers { + if _, has := projectMap[rpi.ProjectID]; !has { + projectMap[rpi.ProjectID] = struct{}{} + projectIDs = append(projectIDs, rpi.ProjectID) + } + } + return projectIDs, nil +} diff --git a/engine/api/rbac/dao_rbac_project_user.go b/engine/api/rbac/dao_rbac_project_user.go new file mode 100644 index 0000000000..0ab5890c2f --- /dev/null +++ b/engine/api/rbac/dao_rbac_project_user.go @@ -0,0 +1,50 @@ +package rbac + +import ( + "context" + + "github.com/go-gorp/gorp" + "github.com/rockbears/log" + + "github.com/ovh/cds/engine/api/database/gorpmapping" + "github.com/ovh/cds/sdk" +) + +func loadRbacProjectUsersByUserID(ctx context.Context, db gorp.SqlExecutor, userID string) ([]rbacProjectUser, error) { + q := gorpmapping.NewQuery("SELECT * FROM rbac_project_users WHERE user_id = $1").Args(userID) + return getAllRBACProjectUsers(ctx, db, q) +} + +func loadRBACProjectUsers(ctx context.Context, db gorp.SqlExecutor, rbacProject *rbacProject) error { + q := gorpmapping.NewQuery("SELECT * FROM rbac_project_users WHERE rbac_project_id = $1").Args(rbacProject.ID) + rbacUserIDS, err := getAllRBACProjectUsers(ctx, db, q) + if err != nil { + return err + } + rbacProject.RBACProject.RBACUsersIDs = make([]string, 0, len(rbacUserIDS)) + for _, rbacUsers := range rbacUserIDS { + rbacProject.RBACProject.RBACUsersIDs = append(rbacProject.RBACProject.RBACUsersIDs, rbacUsers.RbacProjectUserID) + } + return nil +} + +func getAllRBACProjectUsers(ctx context.Context, db gorp.SqlExecutor, q gorpmapping.Query) ([]rbacProjectUser, error) { + var rbacProjectUsers []rbacProjectUser + if err := gorpmapping.GetAll(ctx, db, q, &rbacProjectUsers); err != nil { + return nil, err + } + + usersFiltered := make([]rbacProjectUser, 0, len(rbacProjectUsers)) + for _, rbacUsers := range rbacProjectUsers { + isValid, err := gorpmapping.CheckSignature(rbacUsers, rbacUsers.Signature) + if err != nil { + return nil, sdk.WrapError(err, "error when checking signature for rbac_project_users %d", rbacUsers.ID) + } + if !isValid { + log.Error(ctx, "rbac.getAllRBACProjectUsers> rbac_project_users %d data corrupted", rbacUsers.ID) + continue + } + usersFiltered = append(usersFiltered, rbacUsers) + } + return usersFiltered, nil +} diff --git a/engine/api/rbac/dao_rbac_test.go b/engine/api/rbac/dao_rbac_test.go new file mode 100644 index 0000000000..dcbd040a6e --- /dev/null +++ b/engine/api/rbac/dao_rbac_test.go @@ -0,0 +1,205 @@ +package rbac + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" + + "github.com/ovh/cds/engine/api/test" + "github.com/ovh/cds/engine/api/test/assets" + "github.com/ovh/cds/sdk" +) + +func TestLoadRbacProject(t *testing.T) { + // user1 can read (proj1) + // Group 1 can read (proj1,prj2), manage(prj2) + // user2 in group 1 + db, cache := test.SetupPG(t) + + _, err := db.Exec("DELETE FROM rbac") + require.NoError(t, err) + + key1 := sdk.RandomString(10) + proj1 := assets.InsertTestProject(t, db, cache, key1, key1) + + key2 := sdk.RandomString(10) + proj2 := assets.InsertTestProject(t, db, cache, key2, key2) + + grpName1 := sdk.RandomString(10) + group1 := assets.InsertTestGroup(t, db, grpName1) + + users1, _ := assets.InsertLambdaUser(t, db) + users2, _ := assets.InsertLambdaUser(t, db, group1) + + perm := fmt.Sprintf(`name: perm-test +projects: + - role: read + projects: [%s] + users: [%s] + groups: [%s] + - role: read + projects: [%s] + groups: [%s] + - role: manage + projects: [%s] + groups: [%s] +`, proj1.Key, users1.Username, group1.Name, proj2.Key, group1.Name, proj2.Key, group1.Name) + + var r sdk.RBAC + require.NoError(t, yaml.Unmarshal([]byte(perm), &r)) + + err = fillWithIDs(context.TODO(), db, &r) + require.NoError(t, err) + + require.NoError(t, Insert(context.Background(), db, &r)) + + prjusers1, err := LoadProjectIDsByRoleAndUserID(context.TODO(), db, sdk.RoleRead, users1.ID) + require.NoError(t, err) + t.Logf("%+v", prjusers1) + require.Len(t, prjusers1, 1) + require.Equal(t, prjusers1[0], proj1.ID) + + prjusers2, err := LoadProjectIDsByRoleAndUserID(context.TODO(), db, sdk.RoleManage, users2.ID) + require.NoError(t, err) + require.Len(t, prjusers2, 1) + require.Equal(t, prjusers2[0], proj2.ID) +} + +func TestLoadRbac(t *testing.T) { + db, cache := test.SetupPG(t) + + _, err := db.Exec("DELETE FROM rbac WHERE name = 'perm-test'") + require.NoError(t, err) + + key1 := sdk.RandomString(10) + proj1 := assets.InsertTestProject(t, db, cache, key1, key1) + + key2 := sdk.RandomString(10) + proj2 := assets.InsertTestProject(t, db, cache, key2, key2) + + grpName1 := sdk.RandomString(10) + group1 := assets.InsertTestGroup(t, db, grpName1) + + users1, _ := assets.InsertLambdaUser(t, db) + + perm := fmt.Sprintf(`name: perm-test +projects: + - role: read + users: [%s] + groups: [%s] + projects: [%s] + - role: manage + groups: [%s] + projects: [%s] +globals: + - role: create-project + users: [%s] +`, users1.Username, group1.Name, proj1.Key, group1.Name, proj2.Key, users1.Username) + + var r sdk.RBAC + require.NoError(t, yaml.Unmarshal([]byte(perm), &r)) + err = fillWithIDs(context.TODO(), db, &r) + require.NoError(t, err) + + require.NoError(t, Insert(context.Background(), db, &r)) + + rbacDB, err := LoadRbacByName(context.TODO(), db, r.Name, LoadOptions.Default) + require.NoError(t, err) + + // Global part + require.Equal(t, len(r.Globals), len(rbacDB.Globals)) + require.Equal(t, r.Globals[0].Role, rbacDB.Globals[0].Role) + require.Equal(t, users1.ID, rbacDB.Globals[0].RBACUsersIDs[0]) + + // Project part + require.Equal(t, len(r.Projects), len(rbacDB.Projects)) + + manageCheck := false + readCheck := false + for _, rp := range r.Projects { + if rp.Role == "manage" { + require.Equal(t, 1, len(rp.RBACGroupsName)) + require.Equal(t, 1, len(rp.RBACGroupsIDs)) + require.Equal(t, 1, len(rp.RBACProjectKeys)) + require.Equal(t, 1, len(rp.RBACProjectsIDs)) + require.Equal(t, proj2.Key, rp.RBACProjectKeys[0]) + require.Equal(t, proj2.ID, rp.RBACProjectsIDs[0]) + require.Equal(t, group1.Name, rp.RBACGroupsName[0]) + require.Equal(t, group1.ID, rp.RBACGroupsIDs[0]) + manageCheck = true + } + if rp.Role == "read" { + require.Equal(t, 1, len(rp.RBACGroupsName)) + require.Equal(t, 1, len(rp.RBACGroupsIDs)) + require.Equal(t, 1, len(rp.RBACUsersIDs)) + require.Equal(t, 1, len(rp.RBACUsersName)) + require.Equal(t, 1, len(rp.RBACProjectKeys)) + require.Equal(t, 1, len(rp.RBACProjectsIDs)) + require.Equal(t, proj1.Key, rp.RBACProjectKeys[0]) + require.Equal(t, proj1.ID, rp.RBACProjectsIDs[0]) + require.Equal(t, group1.Name, rp.RBACGroupsName[0]) + require.Equal(t, group1.ID, rp.RBACGroupsIDs[0]) + require.Equal(t, users1.Username, rp.RBACUsersName[0]) + require.Equal(t, users1.ID, rp.RBACUsersIDs[0]) + readCheck = true + } + } + require.True(t, manageCheck) + require.True(t, readCheck) +} + +func TestUpdateRbac(t *testing.T) { + db, cache := test.SetupPG(t) + + _, err := db.Exec("DELETE FROM rbac WHERE name = 'perm-test'") + require.NoError(t, err) + + key1 := sdk.RandomString(10) + proj1 := assets.InsertTestProject(t, db, cache, key1, key1) + + users1, _ := assets.InsertLambdaUser(t, db) + + perm := fmt.Sprintf(`name: perm-test +projects: + - role: read + users: [%s] + projects: [%s] + +`, users1.Username, proj1.Key) + + var r sdk.RBAC + require.NoError(t, yaml.Unmarshal([]byte(perm), &r)) + err = fillWithIDs(context.TODO(), db, &r) + require.NoError(t, err) + + require.NoError(t, Insert(context.Background(), db, &r)) + + rbacDB, err := LoadRbacByName(context.TODO(), db, r.Name, LoadOptions.Default) + require.NoError(t, err) + + require.Equal(t, "read", rbacDB.Projects[0].Role) + + // Update change role + permUpdated := fmt.Sprintf(`name: perm-test +projects: + - role: manage + users: [%s] + projects: [%s] + +`, users1.Username, proj1.Key) + + var rUpdated sdk.RBAC + require.NoError(t, yaml.Unmarshal([]byte(permUpdated), &rUpdated)) + err = fillWithIDs(context.TODO(), db, &rUpdated) + require.NoError(t, err) + + require.NoError(t, Update(context.TODO(), db, &rUpdated)) + + rbacDBUpdate, err := LoadRbacByName(context.TODO(), db, r.Name, LoadOptions.Default) + require.NoError(t, err) + require.Equal(t, "manage", rbacDBUpdate.Projects[0].Role) + +} diff --git a/engine/api/rbac/gorp_model.go b/engine/api/rbac/gorp_model.go new file mode 100644 index 0000000000..fa2b5fbff3 --- /dev/null +++ b/engine/api/rbac/gorp_model.go @@ -0,0 +1,128 @@ +package rbac + +import ( + "github.com/ovh/cds/engine/api/database/gorpmapping" + "github.com/ovh/cds/engine/gorpmapper" + "github.com/ovh/cds/sdk" +) + +type rbac struct { + sdk.RBAC + gorpmapper.SignedEntity +} + +func (r rbac) Canonical() gorpmapper.CanonicalForms { + _ = []interface{}{r.UUID, r.Name} + return []gorpmapper.CanonicalForm{ + "{{.UUID}}{{.Name}}", + } +} + +type rbacGlobal struct { + ID int64 `db:"id"` + RbacUUID string `db:"rbac_uuid"` + sdk.RBACGlobal + gorpmapper.SignedEntity +} + +func (rg rbacGlobal) Canonical() gorpmapper.CanonicalForms { + _ = []interface{}{rg.ID, rg.RbacUUID, rg.Role} + return []gorpmapper.CanonicalForm{ + "{{.ID}}{{.RbacUUID}}{{.Role}}", + } +} + +type rbacGlobalUser struct { + ID int64 `db:"id"` + RbacGlobalID int64 `db:"rbac_global_id"` + RbacGlobalUserID string `db:"user_id"` + gorpmapper.SignedEntity +} + +func (rgu rbacGlobalUser) Canonical() gorpmapper.CanonicalForms { + _ = []interface{}{rgu.ID, rgu.RbacGlobalID, rgu.RbacGlobalUserID} + return []gorpmapper.CanonicalForm{ + "{{.ID}}{{.RbacGlobalID}}{{.RbacGlobalUserID}}", + } +} + +type rbacGlobalGroup struct { + ID int64 `json:"-" db:"id" yaml:"-"` + RbacGlobalID int64 `db:"rbac_global_id"` + RbacGlobalGroupID int64 `db:"group_id"` + gorpmapper.SignedEntity +} + +func (rgg rbacGlobalGroup) Canonical() gorpmapper.CanonicalForms { + _ = []interface{}{rgg.ID, rgg.RbacGlobalID, rgg.RbacGlobalGroupID} + return []gorpmapper.CanonicalForm{ + "{{.ID}}{{.RbacGlobalID}}{{.RbacGlobalGroupID}}", + } +} + +type rbacProject struct { + ID int64 `json:"-" db:"id" yaml:"-"` + RbacUUID string `json:"-" db:"rbac_uuid" yaml:"-"` + sdk.RBACProject + gorpmapper.SignedEntity +} + +func (rp rbacProject) Canonical() gorpmapper.CanonicalForms { + _ = []interface{}{rp.ID, rp.RbacUUID, rp.Role, rp.All} + return []gorpmapper.CanonicalForm{ + "{{.ID}}{{.RbacUUID}}{{.Role}}{{.All}}", + } +} + +type rbacProjectIdentifiers struct { + ID int64 `json:"-" db:"id" yaml:"-"` + RbacProjectID int64 `json:"-" db:"rbac_project_id" yaml:"-"` + ProjectID int64 `json:"-" db:"project_id" yaml:"-"` + gorpmapper.SignedEntity +} + +func (rpi rbacProjectIdentifiers) Canonical() gorpmapper.CanonicalForms { + _ = []interface{}{rpi.ID, rpi.RbacProjectID, rpi.ProjectID} + return []gorpmapper.CanonicalForm{ + "{{.ID}}{{.RbacProjectID}}{{.ProjectID}}", + } +} + +type rbacProjectUser struct { + ID int64 `json:"-" db:"id" yaml:"-"` + RbacProjectID int64 `db:"rbac_project_id"` + RbacProjectUserID string `db:"user_id"` + gorpmapper.SignedEntity +} + +func (rgu rbacProjectUser) Canonical() gorpmapper.CanonicalForms { + _ = []interface{}{rgu.ID, rgu.RbacProjectID, rgu.RbacProjectUserID} + return []gorpmapper.CanonicalForm{ + "{{.ID}}{{.RbacProjectID}}{{.RbacProjectUserID}}", + } +} + +type rbacProjectGroup struct { + ID int64 `json:"-" db:"id" yaml:"-"` + RbacProjectID int64 `db:"rbac_project_id"` + RbacProjectGroupID int64 `db:"group_id"` + gorpmapper.SignedEntity +} + +func (rgg rbacProjectGroup) Canonical() gorpmapper.CanonicalForms { + _ = []interface{}{rgg.ID, rgg.RbacProjectID, rgg.RbacProjectGroupID} + return []gorpmapper.CanonicalForm{ + "{{.ID}}{{.RbacProjectID}}{{.RbacProjectGroupID}}", + } +} + +func init() { + gorpmapping.Register(gorpmapping.New(rbac{}, "rbac", false, "uuid")) + gorpmapping.Register(gorpmapping.New(rbacGlobal{}, "rbac_global", true, "id")) + gorpmapping.Register(gorpmapping.New(rbacGlobalUser{}, "rbac_global_users", true, "id")) + gorpmapping.Register(gorpmapping.New(rbacGlobalGroup{}, "rbac_global_groups", true, "id")) + gorpmapping.Register(gorpmapping.New(rbacProject{}, "rbac_project", true, "id")) + gorpmapping.Register(gorpmapping.New(rbacProjectIdentifiers{}, "rbac_project_projects", true, "id")) + gorpmapping.Register(gorpmapping.New(rbacProjectUser{}, "rbac_project_users", true, "id")) + gorpmapping.Register(gorpmapping.New(rbacProjectGroup{}, "rbac_project_groups", true, "id")) +} diff --git a/engine/api/rbac/loader.go b/engine/api/rbac/loader.go new file mode 100644 index 0000000000..af8b79cd6a --- /dev/null +++ b/engine/api/rbac/loader.go @@ -0,0 +1,96 @@ +package rbac + +import ( + "context" + + "github.com/go-gorp/gorp" + "github.com/rockbears/log" + + "github.com/ovh/cds/engine/api/database/gorpmapping" + "github.com/ovh/cds/sdk" +) + +// LoadOptionFunc is used as options to loadProject functions +type LoadOptionFunc func(context.Context, gorp.SqlExecutor, *rbac) error + +// LoadOptions provides all options on rbac loads functions +var LoadOptions = struct { + Default LoadOptionFunc + LoadRbacGlobal LoadOptionFunc + LoadRbacProject LoadOptionFunc +}{ + Default: loadDefault, + LoadRbacGlobal: loadRbacGlobal, + LoadRbacProject: loadRbacProject, +} + +func loadDefault(ctx context.Context, db gorp.SqlExecutor, rbac *rbac) error { + if err := loadRbacGlobal(ctx, db, rbac); err != nil { + return err + } + if err := loadRbacProject(ctx, db, rbac); err != nil { + return err + } + return nil +} + +func loadRbacProject(ctx context.Context, db gorp.SqlExecutor, rbac *rbac) error { + query := "SELECT * FROM rbac_project WHERE rbac_uuid = $1" + var rbacPrj []rbacProject + if err := gorpmapping.GetAll(ctx, db, gorpmapping.NewQuery(query).Args(rbac.UUID), &rbacPrj); err != nil { + return err + } + rbac.Projects = make([]sdk.RBACProject, 0, len(rbacPrj)) + for i := range rbacPrj { + rp := &rbacPrj[i] + isValid, err := gorpmapping.CheckSignature(rp, rp.Signature) + if err != nil { + return sdk.WrapError(err, "error when checking signature for rbac_project %d", rp.ID) + } + if !isValid { + log.Error(ctx, "rbac_project.get> rbac_project %d data corrupted", rp.ID) + continue + } + if err := loadRBACProjectIdentifiers(ctx, db, rp); err != nil { + return err + } + if !rp.All { + if err := loadRBACProjectUsers(ctx, db, rp); err != nil { + return err + } + if err := loadRBACProjectGroups(ctx, db, rp); err != nil { + return err + } + } + rbac.Projects = append(rbac.Projects, rp.RBACProject) + } + return nil +} + +func loadRbacGlobal(ctx context.Context, db gorp.SqlExecutor, rbac *rbac) error { + query := "SELECT * FROM rbac_global WHERE rbac_uuid = $1" + var rbacGbl []rbacGlobal + if err := gorpmapping.GetAll(ctx, db, gorpmapping.NewQuery(query).Args(rbac.UUID), &rbacGbl); err != nil { + return err + } + rbac.Globals = make([]sdk.RBACGlobal, 0, len(rbacGbl)) + for i := range rbacGbl { + rg := &rbacGbl[i] + isValid, err := gorpmapping.CheckSignature(rg, rg.Signature) + if err != nil { + return sdk.WrapError(err, "error when checking signature for rbac_global %d", rg.ID) + } + if !isValid { + log.Error(ctx, "rbac.loadRbacGlobal> rbac_global %d data corrupted", rg.ID) + continue + } + if err := getAllRBACGlobalUsers(ctx, db, rg); err != nil { + return err + } + if err := getAllRBACGlobalGroups(ctx, db, rg); err != nil { + return err + } + rbac.Globals = append(rbac.Globals, rg.RBACGlobal) + } + return nil +} diff --git a/engine/api/rbac/rbac.go b/engine/api/rbac/rbac.go new file mode 100644 index 0000000000..ccc1abb9bc --- /dev/null +++ b/engine/api/rbac/rbac.go @@ -0,0 +1,113 @@ +package rbac + +import ( + "context" + + "github.com/go-gorp/gorp" + "github.com/ovh/cds/engine/api/group" + "github.com/ovh/cds/engine/api/project" + "github.com/ovh/cds/engine/api/user" + "github.com/ovh/cds/sdk" +) + +func fillWithIDs(ctx context.Context, db gorp.SqlExecutor, r *sdk.RBAC) error { + // Check existing permission + rbacDB, err := LoadRbacByName(ctx, db, r.Name) + if err != nil { + if !sdk.ErrorIs(err, sdk.ErrNotFound) { + return err + } + } + r.UUID = rbacDB.UUID + + userCache := make(map[string]string) + groupCache := make(map[string]int64) + projectCache := make(map[string]int64) + for gID := range r.Globals { + rbacGbl := &r.Globals[gID] + if err := fillRbacGlobalWithID(ctx, db, rbacGbl, userCache, groupCache); err != nil { + return err + } + } + for pID := range r.Projects { + rbacPrj := &r.Projects[pID] + if err := fillRbacProjectWithID(ctx, db, rbacPrj, projectCache, userCache, groupCache); err != nil { + return err + } + } + return nil +} + +func fillRbacProjectWithID(ctx context.Context, db gorp.SqlExecutor, rbacPrj *sdk.RBACProject, projectCache map[string]int64, userCache map[string]string, groupCache map[string]int64) error { + rbacPrj.RBACProjectsIDs = make([]int64, 0, len(rbacPrj.RBACProjectKeys)) + for _, projKey := range rbacPrj.RBACProjectKeys { + projectID := projectCache[projKey] + if projectID == 0 { + prj, err := project.Load(ctx, db, projKey) + if err != nil { + return err + } + projectID = prj.ID + projectCache[projKey] = prj.ID + } + rbacPrj.RBACProjectsIDs = append(rbacPrj.RBACProjectsIDs, projectID) + } + rbacPrj.RBACUsersIDs = make([]string, 0, len(rbacPrj.RBACUsersName)) + for _, userName := range rbacPrj.RBACUsersName { + userID := userCache[userName] + if userID == "" { + authentifierUser, err := user.LoadByUsername(ctx, db, userName) + if err != nil { + return err + } + userID = authentifierUser.ID + userCache[userName] = userID + } + rbacPrj.RBACUsersIDs = append(rbacPrj.RBACUsersIDs, userID) + } + rbacPrj.RBACGroupsIDs = make([]int64, 0, len(rbacPrj.RBACGroupsName)) + for _, groupName := range rbacPrj.RBACGroupsName { + groupID := groupCache[groupName] + if groupID == 0 { + groupDB, err := group.LoadByName(ctx, db, groupName) + if err != nil { + return err + } + groupID = groupDB.ID + groupCache[groupDB.Name] = groupID + } + rbacPrj.RBACGroupsIDs = append(rbacPrj.RBACGroupsIDs, groupID) + } + return nil +} + +func fillRbacGlobalWithID(ctx context.Context, db gorp.SqlExecutor, rbacGbl *sdk.RBACGlobal, userCache map[string]string, groupCache map[string]int64) error { + rbacGbl.RBACUsersIDs = make([]string, 0, len(rbacGbl.RBACUsersName)) + for _, rbacUserName := range rbacGbl.RBACUsersName { + userID := userCache[rbacUserName] + if userID == "" { + authentifierUser, err := user.LoadByUsername(ctx, db, rbacUserName) + if err != nil { + return err + } + userID = authentifierUser.ID + userCache[rbacUserName] = userID + } + rbacGbl.RBACUsersIDs = append(rbacGbl.RBACUsersIDs, userID) + } + + rbacGbl.RBACGroupsIDs = make([]int64, 0, len(rbacGbl.RBACGroupsName)) + for _, groupName := range rbacGbl.RBACGroupsName { + groupID := groupCache[groupName] + if groupID == 0 { + groupDB, err := group.LoadByName(ctx, db, groupName) + if err != nil { + return err + } + groupID = groupDB.ID + groupCache[groupDB.Name] = groupID + } + rbacGbl.RBACGroupsIDs = append(rbacGbl.RBACGroupsIDs, groupID) + } + return nil +} diff --git a/engine/api/rbac/rule_project.go b/engine/api/rbac/rule_project.go new file mode 100644 index 0000000000..8484a6cc54 --- /dev/null +++ b/engine/api/rbac/rule_project.go @@ -0,0 +1,30 @@ +package rbac + +import ( + "context" + + "github.com/go-gorp/gorp" + "github.com/rockbears/log" + + "github.com/ovh/cds/engine/api/project" + "github.com/ovh/cds/sdk" +) + +func ProjectExist(ctx context.Context, db gorp.SqlExecutor, vars map[string]string) error { + projectKey := vars["projectKey"] + exist, err := project.Exist(db, projectKey) + if err != nil { + return err + } + if !exist { + return sdk.WithStack(sdk.ErrNotFound) + } + return nil +} + +func ProjectManage(ctx context.Context, db gorp.SqlExecutor, vars map[string]string) error { + projectKey := vars["projectKey"] + // TODO Check role manage project + log.Debug(ctx, "Checking manage project role on %s", projectKey) + return nil +} diff --git a/engine/api/router.go b/engine/api/router.go index 19cdeab903..13e25fda4a 100644 --- a/engine/api/router.go +++ b/engine/api/router.go @@ -263,10 +263,7 @@ func (r *Router) handle(uri string, scope HandlerScope, handlers ...*service.Han for i := range handlers { handlers[i].CleanURL = cleanURL handlers[i].AllowedScopes = scope - name := runtime.FuncForPC(reflect.ValueOf(handlers[i].Handler).Pointer()).Name() - name = strings.Replace(name, ".func1", "", 1) - name = strings.Replace(name, ".1", "", 1) - name = strings.Replace(name, "github.com/ovh/cds/engine/", "", 1) + name := sdk.GetFuncName(handlers[i].Handler) handlers[i].Name = name cfg.Config[handlers[i].Method] = handlers[i] } @@ -490,6 +487,18 @@ func (r *Router) GET(h service.HandlerFunc, cfg ...service.HandlerConfigParam) * return &rc } +func (r *Router) POSTv2(h service.HandlerFuncV2, cfg ...service.HandlerConfigParam) *service.HandlerConfig { + var rc service.HandlerConfig + handler, rbacCheckers := h() + rc.Handler = handler + rc.RbacCheckers = rbacCheckers + rc.Method = "POST" + for _, c := range cfg { + c(&rc) + } + return &rc +} + // POST will set given handler only for POST request func (r *Router) POST(h service.HandlerFunc, cfg ...service.HandlerConfigParam) *service.HandlerConfig { var rc service.HandlerConfig diff --git a/engine/api/router_middleware_rbac.go b/engine/api/router_middleware_rbac.go new file mode 100644 index 0000000000..ba0771f205 --- /dev/null +++ b/engine/api/router_middleware_rbac.go @@ -0,0 +1,24 @@ +package api + +import ( + "context" + "net/http" + + "github.com/gorilla/mux" + "github.com/rockbears/log" + + "github.com/ovh/cds/engine/service" + "github.com/ovh/cds/sdk" + cdslog "github.com/ovh/cds/sdk/log" +) + +func (api *API) rbacMiddleware(ctx context.Context, _ http.ResponseWriter, req *http.Request, rc *service.HandlerConfig) (context.Context, error) { + for _, checker := range rc.RbacCheckers { + ctx := context.WithValue(ctx, cdslog.RbackCheckerName, sdk.GetFuncName(checker)) + if err := checker(ctx, api.mustDB(), mux.Vars(req)); err != nil { + log.ErrorWithStackTrace(ctx, err) + return ctx, sdk.WithStack(sdk.ErrForbidden) + } + } + return ctx, nil +} diff --git a/engine/api/router_util.go b/engine/api/router_util.go index 96b01a1328..1fc4b01390 100644 --- a/engine/api/router_util.go +++ b/engine/api/router_util.go @@ -35,9 +35,7 @@ func writeNoContentPostMiddleware(ctx context.Context, w http.ResponseWriter, re return ctx, nil } -// GetRoute returns the routes given a handler -func (r *Router) GetRoute(method string, handler service.HandlerFunc, vars map[string]string) string { - p1 := reflect.ValueOf(handler()).Pointer() +func (r *Router) getRoute(method string, p1 uintptr, routeName string, vars map[string]string) string { var url string for uri, routerConfig := range r.mapRouterConfigs { rc := routerConfig.Config[method] @@ -59,12 +57,26 @@ func (r *Router) GetRoute(method string, handler service.HandlerFunc, vars map[s } if url == "" { - log.Debug(context.Background(), "Cant find route for Handler %s %v", method, handler) - } + log.Debug(context.Background(), "Cant find route for Handler %s %v", method, routeName) + } return url } +func (r *Router) GetRouteV2(method string, handler service.HandlerFuncV2, vars map[string]string) string { + routeHandler, _ := handler() + p1 := reflect.ValueOf(routeHandler).Pointer() + routeName := sdk.GetFuncName(routeHandler) + return r.getRoute(method, p1, routeName, vars) +} + +// GetRoute returns the routes given a handler +func (r *Router) GetRoute(method string, handler service.HandlerFunc, vars map[string]string) string { + p1 := reflect.ValueOf(handler()).Pointer() + routeName := sdk.GetFuncName(handler) + return r.getRoute(method, p1, routeName, vars) +} + // FormString return a string func FormString(r *http.Request, s string) string { return r.FormValue(s) diff --git a/engine/api/user/gorp_model.go b/engine/api/user/gorp_model.go index 5f157a4cf1..15a66bc933 100644 --- a/engine/api/user/gorp_model.go +++ b/engine/api/user/gorp_model.go @@ -12,6 +12,7 @@ type authentifiedUser struct { } func (u authentifiedUser) Canonical() gorpmapper.CanonicalForms { + _ = []interface{}{u.ID, u.Username, u.Fullname, u.Ring, u.Created} return []gorpmapper.CanonicalForm{ "{{.ID}}{{.Username}}{{.Fullname}}{{.Ring}}{{printDate .Created}}", } @@ -23,6 +24,7 @@ type userContact struct { } func (c userContact) Canonical() gorpmapper.CanonicalForms { + _ = []interface{}{c.ID, c.UserID, c.Type, c.Value, c.Primary, c.Verified} return []gorpmapper.CanonicalForm{ "{{.ID}}{{.UserID}}{{.Type}}{{.Value}}{{.Primary}}{{.Verified}}", } diff --git a/engine/api/v2_project.go b/engine/api/v2_project.go new file mode 100644 index 0000000000..f84e15bdc9 --- /dev/null +++ b/engine/api/v2_project.go @@ -0,0 +1,28 @@ +package api + +import ( + "context" + "github.com/gorilla/mux" + "github.com/ovh/cds/engine/api/rbac" + "github.com/rockbears/log" + "net/http" + + "github.com/ovh/cds/engine/service" +) + +func (api *API) postVCSOnProjectHandler() (service.Handler, []service.RbacChecker) { + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + pKey := vars["projectKey"] + + // my handler + log.Info(ctx, "My project: %s", pKey) + + return nil + } + permChecker := []service.RbacChecker{ + rbac.ProjectExist, + rbac.ProjectManage, + } + return handler, permChecker +} diff --git a/engine/api/v2_project_test.go b/engine/api/v2_project_test.go new file mode 100644 index 0000000000..5edd8160f6 --- /dev/null +++ b/engine/api/v2_project_test.go @@ -0,0 +1,30 @@ +package api + +import ( + "github.com/ovh/cds/engine/api/test" + "github.com/ovh/cds/engine/api/test/assets" + "github.com/ovh/cds/sdk" + "github.com/stretchr/testify/require" + "net/http/httptest" + "testing" +) + +func Test_addVCSOnProject(t *testing.T) { + api, db, _ := newTestAPI(t) + + u, pass := assets.InsertLambdaUser(t, db) + proj := assets.InsertTestProject(t, db, api.Cache, sdk.RandomString(10), sdk.RandomString(10)) + + vars := map[string]string{ + "projectKey": proj.Key, + } + uri := api.Router.GetRouteV2("POST", api.postVCSOnProjectHandler, vars) + test.NotEmpty(t, uri) + req := assets.NewAuthentifiedRequest(t, u, pass, "POST", uri, nil) + + //Do the request + w := httptest.NewRecorder() + api.Router.Mux.ServeHTTP(w, req) + require.Equal(t, 204, w.Code) + +} diff --git a/engine/service/http.go b/engine/service/http.go index 35892dd335..8672c6468e 100644 --- a/engine/service/http.go +++ b/engine/service/http.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/go-gorp/gorp" "github.com/rockbears/log" "gopkg.in/spacemonkeygo/httpsig.v0" @@ -22,6 +23,7 @@ import ( // Handler defines the HTTP handler used in CDS engine type Handler func(ctx context.Context, w http.ResponseWriter, r *http.Request) error +type RbacChecker func(ctx context.Context, db gorp.SqlExecutor, vars map[string]string) error // AsynchronousHandler defines the HTTP asynchronous handler used in CDS engine type AsynchronousHandler func(ctx context.Context, r *http.Request) error @@ -31,6 +33,7 @@ type Middleware func(ctx context.Context, w http.ResponseWriter, req *http.Reque // HandlerFunc defines the way to instantiate a handler type HandlerFunc func() Handler +type HandlerFuncV2 func() (Handler, []RbacChecker) // AsynchronousHandlerFunc defines the way to instantiate a handler type AsynchronousHandlerFunc func() AsynchronousHandler @@ -54,6 +57,7 @@ type HandlerConfig struct { AllowedScopes []sdk.AuthConsumerScope PermissionLevel int CleanURL string + RbacCheckers []RbacChecker } // Accepted is a helper function used by asynchronous handlers diff --git a/engine/sql/api/236_rbac.sql b/engine/sql/api/236_rbac.sql new file mode 100644 index 0000000000..489e2d8cc6 --- /dev/null +++ b/engine/sql/api/236_rbac.sql @@ -0,0 +1,106 @@ +-- +migrate Up +CREATE TABLE rbac +( + "uuid" uuid PRIMARY KEY, + "name" VARCHAR(255) NOT NULL, + "created" TIMESTAMP WITH TIME ZONE DEFAULT LOCALTIMESTAMP, + "last_modified" TIMESTAMP WITH TIME ZONE DEFAULT LOCALTIMESTAMP, + sig BYTEA, + signer TEXT +); +SELECT create_unique_index('rbac', 'idx_unq_rbac_name', 'name'); + +-- GLOBAL PART +CREATE TABLE rbac_global +( + "id" BIGSERIAL PRIMARY KEY, + "rbac_uuid" uuid NOT NULL, + "role" VARCHAR(255) NOT NULL, + sig BYTEA, + signer TEXT +); +SELECT create_foreign_key_idx_cascade('FK_rbac_global', 'rbac_global', 'rbac', 'rbac_uuid', 'uuid'); +SELECT create_index('rbac_global', 'idx_rbac_global_role', 'role'); + +CREATE TABLE rbac_global_users +( + "id" BIGSERIAL PRIMARY KEY, + "rbac_global_id" BIGINT, + "user_id" character varying(36), + sig BYTEA, + signer TEXT +); +SELECT create_foreign_key_idx_cascade('FK_rbac_global_users', 'rbac_global_users', 'rbac_global', 'rbac_global_id', 'id'); +SELECT create_foreign_key_idx_cascade('FK_rbac_global_users_ids', 'rbac_global_users', 'authentified_user', 'user_id', 'id'); +SELECT create_unique_index('rbac_global_users', 'idx_unq_rbac_global_users', 'rbac_global_id,user_id'); + +CREATE TABLE rbac_global_groups +( + "id" BIGSERIAL PRIMARY KEY, + "rbac_global_id" BIGINT, + "group_id" BIGINT, + sig BYTEA, + signer TEXT +); +SELECT create_foreign_key_idx_cascade('FK_rbac_global_groups', 'rbac_global_groups', 'rbac_global', 'rbac_global_id', 'id'); +SELECT create_foreign_key_idx_cascade('FK_rbac_global_groups_ids', 'rbac_global_groups', 'group', 'group_id', 'id'); +SELECT create_unique_index('rbac_global_groups', 'idx_unq_rbac_global_groups', 'rbac_global_id,group_id'); + +-- PROJECT +CREATE TABLE rbac_project +( + "id" BIGSERIAL PRIMARY KEY, + "rbac_uuid" uuid NOT NULL, + "all" BOOLEAN NOT NULL DEFAULT FALSE, + "role" VARCHAR(255) NOT NULL, + sig BYTEA, + signer TEXT +); +SELECT create_foreign_key_idx_cascade('FK_rbac_project', 'rbac_project', 'rbac', 'rbac_uuid', 'uuid'); +SELECT create_index('rbac_project', 'idx_rbac_project_role', 'role'); + +CREATE TABLE rbac_project_projects +( + "id" BIGSERIAL PRIMARY KEY, + "rbac_project_id" BIGINT, + "project_id" BIGINT, + sig BYTEA, + signer TEXT +); +SELECT create_foreign_key_idx_cascade('FK_rbac_project_projects', 'rbac_project_projects', 'rbac_project', 'rbac_project_id', 'id'); +SELECT create_foreign_key_idx_cascade('FK_rbac_project_projects_project', 'rbac_project_projects', 'project', 'project_id', 'id'); +SELECT create_unique_index('rbac_project_projects', 'idx_unq_rbac_project_projects', 'rbac_project_id,project_id'); + +CREATE TABLE rbac_project_users +( + "id" BIGSERIAL PRIMARY KEY, + "rbac_project_id" BIGINT, + "user_id" character varying(36), + sig BYTEA, + signer TEXT +); +SELECT create_foreign_key_idx_cascade('FK_rbac_project_users', 'rbac_project_users', 'rbac_project', 'rbac_project_id', 'id'); +SELECT create_foreign_key_idx_cascade('FK_rbac_project_users_id', 'rbac_project_users', 'authentified_user', 'user_id', 'id'); +SELECT create_unique_index('rbac_project_users', 'idx_unq_rbac_project_users', 'rbac_project_id,user_id'); + +CREATE TABLE rbac_project_groups +( + "id" BIGSERIAL PRIMARY KEY, + "rbac_project_id" BIGINT, + "group_id" BIGINT, + sig BYTEA, + signer TEXT +); +SELECT create_foreign_key_idx_cascade('FK_rbac_project_groups', 'rbac_project_groups', 'rbac_project', 'rbac_project_id', 'id'); +SELECT create_foreign_key_idx_cascade('FK_rbac_project_groups_ids', 'rbac_project_groups', 'group', 'group_id', 'id'); +SELECT create_unique_index('rbac_project_groups', 'idx_unq_rbac_project_groups', 'rbac_project_id,group_id'); + +-- +migrate Down +DROP TABLE rbac_project_groups; +DROP TABLE rbac_project_users; +DROP TABLE rbac_project_projects; +DROP TABLE rbac_project; +DROP TABLE rbac_global_groups; +DROP TABLE rbac_global_users; +DROP TABLE rbac_global; +DROP TABLE rbac; diff --git a/sdk/doc/route.go b/sdk/doc/route.go index 4dd5ef535c..cefc12ac46 100644 --- a/sdk/doc/route.go +++ b/sdk/doc/route.go @@ -29,7 +29,7 @@ func CleanURLParameter(u string) string { switch u { case "consumerType": u = "consumer-type" - case "key", "permProjectKey": + case "key", "permProjectKey", "projectKey": u = "project-key" case "permWorkflowName", "permWorkflowNameAdvanced", "workflowName": u = "workflow-name" diff --git a/sdk/log/fields.go b/sdk/log/fields.go index de51136fd4..f3ae5fd911 100644 --- a/sdk/log/fields.go +++ b/sdk/log/fields.go @@ -17,6 +17,7 @@ const ( AuthUserID = log.Field("auth_user_id") AuthUsername = log.Field("auth_user_name") AuthWorkerName = log.Field("auth_worker_name") + RbackCheckerName = log.Field("rbac_checker_name") Deprecated = log.Field("deprecated") Duration = log.Field("duration_milliseconds_num") Goroutine = log.Field("goroutine") @@ -49,22 +50,23 @@ func init() { AuthSessionID, AuthSessionIAT, AuthSessionTokenID, - IPAddress, - Method, - Route, - RequestURI, Deprecated, + Duration, + Goroutine, Handler, + IPAddress, Latency, LatencyNum, - Status, - StatusNum, - Goroutine, + Method, + RbackCheckerName, + Route, RequestID, + RequestURI, Service, - Stacktrace, - Duration, Size, + Stacktrace, + Status, + StatusNum, Sudo, ) } diff --git a/sdk/project.go b/sdk/project.go index 3e3cd0fff0..93c11bbe0a 100644 --- a/sdk/project.go +++ b/sdk/project.go @@ -10,6 +10,11 @@ import ( "github.com/mitchellh/hashstructure" ) +type ProjectIdentifiers struct { + ID int64 `json:"-" yaml:"-" db:"id"` + Key string `json:"-" yaml:"-"db:"projectkey"` +} + type Projects []Project func (projects Projects) Keys() []string { diff --git a/sdk/rbac.go b/sdk/rbac.go new file mode 100644 index 0000000000..7ef55aef6d --- /dev/null +++ b/sdk/rbac.go @@ -0,0 +1,37 @@ +package sdk + +import "time" + +const ( + RoleRead = "read" + RoleManage = "manage" + RoleDelete = "delete" + RoleCreateProject = "create-project" + RoleManagePermission = "manage-permission" +) + +type RBAC struct { + UUID string `json:"uuid" db:"uuid" yaml:"-"` + Name string `json:"name" db:"name" yaml:"name"` + Created time.Time `json:"created" db:"created" yaml:"-"` + LastModified time.Time `json:"last_modified" db:"last_modified" yaml:"-"` + Globals []RBACGlobal `json:"globals" db:"-" yaml:"globals"` + Projects []RBACProject `json:"projects" db:"-" yaml:"projects"` +} + +func IsValidRbac(rbac *RBAC) error { + if rbac.Name == "" { + return WrapError(ErrInvalidData, "missing permission name") + } + for _, g := range rbac.Globals { + if err := isValidRbacGlobal(rbac.Name, g); err != nil { + return err + } + } + for _, p := range rbac.Projects { + if err := isValidRbacProject(rbac.Name, p); err != nil { + return err + } + } + return nil +} diff --git a/sdk/rbac_global.go b/sdk/rbac_global.go new file mode 100644 index 0000000000..88e786d2e2 --- /dev/null +++ b/sdk/rbac_global.go @@ -0,0 +1,33 @@ +package sdk + +var ( + GlobalRoles = []string{RoleCreateProject, RoleManagePermission} +) + +type RBACGlobal struct { + Role string `json:"role" db:"role" yaml:"role"` + RBACUsersName []string `json:"users" db:"-" yaml:"users"` + RBACGroupsName []string `json:"groups" db:"-" yaml:"groups"` + RBACUsersIDs []string `json:"-" db:"-" yaml:"-"` + RBACGroupsIDs []int64 `json:"-" db:"-" yaml:"-"` +} + +func isValidRbacGlobal(rbacName string, rg RBACGlobal) error { + if len(rg.RBACGroupsIDs) == 0 && len(rg.RBACUsersIDs) == 0 { + return NewErrorFrom(ErrInvalidData, "rbac %s: missing groups or users on global permission", rbacName) + } + if rg.Role == "" { + return NewErrorFrom(ErrInvalidData, "rbac %s: role for global permission cannot be empty", rbacName) + } + roleFound := false + for _, r := range GlobalRoles { + if rg.Role == r { + roleFound = true + break + } + } + if !roleFound { + return NewErrorFrom(ErrInvalidData, "rbac %s: role %s is not allowed on a global permission", rbacName, rg.Role) + } + return nil +} diff --git a/sdk/rbac_global_test.go b/sdk/rbac_global_test.go new file mode 100644 index 0000000000..1186bb5b02 --- /dev/null +++ b/sdk/rbac_global_test.go @@ -0,0 +1,38 @@ +package sdk + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestRbacGlobalInvalidGlobalRole(t *testing.T) { + rb := RBACGlobal{ + Role: "runWorkflow", + RBACGroupsIDs: []int64{1}, + RBACUsersIDs: []string{"aa-aa-aa"}, + } + err := isValidRbacGlobal("myRule", rb) + require.Error(t, err) + require.Contains(t, err.Error(), "rbac myRule: role runWorkflow is not allowed on a global permission") +} +func TestRbacGlobalInvalidGroupAndUsers(t *testing.T) { + rb := RBACGlobal{ + Role: RoleCreateProject, + RBACGroupsIDs: []int64{}, + RBACUsersIDs: []string{}, + } + err := isValidRbacGlobal("myRule", rb) + require.Error(t, err) + require.Contains(t, err.Error(), "rbac myRule: missing groups or users on global permission") +} + +func TestRbacGlobalEmptyRole(t *testing.T) { + rb := RBACGlobal{ + Role: "", + RBACGroupsIDs: []int64{1}, + RBACUsersIDs: []string{}, + } + err := isValidRbacGlobal("myRule", rb) + require.Error(t, err) + require.Contains(t, err.Error(), "rbac myRule: role for global permission cannot be empty") +} diff --git a/sdk/rbac_project.go b/sdk/rbac_project.go new file mode 100644 index 0000000000..1bf51f108b --- /dev/null +++ b/sdk/rbac_project.go @@ -0,0 +1,48 @@ +package sdk + +var ( + ProjectRoles = []string{RoleRead, RoleManage, RoleDelete} +) + +type RBACProject struct { + All bool `json:"all" db:"all" yaml:"all"` + Role string `json:"role" db:"role" yaml:"role"` + RBACProjectKeys []string `json:"projects" db:"-" yaml:"projects"` + RBACUsersName []string `json:"users" db:"-" yaml:"users"` + RBACGroupsName []string `json:"groups" db:"-" yaml:"groups"` + + RBACProjectsIDs []int64 `json:"-" db:"-" yaml:"-"` + RBACUsersIDs []string `json:"-" db:"-" yaml:"-"` + RBACGroupsIDs []int64 `json:"-" db:"-" yaml:"-"` +} + +func isValidRbacProject(rbacName string, rp RBACProject) error { + // Check empty group and users + if len(rp.RBACGroupsIDs) == 0 && len(rp.RBACUsersIDs) == 0 { + return NewErrorFrom(ErrInvalidData, "rbac %s: missing groups or users on project permission", rbacName) + } + + // Check role + if rp.Role == "" { + return NewErrorFrom(ErrInvalidData, "rbac %s: role for project permission cannot be empty", rbacName) + } + roleFound := false + for _, r := range ProjectRoles { + if r == rp.Role { + roleFound = true + break + } + } + if !roleFound { + return NewErrorFrom(ErrInvalidData, "rbac %s: role %s is not allowed on a project permission", rbacName, rp.Role) + } + + // Check project_ids and all flag + if len(rp.RBACProjectsIDs) == 0 && !rp.All { + return NewErrorFrom(ErrInvalidData, "rbac %s: must have at least 1 project on a project permission", rbacName) + } + if len(rp.RBACProjectsIDs) > 0 && rp.All { + return NewErrorFrom(ErrInvalidData, "rbac %s: you can't have a list of project and the all flag checked on a project permission", rbacName) + } + return nil +} diff --git a/sdk/rbac_project_test.go b/sdk/rbac_project_test.go new file mode 100644 index 0000000000..fcb87c1032 --- /dev/null +++ b/sdk/rbac_project_test.go @@ -0,0 +1,68 @@ +package sdk + +import ( + "fmt" + "github.com/stretchr/testify/require" + "testing" +) + +func TestRbacProjectInvalidRole(t *testing.T) { + rb := RBACProject{ + RBACProjectsIDs: []int64{1}, + All: false, + Role: RoleCreateProject, + RBACGroupsIDs: []int64{1}, + RBACUsersIDs: []string{"aa-aa-aa"}, + } + err := isValidRbacProject("myRule", rb) + require.Error(t, err) + require.Contains(t, err.Error(), fmt.Sprintf("rbac myRule: role %s is not allowed on a project permission", RoleCreateProject)) +} +func TestRbacProjectInvalidGroupAndUsers(t *testing.T) { + rb := RBACProject{ + RBACProjectsIDs: []int64{1}, + All: false, + Role: RoleRead, + RBACGroupsIDs: []int64{}, + RBACUsersIDs: []string{}, + } + err := isValidRbacProject("myRule", rb) + require.Error(t, err) + require.Contains(t, err.Error(), "rbac myRule: missing groups or users on project permission") +} +func TestRbacProjectInvalidProjectIDs(t *testing.T) { + rb := RBACProject{ + RBACProjectsIDs: []int64{}, + All: false, + Role: RoleRead, + RBACGroupsIDs: []int64{1}, + RBACUsersIDs: []string{}, + } + err := isValidRbacProject("myRule", rb) + require.Error(t, err) + require.Contains(t, err.Error(), "rbac myRule: must have at least 1 project on a project permission") +} +func TestRbacProjectEmptyRole(t *testing.T) { + rb := RBACProject{ + RBACProjectsIDs: []int64{1}, + All: false, + Role: "", + RBACGroupsIDs: []int64{1}, + RBACUsersIDs: []string{}, + } + err := isValidRbacProject("myRule", rb) + require.Error(t, err) + require.Contains(t, err.Error(), "rbac myRule: role for project permission cannot be empty") +} +func TestRbacProjectInvalidAllAndListOfProject(t *testing.T) { + rb := RBACProject{ + RBACProjectsIDs: []int64{1}, + All: true, + Role: RoleRead, + RBACGroupsIDs: []int64{1}, + RBACUsersIDs: []string{}, + } + err := isValidRbacProject("myRule", rb) + require.Error(t, err) + require.Contains(t, err.Error(), "rbac myRule: you can't have a list of project and the all flag checked on a project permission") +} diff --git a/sdk/reflect.go b/sdk/reflect.go index 1d21e12ad3..09e2e83613 100644 --- a/sdk/reflect.go +++ b/sdk/reflect.go @@ -2,9 +2,18 @@ package sdk import ( "reflect" + "runtime" "strings" ) +func GetFuncName(i interface{}) string { + name := runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() + name = strings.Replace(name, ".func1", "", 1) + name = strings.Replace(name, ".1", "", 1) + name = strings.Replace(name, "github.com/ovh/cds/engine/", "", 1) + return name +} + // From https://github.com/fsamin/go-dump/blob/master/helper.go // Apache-2.0 License // https://github.com/fsamin/go-dump/blob/master/LICENSE.md