From 04c4354df9cee247464d858f399f751401d54019 Mon Sep 17 00:00:00 2001 From: Wang Yan Date: Thu, 19 Nov 2020 15:39:45 +0800 Subject: [PATCH] add robot account version 2 controller (#13472) the controller is for the enhanced robot account Signed-off-by: Wang Yan --- src/controller/robot/controller.go | 343 ++++++++++++++++++++++++ src/controller/robot/controller_test.go | 288 ++++++++++++++++++++ src/controller/robot/model.go | 50 ++++ src/controller/robot/model_test.go | 34 +++ 4 files changed, 715 insertions(+) create mode 100644 src/controller/robot/controller.go create mode 100644 src/controller/robot/controller_test.go create mode 100644 src/controller/robot/model.go create mode 100644 src/controller/robot/model_test.go diff --git a/src/controller/robot/controller.go b/src/controller/robot/controller.go new file mode 100644 index 0000000000..0bf241e99d --- /dev/null +++ b/src/controller/robot/controller.go @@ -0,0 +1,343 @@ +package robot + +import ( + "context" + "fmt" + rbac_common "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/common/utils" + "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/lib/log" + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg/permission/types" + "github.com/goharbor/harbor/src/pkg/project" + "github.com/goharbor/harbor/src/pkg/rbac" + rbac_model "github.com/goharbor/harbor/src/pkg/rbac/model" + robot "github.com/goharbor/harbor/src/pkg/robot2" + "github.com/goharbor/harbor/src/pkg/robot2/model" + "time" +) + +var ( + // Ctl is a global variable for the default robot account controller implementation + Ctl = NewController() +) + +// Controller to handle the requests related with robot account +type Controller interface { + // Get ... + Get(ctx context.Context, id int64, option *Option) (*Robot, error) + + // Count returns the total count of robots according to the query + Count(ctx context.Context, query *q.Query) (total int64, err error) + + // Create ... + Create(ctx context.Context, r *Robot) (int64, error) + + // Delete ... + Delete(ctx context.Context, id int64) error + + // Update ... + Update(ctx context.Context, r *Robot) error + + // List ... + List(ctx context.Context, query *q.Query, option *Option) ([]*Robot, error) +} + +// controller ... +type controller struct { + robotMgr robot.Manager + proMgr project.Manager + rbacMgr rbac.Manager +} + +// NewController ... +func NewController() Controller { + return &controller{ + robotMgr: robot.Mgr, + proMgr: project.Mgr, + rbacMgr: rbac.Mgr, + } +} + +// Get ... +func (d *controller) Get(ctx context.Context, id int64, option *Option) (*Robot, error) { + robot, err := d.robotMgr.Get(ctx, id) + if err != nil { + return nil, err + } + return d.populate(ctx, robot, option) +} + +// Count ... +func (d *controller) Count(ctx context.Context, query *q.Query) (int64, error) { + return d.robotMgr.Count(ctx, query) +} + +// Create ... +func (d *controller) Create(ctx context.Context, r *Robot) (int64, error) { + if err := d.setProjectID(ctx, r); err != nil { + return 0, err + } + + if r.ExpiresAt == 0 { + tokenDuration := time.Duration(config.RobotTokenDuration()) * time.Minute + r.ExpiresAt = time.Now().UTC().Add(tokenDuration).Unix() + } + + key, err := config.SecretKey() + if err != nil { + return 0, err + } + str := utils.GenerateRandomString() + secret, err := utils.ReversibleEncrypt(str, key) + if err != nil { + return 0, err + } + + robotID, err := d.robotMgr.Create(ctx, &model.Robot{ + Name: r.Name, + Description: r.Description, + ProjectID: r.ProjectID, + ExpiresAt: r.ExpiresAt, + Secret: secret, + }) + if err != nil { + return 0, err + } + + if err := d.createPermission(ctx, r); err != nil { + return 0, err + } + return robotID, nil +} + +// Delete ... +func (d *controller) Delete(ctx context.Context, id int64) error { + if err := d.robotMgr.Delete(ctx, id); err != nil { + return err + } + if err := d.rbacMgr.DeletePermissionsByRole(ctx, ROBOTTYPE, id); err != nil { + return err + } + return nil +} + +// Update ... +func (d *controller) Update(ctx context.Context, r *Robot) error { + if r == nil { + return errors.New("cannot update a nil robot").WithCode(errors.BadRequestCode) + } + if err := d.robotMgr.Update(ctx, &r.Robot); err != nil { + return err + } + if err := d.setProjectID(ctx, r); err != nil { + return err + } + // update the permission + if err := d.rbacMgr.DeletePermissionsByRole(ctx, ROBOTTYPE, r.ID); err != nil { + return err + } + if err := d.createPermission(ctx, r); err != nil { + return err + } + return nil +} + +// List ... +func (d *controller) List(ctx context.Context, query *q.Query, option *Option) ([]*Robot, error) { + robots, err := d.robotMgr.List(ctx, query) + if err != nil { + return nil, err + } + var robotAccounts []*Robot + for _, r := range robots { + rb, err := d.populate(ctx, r, option) + if err != nil { + return nil, err + } + robotAccounts = append(robotAccounts, rb) + } + return robotAccounts, nil +} + +func (d *controller) createPermission(ctx context.Context, r *Robot) error { + if r == nil { + return nil + } + + for _, per := range r.Permissions { + policy := &rbac_model.PermissionPolicy{} + scope, err := d.toScope(ctx, per) + if err != nil { + return err + } + policy.Scope = scope + + for _, access := range per.Access { + policy.Resource = access.Resource.String() + policy.Action = access.Action.String() + policy.Effect = access.Effect.String() + + policyID, err := d.rbacMgr.CreateRbacPolicy(ctx, policy) + if err != nil { + return err + } + + _, err = d.rbacMgr.CreatePermission(ctx, &rbac_model.RolePermission{ + RoleType: ROBOTTYPE, + RoleID: r.ID, + PermissionPolicyID: policyID, + }) + if err != nil { + return err + } + } + } + return nil +} + +func (d *controller) populate(ctx context.Context, r *model.Robot, option *Option) (*Robot, error) { + if r == nil { + return nil, nil + } + robot := &Robot{ + Robot: *r, + } + robot.Name = fmt.Sprintf("%s%s", config.RobotPrefix(), r.Name) + robot.setLevel() + if option == nil { + return robot, nil + } + if option.WithPermission { + if err := d.populatePermissions(ctx, robot); err != nil { + return nil, err + } + } + return robot, nil +} + +func (d *controller) populatePermissions(ctx context.Context, r *Robot) error { + if r == nil { + return nil + } + rolePermissions, err := d.rbacMgr.GetPermissionsByRole(ctx, ROBOTTYPE, r.ID) + if err != nil { + log.Errorf("failed to get permissions of robot %d: %v", r.ID, err) + return err + } + if len(rolePermissions) == 0 { + return nil + } + + // scope: accesses + accessMap := make(map[string][]*types.Policy) + + // group by scope + for _, rp := range rolePermissions { + _, exist := accessMap[rp.Scope] + if !exist { + accessMap[rp.Scope] = []*types.Policy{{ + Resource: types.Resource(rp.Resource), + Action: types.Action(rp.Action), + Effect: types.Effect(rp.Effect), + }} + } else { + accesses := accessMap[rp.Scope] + accesses = append(accesses, &types.Policy{ + Resource: types.Resource(rp.Resource), + Action: types.Action(rp.Action), + Effect: types.Effect(rp.Effect), + }) + accessMap[rp.Scope] = accesses + } + } + + var permissions []*Permission + for scope, accesses := range accessMap { + p := &Permission{} + kind, namespace, err := d.convertScope(ctx, scope) + if err != nil { + log.Errorf("failed to decode scope of robot %d: %v", r.ID, err) + return err + } + p.Kind = kind + p.Namespace = namespace + p.Access = accesses + permissions = append(permissions, p) + } + r.Permissions = permissions + return nil +} + +func (d *controller) setProjectID(ctx context.Context, r *Robot) error { + if r == nil { + return nil + } + var projectID int64 + switch r.Level { + case LEVELSYSTEM: + projectID = 0 + case LEVELPROJECT: + pro, err := d.proMgr.Get(ctx, r.Permissions[0].Namespace) + if err != nil { + return err + } + projectID = pro.ProjectID + default: + return errors.New(nil).WithMessage("unknown robot account level").WithCode(errors.BadRequestCode) + } + r.ProjectID = projectID + return nil +} + +// convertScope converts the db scope into robot model +// /system => Kind: system Namespace: / +// /project/* => Kind: project Namespace: * +// /project/1 => Kind: project Namespace: library +func (d *controller) convertScope(ctx context.Context, scope string) (kind, namespace string, err error) { + if scope == "" { + return + } + if scope == SCOPESYSTEM { + kind = LEVELSYSTEM + namespace = "/" + } else if scope == SCOPEALLPROJECT { + kind = LEVELPROJECT + namespace = "*" + } else { + kind = LEVELPROJECT + ns, ok := rbac_common.ProjectNamespaceParse(types.Resource(scope)) + if !ok { + log.Debugf("got no namespace from the resource %s", scope) + return "", "", errors.Errorf("got no namespace from the resource %s", scope) + } + pro, err := d.proMgr.Get(ctx, ns.Identity()) + if err != nil { + return "", "", err + } + namespace = pro.Name + } + return +} + +// toScope ... +func (d *controller) toScope(ctx context.Context, p *Permission) (string, error) { + switch p.Kind { + case LEVELSYSTEM: + if p.Namespace != "/" { + return "", errors.New(nil).WithMessage("unknown namespace").WithCode(errors.BadRequestCode) + } + return SCOPESYSTEM, nil + case LEVELPROJECT: + if p.Namespace == "*" { + return SCOPEALLPROJECT, nil + } + pro, err := d.proMgr.Get(ctx, p.Namespace) + if err != nil { + return "", err + } + return fmt.Sprintf("/project/%d", pro.ProjectID), nil + } + return "", errors.New(nil).WithMessage("unknown robot kind").WithCode(errors.BadRequestCode) +} diff --git a/src/controller/robot/controller_test.go b/src/controller/robot/controller_test.go new file mode 100644 index 0000000000..721bf00a23 --- /dev/null +++ b/src/controller/robot/controller_test.go @@ -0,0 +1,288 @@ +package robot + +import ( + "context" + "github.com/aliyun/alibaba-cloud-sdk-go/sdk/utils" + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils/test" + core_cfg "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg/permission/types" + rbac_model "github.com/goharbor/harbor/src/pkg/rbac/model" + "github.com/goharbor/harbor/src/pkg/robot2/model" + "github.com/goharbor/harbor/src/testing/mock" + "github.com/goharbor/harbor/src/testing/pkg/project" + "github.com/goharbor/harbor/src/testing/pkg/rbac" + "github.com/goharbor/harbor/src/testing/pkg/robot2" + "github.com/stretchr/testify/suite" + "os" + "testing" +) + +type ControllerTestSuite struct { + suite.Suite +} + +func (suite *ControllerTestSuite) TestGet() { + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot2.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + ctx := context.TODO() + projectMgr.On("Get", mock.Anything, mock.Anything).Return(&models.Project{ProjectID: 1, Name: "library"}, nil) + robotMgr.On("Get", mock.Anything, mock.Anything).Return(&model.Robot{ + Name: "test", + Description: "test get method", + ProjectID: 1, + Secret: utils.RandStringBytes(10), + }, nil) + rbacMgr.On("GetPermissionsByRole", mock.Anything, mock.Anything, mock.Anything).Return([]*rbac_model.UniversalRolePermission{ + { + RoleType: ROBOTTYPE, + RoleID: 1, + Scope: "/project/1", + Resource: "repository", + Action: "pull", + }, + { + RoleType: ROBOTTYPE, + RoleID: 1, + Scope: "/project/1", + Resource: "repository", + Action: "push", + }, + }, nil) + robot, err := c.Get(ctx, int64(1), &Option{ + WithPermission: true, + }) + suite.Nil(err) + + suite.Equal("project", robot.Permissions[0].Kind) + suite.Equal("library", robot.Permissions[0].Namespace) + suite.Equal("pull", robot.Permissions[0].Access[0].Action.String()) + suite.Equal("project", robot.Level) + +} + +func (suite *ControllerTestSuite) TestCount() { + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot2.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + ctx := context.TODO() + + robotMgr.On("Count", mock.Anything, mock.Anything).Return(int64(1), nil) + + ct, err := c.Count(ctx, nil) + suite.Nil(err) + suite.Equal(int64(1), ct) +} + +func (suite *ControllerTestSuite) TestCreate() { + secretKeyPath := "/tmp/secretkey" + _, err := test.GenerateKey(secretKeyPath) + suite.Nil(err) + defer os.Remove(secretKeyPath) + os.Setenv("KEY_PATH", secretKeyPath) + + conf := map[string]interface{}{ + common.RobotTokenDuration: "30", + } + core_cfg.InitWithSettings(conf) + + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot2.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + ctx := context.TODO() + projectMgr.On("Get", mock.Anything, mock.Anything).Return(&models.Project{ProjectID: 1, Name: "library"}, nil) + robotMgr.On("Create", mock.Anything, mock.Anything).Return(int64(1), nil) + rbacMgr.On("CreateRbacPolicy", mock.Anything, mock.Anything, mock.Anything).Return(int64(1), nil) + rbacMgr.On("CreatePermission", mock.Anything, mock.Anything, mock.Anything).Return(int64(1), nil) + + id, err := c.Create(ctx, &Robot{ + Robot: model.Robot{ + Name: "testcreate", + Description: "testcreate", + ExpiresAt: 0, + }, + ProjectName: "library", + Level: LEVELPROJECT, + Permissions: []*Permission{ + { + Kind: "project", + Namespace: "library", + Access: []*types.Policy{ + { + Resource: "repository", + Action: "push", + }, + { + Resource: "repository", + Action: "pull", + }, + }, + }, + }, + }) + suite.Nil(err) + suite.Equal(int64(1), id) +} + +func (suite *ControllerTestSuite) TestDelete() { + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot2.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + ctx := context.TODO() + + robotMgr.On("Delete", mock.Anything, mock.Anything).Return(nil) + rbacMgr.On("DeletePermissionsByRole", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + err := c.Delete(ctx, int64(1)) + suite.Nil(err) +} + +func (suite *ControllerTestSuite) TestUpdate() { + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot2.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + ctx := context.TODO() + + robotMgr.On("Update", mock.Anything, mock.Anything).Return(nil) + projectMgr.On("Get", mock.Anything, mock.Anything).Return(&models.Project{ProjectID: 1, Name: "library"}, nil) + rbacMgr.On("DeletePermissionsByRole", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + rbacMgr.On("CreateRbacPolicy", mock.Anything, mock.Anything, mock.Anything).Return(int64(1), nil) + rbacMgr.On("CreatePermission", mock.Anything, mock.Anything, mock.Anything).Return(int64(1), nil) + + err := c.Update(ctx, &Robot{ + Robot: model.Robot{ + Name: "testcreate", + Description: "testcreate", + ExpiresAt: 0, + }, + ProjectName: "library", + Level: LEVELPROJECT, + Permissions: []*Permission{ + { + Kind: "project", + Namespace: "library", + Access: []*types.Policy{ + { + Resource: "repository", + Action: "push", + }, + { + Resource: "repository", + Action: "pull", + }, + }, + }, + }, + }) + suite.Nil(err) +} + +func (suite *ControllerTestSuite) TestList() { + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot2.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + ctx := context.TODO() + + projectMgr.On("Get", mock.Anything, mock.Anything).Return(&models.Project{ProjectID: 1, Name: "library"}, nil) + robotMgr.On("List", mock.Anything, mock.Anything).Return([]*model.Robot{ + { + Name: "test", + Description: "test list method", + ProjectID: 1, + Secret: utils.RandStringBytes(10), + }, + }, nil) + rbacMgr.On("GetPermissionsByRole", mock.Anything, mock.Anything, mock.Anything).Return([]*rbac_model.UniversalRolePermission{ + { + RoleType: ROBOTTYPE, + RoleID: 1, + Scope: "/project/1", + Resource: "repository", + Action: "pull", + }, + { + RoleType: ROBOTTYPE, + RoleID: 1, + Scope: "/project/1", + Resource: "repository", + Action: "push", + }, + }, nil) + projectMgr.On("Get", mock.Anything, mock.Anything).Return(&models.Project{ProjectID: 1, Name: "library"}, nil) + rs, err := c.List(ctx, &q.Query{ + Keywords: map[string]interface{}{ + "name": "test3", + }, + }, &Option{ + WithPermission: true, + }) + suite.Nil(err) + suite.Equal("project", rs[0].Permissions[0].Kind) + suite.Equal("library", rs[0].Permissions[0].Namespace) + suite.Equal("pull", rs[0].Permissions[0].Access[0].Action.String()) + suite.Equal("project", rs[0].Level) + +} + +func (suite *ControllerTestSuite) TestToScope() { + projectMgr := &project.Manager{} + rbacMgr := &rbac.Manager{} + robotMgr := &robot2.Manager{} + + c := controller{robotMgr: robotMgr, rbacMgr: rbacMgr, proMgr: projectMgr} + ctx := context.TODO() + + projectMgr.On("Get", mock.Anything, mock.Anything).Return(&models.Project{ProjectID: 1, Name: "library"}, nil) + + p := &Permission{ + Kind: "system", + Namespace: "/", + } + scope, err := c.toScope(ctx, p) + suite.Nil(err) + suite.Equal("/system", scope) + + p = &Permission{ + Kind: "system", + Namespace: "&", + } + _, err = c.toScope(ctx, p) + suite.NotNil(err) + + p = &Permission{ + Kind: "project", + Namespace: "library", + } + scope, err = c.toScope(ctx, p) + suite.Nil(err) + suite.Equal("/project/1", scope) + + p = &Permission{ + Kind: "project", + Namespace: "*", + } + scope, err = c.toScope(ctx, p) + suite.Nil(err) + suite.Equal("/project/*", scope) + +} + +func TestControllerTestSuite(t *testing.T) { + suite.Run(t, &ControllerTestSuite{}) +} diff --git a/src/controller/robot/model.go b/src/controller/robot/model.go new file mode 100644 index 0000000000..8a3f9d35d3 --- /dev/null +++ b/src/controller/robot/model.go @@ -0,0 +1,50 @@ +package robot + +import ( + "github.com/goharbor/harbor/src/pkg/permission/types" + "github.com/goharbor/harbor/src/pkg/robot2/model" +) + +const ( + // LEVELSYSTEM ... + LEVELSYSTEM = "system" + // LEVELPROJECT ... + LEVELPROJECT = "project" + + // SCOPESYSTEM ... + SCOPESYSTEM = "/system" + // SCOPEALLPROJECT ... + SCOPEALLPROJECT = "/project/*" + + // ROBOTTYPE ... + ROBOTTYPE = "robotaccount" +) + +// Robot ... +type Robot struct { + model.Robot + ProjectName string + Level string + Permissions []*Permission `json:"permissions"` +} + +// setLevel, 0 is a system level robot, others are project level. +func (r *Robot) setLevel() { + if r.ProjectID == 0 { + r.Level = LEVELSYSTEM + } else { + r.Level = LEVELPROJECT + } +} + +// Permission ... +type Permission struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Access []*types.Policy `json:"access"` +} + +// Option ... +type Option struct { + WithPermission bool +} diff --git a/src/controller/robot/model_test.go b/src/controller/robot/model_test.go new file mode 100644 index 0000000000..a17285498b --- /dev/null +++ b/src/controller/robot/model_test.go @@ -0,0 +1,34 @@ +package robot + +import ( + "github.com/goharbor/harbor/src/pkg/robot2/model" + "github.com/stretchr/testify/suite" + "testing" +) + +type ModelTestSuite struct { + suite.Suite +} + +func (suite *ModelTestSuite) TestSetLevel() { + r := Robot{ + Robot: model.Robot{ + ProjectID: 0, + }, + } + r.setLevel() + + suite.Equal(LEVELSYSTEM, r.Level) + + r = Robot{ + Robot: model.Robot{ + ProjectID: 1, + }, + } + r.setLevel() + suite.Equal(LEVELPROJECT, r.Level) +} + +func TestModelTestSuite(t *testing.T) { + suite.Run(t, &ModelTestSuite{}) +}