diff --git a/src/common/dao/base.go b/src/common/dao/base.go index 4de0f8648..2c397c16c 100644 --- a/src/common/dao/base.go +++ b/src/common/dao/base.go @@ -15,6 +15,7 @@ package dao import ( + "errors" "fmt" "strconv" "strings" @@ -32,6 +33,9 @@ const ( ClairDBAlias = "clair-db" ) +// ErrDupRows is returned by DAO when inserting failed with error "duplicate key value violates unique constraint" +var ErrDupRows = errors.New("sql: duplicate row in DB") + // Database is an interface of different databases type Database interface { // Name returns the name of database diff --git a/src/common/dao/robot.go b/src/common/dao/robot.go index 873f89c20..0d8b5c7f1 100644 --- a/src/common/dao/robot.go +++ b/src/common/dao/robot.go @@ -17,6 +17,7 @@ package dao import ( "github.com/astaxie/beego/orm" "github.com/goharbor/harbor/src/common/models" + "strings" "time" ) @@ -25,7 +26,14 @@ func AddRobot(robot *models.Robot) (int64, error) { now := time.Now() robot.CreationTime = now robot.UpdateTime = now - return GetOrmer().Insert(robot) + id, err := GetOrmer().Insert(robot) + if err != nil { + if strings.Contains(err.Error(), "duplicate key value violates unique constraint") { + return 0, ErrDupRows + } + return 0, err + } + return id, nil } // GetRobotByID ... @@ -79,6 +87,11 @@ func getRobotQuerySetter(query *models.RobotQuery) orm.QuerySeter { return qs } +// CountRobot ... +func CountRobot(query *models.RobotQuery) (int64, error) { + return getRobotQuerySetter(query).Count() +} + // UpdateRobot ... func UpdateRobot(robot *models.Robot) error { robot.UpdateTime = time.Now() diff --git a/src/common/models/robot.go b/src/common/models/robot.go index da81a5025..78c4d21b8 100644 --- a/src/common/models/robot.go +++ b/src/common/models/robot.go @@ -15,6 +15,7 @@ package models import ( + "github.com/astaxie/beego/validation" "time" ) @@ -42,7 +43,26 @@ type RobotQuery struct { Pagination } +// RobotReq ... +type RobotReq struct { + Name string `json:"name"` + Description string `json:"description"` + Disabled bool `json:"disabled"` + Access []*ResourceActions `json:"access"` +} + +// Valid put request validation +func (rq *RobotReq) Valid(v *validation.Validation) { + // ToDo: add validation for access info. +} + +// RobotRep ... +type RobotRep struct { + Name string + Token string +} + // TableName ... -func (u *Robot) TableName() string { +func (r *Robot) TableName() string { return RobotTable } diff --git a/src/common/models/token.go b/src/common/models/token.go index 1b9122b00..f5bbd797b 100644 --- a/src/common/models/token.go +++ b/src/common/models/token.go @@ -20,3 +20,9 @@ type Token struct { ExpiresIn int `json:"expires_in"` IssuedAt string `json:"issued_at"` } + +// ResourceActions ... +type ResourceActions struct { + Name string `json:"name"` + Actions []string `json:"actions"` +} diff --git a/src/core/api/api_test.go b/src/core/api/api_test.go index 8b7da6a75..8b9d3bfaf 100644 --- a/src/core/api/api_test.go +++ b/src/core/api/api_test.go @@ -40,8 +40,8 @@ import ( ) var ( - nonSysAdminID, projAdminID, projDeveloperID, projGuestID int64 - projAdminPMID, projDeveloperPMID, projGuestPMID int + nonSysAdminID, projAdminID, projDeveloperID, projGuestID, projAdminRobotID int64 + projAdminPMID, projDeveloperPMID, projGuestPMID, projAdminRobotPMID int // The following users/credentials are registered and assigned roles at the beginning of // running testing and cleaned up at the end. // Do not try to change the system and project roles that the users have during @@ -67,6 +67,10 @@ var ( Name: "proj_guest", Passwd: "Harbor12345", } + projAdmin4Robot = &usrInfo{ + Name: "proj_admin_robot", + Passwd: "Harbor12345", + } ) type testingRequest struct { @@ -240,6 +244,25 @@ func prepare() error { return err } + // register projAdminRobots and assign project admin role + projAdminRobotID, err = dao.Register(models.User{ + Username: projAdmin4Robot.Name, + Password: projAdmin4Robot.Passwd, + Email: projAdmin4Robot.Name + "@test.com", + }) + if err != nil { + return err + } + + if projAdminRobotPMID, err = project.AddProjectMember(models.Member{ + ProjectID: 1, + Role: models.PROJECTADMIN, + EntityID: int(projAdminRobotID), + EntityType: common.UserMember, + }); err != nil { + return err + } + // register projDeveloper and assign project developer role projDeveloperID, err = dao.Register(models.User{ Username: projDeveloper.Name, diff --git a/src/core/api/harborapi_test.go b/src/core/api/harborapi_test.go index 9b501f108..5f1f59cc5 100644 --- a/src/core/api/harborapi_test.go +++ b/src/core/api/harborapi_test.go @@ -153,6 +153,9 @@ func init() { beego.Router("/api/system/gc/:id([0-9]+)/log", &GCAPI{}, "get:GetLog") beego.Router("/api/system/gc/schedule", &GCAPI{}, "get:Get;put:Put;post:Post") + beego.Router("/api/projects/:pid([0-9]+)/robots/", &RobotAPI{}, "post:Post;get:List") + beego.Router("/api/projects/:pid([0-9]+)/robots/:id([0-9]+)", &RobotAPI{}, "get:Get;put:Put;delete:Delete") + // Charts are controlled under projects chartRepositoryAPIType := &ChartRepositoryAPI{} beego.Router("/api/chartrepo/health", chartRepositoryAPIType, "get:GetHealthStatus") diff --git a/src/core/api/robot.go b/src/core/api/robot.go new file mode 100644 index 000000000..7e2ba2b91 --- /dev/null +++ b/src/core/api/robot.go @@ -0,0 +1,197 @@ +// Copyright 2018 Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "fmt" + "github.com/goharbor/harbor/src/common/dao" + "github.com/goharbor/harbor/src/common/models" + "net/http" + "strconv" +) + +// User this prefix to distinguish harbor user, +// The prefix contains a specific character($), so it cannot be registered as a harbor user. +const robotPrefix = "robot$" + +// RobotAPI ... +type RobotAPI struct { + BaseController + project *models.Project + robot *models.Robot +} + +// Prepare ... +func (r *RobotAPI) Prepare() { + r.BaseController.Prepare() + method := r.Ctx.Request.Method + + if !r.SecurityCtx.IsAuthenticated() { + r.HandleUnauthorized() + return + } + + pid, err := r.GetInt64FromPath(":pid") + if err != nil || pid <= 0 { + var errMsg string + if err != nil { + errMsg = "failed to get project ID " + err.Error() + } else { + errMsg = "invalid project ID: " + fmt.Sprintf("%d", pid) + } + r.HandleBadRequest(errMsg) + return + } + project, err := r.ProjectMgr.Get(pid) + if err != nil { + r.ParseAndHandleError(fmt.Sprintf("failed to get project %d", pid), err) + return + } + if project == nil { + r.HandleNotFound(fmt.Sprintf("project %d not found", pid)) + return + } + r.project = project + + if method == http.MethodPut || method == http.MethodDelete { + id, err := r.GetInt64FromPath(":id") + if err != nil || id <= 0 { + r.HandleBadRequest("invalid robot ID") + return + } + + robot, err := dao.GetRobotByID(id) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to get robot %d: %v", id, err)) + return + } + + if robot == nil { + r.HandleNotFound(fmt.Sprintf("robot %d not found", id)) + return + } + + r.robot = robot + } + + if !(r.Ctx.Input.IsGet() && r.SecurityCtx.HasReadPerm(pid) || + r.SecurityCtx.HasAllPerm(pid)) { + r.HandleForbidden(r.SecurityCtx.GetUsername()) + return + } + +} + +// Post ... +func (r *RobotAPI) Post() { + var robotReq models.RobotReq + r.DecodeJSONReq(&robotReq) + + createdName := robotPrefix + robotReq.Name + + robot := models.Robot{ + Name: createdName, + Description: robotReq.Description, + ProjectID: r.project.ProjectID, + // TODO: use token service to generate token per access information + Token: "this is a placeholder", + } + + id, err := dao.AddRobot(&robot) + if err != nil { + if err == dao.ErrDupRows { + r.HandleConflict() + return + } + r.HandleInternalServerError(fmt.Sprintf("failed to create robot account: %v", err)) + return + } + + robotRep := models.RobotRep{ + Name: robot.Name, + Token: robot.Token, + } + + r.Redirect(http.StatusCreated, strconv.FormatInt(id, 10)) + r.Data["json"] = robotRep + r.ServeJSON() +} + +// List list all the robots of a project +func (r *RobotAPI) List() { + query := models.RobotQuery{ + ProjectID: r.project.ProjectID, + } + + count, err := dao.CountRobot(&query) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to list robots on project: %d, %v", r.project.ProjectID, err)) + return + } + query.Page, query.Size = r.GetPaginationParams() + + robots, err := dao.ListRobots(&query) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to get robots %v", err)) + return + } + + r.SetPaginationHeader(count, query.Page, query.Size) + r.Data["json"] = robots + r.ServeJSON() +} + +// Get get robot by id +func (r *RobotAPI) Get() { + id, err := r.GetInt64FromPath(":id") + if err != nil || id <= 0 { + r.HandleBadRequest(fmt.Sprintf("invalid robot ID: %s", r.GetStringFromPath(":id"))) + return + } + + robot, err := dao.GetRobotByID(id) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to get robot %d: %v", id, err)) + return + } + if robot == nil { + r.HandleNotFound(fmt.Sprintf("robot %d not found", id)) + return + } + + r.Data["json"] = robot + r.ServeJSON() +} + +// Put disable or enable a robot account +func (r *RobotAPI) Put() { + var robotReq models.RobotReq + r.DecodeJSONReqAndValidate(&robotReq) + r.robot.Disabled = robotReq.Disabled + + if err := dao.UpdateRobot(r.robot); err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to update robot %d: %v", r.robot.ID, err)) + return + } + +} + +// Delete delete robot by id +func (r *RobotAPI) Delete() { + if err := dao.DeleteRobot(r.robot.ID); err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to delete robot %d: %v", r.robot.ID, err)) + return + } +} diff --git a/src/core/api/robot_test.go b/src/core/api/robot_test.go new file mode 100644 index 000000000..f69791e71 --- /dev/null +++ b/src/core/api/robot_test.go @@ -0,0 +1,314 @@ +// Copyright 2018 Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "fmt" + "github.com/goharbor/harbor/src/common/models" + "net/http" + "testing" +) + +var ( + robotPath = "/api/projects/1/robots" + robotID int64 +) + +func TestRobotAPIPost(t *testing.T) { + cases := []*codeCheckingCase{ + // 401 + { + request: &testingRequest{ + method: http.MethodPost, + url: robotPath, + }, + code: http.StatusUnauthorized, + }, + + // 403 + { + request: &testingRequest{ + method: http.MethodPost, + url: robotPath, + bodyJSON: &models.Robot{}, + credential: nonSysAdmin, + }, + code: http.StatusForbidden, + }, + // 201 + { + request: &testingRequest{ + method: http.MethodPost, + url: robotPath, + bodyJSON: &models.Robot{ + Name: "test", + Description: "test desc", + }, + credential: projAdmin4Robot, + }, + code: http.StatusCreated, + }, + // 403 -- developer + { + request: &testingRequest{ + method: http.MethodPost, + url: robotPath, + bodyJSON: &models.Robot{ + Name: "test2", + Description: "test2 desc", + }, + credential: projDeveloper, + }, + code: http.StatusForbidden, + }, + + // 409 + { + request: &testingRequest{ + method: http.MethodPost, + url: robotPath, + bodyJSON: &models.Robot{ + Name: "test", + Description: "test desc", + ProjectID: 1, + }, + credential: projAdmin4Robot, + }, + code: http.StatusConflict, + }, + } + runCodeCheckingCases(t, cases...) +} + +func TestRobotAPIGet(t *testing.T) { + cases := []*codeCheckingCase{ + // 400 + { + request: &testingRequest{ + method: http.MethodGet, + url: fmt.Sprintf("%s/%d", robotPath, 0), + }, + code: http.StatusUnauthorized, + }, + + // 404 + { + request: &testingRequest{ + method: http.MethodGet, + url: fmt.Sprintf("%s/%d", robotPath, 1000), + credential: projDeveloper, + }, + code: http.StatusNotFound, + }, + + // 200 + { + request: &testingRequest{ + method: http.MethodGet, + url: fmt.Sprintf("%s/%d", robotPath, 1), + credential: projDeveloper, + }, + code: http.StatusOK, + }, + + // 200 + { + request: &testingRequest{ + method: http.MethodGet, + url: fmt.Sprintf("%s/%d", robotPath, 1), + credential: projAdmin4Robot, + }, + code: http.StatusOK, + }, + } + runCodeCheckingCases(t, cases...) +} + +func TestRobotAPIList(t *testing.T) { + cases := []*codeCheckingCase{ + // 401 + { + request: &testingRequest{ + method: http.MethodGet, + url: robotPath, + }, + code: http.StatusUnauthorized, + }, + + // 400 + { + request: &testingRequest{ + method: http.MethodGet, + url: "/api/projects/0/robots", + credential: projAdmin4Robot, + }, + code: http.StatusBadRequest, + }, + + // 200 + { + request: &testingRequest{ + method: http.MethodGet, + url: robotPath, + credential: projDeveloper, + }, + code: http.StatusOK, + }, + + // 200 + { + request: &testingRequest{ + method: http.MethodGet, + url: robotPath, + credential: projAdmin4Robot, + }, + code: http.StatusOK, + }, + } + runCodeCheckingCases(t, cases...) +} + +func TestRobotAPIPut(t *testing.T) { + cases := []*codeCheckingCase{ + // 401 + { + request: &testingRequest{ + method: http.MethodPut, + url: fmt.Sprintf("%s/%d", robotPath, 1), + }, + code: http.StatusUnauthorized, + }, + + // 400 + { + request: &testingRequest{ + method: http.MethodPut, + url: fmt.Sprintf("%s/%d", robotPath, 0), + credential: projAdmin4Robot, + }, + code: http.StatusBadRequest, + }, + + // 404 + { + request: &testingRequest{ + method: http.MethodPut, + url: fmt.Sprintf("%s/%d", robotPath, 10000), + credential: projAdmin4Robot, + }, + code: http.StatusNotFound, + }, + + // 403 non-member user + { + request: &testingRequest{ + method: http.MethodPut, + url: fmt.Sprintf("%s/%d", robotPath, 1), + credential: nonSysAdmin, + }, + code: http.StatusForbidden, + }, + + // 403 developer + { + request: &testingRequest{ + method: http.MethodPut, + url: fmt.Sprintf("%s/%d", robotPath, 1), + credential: projDeveloper, + }, + code: http.StatusForbidden, + }, + + // 200 + { + request: &testingRequest{ + method: http.MethodPut, + url: fmt.Sprintf("%s/%d", robotPath, 1), + bodyJSON: &models.Robot{ + Disabled: true, + }, + credential: projAdmin4Robot, + }, + code: http.StatusOK, + }, + } + + runCodeCheckingCases(t, cases...) +} + +func TestRobotAPIDelete(t *testing.T) { + cases := []*codeCheckingCase{ + // 401 + { + request: &testingRequest{ + method: http.MethodDelete, + url: fmt.Sprintf("%s/%d", robotPath, 1), + }, + code: http.StatusUnauthorized, + }, + + // 400 + { + request: &testingRequest{ + method: http.MethodDelete, + url: fmt.Sprintf("%s/%d", robotPath, 0), + credential: projAdmin4Robot, + }, + code: http.StatusBadRequest, + }, + + // 404 + { + request: &testingRequest{ + method: http.MethodDelete, + url: fmt.Sprintf("%s/%d", robotPath, 10000), + credential: projAdmin4Robot, + }, + code: http.StatusNotFound, + }, + + // 403 non-member user + { + request: &testingRequest{ + method: http.MethodDelete, + url: fmt.Sprintf("%s/%d", robotPath, 1), + credential: nonSysAdmin, + }, + code: http.StatusForbidden, + }, + + // 403 developer + { + request: &testingRequest{ + method: http.MethodDelete, + url: fmt.Sprintf("%s/%d", robotPath, 1), + credential: projDeveloper, + }, + code: http.StatusForbidden, + }, + + // 200 + { + request: &testingRequest{ + method: http.MethodDelete, + url: fmt.Sprintf("%s/%d", robotPath, 1), + credential: projAdmin4Robot, + }, + code: http.StatusOK, + }, + } + + runCodeCheckingCases(t, cases...) +} diff --git a/src/core/router.go b/src/core/router.go index 734fd84b6..1fe068613 100644 --- a/src/core/router.go +++ b/src/core/router.go @@ -65,6 +65,10 @@ func initRouters() { beego.Router("/api/projects/:id([0-9]+)/metadatas/?:name", &api.MetadataAPI{}, "get:Get") beego.Router("/api/projects/:id([0-9]+)/metadatas/", &api.MetadataAPI{}, "post:Post") beego.Router("/api/projects/:id([0-9]+)/metadatas/:name", &api.MetadataAPI{}, "put:Put;delete:Delete") + + beego.Router("/api/projects/:pid([0-9]+)/robots", &api.RobotAPI{}, "post:Post;get:List") + beego.Router("/api/projects/:pid([0-9]+)/robots/:id([0-9]+)", &api.RobotAPI{}, "get:Get;put:Put;delete:Delete") + beego.Router("/api/repositories", &api.RepositoryAPI{}, "get:Get") beego.Router("/api/repositories/scanAll", &api.RepositoryAPI{}, "post:ScanAll") beego.Router("/api/repositories/*", &api.RepositoryAPI{}, "delete:Delete;put:Put")