diff --git a/.travis.yml b/.travis.yml index e0099564e..550589eb8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -34,8 +34,9 @@ env: - CORE_SECRET: tempString - KEY_PATH: "/data/secretkey" - REDIS_HOST: localhost - - REG_VERSION: v2.6.2 + - REG_VERSION: v2.7.1 - UI_BUILDER_VERSION: 1.6.0 + - TOKEN_PRIVATE_KEY_PATH: "/home/travis/gopath/src/github.com/goharbor/harbor/tests/private_key.pem" addons: apt: sources: diff --git a/Makefile b/Makefile index 7f9c7a51b..d871c31e8 100644 --- a/Makefile +++ b/Makefile @@ -95,7 +95,7 @@ VERSIONFILEPATH=$(CURDIR) VERSIONFILENAME=UIVERSION #versions -REGISTRYVERSION=v2.6.2 +REGISTRYVERSION=v2.7.1 NGINXVERSION=$(VERSIONTAG) NOTARYVERSION=v0.6.1 CLAIRVERSION=v2.0.7 diff --git a/docs/import_vulnerability_data.md b/docs/import_vulnerability_data.md index e39edda36..797ad11da 100644 --- a/docs/import_vulnerability_data.md +++ b/docs/import_vulnerability_data.md @@ -2,13 +2,13 @@ Harbor has integrated with Clair to scan vulnerabilities in images. When Harbor is installed in an environment without internet connection, Clair cannot fetch data from the public vulnerability database. Under this circumstance, Harbor administrator needs to manually update the Clair database. -This document provides step-by-step instructions on updating Clair vulnerability database in Harbor v1.2. +This document provides step-by-step instructions on updating Clair vulnerability database in Harbor. **NOTE:** Harbor does not ship with any vulnerability data. For this reason, if Harbor cannot connect to Internet, the administrator must manually import vulnerability data to Harbor by using instructions given in this document. ### Preparation -A. You need to install an instance of Clair 2.0.1 with internet connection. If you have another instance of Harbor v1.2 with internet access, it also works. +A. You need to install an instance of Clair with internet connection. If you have another instance of Harbor with internet access, it also works. B. Check whether your Clair instance has already updated the vulnerability database to the latest version. If it has not, wait for Clair to get the data from public endpoints. @@ -29,28 +29,39 @@ B. Check whether your Clair instance has already updated the vulnerability datab ``` - The phrase "finished fetching" indicates that Clair has finished a round of vulnerability update from an endpoint. Please make sure all five endpoints (rhel, alpine, oracle, debian, ubuntu) are updated correctly. +## Harbor version < 1.6 + +If you're using a version of Harbor prior to 1.6, you can access the correct instructions for your version using the following URL. +https://github.com/goharbor/harbor/blob/v\/docs/import_vulnerability_data.md + +## Harbor version >= 1.6 + +Databased were consolidated in version 1.6 which moved the clair database to the harbor-db container and removed the clair-db container. + ### Dumping vulnerability data - Log in to the host (that is connected to Internet) where Clair database (Postgres) is running. - Dump Clair's vulnerability database by the following commands, two files (`vulnerability.sql` and `clear.sql`) are generated: +_NOTE: The container name 'clair-db' is a placeholder for the db container used by the internet connected instance of clair_ + ``` - $ docker exec clair-db /bin/bash -c "pg_dump -U postgres -a -t feature -t keyvalue -t namespace -t schema_migrations -t vulnerability -t vulnerability_fixedin_feature" > vulnerability.sql - $ docker exec clair-db /bin/bash -c "pg_dump -U postgres -c -s" > clear.sql + $ docker exec clair-db /bin/sh -c "pg_dump -U postgres -a -t feature -t keyvalue -t namespace -t schema_migrations -t vulnerability -t vulnerability_fixedin_feature" > vulnerability.sql + $ docker exec clair-db /bin/sh -c "pg_dump -U postgres -c -s" > clear.sql ``` ### Back up Harbor's Clair database Before importing the data, it is strongly recommended to back up the Clair database in Harbor. ``` - $ docker exec clair-db /bin/bash -c "pg_dump -U postgres -c" > all.sql + $ docker exec harbor-db /bin/sh -c "pg_dump -U postgres -c" > all.sql ``` ### Update Harbor's Clair database Copy the `vulnerability.sql` and `clear.sql` to the host where Harbor is running on. Run the below commands to import the data to Harbor's Clair database: ``` - $ docker exec -i clair-db psql -U postgres < clear.sql - $ docker exec -i clair-db psql -U postgres < vulnerability.sql + $ docker exec -i harbor-db psql -U postgres < clear.sql + $ docker exec -i harbor-db psql -U postgres < vulnerability.sql ``` ### Rescanning images diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b33e9ebc1..37983fd86 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -19,6 +19,18 @@ securityDefinitions: security: - basicAuth: [] paths: + /health: + get: + summary: 'Health check API' + description: | + The endpoint returns the health stauts of the system. + tags: + - Products + responses: + '200': + description: The system health status. + schema: + $ref: '#/definitions/OverallHealthStatus' /search: get: summary: 'Search for projects, repositories and helm charts' @@ -705,6 +717,37 @@ paths: $ref: '#/definitions/User' '401': description: User need to log in first. + /users/current/permissions: + get: + summary: Get current user permissions. + description: | + This endpoint is to get the current user permissions. + parameters: + - name: scope + in: query + type: string + required: false + description: Get permissions of the scope + - name: relative + in: query + type: boolean + required: false + description: | + If true, the resources in the response are relative to the scope, + eg for resource '/project/1/repository' if relative is 'true' then the resource in response will be 'repository'. + tags: + - Products + responses: + '200': + description: Get current user permission successfully. + schema: + type: array + items: + $ref: '#/definitions/Permission' + '401': + description: User need to log in first. + '500': + description: Internal errors. '/users/{user_id}': get: summary: Get a user's profile. @@ -3136,6 +3179,162 @@ paths: $ref: '#/definitions/NotFoundChartAPIError' '500': $ref: '#/definitions/InternalChartAPIError' + '/projects/{project_id}/robots': + get: + summary: Get all robot accounts of specified project + description: Get all robot accounts of specified project + parameters: + - name: project_id + in: path + type: integer + format: int64 + required: true + description: Relevant project ID. + tags: + - Products + - Robot Account + responses: + '200': + description: Get project robot accounts successfully. + schema: + type: array + items: + $ref: '#/definitions/RobotAccount' + '400': + description: The project id is invalid. + '401': + description: User need to log in first. + '403': + description: User in session does not have permission to the project. + '404': + description: Project ID does not exist. + '500': + description: Unexpected internal errors. + post: + summary: Create a robot account for project + description: Create a robot account for project + tags: + - Products + - Robot Account + parameters: + - name: project_id + in: path + type: integer + format: int64 + required: true + description: Relevant project ID. + - name: robot + in: body + description: Request body of creating a robot account. + required: true + schema: + $ref: '#/definitions/RobotAccountCreate' + responses: + '201': + description: Project member created successfully. + schema: + $ref: '#/definitions/RobotAccountPostRep' + '400': + description: Project id is not valid. + '401': + description: User need to log in first. + '403': + description: User in session does not have permission to the project. + '409': + description: An robot account with same name already exist in the project. + '500': + description: Unexpected internal errors. + '/projects/{project_id}/robots/{robot_id}': + get: + summary: Return the infor of the specified robot account. + description: Return the infor of the specified robot account. + tags: + - Products + - Robot Account + parameters: + - name: project_id + in: path + type: integer + format: int64 + required: true + description: Relevant project ID. + - name: robot_id + in: path + type: integer + format: int64 + required: true + description: The ID of robot account. + responses: + '200': + description: '#/definitions/RobotAccount' + '401': + description: User need to log in first. + '403': + description: User in session does not have permission to the project. + '404': + description: The robot account is not found. + '500': + description: Unexpected internal errors. + put: + summary: Update status of robot account. + description: Used to disable/enable a specified robot account. + tags: + - Products + - Robot Account + parameters: + - name: project_id + in: path + type: integer + format: int64 + required: true + description: Relevant project ID. + - name: robot_id + in: path + type: integer + format: int64 + required: true + description: The ID of robot account. + - name: robot + in: body + description: Request body of enable/disable a robot account. + required: true + schema: + $ref: '#/definitions/RobotAccountUpdate' + responses: + '200': + description: Robot account has been modified success. + '500': + description: Unexpected internal errors. + delete: + summary: Delete the specified robot account + description: Delete the specified robot account + tags: + - Products + - Robot Account + parameters: + - name: project_id + in: path + type: integer + format: int64 + required: true + description: Relevant project ID. + - name: robot_id + in: path + type: integer + format: int64 + required: true + description: The ID of robot account. + responses: + '200': + description: The specified robot account is successfully deleted. + '401': + description: User need to log in first. + '403': + description: User in session does not have permission to the project. + '404': + description: The robot account is not found. + '500': + description: Unexpected internal errors. responses: UnsupportedMediaType: description: 'The Media Type of the request is not supported, it has to be "application/json"' @@ -4170,7 +4369,7 @@ definitions: properties: role_id: type: integer - description: 'The role id 1 for projectAdmin, 2 for developer, 3 for guest' + description: 'The role id 1 for projectAdmin, 2 for developer, 3 for guest, 4 for master' member_user: $ref: '#/definitions/UserEntity' member_group: @@ -4180,7 +4379,7 @@ definitions: properties: role_id: type: integer - description: 'The role id 1 for projectAdmin, 2 for developer, 3 for guest' + description: 'The role id 1 for projectAdmin, 2 for developer, 3 for guest, 4 for master' UserEntity: type: object properties: @@ -4514,3 +4713,100 @@ definitions: description: A list of label items: $ref: '#/definitions/Label' + OverallHealthStatus: + type: object + description: The system health status + properties: + status: + type: string + description: The overall health status. It is "healthy" only when all the components' status are "healthy" + components: + type: array + items: + $ref: '#/definitions/ComponentHealthStatus' + ComponentHealthStatus: + type: object + description: The health status of component + properties: + name: + type: string + description: The component name + status: + type: string + description: The health status of component + error: + type: string + description: (optional) The error message when the status is "unhealthy" + RobotAccount: + type: object + description: The object of robot account + properties: + id: + type: integer + description: The id of robot account + name: + type: string + description: The name of robot account + description: + type: string + description: The description of robot account + project_id: + type: integer + description: The project id of robot account + disabled: + type: boolean + description: The robot account is disable or enable + creation_time: + type: string + description: The creation time of the robot account + update_time: + type: string + description: The update time of the robot account + RobotAccountCreate: + type: object + properties: + name: + type: string + description: The name of robot account + description: + type: string + description: The description of robot account + access: + type: array + description: The permission of robot account + items: + $ref: '#/definitions/RobotAccountAccess' + RobotAccountPostRep: + type: object + properties: + name: + type: string + description: the name of robot account + token: + type: string + description: the token of robot account + RobotAccountAccess: + type: object + properties: + resource: + type: string + description: the resource of harbor + action: + type: string + description: the action to resource that perdefined in harbor rbac + RobotAccountUpdate: + type: object + properties: + disable: + type: boolean + description: The robot account is disable or enable + Permission: + type: object + description: The permission + properties: + resource: + type: string + description: The permission resoruce + action: + type: string + description: The permission action diff --git a/make/migrations/postgresql/0004_add_robot_account.up.sql b/make/migrations/postgresql/0004_add_robot_account.up.sql index 8255b3788..c2e3d273b 100644 --- a/make/migrations/postgresql/0004_add_robot_account.up.sql +++ b/make/migrations/postgresql/0004_add_robot_account.up.sql @@ -1,10 +1,6 @@ CREATE TABLE robot ( id SERIAL PRIMARY KEY NOT NULL, name varchar(255), - /* - The maximum length of token is 7k -*/ - token varchar(7168), description varchar(1024), project_id int, disabled boolean DEFAULT false NOT NULL, diff --git a/make/migrations/postgresql/0005_add_master_role.up.sql b/make/migrations/postgresql/0005_add_master_role.up.sql new file mode 100644 index 000000000..d24d3f5e6 --- /dev/null +++ b/make/migrations/postgresql/0005_add_master_role.up.sql @@ -0,0 +1 @@ +INSERT INTO role (role_code, name) VALUES ('DRWS', 'master'); \ No newline at end of file diff --git a/make/photon/Makefile b/make/photon/Makefile index 12ea7d3c4..6546c688c 100644 --- a/make/photon/Makefile +++ b/make/photon/Makefile @@ -184,7 +184,7 @@ _build_notary: _build_registry: @if [ "$(BUILDBIN)" != "true" ] ; then \ rm -rf $(DOCKERFILEPATH_REG)/binary && mkdir -p $(DOCKERFILEPATH_REG)/binary && \ - $(call _get_binary, https://storage.googleapis.com/harbor-builds/bin/registry, $(DOCKERFILEPATH_REG)/binary/registry); \ + $(call _get_binary, https://storage.googleapis.com/harbor-builds/bin/registry/release-$(REGISTRYVERSION)/registry, $(DOCKERFILEPATH_REG)/binary/registry); \ else \ cd $(DOCKERFILEPATH_REG) && $(DOCKERFILEPATH_REG)/builder $(REGISTRYVERSION) && cd - ; \ fi @@ -204,9 +204,11 @@ _build_redis: @echo "Done." _build_migrator: - @echo "building db migrator container for photon..." - @cd $(DOCKERFILEPATH_MIGRATOR) && $(DOCKERBUILD) -f $(DOCKERFILEPATH_MIGRATOR)/$(DOCKERFILENAME_MIGRATOR) -t $(DOCKERIMAGENAME_MIGRATOR):$(MIGRATORVERSION) . - @echo "Done." + @if [ "$(MIGRATORFLAG)" = "true" ] ; then \ + echo "building db migrator container for photon..."; \ + cd $(DOCKERFILEPATH_MIGRATOR) && $(DOCKERBUILD) -f $(DOCKERFILEPATH_MIGRATOR)/$(DOCKERFILENAME_MIGRATOR) -t $(DOCKERIMAGENAME_MIGRATOR):$(MIGRATORVERSION) . ; \ + echo "Done."; \ + fi define _get_binary $(WGET) --timeout 30 --no-check-certificate $1 -O $2 diff --git a/make/photon/registry/Dockerfile.binary b/make/photon/registry/Dockerfile.binary index a8bc2f14a..41fad81c7 100644 --- a/make/photon/registry/Dockerfile.binary +++ b/make/photon/registry/Dockerfile.binary @@ -1,10 +1,9 @@ -FROM golang:1.7.3 +FROM golang:1.11 ENV DISTRIBUTION_DIR /go/src/github.com/docker/distribution ENV DOCKER_BUILDTAGS include_oss include_gcs WORKDIR $DISTRIBUTION_DIR COPY . $DISTRIBUTION_DIR -COPY cmd/registry/config-dev.yml /etc/docker/registry/config.yml -RUN make PREFIX=/go clean binaries +RUN CGO_ENABLED=0 make PREFIX=/go clean binaries diff --git a/make/photon/registry/builder b/make/photon/registry/builder index c249c1d09..67ea71ede 100755 --- a/make/photon/registry/builder +++ b/make/photon/registry/builder @@ -22,13 +22,13 @@ cur=$PWD TEMP=`mktemp -d /$TMPDIR/distribution.XXXXXX` git clone -b $VERSION https://github.com/docker/distribution.git $TEMP -echo 'build the registry binary bases on the golang:1.11.2...' +echo 'build the registry binary bases on the golang:1.11...' cp Dockerfile.binary $TEMP docker build -f $TEMP/Dockerfile.binary -t registry-golang $TEMP echo 'copy the registry binary to local...' ID=$(docker create registry-golang) -docker cp $ID:/go/bin/registry binary +docker cp $ID:/go/src/github.com/docker/distribution/bin binary docker rm -f $ID docker rmi -f registry-golang diff --git a/src/adminserver/api/base_test.go b/src/adminserver/api/base_test.go index 159fc86df..626684d97 100644 --- a/src/adminserver/api/base_test.go +++ b/src/adminserver/api/base_test.go @@ -27,5 +27,52 @@ func TestHandleInternalServerError(t *testing.T) { if w.Code != http.StatusInternalServerError { t.Errorf("unexpected status code: %d != %d", w.Code, http.StatusInternalServerError) } - +} + +func TestHandleBadRequestError(t *testing.T) { + w := httptest.NewRecorder() + err := "error message" + handleBadRequestError(w, err) + + if w.Code != http.StatusBadRequest { + t.Errorf("unexpected status code: %d != %d", w.Code, http.StatusBadRequest) + } +} + +func TestHandleUnauthorized(t *testing.T) { + w := httptest.NewRecorder() + handleUnauthorized(w) + + if w.Code != http.StatusUnauthorized { + t.Errorf("unexpected status code: %d != %d", w.Code, http.StatusUnauthorized) + } +} + +func TestWriteJSONNilInterface(t *testing.T) { + w := httptest.NewRecorder() + + if err := writeJSON(w, nil); err != nil { + t.Errorf("Expected nil error, received: %v", err) + } +} + +func TestWriteJSONMarshallErr(t *testing.T) { + // Tests capture json.Marshall error + x := map[string]interface{}{ + "foo": make(chan int), + } + + w := httptest.NewRecorder() + + if err := writeJSON(w, x); err == nil { + t.Errorf("Expected %v error received: no no error", err) + } +} + +func TestWriteJSON(t *testing.T) { + w := httptest.NewRecorder() + + if err := writeJSON(w, "Pong"); err != nil { + t.Errorf("Expected nil error, received: %v", err) + } } diff --git a/src/common/const.go b/src/common/const.go index 4cb2d1c84..3f749626c 100644 --- a/src/common/const.go +++ b/src/common/const.go @@ -19,6 +19,7 @@ const ( DBAuth = "db_auth" LDAPAuth = "ldap_auth" UAAAuth = "uaa_auth" + HTTPAuth = "http_auth" ProCrtRestrEveryone = "everyone" ProCrtRestrAdmOnly = "adminonly" LDAPScopeBase = 0 @@ -28,6 +29,7 @@ const ( RoleProjectAdmin = 1 RoleDeveloper = 2 RoleGuest = 3 + RoleMaster = 4 LabelLevelSystem = "s" LabelLevelUser = "u" @@ -115,6 +117,11 @@ const ( WithChartMuseum = "with_chartmuseum" ChartRepoURL = "chart_repository_url" DefaultChartRepoURL = "http://chartmuseum:9999" + DefaultPortalURL = "http://portal" + DefaultRegistryCtlURL = "http://registryctl:8080" + DefaultClairHealthCheckServerURL = "http://clair:6061" + // Use this prefix to distinguish harbor user, the prefix contains a special character($), so it cannot be registered as a harbor user. + RobotPrefix = "robot$" ) // Shared variable, not allowed to modify 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/project.go b/src/common/dao/project.go index 80751a35f..423b6b23b 100644 --- a/src/common/dao/project.go +++ b/src/common/dao/project.go @@ -249,7 +249,8 @@ func projectQueryConditions(query *models.ProjectQueryParam) (string, []interfac roleID = 2 case common.RoleGuest: roleID = 3 - + case common.RoleMaster: + roleID = 4 } params = append(params, roleID) } @@ -299,7 +300,7 @@ func GetRolesByLDAPGroup(projectID int64, groupDNCondition string) ([]int, error } o := GetOrmer() // Because an LDAP user can be memberof multiple groups, - // the role is in descent order (1-admin, 2-developer, 3-guest), use min to select the max privilege role. + // the role is in descent order (1-admin, 2-developer, 3-guest, 4-master), use min to select the max privilege role. sql := fmt.Sprintf( `select min(pm.role) from project_member pm left join user_group ug on pm.entity_type = 'g' and pm.entity_id = ug.id 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/dao/robot_test.go b/src/common/dao/robot_test.go index ea68d880d..0ffbcf081 100644 --- a/src/common/dao/robot_test.go +++ b/src/common/dao/robot_test.go @@ -27,7 +27,6 @@ func TestAddRobot(t *testing.T) { robotName := "test1" robot := &models.Robot{ Name: robotName, - Token: "rKgjKEMpMEK23zqejkWn5GIVvgJps1vKACTa6tnGXXyOlOTsXFESccDvgaJx047q1", Description: "test1 description", ProjectID: 1, } @@ -46,7 +45,6 @@ func TestGetRobot(t *testing.T) { robotName := "test2" robot := &models.Robot{ Name: robotName, - Token: "rKgjKEMpMEK23zqejkWn5GIVvgJps1vKACTa6tnGXXyOlOTsXFESccDvgaJx047q2", Description: "test2 description", ProjectID: 1, } @@ -66,7 +64,6 @@ func TestListRobots(t *testing.T) { robotName := "test3" robot := &models.Robot{ Name: robotName, - Token: "rKgjKEMpMEK23zqejkWn5GIVvgJps1vKACTa6tnGXXyOlOTsXFESccDvgaJx047q3", Description: "test3 description", ProjectID: 1, } @@ -86,7 +83,6 @@ func TestDisableRobot(t *testing.T) { robotName := "test4" robot := &models.Robot{ Name: robotName, - Token: "rKgjKEMpMEK23zqejkWn5GIVvgJps1vKACTa6tnGXXyOlOTsXFESccDvgaJx047q4", Description: "test4 description", ProjectID: 1, } @@ -111,7 +107,6 @@ func TestEnableRobot(t *testing.T) { robotName := "test5" robot := &models.Robot{ Name: robotName, - Token: "rKgjKEMpMEK23zqejkWn5GIVvgJps1vKACTa6tnGXXyOlOTsXFESccDvgaJx047q5", Description: "test5 description", Disabled: true, ProjectID: 1, @@ -137,7 +132,6 @@ func TestDeleteRobot(t *testing.T) { robotName := "test6" robot := &models.Robot{ Name: robotName, - Token: "rKgjKEMpMEK23zqejkWn5GIVvgJps1vKACTa6tnGXXyOlOTsXFESccDvgaJx047q6", Description: "test6 description", ProjectID: 1, } diff --git a/src/common/models/robot.go b/src/common/models/robot.go index da81a5025..6998c4a3f 100644 --- a/src/common/models/robot.go +++ b/src/common/models/robot.go @@ -15,6 +15,8 @@ package models import ( + "github.com/astaxie/beego/validation" + "github.com/goharbor/harbor/src/common/rbac" "time" ) @@ -25,7 +27,6 @@ const RobotTable = "robot" type Robot struct { ID int64 `orm:"pk;auto;column(id)" json:"id"` Name string `orm:"column(name)" json:"name"` - Token string `orm:"column(token)" json:"token"` Description string `orm:"column(description)" json:"description"` ProjectID int64 `orm:"column(project_id)" json:"project_id"` Disabled bool `orm:"column(disabled)" json:"disabled"` @@ -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 []*rbac.Policy `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 `json:"name"` + Token string `json:"token"` +} + // 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/common/ram/casbin.go b/src/common/rbac/casbin.go similarity index 79% rename from src/common/ram/casbin.go rename to src/common/rbac/casbin.go index d90294d9a..4ec835bd2 100644 --- a/src/common/ram/casbin.go +++ b/src/common/rbac/casbin.go @@ -12,15 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -package ram +package rbac import ( "errors" "fmt" + "regexp" + "strings" "github.com/casbin/casbin" "github.com/casbin/casbin/model" "github.com/casbin/casbin/persist" + "github.com/casbin/casbin/util" ) var ( @@ -50,6 +53,30 @@ e = some(where (p.eft == allow)) && !some(where (p.eft == deny)) m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == '*') ` +// keyMatch2 determines whether key1 matches the pattern of key2, its behavior most likely the builtin KeyMatch2 +// except that the match of ("/project/1/robot", "/project/1") will return false +func keyMatch2(key1 string, key2 string) bool { + key2 = strings.Replace(key2, "/*", "/.*", -1) + + re := regexp.MustCompile(`(.*):[^/]+(.*)`) + for { + if !strings.Contains(key2, "/:") { + break + } + + key2 = re.ReplaceAllString(key2, "$1[^/]+$2") + } + + return util.RegexMatch(key1, "^"+key2+"$") +} + +func keyMatch2Func(args ...interface{}) (interface{}, error) { + name1 := args[0].(string) + name2 := args[1].(string) + + return bool(keyMatch2(name1, name2)), nil +} + type userAdapter struct { User } @@ -134,5 +161,8 @@ func (a *userAdapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex func enforcerForUser(user User) *casbin.Enforcer { m := model.Model{} m.LoadModelFromText(modelText) - return casbin.NewEnforcer(m, &userAdapter{User: user}) + + e := casbin.NewEnforcer(m, &userAdapter{User: user}) + e.AddFunction("keyMatch2", keyMatch2Func) + return e } diff --git a/src/common/rbac/casbin_test.go b/src/common/rbac/casbin_test.go new file mode 100644 index 000000000..01c6f04f8 --- /dev/null +++ b/src/common/rbac/casbin_test.go @@ -0,0 +1,59 @@ +// Copyright 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 rbac + +import ( + "testing" +) + +func Test_keyMatch2(t *testing.T) { + type args struct { + key1 string + key2 string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "match /project/1/robot, /project/1", + args: args{"/project/1/robot", "/project/1"}, + want: false, + }, + { + name: "match /project/1/robot, /project/:pid", + args: args{"/project/1/robot", "/project/:pid"}, + want: false, + }, + { + name: "match /project/1/robot, /project/1/*", + args: args{"/project/1/robot", "/project/1/*"}, + want: true, + }, + { + name: "match /project/1/robot, /project/:pid/robot", + args: args{"/project/1/robot", "/project/:pid/robot"}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := keyMatch2(tt.args.key1, tt.args.key2); got != tt.want { + t.Errorf("keyMatch2() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/src/common/rbac/const.go b/src/common/rbac/const.go new file mode 100644 index 000000000..b0e1de59b --- /dev/null +++ b/src/common/rbac/const.go @@ -0,0 +1,56 @@ +// Copyright 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 rbac + +// const action variables +const ( + ActionAll = Action("*") // action match any other actions + + ActionPull = Action("pull") // pull repository tag + ActionPush = Action("push") // push repository tag + ActionPushPull = Action("push+pull") // compatible with security all perm of project + + // create, read, update, delete, list actions compatible with restful api methods + ActionCreate = Action("create") + ActionRead = Action("read") + ActionUpdate = Action("update") + ActionDelete = Action("delete") + ActionList = Action("list") +) + +// const resource variables +const ( + ResourceAll = Resource("*") // resource match any other resources + ResourceConfiguration = Resource("configuration") // project configuration compatible for portal only + ResourceHelmChart = Resource("helm-chart") + ResourceHelmChartVersion = Resource("helm-chart-version") + ResourceHelmChartVersionLabel = Resource("helm-chart-version-label") + ResourceLabel = Resource("label") + ResourceLabelResource = Resource("label-resource") + ResourceLog = Resource("log") + ResourceMember = Resource("member") + ResourceMetadata = Resource("metadata") + ResourceReplication = Resource("replication") + ResourceReplicationJob = Resource("replication-job") + ResourceRepository = Resource("repository") + ResourceRepositoryLabel = Resource("repository-label") + ResourceRepositoryTag = Resource("repository-tag") + ResourceRepositoryTagLabel = Resource("repository-tag-label") + ResourceRepositoryTagManifest = Resource("repository-tag-manifest") + ResourceRepositoryTagScanJob = Resource("repository-tag-scan-job") + ResourceRepositoryTagVulnerability = Resource("repository-tag-vulnerability") + ResourceRobot = Resource("robot") + ResourceSelf = Resource("") // subresource for self +) diff --git a/src/common/rbac/namespace.go b/src/common/rbac/namespace.go new file mode 100644 index 000000000..7f4f0f6a3 --- /dev/null +++ b/src/common/rbac/namespace.go @@ -0,0 +1,61 @@ +// Copyright 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 rbac + +import ( + "fmt" +) + +// Namespace the namespace interface +type Namespace interface { + // Kind returns the kind of namespace + Kind() string + // Resource returns new resource for subresources with the namespace + Resource(subresources ...Resource) Resource + // Identity returns identity attached with namespace + Identity() interface{} + // IsPublic returns true if namespace is public + IsPublic() bool +} + +type projectNamespace struct { + projectIDOrName interface{} + isPublic bool +} + +func (ns *projectNamespace) Kind() string { + return "project" +} + +func (ns *projectNamespace) Resource(subresources ...Resource) Resource { + return Resource(fmt.Sprintf("/project/%v", ns.projectIDOrName)).Subresource(subresources...) +} + +func (ns *projectNamespace) Identity() interface{} { + return ns.projectIDOrName +} + +func (ns *projectNamespace) IsPublic() bool { + return ns.isPublic +} + +// NewProjectNamespace returns namespace for project +func NewProjectNamespace(projectIDOrName interface{}, isPublic ...bool) Namespace { + isPublicNamespace := false + if len(isPublic) > 0 { + isPublicNamespace = isPublic[0] + } + return &projectNamespace{projectIDOrName: projectIDOrName, isPublic: isPublicNamespace} +} diff --git a/src/common/rbac/namespace_test.go b/src/common/rbac/namespace_test.go new file mode 100644 index 000000000..5fddad0e4 --- /dev/null +++ b/src/common/rbac/namespace_test.go @@ -0,0 +1,45 @@ +// Copyright 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 rbac + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type ProjectNamespaceTestSuite struct { + suite.Suite +} + +func (suite *ProjectNamespaceTestSuite) TestResource() { + var namespace Namespace + + namespace = &projectNamespace{projectIDOrName: int64(1)} + + suite.Equal(namespace.Resource(Resource("image")), Resource("/project/1/image")) +} + +func (suite *ProjectNamespaceTestSuite) TestIdentity() { + namespace, _ := Resource("/project/1/image").GetNamespace() + suite.Equal(namespace.Identity(), int64(1)) + + namespace, _ = Resource("/project/library/image").GetNamespace() + suite.Equal(namespace.Identity(), "library") +} + +func TestProjectNamespaceTestSuite(t *testing.T) { + suite.Run(t, new(ProjectNamespaceTestSuite)) +} diff --git a/src/common/rbac/parser.go b/src/common/rbac/parser.go new file mode 100644 index 000000000..bb65943e6 --- /dev/null +++ b/src/common/rbac/parser.go @@ -0,0 +1,50 @@ +// Copyright 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 rbac + +import ( + "errors" + "regexp" + "strconv" +) + +var ( + namespaceParsers = map[string]namespaceParser{ + "project": projectNamespaceParser, + } +) + +type namespaceParser func(resource Resource) (Namespace, error) + +func projectNamespaceParser(resource Resource) (Namespace, error) { + parserRe := regexp.MustCompile("^/project/([^/]*)/?") + + matches := parserRe.FindStringSubmatch(resource.String()) + + if len(matches) <= 1 { + return nil, errors.New("not support resource") + } + + var projectIDOrName interface{} + + id, err := strconv.ParseInt(matches[1], 10, 64) + if err == nil { + projectIDOrName = id + } else { + projectIDOrName = matches[1] + } + + return &projectNamespace{projectIDOrName: projectIDOrName}, nil +} diff --git a/src/common/rbac/parser_test.go b/src/common/rbac/parser_test.go new file mode 100644 index 000000000..cf23d517b --- /dev/null +++ b/src/common/rbac/parser_test.go @@ -0,0 +1,39 @@ +// Copyright 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 rbac + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type ProjectParserTestSuite struct { + suite.Suite +} + +func (suite *ProjectParserTestSuite) TestParse() { + namespace, err := projectNamespaceParser(Resource("/project/1/image")) + suite.Equal(namespace, &projectNamespace{projectIDOrName: int64(1)}) + suite.Nil(err) + + namespace, err = projectNamespaceParser(Resource("/fake/1/image")) + suite.Nil(namespace) + suite.Error(err) +} + +func TestProjectParserTestSuite(t *testing.T) { + suite.Run(t, new(ProjectParserTestSuite)) +} diff --git a/src/common/rbac/project/util.go b/src/common/rbac/project/util.go new file mode 100644 index 000000000..75dd8b13d --- /dev/null +++ b/src/common/rbac/project/util.go @@ -0,0 +1,166 @@ +// Copyright 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 project + +import ( + "github.com/goharbor/harbor/src/common/rbac" +) + +var ( + // subresource policies for public project + publicProjectPolicies = []*rbac.Policy{ + {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, + + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagVulnerability, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagManifest, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionRead}, + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionRead}, + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionList}, + } + + // all policies for the projects + allPolicies = []*rbac.Policy{ + {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, + {Resource: rbac.ResourceSelf, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceSelf, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceMember, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceMember, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceMember, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceMember, Action: rbac.ActionList}, + + {Resource: rbac.ResourceMetadata, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceMetadata, Action: rbac.ActionRead}, + {Resource: rbac.ResourceMetadata, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceMetadata, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceLog, Action: rbac.ActionList}, + + {Resource: rbac.ResourceReplication, Action: rbac.ActionList}, + {Resource: rbac.ResourceReplication, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceReplication, Action: rbac.ActionRead}, + {Resource: rbac.ResourceReplication, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceReplication, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceReplicationJob, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceReplicationJob, Action: rbac.ActionRead}, + {Resource: rbac.ResourceReplicationJob, Action: rbac.ActionList}, + + {Resource: rbac.ResourceLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceLabelResource, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepository, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPush}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPushPull}, // compatible with security all perm of project + + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagScanJob, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepositoryTagScanJob, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRepositoryTagVulnerability, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagManifest, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionRead}, + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionRead}, + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChartVersionLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceHelmChartVersionLabel, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, + {Resource: rbac.ResourceConfiguration, Action: rbac.ActionUpdate}, + + {Resource: rbac.ResourceRobot, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionList}, + } +) + +// PoliciesForPublicProject ... +func PoliciesForPublicProject(namespace rbac.Namespace) []*rbac.Policy { + policies := []*rbac.Policy{} + + for _, policy := range publicProjectPolicies { + policies = append(policies, &rbac.Policy{ + Resource: namespace.Resource(policy.Resource), + Action: policy.Action, + Effect: policy.Effect, + }) + } + + return policies +} + +// GetAllPolicies returns all policies for namespace of the project +func GetAllPolicies(namespace rbac.Namespace) []*rbac.Policy { + policies := []*rbac.Policy{} + + for _, policy := range allPolicies { + policies = append(policies, &rbac.Policy{ + Resource: namespace.Resource(policy.Resource), + Action: policy.Action, + Effect: policy.Effect, + }) + } + + return policies +} diff --git a/src/common/rbac/project/visitor.go b/src/common/rbac/project/visitor.go new file mode 100644 index 000000000..d13523e2c --- /dev/null +++ b/src/common/rbac/project/visitor.go @@ -0,0 +1,83 @@ +// Copyright 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 project + +import ( + "github.com/goharbor/harbor/src/common/rbac" +) + +// visitorContext the context interface for the project visitor +type visitorContext interface { + IsAuthenticated() bool + // GetUsername returns the username of user related to the context + GetUsername() string + // IsSysAdmin returns whether the user is system admin + IsSysAdmin() bool +} + +// visitor implement the rbac.User interface for project visitor +type visitor struct { + ctx visitorContext + namespace rbac.Namespace + projectRoles []int +} + +// GetUserName returns username of the visitor +func (v *visitor) GetUserName() string { + // anonymous username for unauthenticated Visitor + if !v.ctx.IsAuthenticated() { + return "anonymous" + } + + return v.ctx.GetUsername() +} + +// GetPolicies returns policies of the visitor +func (v *visitor) GetPolicies() []*rbac.Policy { + if v.ctx.IsSysAdmin() { + return GetAllPolicies(v.namespace) + } + + if v.namespace.IsPublic() { + return PoliciesForPublicProject(v.namespace) + } + + return nil +} + +// GetRoles returns roles of the visitor +func (v *visitor) GetRoles() []rbac.Role { + // Ignore roles when visitor is anonymous or system admin + if !v.ctx.IsAuthenticated() || v.ctx.IsSysAdmin() { + return nil + } + + roles := []rbac.Role{} + + for _, roleID := range v.projectRoles { + roles = append(roles, &visitorRole{roleID: roleID, namespace: v.namespace}) + } + + return roles +} + +// NewUser returns rbac.User interface for the project visitor +func NewUser(ctx visitorContext, namespace rbac.Namespace, projectRoles ...int) rbac.User { + return &visitor{ + ctx: ctx, + namespace: namespace, + projectRoles: projectRoles, + } +} diff --git a/src/common/rbac/project/visitor_role.go b/src/common/rbac/project/visitor_role.go new file mode 100644 index 000000000..97aeae87f --- /dev/null +++ b/src/common/rbac/project/visitor_role.go @@ -0,0 +1,301 @@ +// Copyright 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 project + +import ( + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/rbac" +) + +var ( + rolePoliciesMap = map[string][]*rbac.Policy{ + "projectAdmin": { + {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, + {Resource: rbac.ResourceSelf, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceSelf, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceMember, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceMember, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceMember, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceMember, Action: rbac.ActionList}, + + {Resource: rbac.ResourceMetadata, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceMetadata, Action: rbac.ActionRead}, + {Resource: rbac.ResourceMetadata, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceMetadata, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceLog, Action: rbac.ActionList}, + + {Resource: rbac.ResourceReplication, Action: rbac.ActionRead}, + {Resource: rbac.ResourceReplication, Action: rbac.ActionList}, + + {Resource: rbac.ResourceReplicationJob, Action: rbac.ActionRead}, + {Resource: rbac.ResourceReplicationJob, Action: rbac.ActionList}, + + {Resource: rbac.ResourceLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceLabelResource, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepository, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPush}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPushPull}, // compatible with security all perm of project + + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagScanJob, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepositoryTagScanJob, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRepositoryTagVulnerability, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagManifest, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionCreate}, // upload helm chart + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionRead}, // download helm chart + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionCreate}, // upload helm chart version + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionRead}, // read and download helm chart version + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChartVersionLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceHelmChartVersionLabel, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, + {Resource: rbac.ResourceConfiguration, Action: rbac.ActionUpdate}, + + {Resource: rbac.ResourceRobot, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionList}, + }, + + "master": { + {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceMember, Action: rbac.ActionList}, + + {Resource: rbac.ResourceMetadata, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceMetadata, Action: rbac.ActionRead}, + {Resource: rbac.ResourceMetadata, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceMetadata, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceLog, Action: rbac.ActionList}, + + {Resource: rbac.ResourceReplication, Action: rbac.ActionRead}, + {Resource: rbac.ResourceReplication, Action: rbac.ActionList}, + + {Resource: rbac.ResourceLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepository, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPush}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, + + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagScanJob, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepositoryTagScanJob, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRepositoryTagVulnerability, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagManifest, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionRead}, + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionRead}, + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChartVersionLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceHelmChartVersionLabel, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRobot, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionList}, + }, + + "developer": { + {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceMember, Action: rbac.ActionList}, + + {Resource: rbac.ResourceLog, Action: rbac.ActionList}, + + {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepository, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPush}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, + + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagVulnerability, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagManifest, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionDelete}, + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionRead}, + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionRead}, + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChartVersionLabel, Action: rbac.ActionCreate}, + {Resource: rbac.ResourceHelmChartVersionLabel, Action: rbac.ActionDelete}, + + {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRobot, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionList}, + }, + + "guest": { + {Resource: rbac.ResourceSelf, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceMember, Action: rbac.ActionList}, + + {Resource: rbac.ResourceLog, Action: rbac.ActionList}, + + {Resource: rbac.ResourceLabel, Action: rbac.ActionRead}, + {Resource: rbac.ResourceLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepository, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionList}, + {Resource: rbac.ResourceRepository, Action: rbac.ActionPull}, + + {Resource: rbac.ResourceRepositoryLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRepositoryTag, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagLabel, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagVulnerability, Action: rbac.ActionList}, + + {Resource: rbac.ResourceRepositoryTagManifest, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionRead}, + {Resource: rbac.ResourceHelmChart, Action: rbac.ActionList}, + + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionRead}, + {Resource: rbac.ResourceHelmChartVersion, Action: rbac.ActionList}, + + {Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead}, + + {Resource: rbac.ResourceRobot, Action: rbac.ActionRead}, + {Resource: rbac.ResourceRobot, Action: rbac.ActionList}, + }, + } +) + +// visitorRole implement the rbac.Role interface +type visitorRole struct { + namespace rbac.Namespace + roleID int +} + +// GetRoleName returns role name for the visitor role +func (role *visitorRole) GetRoleName() string { + switch role.roleID { + case common.RoleProjectAdmin: + return "projectAdmin" + case common.RoleMaster: + return "master" + case common.RoleDeveloper: + return "developer" + case common.RoleGuest: + return "guest" + default: + return "" + } +} + +// GetPolicies returns policies for the visitor role +func (role *visitorRole) GetPolicies() []*rbac.Policy { + policies := []*rbac.Policy{} + + roleName := role.GetRoleName() + if roleName == "" { + return policies + } + + for _, policy := range rolePoliciesMap[roleName] { + policies = append(policies, &rbac.Policy{ + Resource: role.namespace.Resource(policy.Resource), + Action: policy.Action, + Effect: policy.Effect, + }) + } + + return policies +} diff --git a/src/common/rbac/project/visitor_role_test.go b/src/common/rbac/project/visitor_role_test.go new file mode 100644 index 000000000..b1f22d24a --- /dev/null +++ b/src/common/rbac/project/visitor_role_test.go @@ -0,0 +1,44 @@ +// Copyright 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 project + +import ( + "testing" + + "github.com/goharbor/harbor/src/common" + "github.com/stretchr/testify/suite" +) + +type VisitorRoleTestSuite struct { + suite.Suite +} + +func (suite *VisitorRoleTestSuite) TestGetRoleName() { + projectAdmin := visitorRole{roleID: common.RoleProjectAdmin} + suite.Equal(projectAdmin.GetRoleName(), "projectAdmin") + + developer := visitorRole{roleID: common.RoleDeveloper} + suite.Equal(developer.GetRoleName(), "developer") + + guest := visitorRole{roleID: common.RoleGuest} + suite.Equal(guest.GetRoleName(), "guest") + + unknow := visitorRole{roleID: 404} + suite.Equal(unknow.GetRoleName(), "") +} + +func TestVisitorRoleTestSuite(t *testing.T) { + suite.Run(t, new(VisitorRoleTestSuite)) +} diff --git a/src/common/rbac/project/visitor_test.go b/src/common/rbac/project/visitor_test.go new file mode 100644 index 000000000..32fa78df6 --- /dev/null +++ b/src/common/rbac/project/visitor_test.go @@ -0,0 +1,93 @@ +// Copyright 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 project + +import ( + "testing" + + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/rbac" + "github.com/stretchr/testify/suite" +) + +type fakeVisitorContext struct { + username string + isSysAdmin bool +} + +func (ctx *fakeVisitorContext) IsAuthenticated() bool { + return ctx.username != "" +} + +func (ctx *fakeVisitorContext) GetUsername() string { + return ctx.username +} + +func (ctx *fakeVisitorContext) IsSysAdmin() bool { + return ctx.IsAuthenticated() && ctx.isSysAdmin +} + +var ( + anonymousCtx = &fakeVisitorContext{} + authenticatedCtx = &fakeVisitorContext{username: "user"} + sysAdminCtx = &fakeVisitorContext{username: "admin", isSysAdmin: true} +) + +type VisitorTestSuite struct { + suite.Suite +} + +func (suite *VisitorTestSuite) TestGetPolicies() { + namespace := rbac.NewProjectNamespace("library", false) + publicNamespace := rbac.NewProjectNamespace("library", true) + + anonymous := NewUser(anonymousCtx, namespace) + suite.Nil(anonymous.GetPolicies()) + + anonymousForPublicProject := NewUser(anonymousCtx, publicNamespace) + suite.Equal(anonymousForPublicProject.GetPolicies(), PoliciesForPublicProject(publicNamespace)) + + authenticated := NewUser(authenticatedCtx, namespace) + suite.Nil(authenticated.GetPolicies()) + + authenticatedForPublicProject := NewUser(authenticatedCtx, publicNamespace) + suite.Equal(authenticatedForPublicProject.GetPolicies(), PoliciesForPublicProject(publicNamespace)) + + systemAdmin := NewUser(sysAdminCtx, namespace) + suite.Equal(systemAdmin.GetPolicies(), GetAllPolicies(namespace)) + + systemAdminForPublicProject := NewUser(sysAdminCtx, publicNamespace) + suite.Equal(systemAdminForPublicProject.GetPolicies(), GetAllPolicies(publicNamespace)) +} + +func (suite *VisitorTestSuite) TestGetRoles() { + namespace := rbac.NewProjectNamespace("library", false) + + anonymous := NewUser(anonymousCtx, namespace) + suite.Nil(anonymous.GetRoles()) + + authenticated := NewUser(authenticatedCtx, namespace) + suite.Empty(authenticated.GetRoles()) + + authenticated = NewUser(authenticatedCtx, namespace, common.RoleProjectAdmin) + suite.Len(authenticated.GetRoles(), 1) + + authenticated = NewUser(authenticatedCtx, namespace, common.RoleProjectAdmin, common.RoleDeveloper) + suite.Len(authenticated.GetRoles(), 2) +} + +func TestVisitorTestSuite(t *testing.T) { + suite.Run(t, new(VisitorTestSuite)) +} diff --git a/src/common/ram/ram.go b/src/common/rbac/rbac.go similarity index 75% rename from src/common/ram/ram.go rename to src/common/rbac/rbac.go index c7d8a2303..45d91dcfe 100644 --- a/src/common/ram/ram.go +++ b/src/common/rbac/rbac.go @@ -12,10 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -package ram +package rbac import ( + "errors" + "fmt" "path" + "strings" ) const ( @@ -28,6 +31,27 @@ const ( // Resource the type of resource type Resource string +// RelativeTo returns relative resource to other resource +func (res Resource) RelativeTo(other Resource) (Resource, error) { + prefix := other.String() + str := res.String() + + if !strings.HasPrefix(str, prefix) { + return Resource(""), errors.New("value error") + } + + relative := strings.TrimPrefix(str, prefix) + if strings.HasPrefix(relative, "/") { + relative = relative[1:] + } + + if relative == "" { + relative = "." + } + + return Resource(relative), nil +} + func (res Resource) String() string { return string(res) } @@ -43,6 +67,18 @@ func (res Resource) Subresource(resources ...Resource) Resource { return Resource(path.Join(elements...)) } +// GetNamespace returns namespace from resource +func (res Resource) GetNamespace() (Namespace, error) { + for _, parser := range namespaceParsers { + namespace, err := parser(res) + if err == nil { + return namespace, nil + } + } + + return nil, fmt.Errorf("no namespace found for %s", res) +} + // Action the type of action type Action string @@ -74,14 +110,14 @@ func (p *Policy) GetEffect() string { return eft.String() } -// Role the interface of ram role +// Role the interface of rbac role type Role interface { // GetRoleName returns the role identity, if empty string role's policies will be ignore GetRoleName() string GetPolicies() []*Policy } -// User the interface of ram user +// User the interface of rbac user type User interface { // GetUserName returns the user identity, if empty string user's all policies will be ignore GetUserName() string diff --git a/src/common/ram/ram_test.go b/src/common/rbac/rbac_test.go similarity index 79% rename from src/common/ram/ram_test.go rename to src/common/rbac/rbac_test.go index cd435c369..e8881b4f4 100644 --- a/src/common/ram/ram_test.go +++ b/src/common/rbac/rbac_test.go @@ -12,9 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package ram +package rbac import ( + "reflect" "testing" ) @@ -126,7 +127,7 @@ func TestHasPermissionUserWithoutRoles(t *testing.T) { { name: "project create for user without roles", args: args{ - &userWithoutRoles{Username: "user1", UserPolicies: []*Policy{{Resource: "project", Action: "create"}}}, + &userWithoutRoles{Username: "user1", UserPolicies: []*Policy{{Resource: "/project", Action: "create"}}}, "/project", "create", }, @@ -135,7 +136,7 @@ func TestHasPermissionUserWithoutRoles(t *testing.T) { { name: "project delete test for user without roles", args: args{ - &userWithoutRoles{Username: "user1", UserPolicies: []*Policy{{Resource: "project", Action: "create"}}}, + &userWithoutRoles{Username: "user1", UserPolicies: []*Policy{{Resource: "/project", Action: "create"}}}, "/project", "delete", }, @@ -167,7 +168,7 @@ func TestHasPermissionUsernameEmpty(t *testing.T) { { name: "project create for user without roles", args: args{ - &userWithoutRoles{Username: "", UserPolicies: []*Policy{{Resource: "project", Action: "create"}}}, + &userWithoutRoles{Username: "", UserPolicies: []*Policy{{Resource: "/project", Action: "create"}}}, "/project", "create", }, @@ -355,3 +356,84 @@ func TestResource_Subresource(t *testing.T) { }) } } + +func TestResource_GetNamespace(t *testing.T) { + tests := []struct { + name string + res Resource + want Namespace + wantErr bool + }{ + { + name: "project namespace", + res: Resource("/project/1"), + want: &projectNamespace{int64(1), false}, + wantErr: false, + }, + { + name: "unknow namespace", + res: Resource("/unknow/1"), + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.res.GetNamespace() + if (err != nil) != tt.wantErr { + t.Errorf("Resource.GetNamespace() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Resource.GetNamespace() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestResource_RelativeTo(t *testing.T) { + type args struct { + other Resource + } + tests := []struct { + name string + res Resource + args args + want Resource + wantErr bool + }{ + { + name: "/project/1/image", + res: Resource("/project/1/image"), + args: args{other: Resource("/project/1")}, + want: Resource("image"), + wantErr: false, + }, + { + name: "/project/1", + res: Resource("/project/1"), + args: args{other: Resource("/project/1")}, + want: Resource("."), + wantErr: false, + }, + { + name: "/project/1", + res: Resource("/project/1"), + args: args{other: Resource("/system")}, + want: Resource(""), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.res.RelativeTo(tt.args.other) + if (err != nil) != tt.wantErr { + t.Errorf("Resource.RelativeTo() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Resource.RelativeTo() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/src/common/security/admiral/context.go b/src/common/security/admiral/context.go index 3b3b5476b..962a6dafb 100644 --- a/src/common/security/admiral/context.go +++ b/src/common/security/admiral/context.go @@ -15,10 +15,10 @@ package admiral import ( - "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/common/rbac/project" "github.com/goharbor/harbor/src/common/security/admiral/authcontext" - "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/promgr" ) @@ -69,72 +69,17 @@ func (s *SecurityContext) IsSolutionUser() bool { return false } -// HasReadPerm returns whether the user has read permission to the project -func (s *SecurityContext) HasReadPerm(projectIDOrName interface{}) bool { - public, err := s.pm.IsPublic(projectIDOrName) - if err != nil { - log.Errorf("failed to check the public of project %v: %v", - projectIDOrName, err) - return false - } - if public { - return true - } - - // private project - if !s.IsAuthenticated() { - return false - } - - // system admin - if s.IsSysAdmin() { - return true - } - - roles := s.GetProjectRoles(projectIDOrName) - - return len(roles) > 0 -} - -// HasWritePerm returns whether the user has write permission to the project -func (s *SecurityContext) HasWritePerm(projectIDOrName interface{}) bool { - if !s.IsAuthenticated() { - return false - } - - // system admin - if s.IsSysAdmin() { - return true - } - - roles := s.GetProjectRoles(projectIDOrName) - for _, role := range roles { - switch role { - case common.RoleProjectAdmin, - common.RoleDeveloper: - return true - } - } - - return false -} - -// HasAllPerm returns whether the user has all permissions to the project -func (s *SecurityContext) HasAllPerm(projectIDOrName interface{}) bool { - if !s.IsAuthenticated() { - return false - } - - // system admin - if s.IsSysAdmin() { - return true - } - - roles := s.GetProjectRoles(projectIDOrName) - for _, role := range roles { - switch role { - case common.RoleProjectAdmin: - return true +// Can returns whether the user can do action on resource +func (s *SecurityContext) Can(action rbac.Action, resource rbac.Resource) bool { + ns, err := resource.GetNamespace() + if err == nil { + switch ns.Kind() { + case "project": + projectIDOrName := ns.Identity() + isPublicProject, _ := s.pm.IsPublic(projectIDOrName) + projectNamespace := rbac.NewProjectNamespace(projectIDOrName, isPublicProject) + user := project.NewUser(s, projectNamespace, s.GetProjectRoles(projectIDOrName)...) + return rbac.HasPermission(user, resource, action) } } diff --git a/src/common/security/context.go b/src/common/security/context.go index 7a6ea56f0..4b879a218 100644 --- a/src/common/security/context.go +++ b/src/common/security/context.go @@ -16,6 +16,7 @@ package security import ( "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" ) // Context abstracts the operations related with authN and authZ @@ -28,14 +29,10 @@ type Context interface { IsSysAdmin() bool // IsSolutionUser returns whether the user is solution user IsSolutionUser() bool - // HasReadPerm returns whether the user has read permission to the project - HasReadPerm(projectIDOrName interface{}) bool - // HasWritePerm returns whether the user has write permission to the project - HasWritePerm(projectIDOrName interface{}) bool - // HasAllPerm returns whether the user has all permissions to the project - HasAllPerm(projectIDOrName interface{}) bool // Get current user's all project GetMyProjects() ([]*models.Project, error) // Get user's role in provided project GetProjectRoles(projectIDOrName interface{}) []int + // Can returns whether the user can do action on resource + Can(action rbac.Action, resource rbac.Resource) bool } diff --git a/src/common/security/local/context.go b/src/common/security/local/context.go index 48af21b30..655fe34b1 100644 --- a/src/common/security/local/context.go +++ b/src/common/security/local/context.go @@ -19,6 +19,8 @@ import ( "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/common/dao/group" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/common/rbac/project" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/promgr" ) @@ -65,69 +67,20 @@ func (s *SecurityContext) IsSolutionUser() bool { return false } -// HasReadPerm returns whether the user has read permission to the project -func (s *SecurityContext) HasReadPerm(projectIDOrName interface{}) bool { - // public project - public, err := s.pm.IsPublic(projectIDOrName) - if err != nil { - log.Errorf("failed to check the public of project %v: %v", - projectIDOrName, err) - return false - } - if public { - return true - } - - // private project - if !s.IsAuthenticated() { - return false - } - - // system admin - if s.IsSysAdmin() { - return true - } - - roles := s.GetProjectRoles(projectIDOrName) - return len(roles) > 0 -} - -// HasWritePerm returns whether the user has write permission to the project -func (s *SecurityContext) HasWritePerm(projectIDOrName interface{}) bool { - if !s.IsAuthenticated() { - return false - } - // system admin - if s.IsSysAdmin() { - return true - } - roles := s.GetProjectRoles(projectIDOrName) - for _, role := range roles { - switch role { - case common.RoleProjectAdmin, - common.RoleDeveloper: - return true +// Can returns whether the user can do action on resource +func (s *SecurityContext) Can(action rbac.Action, resource rbac.Resource) bool { + ns, err := resource.GetNamespace() + if err == nil { + switch ns.Kind() { + case "project": + projectIDOrName := ns.Identity() + isPublicProject, _ := s.pm.IsPublic(projectIDOrName) + projectNamespace := rbac.NewProjectNamespace(projectIDOrName, isPublicProject) + user := project.NewUser(s, projectNamespace, s.GetProjectRoles(projectIDOrName)...) + return rbac.HasPermission(user, resource, action) } } - return false -} -// HasAllPerm returns whether the user has all permissions to the project -func (s *SecurityContext) HasAllPerm(projectIDOrName interface{}) bool { - if !s.IsAuthenticated() { - return false - } - // system admin - if s.IsSysAdmin() { - return true - } - roles := s.GetProjectRoles(projectIDOrName) - for _, role := range roles { - switch role { - case common.RoleProjectAdmin: - return true - } - } return false } @@ -167,6 +120,8 @@ func (s *SecurityContext) GetProjectRoles(projectIDOrName interface{}) []int { switch role.RoleCode { case "MDRWS": roles = append(roles, common.RoleProjectAdmin) + case "DRWS": + roles = append(roles, common.RoleMaster) case "RWS": roles = append(roles, common.RoleDeveloper) case "RS": diff --git a/src/common/security/local/context_test.go b/src/common/security/local/context_test.go index 976237fd6..80b40818b 100644 --- a/src/common/security/local/context_test.go +++ b/src/common/security/local/context_test.go @@ -23,6 +23,7 @@ import ( "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/common/dao/project" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/promgr" "github.com/goharbor/harbor/src/core/promgr/pmsdriver/local" @@ -210,66 +211,73 @@ func TestIsSolutionUser(t *testing.T) { func TestHasReadPerm(t *testing.T) { // public project ctx := NewSecurityContext(nil, pm) - assert.True(t, ctx.HasReadPerm("library")) + + resource := rbac.NewProjectNamespace("library").Resource(rbac.ResourceRepository) + assert.True(t, ctx.Can(rbac.ActionPull, resource)) // private project, unauthenticated ctx = NewSecurityContext(nil, pm) - assert.False(t, ctx.HasReadPerm(private.Name)) + resource = rbac.NewProjectNamespace(private.Name).Resource(rbac.ResourceRepository) + assert.False(t, ctx.Can(rbac.ActionPull, resource)) // private project, authenticated, has no perm ctx = NewSecurityContext(&models.User{ Username: "test", }, pm) - assert.False(t, ctx.HasReadPerm(private.Name)) + assert.False(t, ctx.Can(rbac.ActionPull, resource)) // private project, authenticated, has read perm ctx = NewSecurityContext(guestUser, pm) - assert.True(t, ctx.HasReadPerm(private.Name)) + assert.True(t, ctx.Can(rbac.ActionPull, resource)) // private project, authenticated, system admin ctx = NewSecurityContext(&models.User{ Username: "admin", HasAdminRole: true, }, pm) - assert.True(t, ctx.HasReadPerm(private.Name)) + assert.True(t, ctx.Can(rbac.ActionPull, resource)) } func TestHasWritePerm(t *testing.T) { + resource := rbac.NewProjectNamespace(private.Name).Resource(rbac.ResourceRepository) + // unauthenticated ctx := NewSecurityContext(nil, pm) - assert.False(t, ctx.HasWritePerm(private.Name)) + assert.False(t, ctx.Can(rbac.ActionPush, resource)) // authenticated, has read perm ctx = NewSecurityContext(guestUser, pm) - assert.False(t, ctx.HasWritePerm(private.Name)) + assert.False(t, ctx.Can(rbac.ActionPush, resource)) // authenticated, has write perm ctx = NewSecurityContext(developerUser, pm) - assert.True(t, ctx.HasWritePerm(private.Name)) + assert.True(t, ctx.Can(rbac.ActionPush, resource)) // authenticated, system admin ctx = NewSecurityContext(&models.User{ Username: "admin", HasAdminRole: true, }, pm) - assert.True(t, ctx.HasReadPerm(private.Name)) + assert.True(t, ctx.Can(rbac.ActionPush, resource)) } func TestHasAllPerm(t *testing.T) { + resource := rbac.NewProjectNamespace(private.Name).Resource(rbac.ResourceRepository) + // unauthenticated ctx := NewSecurityContext(nil, pm) - assert.False(t, ctx.HasAllPerm(private.Name)) + assert.False(t, ctx.Can(rbac.ActionPushPull, resource)) // authenticated, has all perms ctx = NewSecurityContext(projectAdminUser, pm) - assert.True(t, ctx.HasAllPerm(private.Name)) + assert.True(t, ctx.Can(rbac.ActionPushPull, resource)) // authenticated, system admin ctx = NewSecurityContext(&models.User{ Username: "admin", HasAdminRole: true, }, pm) - assert.True(t, ctx.HasAllPerm(private.Name)) + assert.True(t, ctx.Can(rbac.ActionPushPull, resource)) } func TestHasAllPermWithGroup(t *testing.T) { @@ -285,10 +293,13 @@ func TestHasAllPermWithGroup(t *testing.T) { developer.GroupList = []*models.UserGroup{ {GroupName: "test_group", GroupType: 1, LdapGroupDN: "cn=harbor_user,dc=example,dc=com"}, } + + resource := rbac.NewProjectNamespace(project.Name).Resource(rbac.ResourceRepository) + ctx := NewSecurityContext(developer, pm) - assert.False(t, ctx.HasAllPerm(project.Name)) - assert.True(t, ctx.HasWritePerm(project.Name)) - assert.True(t, ctx.HasReadPerm(project.Name)) + assert.False(t, ctx.Can(rbac.ActionPushPull, resource)) + assert.True(t, ctx.Can(rbac.ActionPush, resource)) + assert.True(t, ctx.Can(rbac.ActionPull, resource)) } func TestGetMyProjects(t *testing.T) { diff --git a/src/common/security/robot/context.go b/src/common/security/robot/context.go new file mode 100644 index 000000000..49d80ef35 --- /dev/null +++ b/src/common/security/robot/context.go @@ -0,0 +1,88 @@ +// Copyright 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 robot + +import ( + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/core/promgr" +) + +// SecurityContext implements security.Context interface based on database +type SecurityContext struct { + robot *models.Robot + pm promgr.ProjectManager + policy []*rbac.Policy +} + +// NewSecurityContext ... +func NewSecurityContext(robot *models.Robot, pm promgr.ProjectManager, policy []*rbac.Policy) *SecurityContext { + return &SecurityContext{ + robot: robot, + pm: pm, + policy: policy, + } +} + +// IsAuthenticated returns true if the user has been authenticated +func (s *SecurityContext) IsAuthenticated() bool { + return s.robot != nil +} + +// GetUsername returns the username of the authenticated user +// It returns null if the user has not been authenticated +func (s *SecurityContext) GetUsername() string { + if !s.IsAuthenticated() { + return "" + } + return s.robot.Name +} + +// IsSysAdmin robot cannot be a system admin +func (s *SecurityContext) IsSysAdmin() bool { + return false +} + +// IsSolutionUser robot cannot be a system admin +func (s *SecurityContext) IsSolutionUser() bool { + return false +} + +// GetMyProjects no implementation +func (s *SecurityContext) GetMyProjects() ([]*models.Project, error) { + return nil, nil +} + +// GetProjectRoles no implementation +func (s *SecurityContext) GetProjectRoles(projectIDOrName interface{}) []int { + return nil +} + +// Can returns whether the robot can do action on resource +func (s *SecurityContext) Can(action rbac.Action, resource rbac.Resource) bool { + ns, err := resource.GetNamespace() + if err == nil { + switch ns.Kind() { + case "project": + projectIDOrName := ns.Identity() + isPublicProject, _ := s.pm.IsPublic(projectIDOrName) + projectNamespace := rbac.NewProjectNamespace(projectIDOrName, isPublicProject) + robot := NewRobot(s.GetUsername(), projectNamespace, s.policy) + return rbac.HasPermission(robot, resource, action) + } + } + + return false +} diff --git a/src/common/security/robot/context_test.go b/src/common/security/robot/context_test.go new file mode 100644 index 000000000..46225b52a --- /dev/null +++ b/src/common/security/robot/context_test.go @@ -0,0 +1,200 @@ +// Copyright 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 robot + +import ( + "os" + "strconv" + "testing" + + "github.com/goharbor/harbor/src/common/dao" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/promgr" + "github.com/goharbor/harbor/src/core/promgr/pmsdriver/local" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + private = &models.Project{ + Name: "testrobot", + OwnerID: 1, + } + pm = promgr.NewDefaultProjectManager(local.NewDriver(), true) +) + +func TestMain(m *testing.M) { + dbHost := os.Getenv("POSTGRESQL_HOST") + if len(dbHost) == 0 { + log.Fatalf("environment variable POSTGRES_HOST is not set") + } + dbUser := os.Getenv("POSTGRESQL_USR") + if len(dbUser) == 0 { + log.Fatalf("environment variable POSTGRES_USR is not set") + } + dbPortStr := os.Getenv("POSTGRESQL_PORT") + if len(dbPortStr) == 0 { + log.Fatalf("environment variable POSTGRES_PORT is not set") + } + dbPort, err := strconv.Atoi(dbPortStr) + if err != nil { + log.Fatalf("invalid POSTGRESQL_PORT: %v", err) + } + + dbPassword := os.Getenv("POSTGRESQL_PWD") + dbDatabase := os.Getenv("POSTGRESQL_DATABASE") + if len(dbDatabase) == 0 { + log.Fatalf("environment variable POSTGRESQL_DATABASE is not set") + } + + database := &models.Database{ + Type: "postgresql", + PostGreSQL: &models.PostGreSQL{ + Host: dbHost, + Port: dbPort, + Username: dbUser, + Password: dbPassword, + Database: dbDatabase, + }, + } + + log.Infof("POSTGRES_HOST: %s, POSTGRES_USR: %s, POSTGRES_PORT: %d, POSTGRES_PWD: %s\n", dbHost, dbUser, dbPort, dbPassword) + + if err := dao.InitDatabase(database); err != nil { + log.Fatalf("failed to initialize database: %v", err) + } + + // add project + id, err := dao.AddProject(*private) + if err != nil { + log.Fatalf("failed to add project: %v", err) + } + private.ProjectID = id + defer dao.DeleteProject(id) + + os.Exit(m.Run()) +} + +func TestIsAuthenticated(t *testing.T) { + // unauthenticated + ctx := NewSecurityContext(nil, nil, nil) + assert.False(t, ctx.IsAuthenticated()) + + // authenticated + ctx = NewSecurityContext(&models.Robot{ + Name: "test", + Disabled: false, + }, nil, nil) + assert.True(t, ctx.IsAuthenticated()) +} + +func TestGetUsername(t *testing.T) { + // unauthenticated + ctx := NewSecurityContext(nil, nil, nil) + assert.Equal(t, "", ctx.GetUsername()) + + // authenticated + ctx = NewSecurityContext(&models.Robot{ + Name: "test", + Disabled: false, + }, nil, nil) + assert.Equal(t, "test", ctx.GetUsername()) +} + +func TestIsSysAdmin(t *testing.T) { + // unauthenticated + ctx := NewSecurityContext(nil, nil, nil) + assert.False(t, ctx.IsSysAdmin()) + + // authenticated, non admin + ctx = NewSecurityContext(&models.Robot{ + Name: "test", + Disabled: false, + }, nil, nil) + assert.False(t, ctx.IsSysAdmin()) +} + +func TestIsSolutionUser(t *testing.T) { + ctx := NewSecurityContext(nil, nil, nil) + assert.False(t, ctx.IsSolutionUser()) +} + +func TestHasReadPerm(t *testing.T) { + + rbacPolicy := &rbac.Policy{ + Resource: "/project/testrobot/repository", + Action: "pull", + } + policies := []*rbac.Policy{} + policies = append(policies, rbacPolicy) + robot := &models.Robot{ + Name: "test_robot_1", + Description: "desc", + } + + ctx := NewSecurityContext(robot, pm, policies) + resource := rbac.NewProjectNamespace(private.Name).Resource(rbac.ResourceRepository) + assert.True(t, ctx.Can(rbac.ActionPull, resource)) +} + +func TestHasWritePerm(t *testing.T) { + + rbacPolicy := &rbac.Policy{ + Resource: "/project/testrobot/repository", + Action: "push", + } + policies := []*rbac.Policy{} + policies = append(policies, rbacPolicy) + robot := &models.Robot{ + Name: "test_robot_2", + Description: "desc", + } + + ctx := NewSecurityContext(robot, pm, policies) + resource := rbac.NewProjectNamespace(private.Name).Resource(rbac.ResourceRepository) + assert.True(t, ctx.Can(rbac.ActionPush, resource)) +} + +func TestHasAllPerm(t *testing.T) { + rbacPolicy := &rbac.Policy{ + Resource: "/project/testrobot/repository", + Action: "push+pull", + } + policies := []*rbac.Policy{} + policies = append(policies, rbacPolicy) + robot := &models.Robot{ + Name: "test_robot_3", + Description: "desc", + } + + ctx := NewSecurityContext(robot, pm, policies) + resource := rbac.NewProjectNamespace(private.Name).Resource(rbac.ResourceRepository) + assert.True(t, ctx.Can(rbac.ActionPushPull, resource)) +} + +func TestGetMyProjects(t *testing.T) { + ctx := NewSecurityContext(nil, nil, nil) + projects, err := ctx.GetMyProjects() + require.Nil(t, err) + assert.Nil(t, projects) +} + +func TestGetProjectRoles(t *testing.T) { + ctx := NewSecurityContext(nil, nil, nil) + roles := ctx.GetProjectRoles("test") + assert.Nil(t, roles) +} diff --git a/src/common/security/robot/robot.go b/src/common/security/robot/robot.go new file mode 100644 index 000000000..9bfec53a9 --- /dev/null +++ b/src/common/security/robot/robot.go @@ -0,0 +1,42 @@ +package robot + +import ( + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/common/rbac/project" +) + +// robot implement the rbac.User interface for project robot account +type robot struct { + username string + namespace rbac.Namespace + policy []*rbac.Policy +} + +// GetUserName get the robot name. +func (r *robot) GetUserName() string { + return r.username +} + +// GetPolicies ... +func (r *robot) GetPolicies() []*rbac.Policy { + policies := []*rbac.Policy{} + if r.namespace.IsPublic() { + policies = append(policies, project.PoliciesForPublicProject(r.namespace)...) + } + policies = append(policies, r.policy...) + return policies +} + +// GetRoles robot has no definition of role, always return nil here. +func (r *robot) GetRoles() []rbac.Role { + return nil +} + +// NewRobot ... +func NewRobot(username string, namespace rbac.Namespace, policy []*rbac.Policy) rbac.User { + return &robot{ + username: username, + namespace: namespace, + policy: policy, + } +} diff --git a/src/common/security/robot/robot_test.go b/src/common/security/robot/robot_test.go new file mode 100644 index 000000000..62acbe11f --- /dev/null +++ b/src/common/security/robot/robot_test.go @@ -0,0 +1,27 @@ +package robot + +import ( + "github.com/goharbor/harbor/src/common/rbac" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGetPolicies(t *testing.T) { + + rbacPolicy := &rbac.Policy{ + Resource: "/project/libray/repository", + Action: "pull", + } + policies := []*rbac.Policy{} + policies = append(policies, rbacPolicy) + + robot := robot{ + username: "test", + namespace: rbac.NewProjectNamespace("library", false), + policy: policies, + } + + assert.Equal(t, robot.GetUserName(), "test") + assert.NotNil(t, robot.GetPolicies()) + assert.Nil(t, robot.GetRoles()) +} diff --git a/src/common/security/secret/context.go b/src/common/security/secret/context.go index ac4a5b2e5..5dc06137a 100644 --- a/src/common/security/secret/context.go +++ b/src/common/security/secret/context.go @@ -19,6 +19,7 @@ import ( "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/common/secret" "github.com/goharbor/harbor/src/common/utils/log" ) @@ -70,27 +71,10 @@ func (s *SecurityContext) IsSolutionUser() bool { return s.IsAuthenticated() } -// HasReadPerm returns true if the corresponding user of the secret +// Can returns whether the user can do action on resource +// returns true if the corresponding user of the secret // is jobservice or core service, otherwise returns false -func (s *SecurityContext) HasReadPerm(projectIDOrName interface{}) bool { - if s.store == nil { - return false - } - return s.store.GetUsername(s.secret) == secret.JobserviceUser || s.store.GetUsername(s.secret) == secret.CoreUser -} - -// HasWritePerm returns true if the corresponding user of the secret -// is jobservice or core service, otherwise returns false -func (s *SecurityContext) HasWritePerm(projectIDOrName interface{}) bool { - if s.store == nil { - return false - } - return s.store.GetUsername(s.secret) == secret.JobserviceUser || s.store.GetUsername(s.secret) == secret.CoreUser -} - -// HasAllPerm returns true if the corresponding user of the secret -// is jobservice or core service, otherwise returns false -func (s *SecurityContext) HasAllPerm(projectIDOrName interface{}) bool { +func (s *SecurityContext) Can(action rbac.Action, resource rbac.Resource) bool { if s.store == nil { return false } @@ -105,7 +89,9 @@ func (s *SecurityContext) GetMyProjects() ([]*models.Project, error) { // GetProjectRoles return guest role if has read permission, otherwise return nil func (s *SecurityContext) GetProjectRoles(projectIDOrName interface{}) []int { roles := []int{} - if s.HasReadPerm(projectIDOrName) { + if s.store != nil && + (s.store.GetUsername(s.secret) == secret.JobserviceUser || + s.store.GetUsername(s.secret) == secret.CoreUser) { roles = append(roles, common.RoleGuest) } return roles diff --git a/src/common/security/secret/context_test.go b/src/common/security/secret/context_test.go index ace3d5dc6..2e743da2b 100644 --- a/src/common/security/secret/context_test.go +++ b/src/common/security/secret/context_test.go @@ -18,6 +18,7 @@ import ( "testing" "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/common/secret" "github.com/stretchr/testify/assert" ) @@ -96,9 +97,11 @@ func TestIsSolutionUser(t *testing.T) { } func TestHasReadPerm(t *testing.T) { + readAction := rbac.Action("pull") + resource := rbac.Resource("/project/project_name/repository") // secret store is null context := NewSecurityContext("", nil) - hasReadPerm := context.HasReadPerm("project_name") + hasReadPerm := context.Can(readAction, resource) assert.False(t, hasReadPerm) // invalid secret @@ -106,7 +109,7 @@ func TestHasReadPerm(t *testing.T) { secret.NewStore(map[string]string{ "jobservice_secret": secret.JobserviceUser, })) - hasReadPerm = context.HasReadPerm("project_name") + hasReadPerm = context.Can(readAction, resource) assert.False(t, hasReadPerm) // valid secret, project name @@ -114,11 +117,12 @@ func TestHasReadPerm(t *testing.T) { secret.NewStore(map[string]string{ "jobservice_secret": secret.JobserviceUser, })) - hasReadPerm = context.HasReadPerm("project_name") + hasReadPerm = context.Can(readAction, resource) assert.True(t, hasReadPerm) // valid secret, project ID - hasReadPerm = context.HasReadPerm(1) + resource = rbac.Resource("/project/1/repository") + hasReadPerm = context.Can(readAction, resource) assert.True(t, hasReadPerm) } @@ -128,12 +132,16 @@ func TestHasWritePerm(t *testing.T) { "secret": "username", })) + writeAction := rbac.Action("push") + // project name - hasWritePerm := context.HasWritePerm("project_name") + resource := rbac.Resource("/project/project_name/repository") + hasWritePerm := context.Can(writeAction, resource) assert.False(t, hasWritePerm) // project ID - hasWritePerm = context.HasWritePerm(1) + resource = rbac.Resource("/project/1/repository") + hasWritePerm = context.Can(writeAction, resource) assert.False(t, hasWritePerm) } @@ -143,12 +151,16 @@ func TestHasAllPerm(t *testing.T) { "secret": "username", })) + allAction := rbac.Action("push+pull") + // project name - hasAllPerm := context.HasAllPerm("project_name") + resource := rbac.Resource("/project/project_name/repository") + hasAllPerm := context.Can(allAction, resource) assert.False(t, hasAllPerm) // project ID - hasAllPerm = context.HasAllPerm(1) + resource = rbac.Resource("/project/1/repository") + hasAllPerm = context.Can(allAction, resource) assert.False(t, hasAllPerm) } diff --git a/src/common/token/claims.go b/src/common/token/claims.go new file mode 100644 index 000000000..4739f9d21 --- /dev/null +++ b/src/common/token/claims.go @@ -0,0 +1,29 @@ +package token + +import ( + "github.com/dgrijalva/jwt-go" + "github.com/goharbor/harbor/src/common/rbac" + "github.com/pkg/errors" +) + +// RobotClaims implements the interface of jwt.Claims +type RobotClaims struct { + jwt.StandardClaims + TokenID int64 `json:"id"` + ProjectID int64 `json:"pid"` + Access []*rbac.Policy `json:"access"` +} + +// Valid valid the claims "tokenID, projectID and access". +func (rc RobotClaims) Valid() error { + if rc.TokenID < 0 { + return errors.New("Token id must an valid INT") + } + if rc.ProjectID < 0 { + return errors.New("Project id must an valid INT") + } + if rc.Access == nil { + return errors.New("The access info cannot be nil") + } + return nil +} diff --git a/src/common/token/claims_test.go b/src/common/token/claims_test.go new file mode 100644 index 000000000..dc25a120a --- /dev/null +++ b/src/common/token/claims_test.go @@ -0,0 +1,68 @@ +package token + +import ( + "github.com/goharbor/harbor/src/common/rbac" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestValid(t *testing.T) { + + rbacPolicy := &rbac.Policy{ + Resource: "/project/libray/repository", + Action: "pull", + } + policies := []*rbac.Policy{} + policies = append(policies, rbacPolicy) + + rClaims := &RobotClaims{ + TokenID: 1, + ProjectID: 2, + Access: policies, + } + assert.Nil(t, rClaims.Valid()) +} + +func TestUnValidTokenID(t *testing.T) { + + rbacPolicy := &rbac.Policy{ + Resource: "/project/libray/repository", + Action: "pull", + } + policies := []*rbac.Policy{} + policies = append(policies, rbacPolicy) + + rClaims := &RobotClaims{ + TokenID: -1, + ProjectID: 2, + Access: policies, + } + assert.NotNil(t, rClaims.Valid()) +} + +func TestUnValidProjectID(t *testing.T) { + + rbacPolicy := &rbac.Policy{ + Resource: "/project/libray/repository", + Action: "pull", + } + policies := []*rbac.Policy{} + policies = append(policies, rbacPolicy) + + rClaims := &RobotClaims{ + TokenID: 1, + ProjectID: -2, + Access: policies, + } + assert.NotNil(t, rClaims.Valid()) +} + +func TestUnValidPolicy(t *testing.T) { + + rClaims := &RobotClaims{ + TokenID: 1, + ProjectID: 2, + Access: nil, + } + assert.NotNil(t, rClaims.Valid()) +} diff --git a/src/common/token/htoken.go b/src/common/token/htoken.go new file mode 100644 index 000000000..ac9067820 --- /dev/null +++ b/src/common/token/htoken.go @@ -0,0 +1,85 @@ +package token + +import ( + "crypto/ecdsa" + "crypto/rsa" + "errors" + "fmt" + "github.com/dgrijalva/jwt-go" + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/common/utils/log" + "time" +) + +// HToken htoken is a jwt token for harbor robot account, +// which contains the robot ID, project ID and the access permission for the project. +// It used for authn/authz for robot account in Harbor. +type HToken struct { + jwt.Token +} + +// New ... +func New(tokenID, projectID int64, access []*rbac.Policy) (*HToken, error) { + rClaims := &RobotClaims{ + TokenID: tokenID, + ProjectID: projectID, + Access: access, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: time.Now().Add(DefaultOptions.TTL).Unix(), + Issuer: DefaultOptions.Issuer, + }, + } + err := rClaims.Valid() + if err != nil { + return nil, err + } + return &HToken{ + Token: *jwt.NewWithClaims(DefaultOptions.SignMethod, rClaims), + }, nil +} + +// Raw get the Raw string of token +func (htk *HToken) Raw() (string, error) { + key, err := DefaultOptions.GetKey() + if err != nil { + return "", nil + } + raw, err := htk.Token.SignedString(key) + if err != nil { + log.Debugf(fmt.Sprintf("failed to issue token %v", err)) + return "", err + } + return raw, err +} + +// ParseWithClaims ... +func ParseWithClaims(rawToken string, claims jwt.Claims) (*HToken, error) { + key, err := DefaultOptions.GetKey() + if err != nil { + return nil, err + } + token, err := jwt.ParseWithClaims(rawToken, claims, func(token *jwt.Token) (interface{}, error) { + if token.Method.Alg() != DefaultOptions.SignMethod.Alg() { + return nil, errors.New("invalid signing method") + } + switch k := key.(type) { + case *rsa.PrivateKey: + return &k.PublicKey, nil + case *ecdsa.PrivateKey: + return &k.PublicKey, nil + default: + return key, nil + } + }) + if err != nil { + log.Errorf(fmt.Sprintf("parse token error, %v", err)) + return nil, err + } + if !token.Valid { + log.Errorf(fmt.Sprintf("invalid jwt token, %v", token)) + return nil, err + } + return &HToken{ + Token: *token, + }, nil +} diff --git a/src/common/token/htoken_test.go b/src/common/token/htoken_test.go new file mode 100644 index 000000000..58a853d94 --- /dev/null +++ b/src/common/token/htoken_test.go @@ -0,0 +1,77 @@ +package token + +import ( + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/common/utils/test" + "github.com/goharbor/harbor/src/core/config" + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +func TestMain(m *testing.M) { + server, err := test.NewAdminserver(nil) + if err != nil { + panic(err) + } + defer server.Close() + + if err := os.Setenv("ADMINSERVER_URL", server.URL); err != nil { + panic(err) + } + + if err := config.Init(); err != nil { + panic(err) + } + + result := m.Run() + if result != 0 { + os.Exit(result) + } +} + +func TestNew(t *testing.T) { + rbacPolicy := &rbac.Policy{ + Resource: "/project/libray/repository", + Action: "pull", + } + policies := []*rbac.Policy{} + policies = append(policies, rbacPolicy) + + tokenID := int64(123) + projectID := int64(321) + token, err := New(tokenID, projectID, policies) + + assert.Nil(t, err) + assert.Equal(t, token.Header["alg"], "RS256") + assert.Equal(t, token.Header["typ"], "JWT") + +} + +func TestRaw(t *testing.T) { + rbacPolicy := &rbac.Policy{ + Resource: "/project/library/repository", + Action: "pull", + } + policies := []*rbac.Policy{} + policies = append(policies, rbacPolicy) + + tokenID := int64(123) + projectID := int64(321) + + token, err := New(tokenID, projectID, policies) + assert.Nil(t, err) + + rawTk, err := token.Raw() + assert.Nil(t, err) + assert.NotNil(t, rawTk) +} + +func TestParseWithClaims(t *testing.T) { + rawTk := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6MTIzLCJQcm9qZWN0SUQiOjAsIkFjY2VzcyI6W3siUmVzb3VyY2UiOiIvcHJvamVjdC9saWJyYXkvcmVwb3NpdG9yeSIsIkFjdGlvbiI6InB1bGwiLCJFZmZlY3QiOiIifV0sIlN0YW5kYXJkQ2xhaW1zIjp7ImV4cCI6MTU0ODE0MDIyOSwiaXNzIjoiaGFyYm9yLXRva2VuLWlzc3VlciJ9fQ.Jc3qSKN4SJVUzAvBvemVpRcSOZaHlu0Avqms04qzPm4ru9-r9IRIl3mnSkI6m9XkzLUeJ7Kiwyw63ghngnVKw_PupeclOGC6s3TK5Cfmo4h-lflecXjZWwyy-dtH_e7Us_ItS-R3nXDJtzSLEpsGHCcAj-1X2s93RB2qD8LNSylvYeDezVkTzqRzzfawPJheKKh9JTrz-3eUxCwQard9-xjlwvfUYULoHTn9npNAUq4-jqhipW4uE8HL-ym33AGF57la8U0RO11hmDM5K8-PiYknbqJ_oONeS3HBNym2pEFeGjtTv2co213wl4T5lemlg4SGolMBuJ03L7_beVZ0o-MKTkKDqDwJalb6_PM-7u3RbxC9IzJMiwZKIPnD3FvV10iPxUUQHaH8Jz5UZ2pFIhi_8BNnlBfT0JOPFVYATtLjHMczZelj2YvAeR1UHBzq3E0jPpjjwlqIFgaHCaN_KMwEvadTo_Fi2sEH4pNGP7M3yehU_72oLJQgF4paJarsmEoij6ZtPs6xekBz1fccVitq_8WNIz9aeCUdkUBRwI5QKw1RdW4ua-w74ld5MZStWJA8veyoLkEb_Q9eq2oAj5KWFjJbW5-ltiIfM8gxKflsrkWAidYGcEIYcuXr7UdqEKXxtPiWM0xb3B91ovYvO5402bn3f9-UGtlcestxNHA" + rClaims := &RobotClaims{} + _, _ = ParseWithClaims(rawTk, rClaims) + assert.Equal(t, int64(123), rClaims.TokenID) + assert.Equal(t, int64(0), rClaims.ProjectID) + assert.Equal(t, "/project/libray/repository", rClaims.Access[0].Resource.String()) +} diff --git a/src/common/token/options.go b/src/common/token/options.go new file mode 100644 index 000000000..a3328d82e --- /dev/null +++ b/src/common/token/options.go @@ -0,0 +1,83 @@ +package token + +import ( + "crypto/rsa" + "fmt" + "github.com/dgrijalva/jwt-go" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/config" + "io/ioutil" + "time" +) + +const ( + ttl = 60 * time.Minute + issuer = "harbor-token-issuer" + signedMethod = "RS256" +) + +var ( + privateKey = config.TokenPrivateKeyPath() + // DefaultOptions ... + DefaultOptions = NewOptions() +) + +// Options ... +type Options struct { + SignMethod jwt.SigningMethod + PublicKey []byte + PrivateKey []byte + TTL time.Duration + Issuer string +} + +// NewOptions ... +func NewOptions() *Options { + privateKey, err := ioutil.ReadFile(privateKey) + if err != nil { + log.Errorf(fmt.Sprintf("failed to read private key %v", err)) + return nil + } + opt := &Options{ + SignMethod: jwt.GetSigningMethod(signedMethod), + PrivateKey: privateKey, + Issuer: issuer, + TTL: ttl, + } + return opt +} + +// GetKey ... +func (o *Options) GetKey() (interface{}, error) { + var err error + var privateKey *rsa.PrivateKey + var publicKey *rsa.PublicKey + + switch o.SignMethod.(type) { + case *jwt.SigningMethodRSA, *jwt.SigningMethodRSAPSS: + if len(o.PrivateKey) > 0 { + privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(o.PrivateKey) + if err != nil { + return nil, err + } + } + if len(o.PublicKey) > 0 { + publicKey, err = jwt.ParseRSAPublicKeyFromPEM(o.PublicKey) + if err != nil { + return nil, err + } + } + if privateKey == nil { + if publicKey != nil { + return publicKey, nil + } + return nil, fmt.Errorf("key is provided") + } + if publicKey != nil && publicKey.E != privateKey.E && publicKey.N.Cmp(privateKey.N) != 0 { + return nil, fmt.Errorf("the public key and private key are not match") + } + return privateKey, nil + default: + return nil, fmt.Errorf(fmt.Sprintf("unsupported sign method, %s", o.SignMethod)) + } +} diff --git a/src/common/token/options_test.go b/src/common/token/options_test.go new file mode 100644 index 000000000..660975fff --- /dev/null +++ b/src/common/token/options_test.go @@ -0,0 +1,23 @@ +package token + +import ( + "github.com/dgrijalva/jwt-go" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestNewOptions(t *testing.T) { + defaultOpt := DefaultOptions + assert.NotNil(t, defaultOpt) + assert.Equal(t, defaultOpt.SignMethod, jwt.GetSigningMethod("RS256")) + assert.Equal(t, defaultOpt.Issuer, "harbor-token-issuer") + assert.Equal(t, defaultOpt.TTL, 60*time.Minute) +} + +func TestGetKey(t *testing.T) { + defaultOpt := DefaultOptions + key, err := defaultOpt.GetKey() + assert.Nil(t, err) + assert.NotNil(t, key) +} 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/base.go b/src/core/api/base.go index 4e8a8ad57..22bc2c059 100644 --- a/src/core/api/base.go +++ b/src/core/api/base.go @@ -125,6 +125,7 @@ func (b *BaseController) WriteYamlData(object interface{}) { // Init related objects/configurations for the API controllers func Init() error { + registerHealthCheckers() // If chart repository is not enabled then directly return if !config.WithChartMuseum() { return nil diff --git a/src/core/api/chart_label.go b/src/core/api/chart_label.go index 2b6f697e0..f3de48c9c 100644 --- a/src/core/api/chart_label.go +++ b/src/core/api/chart_label.go @@ -6,6 +6,7 @@ import ( "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" ) const ( @@ -45,12 +46,6 @@ func (cla *ChartLabelAPI) Prepare() { } cla.project = existingProject - // Check permission - if !cla.checkPermissions(project) { - cla.SendForbiddenError(errors.New(cla.SecurityCtx.GetUsername())) - return - } - // Check the existence of target chart chartName := cla.GetStringFromPath(nameParam) version := cla.GetStringFromPath(versionParam) @@ -62,8 +57,23 @@ func (cla *ChartLabelAPI) Prepare() { cla.chartFullName = fmt.Sprintf("%s/%s:%s", project, chartName, version) } +func (cla *ChartLabelAPI) requireAccess(action rbac.Action) bool { + resource := rbac.NewProjectNamespace(cla.project.ProjectID).Resource(rbac.ResourceHelmChartVersionLabel) + + if !cla.SecurityCtx.Can(action, resource) { + cla.HandleForbidden(cla.SecurityCtx.GetUsername()) + return false + } + + return true +} + // MarkLabel handles the request of marking label to chart. func (cla *ChartLabelAPI) MarkLabel() { + if !cla.requireAccess(rbac.ActionCreate) { + return + } + l := &models.Label{} cla.DecodeJSONReq(l) @@ -83,6 +93,10 @@ func (cla *ChartLabelAPI) MarkLabel() { // RemoveLabel handles the request of removing label from chart. func (cla *ChartLabelAPI) RemoveLabel() { + if !cla.requireAccess(rbac.ActionDelete) { + return + } + lID, err := cla.GetInt64FromPath(idParam) if err != nil { cla.SendInternalServerError(err) diff --git a/src/core/api/chart_repository.go b/src/core/api/chart_repository.go index c595a790b..927bf2c09 100644 --- a/src/core/api/chart_repository.go +++ b/src/core/api/chart_repository.go @@ -15,6 +15,7 @@ import ( "github.com/goharbor/harbor/src/core/label" "github.com/goharbor/harbor/src/chartserver" + "github.com/goharbor/harbor/src/common/rbac" hlog "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/config" ) @@ -84,10 +85,35 @@ func (cra *ChartRepositoryAPI) Prepare() { cra.labelManager = &label.BaseManager{} } +func (cra *ChartRepositoryAPI) requireAccess(action rbac.Action, subresource ...rbac.Resource) bool { + if len(subresource) == 0 { + subresource = append(subresource, rbac.ResourceHelmChart) + } + resource := rbac.NewProjectNamespace(cra.namespace).Resource(subresource...) + + if !cra.SecurityCtx.Can(action, resource) { + if !cra.SecurityCtx.IsAuthenticated() { + cra.SendUnAuthorizedError(errors.New("Unauthorized")) + } else { + cra.HandleForbidden(cra.SecurityCtx.GetUsername()) + } + + return false + } + + return true +} + // GetHealthStatus handles GET /api/chartrepo/health func (cra *ChartRepositoryAPI) GetHealthStatus() { // Check access - if !cra.requireAccess(cra.namespace, accessLevelSystem) { + if !cra.SecurityCtx.IsAuthenticated() { + cra.SendUnAuthorizedError(errors.New("Unauthorized")) + return + } + + if !cra.SecurityCtx.IsSysAdmin() { + cra.HandleForbidden(cra.SecurityCtx.GetUsername()) return } @@ -98,7 +124,7 @@ func (cra *ChartRepositoryAPI) GetHealthStatus() { // GetIndexByRepo handles GET /:repo/index.yaml func (cra *ChartRepositoryAPI) GetIndexByRepo() { // Check access - if !cra.requireAccess(cra.namespace, accessLevelRead) { + if !cra.requireAccess(rbac.ActionRead) { return } @@ -109,7 +135,13 @@ func (cra *ChartRepositoryAPI) GetIndexByRepo() { // GetIndex handles GET /index.yaml func (cra *ChartRepositoryAPI) GetIndex() { // Check access - if !cra.requireAccess(cra.namespace, accessLevelSystem) { + if !cra.SecurityCtx.IsAuthenticated() { + cra.SendUnAuthorizedError(errors.New("Unauthorized")) + return + } + + if !cra.SecurityCtx.IsSysAdmin() { + cra.HandleForbidden(cra.SecurityCtx.GetUsername()) return } @@ -136,7 +168,7 @@ func (cra *ChartRepositoryAPI) GetIndex() { // DownloadChart handles GET /:repo/charts/:filename func (cra *ChartRepositoryAPI) DownloadChart() { // Check access - if !cra.requireAccess(cra.namespace, accessLevelRead) { + if !cra.requireAccess(rbac.ActionRead) { return } @@ -147,7 +179,7 @@ func (cra *ChartRepositoryAPI) DownloadChart() { // ListCharts handles GET /api/:repo/charts func (cra *ChartRepositoryAPI) ListCharts() { // Check access - if !cra.requireAccess(cra.namespace, accessLevelRead) { + if !cra.requireAccess(rbac.ActionList) { return } @@ -163,7 +195,7 @@ func (cra *ChartRepositoryAPI) ListCharts() { // ListChartVersions GET /api/:repo/charts/:name func (cra *ChartRepositoryAPI) ListChartVersions() { // Check access - if !cra.requireAccess(cra.namespace, accessLevelRead) { + if !cra.requireAccess(rbac.ActionList, rbac.ResourceHelmChartVersion) { return } @@ -191,7 +223,7 @@ func (cra *ChartRepositoryAPI) ListChartVersions() { // GetChartVersion handles GET /api/:repo/charts/:name/:version func (cra *ChartRepositoryAPI) GetChartVersion() { // Check access - if !cra.requireAccess(cra.namespace, accessLevelRead) { + if !cra.requireAccess(rbac.ActionRead, rbac.ResourceHelmChartVersion) { return } @@ -219,7 +251,7 @@ func (cra *ChartRepositoryAPI) GetChartVersion() { // DeleteChartVersion handles DELETE /api/:repo/charts/:name/:version func (cra *ChartRepositoryAPI) DeleteChartVersion() { // Check access - if !cra.requireAccess(cra.namespace, accessLevelAll) { + if !cra.requireAccess(rbac.ActionDelete, rbac.ResourceHelmChartVersion) { return } @@ -244,7 +276,7 @@ func (cra *ChartRepositoryAPI) UploadChartVersion() { hlog.Debugf("Header of request of uploading chart: %#v, content-len=%d", cra.Ctx.Request.Header, cra.Ctx.Request.ContentLength) // Check access - if !cra.requireAccess(cra.namespace, accessLevelWrite) { + if !cra.requireAccess(rbac.ActionCreate, rbac.ResourceHelmChartVersion) { return } @@ -272,7 +304,7 @@ func (cra *ChartRepositoryAPI) UploadChartVersion() { // UploadChartProvFile handles POST /api/:repo/prov func (cra *ChartRepositoryAPI) UploadChartProvFile() { // Check access - if !cra.requireAccess(cra.namespace, accessLevelWrite) { + if !cra.requireAccess(rbac.ActionCreate) { return } @@ -297,7 +329,7 @@ func (cra *ChartRepositoryAPI) UploadChartProvFile() { // DeleteChart deletes all the chart versions of the specified chart. func (cra *ChartRepositoryAPI) DeleteChart() { // Check access - if !cra.requireAccess(cra.namespace, accessLevelWrite) { + if !cra.requireAccess(rbac.ActionDelete) { return } @@ -365,62 +397,6 @@ func (cra *ChartRepositoryAPI) requireNamespace(namespace string) bool { return true } -// Check if the related access match the expected requirement -// If with right access, return true -// If without right access, return false -func (cra *ChartRepositoryAPI) requireAccess(namespace string, accessLevel uint) bool { - if accessLevel == accessLevelPublic { - return true // do nothing - } - - theLevel := accessLevel - // If repo is empty, system admin role must be required - if len(namespace) == 0 { - theLevel = accessLevelSystem - } - - var err error - - switch theLevel { - // Should be system admin role - case accessLevelSystem: - if !cra.SecurityCtx.IsSysAdmin() { - err = errors.New("permission denied: system admin role is required") - } - case accessLevelAll: - if !cra.SecurityCtx.HasAllPerm(namespace) { - err = errors.New("permission denied: project admin or higher role is required") - } - case accessLevelWrite: - if !cra.SecurityCtx.HasWritePerm(namespace) { - err = errors.New("permission denied: developer or higher role is required") - } - case accessLevelRead: - if !cra.SecurityCtx.HasReadPerm(namespace) { - err = errors.New("permission denied: guest or higher role is required") - } - default: - // access rejected for invalid scope - cra.SendForbiddenError(errors.New("unrecognized access scope")) - return false - } - - // Access is not granted, check if user has authenticated - if err != nil { - // Unauthenticated, return 401 - if !cra.SecurityCtx.IsAuthenticated() { - cra.SendUnAuthorizedError(errors.New("Unauthorized")) - return false - } - - // Authenticated, return 403 - cra.SendForbiddenError(err) - return false - } - - return true -} - // formFile is used to represent the uploaded files in the form type formFile struct { // form field key contains the form file diff --git a/src/core/api/chart_repository_test.go b/src/core/api/chart_repository_test.go index 030fa85a8..d095ca71f 100644 --- a/src/core/api/chart_repository_test.go +++ b/src/core/api/chart_repository_test.go @@ -8,6 +8,7 @@ import ( "github.com/goharbor/harbor/src/chartserver" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/core/promgr/metamgr" ) @@ -16,29 +17,6 @@ var ( crMockServer *httptest.Server ) -// Test access checking -func TestRequireAccess(t *testing.T) { - chartAPI := &ChartRepositoryAPI{} - chartAPI.SecurityCtx = &mockSecurityContext{} - - ns := "library" - if !chartAPI.requireAccess(ns, accessLevelPublic) { - t.Fatal("expect true result (public access level is granted) but got false") - } - if !chartAPI.requireAccess(ns, accessLevelAll) { - t.Fatal("expect true result (admin has all perm) but got false") - } - if !chartAPI.requireAccess(ns, accessLevelRead) { - t.Fatal("expect true result (admin has read perm) but got false") - } - if !chartAPI.requireAccess(ns, accessLevelWrite) { - t.Fatal("expect true result (admin has write perm) but got false") - } - if !chartAPI.requireAccess(ns, accessLevelSystem) { - t.Fatal("expect true result (admin has system perm) but got false") - } -} - func TestIsMultipartFormData(t *testing.T) { req, err := createRequest(http.MethodPost, "/api/chartrepo/charts") if err != nil { @@ -204,7 +182,7 @@ func TestDeleteChart(t *testing.T) { request: &testingRequest{ url: "/api/chartrepo/library/charts/harbor", method: http.MethodDelete, - credential: projDeveloper, + credential: projAdmin, }, code: http.StatusOK, }) @@ -309,8 +287,15 @@ func (msc *mockSecurityContext) IsSolutionUser() bool { return false } -// HasReadPerm returns whether the user has read permission to the project -func (msc *mockSecurityContext) HasReadPerm(projectIDOrName interface{}) bool { +// Can returns whether the user can do action on resource +func (msc *mockSecurityContext) Can(action rbac.Action, resource rbac.Resource) bool { + namespace, err := resource.GetNamespace() + if err != nil || namespace.Kind() != "project" { + return false + } + + projectIDOrName := namespace.Identity() + if projectIDOrName == nil { return false } @@ -324,26 +309,6 @@ func (msc *mockSecurityContext) HasReadPerm(projectIDOrName interface{}) bool { return false } -// HasWritePerm returns whether the user has write permission to the project -func (msc *mockSecurityContext) HasWritePerm(projectIDOrName interface{}) bool { - if projectIDOrName == nil { - return false - } - - if ns, ok := projectIDOrName.(string); ok { - if ns == "library" { - return true - } - } - - return false -} - -// HasAllPerm returns whether the user has all permissions to the project -func (msc *mockSecurityContext) HasAllPerm(projectIDOrName interface{}) bool { - return msc.HasReadPerm(projectIDOrName) && msc.HasWritePerm(projectIDOrName) -} - // Get current user's all project func (msc *mockSecurityContext) GetMyProjects() ([]*models.Project, error) { return []*models.Project{{ProjectID: 0, Name: "library"}}, nil diff --git a/src/core/api/harborapi_test.go b/src/core/api/harborapi_test.go index 17e1a1e58..277b31086 100644 --- a/src/core/api/harborapi_test.go +++ b/src/core/api/harborapi_test.go @@ -34,6 +34,7 @@ import ( "github.com/goharbor/harbor/src/core/config" "github.com/goharbor/harbor/src/core/filter" "github.com/goharbor/harbor/tests/apitests/apilib" + // "strconv" // "strings" @@ -96,12 +97,14 @@ func init() { filter.Init() beego.InsertFilter("/*", beego.BeforeRouter, filter.SecurityFilter) + beego.Router("/api/health", &HealthAPI{}, "get:CheckHealth") beego.Router("/api/search/", &SearchAPI{}) beego.Router("/api/projects/", &ProjectAPI{}, "get:List;post:Post;head:Head") beego.Router("/api/projects/:id", &ProjectAPI{}, "delete:Delete;get:Get;put:Put") beego.Router("/api/users/:id", &UserAPI{}, "get:Get") beego.Router("/api/users", &UserAPI{}, "get:List;post:Post;delete:Delete;put:Put") beego.Router("/api/users/:id([0-9]+)/password", &UserAPI{}, "put:ChangePassword") + beego.Router("/api/users/:id/permissions", &UserAPI{}, "get:ListUserPermissions") beego.Router("/api/users/:id/sysadmin", &UserAPI{}, "put:ToggleUserAdminRole") beego.Router("/api/projects/:id([0-9]+)/logs", &ProjectAPI{}, "get:Logs") beego.Router("/api/projects/:id([0-9]+)/_deletable", &ProjectAPI{}, "get:Deletable") @@ -152,6 +155,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") @@ -992,6 +998,23 @@ func (a testapi) UsersUpdatePassword(userID int, password apilib.Password, authI return httpStatusCode, err } +func (a testapi) UsersGetPermissions(userID interface{}, scope string, authInfo usrInfo) (int, []apilib.Permission, error) { + _sling := sling.New().Get(a.basePath) + // create path and map variables + path := fmt.Sprintf("/api/users/%v/permissions", userID) + _sling = _sling.Path(path) + type QueryParams struct { + Scope string `url:"scope,omitempty"` + } + _sling = _sling.QueryStruct(&QueryParams{Scope: scope}) + httpStatusCode, body, err := request(_sling, jsonAcceptHeader, authInfo) + var successPayLoad []apilib.Permission + if 200 == httpStatusCode && nil == err { + err = json.Unmarshal(body, &successPayLoad) + } + return httpStatusCode, successPayLoad, err +} + // Mark a registered user as be removed. func (a testapi) UsersDelete(userID int, authInfo usrInfo) (int, error) { _sling := sling.New().Delete(a.basePath) diff --git a/src/core/api/health.go b/src/core/api/health.go new file mode 100644 index 000000000..1a43ab68e --- /dev/null +++ b/src/core/api/health.go @@ -0,0 +1,323 @@ +// Copyright 2019 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 ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "sync" + "time" + + "github.com/goharbor/harbor/src/common/utils" + + "github.com/goharbor/harbor/src/common/dao" + httputil "github.com/goharbor/harbor/src/common/http" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/config" + + "github.com/docker/distribution/health" + "github.com/gomodule/redigo/redis" +) + +var ( + timeout = 60 * time.Second + healthCheckerRegistry = map[string]health.Checker{} +) + +type overallHealthStatus struct { + Status string `json:"status"` + Components []*componentHealthStatus `json:"components"` +} + +type componentHealthStatus struct { + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +type healthy bool + +func (h healthy) String() string { + if h { + return "healthy" + } + return "unhealthy" +} + +// HealthAPI handles the request for "/api/health" +type HealthAPI struct { + BaseController +} + +// CheckHealth checks the health of system +func (h *HealthAPI) CheckHealth() { + var isHealthy healthy = true + components := []*componentHealthStatus{} + c := make(chan *componentHealthStatus, len(healthCheckerRegistry)) + for name, checker := range healthCheckerRegistry { + go check(name, checker, timeout, c) + } + for i := 0; i < len(healthCheckerRegistry); i++ { + componentStatus := <-c + if len(componentStatus.Error) != 0 { + isHealthy = false + } + components = append(components, componentStatus) + } + status := &overallHealthStatus{} + status.Status = isHealthy.String() + status.Components = components + if !isHealthy { + log.Debugf("unhealthy system status: %v", status) + } + h.WriteJSONData(status) +} + +func check(name string, checker health.Checker, + timeout time.Duration, c chan *componentHealthStatus) { + statusChan := make(chan *componentHealthStatus) + go func() { + err := checker.Check() + var healthy healthy = err == nil + status := &componentHealthStatus{ + Name: name, + Status: healthy.String(), + } + if !healthy { + status.Error = err.Error() + } + statusChan <- status + }() + + select { + case status := <-statusChan: + c <- status + case <-time.After(timeout): + var healthy healthy = false + c <- &componentHealthStatus{ + Name: name, + Status: healthy.String(), + Error: "failed to check the health status: timeout", + } + } +} + +// HTTPStatusCodeHealthChecker implements a Checker to check that the HTTP status code +// returned matches the expected one +func HTTPStatusCodeHealthChecker(method string, url string, header http.Header, + timeout time.Duration, statusCode int) health.Checker { + return health.CheckFunc(func() error { + req, err := http.NewRequest(method, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %v", err) + } + for key, values := range header { + for _, value := range values { + req.Header.Add(key, value) + } + } + + client := httputil.NewClient(&http.Client{ + Timeout: timeout, + }) + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to check health: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != statusCode { + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Debugf("failed to read response body: %v", err) + } + return fmt.Errorf("received unexpected status code: %d %s", resp.StatusCode, string(data)) + } + + return nil + }) +} + +type updater struct { + sync.Mutex + status error +} + +func (u *updater) Check() error { + u.Lock() + defer u.Unlock() + + return u.status +} + +func (u *updater) update(status error) { + u.Lock() + defer u.Unlock() + + u.status = status +} + +// PeriodicHealthChecker implements a Checker to check status periodically +func PeriodicHealthChecker(checker health.Checker, period time.Duration) health.Checker { + u := &updater{ + // init the "status" as "unknown status" error to avoid returning nil error(which means healthy) + // before the first health check request finished + status: errors.New("unknown status"), + } + + go func() { + ticker := time.NewTicker(period) + for { + u.update(checker.Check()) + <-ticker.C + } + }() + + return u +} + +func coreHealthChecker() health.Checker { + return health.CheckFunc(func() error { + return nil + }) +} + +func portalHealthChecker() health.Checker { + url := config.GetPortalURL() + timeout := 60 * time.Second + period := 10 * time.Second + checker := HTTPStatusCodeHealthChecker(http.MethodGet, url, nil, timeout, http.StatusOK) + return PeriodicHealthChecker(checker, period) +} + +func jobserviceHealthChecker() health.Checker { + url := config.InternalJobServiceURL() + "/api/v1/stats" + timeout := 60 * time.Second + period := 10 * time.Second + checker := HTTPStatusCodeHealthChecker(http.MethodGet, url, nil, timeout, http.StatusOK) + return PeriodicHealthChecker(checker, period) +} + +func registryHealthChecker() health.Checker { + url := getRegistryURL() + "/v2" + timeout := 60 * time.Second + period := 10 * time.Second + checker := HTTPStatusCodeHealthChecker(http.MethodGet, url, nil, timeout, http.StatusUnauthorized) + return PeriodicHealthChecker(checker, period) +} + +func registryCtlHealthChecker() health.Checker { + url := config.GetRegistryCtlURL() + "/api/health" + timeout := 60 * time.Second + period := 10 * time.Second + checker := HTTPStatusCodeHealthChecker(http.MethodGet, url, nil, timeout, http.StatusOK) + return PeriodicHealthChecker(checker, period) +} + +func chartmuseumHealthChecker() health.Checker { + url, err := config.GetChartMuseumEndpoint() + if err != nil { + log.Errorf("failed to get the URL of chartmuseum: %v", err) + } + url = url + "/health" + timeout := 60 * time.Second + period := 10 * time.Second + checker := HTTPStatusCodeHealthChecker(http.MethodGet, url, nil, timeout, http.StatusOK) + return PeriodicHealthChecker(checker, period) +} + +func clairHealthChecker() health.Checker { + url := config.GetClairHealthCheckServerURL() + "/health" + timeout := 60 * time.Second + period := 10 * time.Second + checker := HTTPStatusCodeHealthChecker(http.MethodGet, url, nil, timeout, http.StatusOK) + return PeriodicHealthChecker(checker, period) +} + +func notaryHealthChecker() health.Checker { + url := config.InternalNotaryEndpoint() + "/_notary_server/health" + timeout := 60 * time.Second + period := 10 * time.Second + checker := HTTPStatusCodeHealthChecker(http.MethodGet, url, nil, timeout, http.StatusOK) + return PeriodicHealthChecker(checker, period) +} + +func databaseHealthChecker() health.Checker { + period := 10 * time.Second + checker := health.CheckFunc(func() error { + _, err := dao.GetOrmer().Raw("SELECT 1").Exec() + if err != nil { + return fmt.Errorf("failed to run SQL \"SELECT 1\": %v", err) + } + return nil + }) + return PeriodicHealthChecker(checker, period) +} + +func redisHealthChecker() health.Checker { + url := config.GetRedisOfRegURL() + timeout := 60 * time.Second + period := 10 * time.Second + checker := health.CheckFunc(func() error { + conn, err := redis.DialURL(url, + redis.DialConnectTimeout(timeout*time.Second), + redis.DialReadTimeout(timeout*time.Second), + redis.DialWriteTimeout(timeout*time.Second)) + if err != nil { + return fmt.Errorf("failed to establish connection with Redis: %v", err) + } + defer conn.Close() + _, err = conn.Do("PING") + if err != nil { + return fmt.Errorf("failed to run \"PING\": %v", err) + } + return nil + }) + return PeriodicHealthChecker(checker, period) +} + +func registerHealthCheckers() { + healthCheckerRegistry["core"] = coreHealthChecker() + healthCheckerRegistry["portal"] = portalHealthChecker() + healthCheckerRegistry["jobservice"] = jobserviceHealthChecker() + healthCheckerRegistry["registry"] = registryHealthChecker() + healthCheckerRegistry["registryctl"] = registryCtlHealthChecker() + healthCheckerRegistry["database"] = databaseHealthChecker() + healthCheckerRegistry["redis"] = redisHealthChecker() + if config.WithChartMuseum() { + healthCheckerRegistry["chartmuseum"] = chartmuseumHealthChecker() + } + if config.WithClair() { + healthCheckerRegistry["clair"] = clairHealthChecker() + } + if config.WithNotary() { + healthCheckerRegistry["notary"] = notaryHealthChecker() + } +} + +func getRegistryURL() string { + endpoint, err := config.RegistryURL() + if err != nil { + log.Errorf("failed to get the URL of registry: %v", err) + return "" + } + url, err := utils.ParseEndpoint(endpoint) + if err != nil { + log.Errorf("failed to parse the URL of registry: %v", err) + return "" + } + return url.String() +} diff --git a/src/core/api/health_test.go b/src/core/api/health_test.go new file mode 100644 index 000000000..8426a74b1 --- /dev/null +++ b/src/core/api/health_test.go @@ -0,0 +1,134 @@ +// Copyright 2019 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 ( + "errors" + "net/http" + "testing" + "time" + + "github.com/docker/distribution/health" + "github.com/goharbor/harbor/src/common/utils/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStringOfHealthy(t *testing.T) { + var isHealthy healthy = true + assert.Equal(t, "healthy", isHealthy.String()) + isHealthy = false + assert.Equal(t, "unhealthy", isHealthy.String()) +} + +func TestUpdater(t *testing.T) { + updater := &updater{} + assert.Equal(t, nil, updater.Check()) + updater.status = errors.New("unhealthy") + assert.Equal(t, "unhealthy", updater.Check().Error()) +} + +func TestHTTPStatusCodeHealthChecker(t *testing.T) { + handler := &test.RequestHandlerMapping{ + Method: http.MethodGet, + Pattern: "/health", + Handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + } + server := test.NewServer(handler) + defer server.Close() + + url := server.URL + "/health" + checker := HTTPStatusCodeHealthChecker( + http.MethodGet, url, map[string][]string{ + "key": {"value"}, + }, 5*time.Second, http.StatusOK) + assert.Equal(t, nil, checker.Check()) + + checker = HTTPStatusCodeHealthChecker( + http.MethodGet, url, nil, 5*time.Second, http.StatusUnauthorized) + assert.NotEqual(t, nil, checker.Check()) +} + +func TestPeriodicHealthChecker(t *testing.T) { + firstCheck := true + checkFunc := func() error { + time.Sleep(2 * time.Second) + if firstCheck { + firstCheck = false + return nil + } + return errors.New("unhealthy") + } + + checker := PeriodicHealthChecker(health.CheckFunc(checkFunc), 1*time.Second) + assert.Equal(t, "unknown status", checker.Check().Error()) + time.Sleep(3 * time.Second) + assert.Equal(t, nil, checker.Check()) + time.Sleep(3 * time.Second) + assert.Equal(t, "unhealthy", checker.Check().Error()) +} + +func fakeHealthChecker(healthy bool) health.Checker { + return health.CheckFunc(func() error { + if healthy { + return nil + } + return errors.New("unhealthy") + }) +} +func TestCheckHealth(t *testing.T) { + // component01: healthy, component02: healthy => status: healthy + healthCheckerRegistry = map[string]health.Checker{} + healthCheckerRegistry["component01"] = fakeHealthChecker(true) + healthCheckerRegistry["component02"] = fakeHealthChecker(true) + status := map[string]interface{}{} + err := handleAndParse(&testingRequest{ + method: http.MethodGet, + url: "/api/health", + }, &status) + require.Nil(t, err) + assert.Equal(t, "healthy", status["status"].(string)) + + // component01: healthy, component02: unhealthy => status: unhealthy + healthCheckerRegistry = map[string]health.Checker{} + healthCheckerRegistry["component01"] = fakeHealthChecker(true) + healthCheckerRegistry["component02"] = fakeHealthChecker(false) + status = map[string]interface{}{} + err = handleAndParse(&testingRequest{ + method: http.MethodGet, + url: "/api/health", + }, &status) + require.Nil(t, err) + assert.Equal(t, "unhealthy", status["status"].(string)) +} + +func TestCoreHealthChecker(t *testing.T) { + checker := coreHealthChecker() + assert.Equal(t, nil, checker.Check()) +} + +func TestDatabaseHealthChecker(t *testing.T) { + checker := databaseHealthChecker() + time.Sleep(1 * time.Second) + assert.Equal(t, nil, checker.Check()) +} + +func TestRegisterHealthCheckers(t *testing.T) { + healthCheckerRegistry = map[string]health.Checker{} + registerHealthCheckers() + assert.NotNil(t, healthCheckerRegistry["core"]) +} diff --git a/src/core/api/label.go b/src/core/api/label.go index 1bf0b18c2..702e3cc37 100644 --- a/src/core/api/label.go +++ b/src/core/api/label.go @@ -22,6 +22,7 @@ import ( "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/replication" "github.com/goharbor/harbor/src/replication/core" rep_models "github.com/goharbor/harbor/src/replication/models" @@ -65,15 +66,36 @@ func (l *LabelAPI) Prepare() { return } - if label.Scope == common.LabelScopeGlobal && !l.SecurityCtx.IsSysAdmin() || - label.Scope == common.LabelScopeProject && !l.SecurityCtx.HasAllPerm(label.ProjectID) { - l.HandleForbidden(l.SecurityCtx.GetUsername()) - return - } l.label = label } } +func (l *LabelAPI) requireAccess(label *models.Label, action rbac.Action, subresources ...rbac.Resource) bool { + var hasPermission bool + + switch label.Scope { + case common.LabelScopeGlobal: + hasPermission = l.SecurityCtx.IsSysAdmin() + case common.LabelScopeProject: + if len(subresources) == 0 { + subresources = append(subresources, rbac.ResourceLabel) + } + resource := rbac.NewProjectNamespace(label.ProjectID).Resource(subresources...) + hasPermission = l.SecurityCtx.Can(action, resource) + } + + if !hasPermission { + if !l.SecurityCtx.IsAuthenticated() { + l.HandleUnauthorized() + } else { + l.HandleForbidden(l.SecurityCtx.GetUsername()) + } + return false + } + + return true +} + // Post creates a label func (l *LabelAPI) Post() { label := &models.Label{} @@ -82,10 +104,6 @@ func (l *LabelAPI) Post() { switch label.Scope { case common.LabelScopeGlobal: - if !l.SecurityCtx.IsSysAdmin() { - l.HandleForbidden(l.SecurityCtx.GetUsername()) - return - } label.ProjectID = 0 case common.LabelScopeProject: exist, err := l.ProjectMgr.Exists(label.ProjectID) @@ -98,10 +116,10 @@ func (l *LabelAPI) Post() { l.HandleNotFound(fmt.Sprintf("project %d not found", label.ProjectID)) return } - if !l.SecurityCtx.HasAllPerm(label.ProjectID) { - l.HandleForbidden(l.SecurityCtx.GetUsername()) - return - } + } + + if !l.requireAccess(label, rbac.ActionCreate) { + return } labels, err := dao.ListLabels(&models.LabelQuery{ @@ -147,15 +165,8 @@ func (l *LabelAPI) Get() { return } - if label.Scope == common.LabelScopeProject { - if !l.SecurityCtx.HasReadPerm(label.ProjectID) { - if !l.SecurityCtx.IsAuthenticated() { - l.HandleUnauthorized() - return - } - l.HandleForbidden(l.SecurityCtx.GetUsername()) - return - } + if !l.requireAccess(label, rbac.ActionRead) { + return } l.Data["json"] = label @@ -189,7 +200,8 @@ func (l *LabelAPI) List() { return } - if !l.SecurityCtx.HasReadPerm(projectID) { + resource := rbac.NewProjectNamespace(projectID).Resource(rbac.ResourceLabel) + if !l.SecurityCtx.Can(rbac.ActionList, resource) { if !l.SecurityCtx.IsAuthenticated() { l.HandleUnauthorized() return @@ -221,6 +233,10 @@ func (l *LabelAPI) List() { // Put updates the label func (l *LabelAPI) Put() { + if !l.requireAccess(l.label, rbac.ActionUpdate) { + return + } + label := &models.Label{} l.DecodeJSONReq(label) @@ -259,6 +275,10 @@ func (l *LabelAPI) Put() { // Delete the label func (l *LabelAPI) Delete() { + if !l.requireAccess(l.label, rbac.ActionDelete) { + return + } + id := l.label.ID if err := dao.DeleteResourceLabelByLabel(id); err != nil { l.HandleInternalServerError(fmt.Sprintf("failed to delete resource label mappings of label %d: %v", id, err)) @@ -272,11 +292,6 @@ func (l *LabelAPI) Delete() { // ListResources lists the resources that the label is referenced by func (l *LabelAPI) ListResources() { - if !l.SecurityCtx.IsAuthenticated() { - l.HandleUnauthorized() - return - } - id, err := l.GetInt64FromPath(":id") if err != nil || id <= 0 { l.HandleBadRequest("invalid label ID") @@ -294,9 +309,7 @@ func (l *LabelAPI) ListResources() { return } - if label.Scope == common.LabelScopeGlobal && !l.SecurityCtx.IsSysAdmin() || - label.Scope == common.LabelScopeProject && !l.SecurityCtx.HasAllPerm(label.ProjectID) { - l.HandleForbidden(l.SecurityCtx.GetUsername()) + if !l.requireAccess(label, rbac.ActionList, rbac.ResourceLabelResource) { return } diff --git a/src/core/api/label_resource.go b/src/core/api/label_resource.go index 4fc96637b..807b11029 100644 --- a/src/core/api/label_resource.go +++ b/src/core/api/label_resource.go @@ -22,23 +22,6 @@ func (lra *LabelResourceAPI) Prepare() { lra.labelManager = &label.BaseManager{} } -func (lra *LabelResourceAPI) checkPermissions(project string) bool { - if lra.Ctx.Request.Method == http.MethodPost || - lra.Ctx.Request.Method == http.MethodDelete { - if lra.SecurityCtx.HasWritePerm(project) { - return true - } - } - - if lra.Ctx.Request.Method == http.MethodGet { - if lra.SecurityCtx.HasReadPerm(project) { - return true - } - } - - return false -} - func (lra *LabelResourceAPI) getLabelsOfResource(rType string, rIDOrName interface{}) { labels, err := lra.labelManager.GetLabelsOfResource(rType, rIDOrName) if err != nil { diff --git a/src/core/api/metadata.go b/src/core/api/metadata.go index 090b7ca5c..146d6de09 100644 --- a/src/core/api/metadata.go +++ b/src/core/api/metadata.go @@ -22,6 +22,7 @@ import ( "strings" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/promgr/metamgr" ) @@ -72,24 +73,6 @@ func (m *MetadataAPI) Prepare() { m.project = project - switch m.Ctx.Request.Method { - case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete: - if !(m.Ctx.Request.Method == http.MethodGet && project.IsPublic()) { - if !m.SecurityCtx.IsAuthenticated() { - m.HandleUnauthorized() - return - } - if !m.SecurityCtx.HasReadPerm(project.ProjectID) { - m.HandleForbidden(m.SecurityCtx.GetUsername()) - return - } - } - default: - log.Debugf("%s method not allowed", m.Ctx.Request.Method) - m.RenderError(http.StatusMethodNotAllowed, "") - return - } - name := m.GetStringFromPath(":name") if len(name) > 0 { m.name = name @@ -105,8 +88,27 @@ func (m *MetadataAPI) Prepare() { } } +func (m *MetadataAPI) requireAccess(action rbac.Action) bool { + resource := rbac.NewProjectNamespace(m.project.ProjectID).Resource(rbac.ResourceMetadata) + + if !m.SecurityCtx.Can(action, resource) { + if !m.SecurityCtx.IsAuthenticated() { + m.HandleUnauthorized() + } else { + m.HandleForbidden(m.SecurityCtx.GetUsername()) + } + return false + } + + return true +} + // Get ... func (m *MetadataAPI) Get() { + if !m.requireAccess(rbac.ActionRead) { + return + } + var metas map[string]string var err error if len(m.name) > 0 { @@ -125,6 +127,10 @@ func (m *MetadataAPI) Get() { // Post ... func (m *MetadataAPI) Post() { + if !m.requireAccess(rbac.ActionCreate) { + return + } + var metas map[string]string m.DecodeJSONReq(&metas) @@ -161,6 +167,10 @@ func (m *MetadataAPI) Post() { // Put ... func (m *MetadataAPI) Put() { + if !m.requireAccess(rbac.ActionUpdate) { + return + } + var metas map[string]string m.DecodeJSONReq(&metas) @@ -188,6 +198,10 @@ func (m *MetadataAPI) Put() { // Delete ... func (m *MetadataAPI) Delete() { + if !m.requireAccess(rbac.ActionDelete) { + return + } + if err := m.metaMgr.Delete(m.project.ProjectID, m.name); err != nil { m.HandleInternalServerError(fmt.Sprintf("failed to delete metadata %s of project %d: %v", m.name, m.project.ProjectID, err)) return diff --git a/src/core/api/project.go b/src/core/api/project.go index ac9ce2ce0..7771b7d77 100644 --- a/src/core/api/project.go +++ b/src/core/api/project.go @@ -22,6 +22,7 @@ import ( "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/common/utils" errutil "github.com/goharbor/harbor/src/common/utils/error" "github.com/goharbor/harbor/src/common/utils/log" @@ -77,6 +78,25 @@ func (p *ProjectAPI) Prepare() { } } +func (p *ProjectAPI) requireAccess(action rbac.Action, subresource ...rbac.Resource) bool { + if len(subresource) == 0 { + subresource = append(subresource, rbac.ResourceSelf) + } + resource := rbac.NewProjectNamespace(p.project.ProjectID).Resource(subresource...) + + if !p.SecurityCtx.Can(action, resource) { + if !p.SecurityCtx.IsAuthenticated() { + p.HandleUnauthorized() + } else { + p.HandleForbidden(p.SecurityCtx.GetUsername()) + } + + return false + } + + return true +} + // Post ... func (p *ProjectAPI) Post() { if !p.SecurityCtx.IsAuthenticated() { @@ -187,16 +207,8 @@ func (p *ProjectAPI) Head() { // Get ... func (p *ProjectAPI) Get() { - if !p.project.IsPublic() { - if !p.SecurityCtx.IsAuthenticated() { - p.HandleUnauthorized() - return - } - - if !p.SecurityCtx.HasReadPerm(p.project.ProjectID) { - p.HandleForbidden(p.SecurityCtx.GetUsername()) - return - } + if !p.requireAccess(rbac.ActionRead) { + return } p.populateProperties(p.project) @@ -207,13 +219,7 @@ func (p *ProjectAPI) Get() { // Delete ... func (p *ProjectAPI) Delete() { - if !p.SecurityCtx.IsAuthenticated() { - p.HandleUnauthorized() - return - } - - if !p.SecurityCtx.HasAllPerm(p.project.ProjectID) { - p.HandleForbidden(p.SecurityCtx.GetUsername()) + if !p.requireAccess(rbac.ActionDelete) { return } @@ -248,13 +254,7 @@ func (p *ProjectAPI) Delete() { // Deletable ... func (p *ProjectAPI) Deletable() { - if !p.SecurityCtx.IsAuthenticated() { - p.HandleUnauthorized() - return - } - - if !p.SecurityCtx.HasAllPerm(p.project.ProjectID) { - p.HandleForbidden(p.SecurityCtx.GetUsername()) + if !p.requireAccess(rbac.ActionDelete) { return } @@ -433,13 +433,7 @@ func (p *ProjectAPI) populateProperties(project *models.Project) { // Put ... func (p *ProjectAPI) Put() { - if !p.SecurityCtx.IsAuthenticated() { - p.HandleUnauthorized() - return - } - - if !p.SecurityCtx.HasAllPerm(p.project.ProjectID) { - p.HandleForbidden(p.SecurityCtx.GetUsername()) + if !p.requireAccess(rbac.ActionUpdate) { return } @@ -458,13 +452,7 @@ func (p *ProjectAPI) Put() { // Logs ... func (p *ProjectAPI) Logs() { - if !p.SecurityCtx.IsAuthenticated() { - p.HandleUnauthorized() - return - } - - if !p.SecurityCtx.HasReadPerm(p.project.ProjectID) { - p.HandleForbidden(p.SecurityCtx.GetUsername()) + if !p.requireAccess(rbac.ActionList, rbac.ResourceLog) { return } diff --git a/src/core/api/projectmember.go b/src/core/api/projectmember.go index 6dfef750a..f4e52d672 100644 --- a/src/core/api/projectmember.go +++ b/src/core/api/projectmember.go @@ -24,6 +24,7 @@ import ( "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/common/dao/project" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/auth" ) @@ -73,12 +74,6 @@ func (pma *ProjectMemberAPI) Prepare() { } pma.project = project - if !(pma.Ctx.Input.IsGet() && pma.SecurityCtx.HasReadPerm(pid) || - pma.SecurityCtx.HasAllPerm(pid)) { - pma.HandleForbidden(pma.SecurityCtx.GetUsername()) - return - } - pmid, err := pma.GetInt64FromPath(":pmid") if err != nil { log.Warningf("Failed to get pmid from path, error %v", err) @@ -90,6 +85,22 @@ func (pma *ProjectMemberAPI) Prepare() { pma.id = int(pmid) } +func (pma *ProjectMemberAPI) requireAccess(action rbac.Action) bool { + resource := rbac.NewProjectNamespace(pma.project.ProjectID).Resource(rbac.ResourceMember) + + if !pma.SecurityCtx.Can(action, resource) { + if !pma.SecurityCtx.IsAuthenticated() { + pma.HandleUnauthorized() + } else { + pma.HandleForbidden(pma.SecurityCtx.GetUsername()) + } + + return false + } + + return true +} + // Get ... func (pma *ProjectMemberAPI) Get() { projectID := pma.project.ProjectID @@ -97,6 +108,9 @@ func (pma *ProjectMemberAPI) Get() { queryMember.ProjectID = projectID pma.Data["json"] = make([]models.Member, 0) if pma.id == 0 { + if !pma.requireAccess(rbac.ActionList) { + return + } entityname := pma.GetString("entityname") memberList, err := project.SearchMemberByName(projectID, entityname) if err != nil { @@ -119,6 +133,10 @@ func (pma *ProjectMemberAPI) Get() { pma.HandleNotFound(fmt.Sprintf("The project member does not exit, pmid:%v", pma.id)) return } + + if !pma.requireAccess(rbac.ActionRead) { + return + } pma.Data["json"] = memberList[0] } pma.ServeJSON() @@ -126,6 +144,9 @@ func (pma *ProjectMemberAPI) Get() { // Post ... Add a project member func (pma *ProjectMemberAPI) Post() { + if !pma.requireAccess(rbac.ActionCreate) { + return + } projectID := pma.project.ProjectID var request models.MemberReq pma.DecodeJSONReq(&request) @@ -156,11 +177,14 @@ func (pma *ProjectMemberAPI) Post() { // Put ... Update an exist project member func (pma *ProjectMemberAPI) Put() { + if !pma.requireAccess(rbac.ActionUpdate) { + return + } pid := pma.project.ProjectID pmID := pma.id var req models.Member pma.DecodeJSONReq(&req) - if req.Role < 1 || req.Role > 3 { + if req.Role < 1 || req.Role > 4 { pma.HandleBadRequest(fmt.Sprintf("Invalid role id %v", req.Role)) return } @@ -173,6 +197,9 @@ func (pma *ProjectMemberAPI) Put() { // Delete ... func (pma *ProjectMemberAPI) Delete() { + if !pma.requireAccess(rbac.ActionDelete) { + return + } pmid := pma.id err := project.DeleteProjectMemberByID(pmid) if err != nil { @@ -226,7 +253,7 @@ func AddProjectMember(projectID int64, request models.MemberReq) (int, error) { return 0, ErrDuplicateProjectMember } - if member.Role < 1 || member.Role > 3 { + if member.Role < 1 || member.Role > 4 { // Return invalid role error return 0, ErrInvalidRole } diff --git a/src/core/api/projectmember_test.go b/src/core/api/projectmember_test.go index 8de569c10..e440ce0e9 100644 --- a/src/core/api/projectmember_test.go +++ b/src/core/api/projectmember_test.go @@ -209,6 +209,18 @@ func TestProjectMemberAPI_PutAndDelete(t *testing.T) { }, code: http.StatusOK, }, + // 200 + { + request: &testingRequest{ + method: http.MethodPut, + url: URL, + bodyJSON: &models.Member{ + Role: 4, + }, + credential: admin, + }, + code: http.StatusOK, + }, // 400 { request: &testingRequest{ diff --git a/src/core/api/replication_job.go b/src/core/api/replication_job.go index c871f6f14..32ee1a7b5 100644 --- a/src/core/api/replication_job.go +++ b/src/core/api/replication_job.go @@ -24,6 +24,7 @@ import ( common_http "github.com/goharbor/harbor/src/common/http" common_job "github.com/goharbor/harbor/src/common/job" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/common/utils/log" api_models "github.com/goharbor/harbor/src/core/api/models" "github.com/goharbor/harbor/src/core/utils" @@ -80,7 +81,8 @@ func (ra *RepJobAPI) List() { return } - if !ra.SecurityCtx.HasAllPerm(policy.ProjectIDs[0]) { + resource := rbac.NewProjectNamespace(policy.ProjectIDs[0]).Resource(rbac.ResourceReplicationJob) + if !ra.SecurityCtx.Can(rbac.ActionList, resource) { ra.HandleForbidden(ra.SecurityCtx.GetUsername()) return } @@ -190,7 +192,8 @@ func (ra *RepJobAPI) GetLog() { return } - if !ra.SecurityCtx.HasAllPerm(policy.ProjectIDs[0]) { + resource := rbac.NewProjectNamespace(policy.ProjectIDs[0]).Resource(rbac.ResourceReplicationJob) + if !ra.SecurityCtx.Can(rbac.ActionRead, resource) { ra.HandleForbidden(ra.SecurityCtx.GetUsername()) return } diff --git a/src/core/api/replication_policy.go b/src/core/api/replication_policy.go index 642cf35d7..ac45fbdad 100644 --- a/src/core/api/replication_policy.go +++ b/src/core/api/replication_policy.go @@ -22,6 +22,7 @@ import ( "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/common/utils/log" api_models "github.com/goharbor/harbor/src/core/api/models" "github.com/goharbor/harbor/src/core/promgr" @@ -63,7 +64,8 @@ func (pa *RepPolicyAPI) Get() { return } - if !pa.SecurityCtx.HasAllPerm(policy.ProjectIDs[0]) { + resource := rbac.NewProjectNamespace(policy.ProjectIDs[0]).Resource(rbac.ResourceReplication) + if !pa.SecurityCtx.Can(rbac.ActionRead, resource) { pa.HandleForbidden(pa.SecurityCtx.GetUsername()) return } @@ -105,7 +107,8 @@ func (pa *RepPolicyAPI) List() { if result != nil { total = result.Total for _, policy := range result.Policies { - if !pa.SecurityCtx.HasAllPerm(policy.ProjectIDs[0]) { + resource := rbac.NewProjectNamespace(policy.ProjectIDs[0]).Resource(rbac.ResourceReplication) + if !pa.SecurityCtx.Can(rbac.ActionRead, resource) { continue } ply, err := convertFromRepPolicy(pa.ProjectMgr, *policy) diff --git a/src/core/api/repository.go b/src/core/api/repository.go index 463365335..a96fdc898 100644 --- a/src/core/api/repository.go +++ b/src/core/api/repository.go @@ -30,6 +30,7 @@ import ( "github.com/goharbor/harbor/src/common/dao" commonhttp "github.com/goharbor/harbor/src/common/http" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/common/utils" "github.com/goharbor/harbor/src/common/utils/clair" "github.com/goharbor/harbor/src/common/utils/log" @@ -131,7 +132,8 @@ func (ra *RepositoryAPI) Get() { return } - if !ra.SecurityCtx.HasReadPerm(projectID) { + resource := rbac.NewProjectNamespace(projectID).Resource(rbac.ResourceRepository) + if !ra.SecurityCtx.Can(rbac.ActionList, resource) { if !ra.SecurityCtx.IsAuthenticated() { ra.HandleUnauthorized() return @@ -247,7 +249,8 @@ func (ra *RepositoryAPI) Delete() { return } - if !ra.SecurityCtx.HasAllPerm(projectName) { + resource := rbac.NewProjectNamespace(project.ProjectID).Resource(rbac.ResourceRepository) + if !ra.SecurityCtx.Can(rbac.ActionDelete, resource) { ra.HandleForbidden(ra.SecurityCtx.GetUsername()) return } @@ -393,7 +396,8 @@ func (ra *RepositoryAPI) GetTag() { return } project, _ := utils.ParseRepository(repository) - if !ra.SecurityCtx.HasReadPerm(project) { + resource := rbac.NewProjectNamespace(project).Resource(rbac.ResourceRepositoryTag) + if !ra.SecurityCtx.Can(rbac.ActionRead, resource) { if !ra.SecurityCtx.IsAuthenticated() { ra.HandleUnauthorized() return @@ -488,14 +492,16 @@ func (ra *RepositoryAPI) Retag() { } // Check whether user has read permission to source project - if !ra.SecurityCtx.HasReadPerm(srcImage.Project) { + srcResource := rbac.NewProjectNamespace(srcImage.Project).Resource(rbac.ResourceRepository) + if !ra.SecurityCtx.Can(rbac.ActionPull, srcResource) { log.Errorf("user has no read permission to project '%s'", srcImage.Project) ra.HandleForbidden(fmt.Sprintf("%s has no read permission to project %s", ra.SecurityCtx.GetUsername(), srcImage.Project)) return } // Check whether user has write permission to target project - if !ra.SecurityCtx.HasWritePerm(project) { + destResource := rbac.NewProjectNamespace(project).Resource(rbac.ResourceRepository) + if !ra.SecurityCtx.Can(rbac.ActionPush, destResource) { log.Errorf("user has no write permission to project '%s'", project) ra.HandleForbidden(fmt.Sprintf("%s has no write permission to project %s", ra.SecurityCtx.GetUsername(), project)) return @@ -533,7 +539,8 @@ func (ra *RepositoryAPI) GetTags() { return } - if !ra.SecurityCtx.HasReadPerm(projectName) { + resource := rbac.NewProjectNamespace(projectName).Resource(rbac.ResourceRepositoryTag) + if !ra.SecurityCtx.Can(rbac.ActionList, resource) { if !ra.SecurityCtx.IsAuthenticated() { ra.HandleUnauthorized() return @@ -741,7 +748,8 @@ func (ra *RepositoryAPI) GetManifests() { return } - if !ra.SecurityCtx.HasReadPerm(projectName) { + resource := rbac.NewProjectNamespace(projectName).Resource(rbac.ResourceRepositoryTagManifest) + if !ra.SecurityCtx.Can(rbac.ActionRead, resource) { if !ra.SecurityCtx.IsAuthenticated() { ra.HandleUnauthorized() return @@ -872,7 +880,8 @@ func (ra *RepositoryAPI) Put() { } project, _ := utils.ParseRepository(name) - if !ra.SecurityCtx.HasWritePerm(project) { + resource := rbac.NewProjectNamespace(project).Resource(rbac.ResourceRepository) + if !ra.SecurityCtx.Can(rbac.ActionUpdate, resource) { ra.HandleForbidden(ra.SecurityCtx.GetUsername()) return } @@ -906,7 +915,8 @@ func (ra *RepositoryAPI) GetSignatures() { return } - if !ra.SecurityCtx.HasReadPerm(projectName) { + resource := rbac.NewProjectNamespace(projectName).Resource(rbac.ResourceRepository) + if !ra.SecurityCtx.Can(rbac.ActionRead, resource) { if !ra.SecurityCtx.IsAuthenticated() { ra.HandleUnauthorized() return @@ -949,7 +959,9 @@ func (ra *RepositoryAPI) ScanImage() { ra.HandleUnauthorized() return } - if !ra.SecurityCtx.HasAllPerm(projectName) { + + resource := rbac.NewProjectNamespace(projectName).Resource(rbac.ResourceRepositoryTagScanJob) + if !ra.SecurityCtx.Can(rbac.ActionCreate, resource) { ra.HandleForbidden(ra.SecurityCtx.GetUsername()) return } @@ -980,7 +992,9 @@ func (ra *RepositoryAPI) VulnerabilityDetails() { return } project, _ := utils.ParseRepository(repository) - if !ra.SecurityCtx.HasReadPerm(project) { + + resource := rbac.NewProjectNamespace(project).Resource(rbac.ResourceRepositoryTagVulnerability) + if !ra.SecurityCtx.Can(rbac.ActionList, resource) { if !ra.SecurityCtx.IsAuthenticated() { ra.HandleUnauthorized() return diff --git a/src/core/api/repository_label.go b/src/core/api/repository_label.go index 547fe5c82..5b658e424 100644 --- a/src/core/api/repository_label.go +++ b/src/core/api/repository_label.go @@ -22,7 +22,7 @@ import ( "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/common/models" - "github.com/goharbor/harbor/src/common/utils" + "github.com/goharbor/harbor/src/common/rbac" coreutils "github.com/goharbor/harbor/src/core/utils" ) @@ -45,12 +45,6 @@ func (r *RepositoryLabelAPI) Prepare() { } repository := r.GetString(":splat") - project, _ := utils.ParseRepository(repository) - if !r.checkPermissions(project) { - r.SendForbiddenError(errors.New(r.SecurityCtx.GetUsername())) - return - } - repo, err := dao.GetRepositoryByName(repository) if err != nil { r.SendInternalServerError(fmt.Errorf("failed to get repository %s: %v", repository, err)) @@ -77,25 +71,6 @@ func (r *RepositoryLabelAPI) Prepare() { r.tag = tag } - if r.Ctx.Request.Method == http.MethodPost { - p, err := r.ProjectMgr.Get(project) - if err != nil { - r.SendInternalServerError(err) - return - } - - l := &models.Label{} - r.DecodeJSONReq(l) - - label, ok := r.validate(l.ID, p.ProjectID) - if !ok { - return - } - r.label = label - - return - } - if r.Ctx.Request.Method == http.MethodDelete { labelID, err := r.GetInt64FromPath(":id") if err != nil { @@ -112,13 +87,59 @@ func (r *RepositoryLabelAPI) Prepare() { } } +func (r *RepositoryLabelAPI) requireAccess(action rbac.Action, subresource ...rbac.Resource) bool { + if len(subresource) == 0 { + subresource = append(subresource, rbac.ResourceRepositoryLabel) + } + resource := rbac.NewProjectNamespace(r.repository.ProjectID).Resource(rbac.ResourceRepositoryLabel) + + if !r.SecurityCtx.Can(action, resource) { + if !r.SecurityCtx.IsAuthenticated() { + r.SendUnAuthorizedError(errors.New("UnAuthorized")) + } else { + r.SendForbiddenError(errors.New(r.SecurityCtx.GetUsername())) + } + + return false + } + + return true +} + +func (r *RepositoryLabelAPI) isValidLabelReq() bool { + p, err := r.ProjectMgr.Get(r.repository.ProjectID) + if err != nil { + r.SendInternalServerError(err) + return false + } + + l := &models.Label{} + r.DecodeJSONReq(l) + + label, ok := r.validate(l.ID, p.ProjectID) + if !ok { + return false + } + r.label = label + + return true +} + // GetOfImage returns labels of an image func (r *RepositoryLabelAPI) GetOfImage() { + if !r.requireAccess(rbac.ActionList, rbac.ResourceRepositoryTagLabel) { + return + } + r.getLabelsOfResource(common.ResourceTypeImage, fmt.Sprintf("%s:%s", r.repository.Name, r.tag)) } // AddToImage adds the label to an image func (r *RepositoryLabelAPI) AddToImage() { + if !r.requireAccess(rbac.ActionCreate, rbac.ResourceRepositoryTagLabel) || !r.isValidLabelReq() { + return + } + rl := &models.ResourceLabel{ LabelID: r.label.ID, ResourceType: common.ResourceTypeImage, @@ -129,17 +150,29 @@ func (r *RepositoryLabelAPI) AddToImage() { // RemoveFromImage removes the label from an image func (r *RepositoryLabelAPI) RemoveFromImage() { + if !r.requireAccess(rbac.ActionDelete, rbac.ResourceRepositoryTagLabel) { + return + } + r.removeLabelFromResource(common.ResourceTypeImage, fmt.Sprintf("%s:%s", r.repository.Name, r.tag), r.label.ID) } // GetOfRepository returns labels of a repository func (r *RepositoryLabelAPI) GetOfRepository() { + if !r.requireAccess(rbac.ActionList) { + return + } + r.getLabelsOfResource(common.ResourceTypeRepository, r.repository.RepositoryID) } // AddToRepository adds the label to a repository func (r *RepositoryLabelAPI) AddToRepository() { + if !r.requireAccess(rbac.ActionCreate) || !r.isValidLabelReq() { + return + } + rl := &models.ResourceLabel{ LabelID: r.label.ID, ResourceType: common.ResourceTypeRepository, @@ -150,6 +183,10 @@ func (r *RepositoryLabelAPI) AddToRepository() { // RemoveFromRepository removes the label from a repository func (r *RepositoryLabelAPI) RemoveFromRepository() { + if !r.requireAccess(rbac.ActionDelete) { + return + } + r.removeLabelFromResource(common.ResourceTypeRepository, r.repository.RepositoryID, r.label.ID) } diff --git a/src/core/api/robot.go b/src/core/api/robot.go new file mode 100644 index 000000000..03850f90f --- /dev/null +++ b/src/core/api/robot.go @@ -0,0 +1,238 @@ +// 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" + "net/http" + "strconv" + + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/dao" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/common/token" +) + +// 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 + } +} + +func (r *RobotAPI) requireAccess(action rbac.Action) bool { + resource := rbac.NewProjectNamespace(r.project.ProjectID).Resource(rbac.ResourceRobot) + if !r.SecurityCtx.Can(action, resource) { + r.HandleForbidden(r.SecurityCtx.GetUsername()) + return false + } + + return true +} + +// Post ... +func (r *RobotAPI) Post() { + if !r.requireAccess(rbac.ActionCreate) { + return + } + + var robotReq models.RobotReq + r.DecodeJSONReq(&robotReq) + createdName := common.RobotPrefix + robotReq.Name + + // first to add a robot account, and get its id. + robot := models.Robot{ + Name: createdName, + Description: robotReq.Description, + ProjectID: r.project.ProjectID, + } + 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 + } + + // generate the token, and return it with response data. + // token is not stored in the database. + jwtToken, err := token.New(id, r.project.ProjectID, robotReq.Access) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to valid parameters to generate token for robot account, %v", err)) + err := dao.DeleteRobot(id) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to delete the robot account: %d, %v", id, err)) + } + return + } + + rawTk, err := jwtToken.Raw() + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to sign token for robot account, %v", err)) + err := dao.DeleteRobot(id) + if err != nil { + r.HandleInternalServerError(fmt.Sprintf("failed to delete the robot account: %d, %v", id, err)) + } + return + } + + robotRep := models.RobotRep{ + Name: robot.Name, + Token: rawTk, + } + 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() { + if !r.requireAccess(rbac.ActionList) { + return + } + + 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() { + if !r.requireAccess(rbac.ActionRead) { + return + } + + 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() { + if !r.requireAccess(rbac.ActionUpdate) { + return + } + + 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 !r.requireAccess(rbac.ActionDelete) { + return + } + + 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..0ece3a667 --- /dev/null +++ b/src/core/api/robot_test.go @@ -0,0 +1,324 @@ +// 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" + "github.com/goharbor/harbor/src/common/rbac" + "net/http" + "testing" +) + +var ( + robotPath = "/api/projects/1/robots" + robotID int64 +) + +func TestRobotAPIPost(t *testing.T) { + + rbacPolicy := &rbac.Policy{ + Resource: "/project/libray/repository", + Action: "pull", + } + policies := []*rbac.Policy{} + policies = append(policies, rbacPolicy) + + cases := []*codeCheckingCase{ + // 401 + { + request: &testingRequest{ + method: http.MethodPost, + url: robotPath, + }, + code: http.StatusUnauthorized, + }, + + // 403 + { + request: &testingRequest{ + method: http.MethodPost, + url: robotPath, + bodyJSON: &models.RobotReq{}, + credential: nonSysAdmin, + }, + code: http.StatusForbidden, + }, + // 201 + { + request: &testingRequest{ + method: http.MethodPost, + url: robotPath, + bodyJSON: &models.RobotReq{ + Name: "test", + Description: "test desc", + Access: policies, + }, + credential: projAdmin4Robot, + }, + code: http.StatusCreated, + }, + // 403 -- developer + { + request: &testingRequest{ + method: http.MethodPost, + url: robotPath, + bodyJSON: &models.RobotReq{ + Name: "test2", + Description: "test2 desc", + }, + credential: projDeveloper, + }, + code: http.StatusForbidden, + }, + + // 409 + { + request: &testingRequest{ + method: http.MethodPost, + url: robotPath, + bodyJSON: &models.RobotReq{ + Name: "test", + Description: "test desc", + Access: policies, + }, + 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/api/scan_job.go b/src/core/api/scan_job.go index 9f3110cc4..9489d57ed 100644 --- a/src/core/api/scan_job.go +++ b/src/core/api/scan_job.go @@ -17,6 +17,7 @@ package api import ( "github.com/goharbor/harbor/src/common/dao" common_http "github.com/goharbor/harbor/src/common/http" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/utils" @@ -54,7 +55,9 @@ func (sj *ScanJobAPI) Prepare() { sj.CustomAbort(http.StatusInternalServerError, "Failed to get Job data") } projectName := strings.SplitN(data.Repository, "/", 2)[0] - if !sj.SecurityCtx.HasReadPerm(projectName) { + + resource := rbac.NewProjectNamespace(projectName).Resource(rbac.ResourceRepositoryTagScanJob) + if !sj.SecurityCtx.Can(rbac.ActionRead, resource) { log.Errorf("User does not have read permission for project: %s", projectName) sj.HandleForbidden(sj.SecurityCtx.GetUsername()) } diff --git a/src/core/api/user.go b/src/core/api/user.go index b3ee72541..fb1cfebce 100644 --- a/src/core/api/user.go +++ b/src/core/api/user.go @@ -24,6 +24,8 @@ import ( "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/common/rbac/project" "github.com/goharbor/harbor/src/common/utils" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/config" @@ -339,6 +341,57 @@ func (ua *UserAPI) ToggleUserAdminRole() { } } +// ListUserPermissions handles GET to /api/users/{}/permissions +func (ua *UserAPI) ListUserPermissions() { + if ua.userID != ua.currentUserID { + log.Warningf("Current user, id: %d can not view other user's permissions", ua.currentUserID) + ua.RenderError(http.StatusForbidden, "User does not have permission") + return + } + + relative := ua.Ctx.Input.Query("relative") == "true" + + scope := rbac.Resource(ua.Ctx.Input.Query("scope")) + policies := []*rbac.Policy{} + + namespace, err := scope.GetNamespace() + if err == nil { + switch namespace.Kind() { + case "project": + for _, policy := range project.GetAllPolicies(namespace) { + if ua.SecurityCtx.Can(policy.Action, policy.Resource) { + policies = append(policies, policy) + } + } + } + } + + results := []map[string]string{} + for _, policy := range policies { + var resource rbac.Resource + + // for resource `/project/1/repository` if `relative` is `true` then the resource in response will be `repository` + if relative { + relativeResource, err := policy.Resource.RelativeTo(scope) + if err != nil { + continue + } + resource = relativeResource + } else { + resource = policy.Resource + } + + results = append(results, map[string]string{ + "resource": resource.String(), + "action": policy.Action.String(), + }) + } + + ua.Data["json"] = results + ua.ServeJSON() + return +} + // modifiable returns whether the modify is allowed based on current auth mode and context func (ua *UserAPI) modifiable() bool { if ua.AuthMode == common.DBAuth { diff --git a/src/core/api/user_test.go b/src/core/api/user_test.go index e309a9560..924c8c294 100644 --- a/src/core/api/user_test.go +++ b/src/core/api/user_test.go @@ -572,3 +572,28 @@ func TestModifiable(t *testing.T) { } assert.True(ua4.modifiable()) } + +func TestUsersCurrentPermissions(t *testing.T) { + fmt.Println("Testing Get Users Current Permissions") + + assert := assert.New(t) + apiTest := newHarborAPI() + + httpStatusCode, permissions, err := apiTest.UsersGetPermissions("current", "/project/library", *projAdmin) + assert.Nil(err) + assert.Equal(int(200), httpStatusCode, "httpStatusCode should be 200") + assert.NotEmpty(permissions, "permissions should not be empty") + + httpStatusCode, permissions, err = apiTest.UsersGetPermissions("current", "/unsupport-scope", *projAdmin) + assert.Nil(err) + assert.Equal(int(200), httpStatusCode, "httpStatusCode should be 200") + assert.Empty(permissions, "permissions should be empty") + + httpStatusCode, _, err = apiTest.UsersGetPermissions(projAdminID, "/project/library", *projAdmin) + assert.Nil(err) + assert.Equal(int(200), httpStatusCode, "httpStatusCode should be 200") + + httpStatusCode, _, err = apiTest.UsersGetPermissions(projDeveloperID, "/project/library", *projAdmin) + assert.Nil(err) + assert.Equal(int(403), httpStatusCode, "httpStatusCode should be 403") +} diff --git a/src/core/auth/authenticator.go b/src/core/auth/authenticator.go index 83393f0c0..48641b37b 100644 --- a/src/core/auth/authenticator.go +++ b/src/core/auth/authenticator.go @@ -123,7 +123,7 @@ func Register(name string, h AuthenticateHelper) { return } registry[name] = h - log.Debugf("Registered authencation helper for auth mode: %s", name) + log.Debugf("Registered authentication helper for auth mode: %s", name) } // Login authenticates user credentials based on setting. diff --git a/src/core/auth/authproxy/auth.go b/src/core/auth/authproxy/auth.go new file mode 100644 index 000000000..a87e567d7 --- /dev/null +++ b/src/core/auth/authproxy/auth.go @@ -0,0 +1,143 @@ +// Copyright 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 authproxy + +import ( + "crypto/tls" + "fmt" + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/dao" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/auth" + "io/ioutil" + "net/http" + "os" + "strings" + "sync" +) + +// Auth implements HTTP authenticator the required attributes. +// The attribute Endpoint is the HTTP endpoint to which the POST request should be issued for authentication +type Auth struct { + auth.DefaultAuthenticateHelper + sync.Mutex + Endpoint string + SkipCertVerify bool + AlwaysOnboard bool + client *http.Client +} + +// Authenticate issues http POST request to Endpoint if it returns 200 the authentication is considered success. +func (a *Auth) Authenticate(m models.AuthModel) (*models.User, error) { + a.ensure() + req, err := http.NewRequest(http.MethodPost, a.Endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to send request, error: %v", err) + } + req.SetBasicAuth(m.Principal, m.Password) + resp, err := a.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return &models.User{Username: m.Principal}, nil + } else if resp.StatusCode == http.StatusUnauthorized { + return nil, auth.ErrAuth{} + } else { + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Warningf("Failed to read response body, error: %v", err) + } + return nil, fmt.Errorf("failed to authenticate, status code: %d, text: %s", resp.StatusCode, string(data)) + } + +} + +// OnBoardUser delegates to dao pkg to insert/update data in DB. +func (a *Auth) OnBoardUser(u *models.User) error { + return dao.OnBoardUser(u) +} + +// PostAuthenticate generates the user model and on board the user. +func (a *Auth) PostAuthenticate(u *models.User) error { + if res, _ := dao.GetUser(*u); res != nil { + return nil + } + if err := a.fillInModel(u); err != nil { + return err + } + return a.OnBoardUser(u) +} + +// SearchUser - TODO: Remove this workaround when #6767 is fixed. +// When the flag is set it always return the default model without searching +func (a *Auth) SearchUser(username string) (*models.User, error) { + a.ensure() + var queryCondition = models.User{ + Username: username, + } + u, err := dao.GetUser(queryCondition) + if err != nil { + return nil, err + } + if a.AlwaysOnboard && u == nil { + u = &models.User{Username: username} + if err := a.fillInModel(u); err != nil { + return nil, err + } + } + return u, nil +} + +func (a *Auth) fillInModel(u *models.User) error { + if strings.TrimSpace(u.Username) == "" { + return fmt.Errorf("username cannot be empty") + } + u.Realname = u.Username + u.Password = "1234567ab" + u.Comment = "By Authproxy" + if strings.Contains(u.Username, "@") { + u.Email = u.Username + } else { + u.Email = fmt.Sprintf("%s@placeholder.com", u.Username) + } + return nil +} + +func (a *Auth) ensure() { + a.Lock() + defer a.Unlock() + if a.Endpoint == "" { + a.Endpoint = os.Getenv("AUTHPROXY_ENDPOINT") + a.SkipCertVerify = strings.EqualFold(os.Getenv("AUTHPROXY_SKIP_CERT_VERIFY"), "true") + a.AlwaysOnboard = strings.EqualFold(os.Getenv("AUTHPROXY_ALWAYS_ONBOARD"), "true") + } + if a.client == nil { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: a.SkipCertVerify, + }, + } + a.client = &http.Client{ + Transport: tr, + } + } +} + +func init() { + auth.Register(common.HTTPAuth, &Auth{}) +} diff --git a/src/core/auth/authproxy/auth_test.go b/src/core/auth/authproxy/auth_test.go new file mode 100644 index 000000000..9c0c81cbd --- /dev/null +++ b/src/core/auth/authproxy/auth_test.go @@ -0,0 +1,144 @@ +// Copyright 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 authproxy + +import ( + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/core/auth" + "github.com/goharbor/harbor/src/core/auth/authproxy/test" + "github.com/stretchr/testify/assert" + "net/http/httptest" + "os" + "testing" +) + +var mockSvr *httptest.Server +var a *Auth +var pwd = "1234567ab" +var cmt = "By Authproxy" + +func TestMain(m *testing.M) { + mockSvr = test.NewMockServer(map[string]string{"jt": "pp", "Admin@vsphere.local": "Admin!23"}) + defer mockSvr.Close() + a = &Auth{ + Endpoint: mockSvr.URL + "/test/login", + SkipCertVerify: true, + } + rc := m.Run() + if rc != 0 { + os.Exit(rc) + } +} + +func TestAuth_Authenticate(t *testing.T) { + t.Log("auth endpoint: ", a.Endpoint) + type output struct { + user models.User + err error + } + type tc struct { + input models.AuthModel + expect output + } + suite := []tc{ + { + input: models.AuthModel{ + Principal: "jt", Password: "pp"}, + expect: output{ + user: models.User{ + Username: "jt", + }, + err: nil, + }, + }, + { + input: models.AuthModel{ + Principal: "Admin@vsphere.local", + Password: "Admin!23", + }, + expect: output{ + user: models.User{ + Username: "Admin@vsphere.local", + // Email: "Admin@placeholder.com", + // Password: pwd, + // Comment: fmt.Sprintf(cmtTmpl, path.Join(mockSvr.URL, "/test/login")), + }, + err: nil, + }, + }, + { + input: models.AuthModel{ + Principal: "jt", + Password: "ppp", + }, + expect: output{ + err: auth.ErrAuth{}, + }, + }, + } + assert := assert.New(t) + for _, c := range suite { + r, e := a.Authenticate(c.input) + if c.expect.err == nil { + assert.Nil(e) + assert.Equal(c.expect.user, *r) + } else { + assert.Nil(r) + assert.NotNil(e) + if _, ok := e.(auth.ErrAuth); ok { + assert.IsType(auth.ErrAuth{}, e) + } + } + } +} + +/* TODO: Enable this case after adminserver refactor is merged. +func TestAuth_PostAuthenticate(t *testing.T) { + type tc struct { + input *models.User + expect models.User + } + suite := []tc{ + { + input: &models.User{ + Username: "jt", + }, + expect: models.User{ + Username: "jt", + Email: "jt@placeholder.com", + Realname: "jt", + Password: pwd, + Comment: fmt.Sprintf(cmtTmpl, mockSvr.URL+"/test/login"), + }, + }, + { + input: &models.User{ + Username: "Admin@vsphere.local", + }, + expect: models.User{ + Username: "Admin@vsphere.local", + Email: "jt@placeholder.com", + Realname: "Admin@vsphere.local", + Password: pwd, + Comment: fmt.Sprintf(cmtTmpl, mockSvr.URL+"/test/login"), + }, + }, + } + for _, c := range suite { + a.PostAuthenticate(c.input) + assert.Equal(t, c.expect, *c.input) + } +} +*/ diff --git a/src/core/auth/authproxy/test/server.go b/src/core/auth/authproxy/test/server.go new file mode 100644 index 000000000..b11ec17aa --- /dev/null +++ b/src/core/auth/authproxy/test/server.go @@ -0,0 +1,49 @@ +// Copyright 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 test + +import ( + "net/http" + "net/http/httptest" +) + +type authHandler struct { + m map[string]string +} + +// ServeHTTP handles HTTP requests +func (ah *authHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(rw, "", http.StatusMethodNotAllowed) + } + if u, p, ok := req.BasicAuth(); !ok { + // Simulate a service error + http.Error(rw, "", http.StatusInternalServerError) + } else if pass, ok := ah.m[u]; !ok || pass != p { + http.Error(rw, "", http.StatusUnauthorized) + } else { + _, e := rw.Write([]byte(`{"session_id": "hgx59wuWI3b0jcbtidv5mU1YCp-DOQ9NKR1iYKACdKCvbVn7"}`)) + if e != nil { + panic(e) + } + } +} + +// NewMockServer creates the mock server for testing +func NewMockServer(creds map[string]string) *httptest.Server { + mux := http.NewServeMux() + mux.Handle("/test/login", &authHandler{m: creds}) + return httptest.NewTLSServer(mux) +} diff --git a/src/core/auth/uaa/uaa.go b/src/core/auth/uaa/uaa.go index 0b3bb9243..b4889302c 100644 --- a/src/core/auth/uaa/uaa.go +++ b/src/core/auth/uaa/uaa.go @@ -63,7 +63,7 @@ func (u *Auth) Authenticate(m models.AuthModel) (*models.User, error) { func (u *Auth) OnBoardUser(user *models.User) error { user.Username = strings.TrimSpace(user.Username) if len(user.Username) == 0 { - return fmt.Errorf("The Username is empty") + return fmt.Errorf("the Username is empty") } if len(user.Password) == 0 { user.Password = "1234567ab" diff --git a/src/core/config/config.go b/src/core/config/config.go index 7f00a40ce..0ecd9f652 100644 --- a/src/core/config/config.go +++ b/src/core/config/config.go @@ -572,3 +572,36 @@ func GetChartMuseumEndpoint() (string, error) { return chartEndpoint, nil } + +// GetRedisOfRegURL returns the URL of Redis used by registry +func GetRedisOfRegURL() string { + return os.Getenv("_REDIS_URL_REG") +} + +// GetPortalURL returns the URL of portal +func GetPortalURL() string { + url := os.Getenv("PORTAL_URL") + if len(url) == 0 { + return common.DefaultPortalURL + } + return url +} + +// GetRegistryCtlURL returns the URL of registryctl +func GetRegistryCtlURL() string { + url := os.Getenv("REGISTRYCTL_URL") + if len(url) == 0 { + return common.DefaultRegistryCtlURL + } + return url +} + +// GetClairHealthCheckServerURL returns the URL of +// the health check server of Clair +func GetClairHealthCheckServerURL() string { + url := os.Getenv("CLAIR_HEALTH_CHECK_SERVER_URL") + if len(url) == 0 { + return common.DefaultClairHealthCheckServerURL + } + return url +} diff --git a/src/core/config/config_test.go b/src/core/config/config_test.go index 2e0e0dd65..4c0dd2014 100644 --- a/src/core/config/config_test.go +++ b/src/core/config/config_test.go @@ -53,6 +53,11 @@ func TestConfig(t *testing.T) { if err := os.Setenv("KEY_PATH", secretKeyPath); err != nil { t.Fatalf("failed to set env %s: %v", "KEY_PATH", err) } + oriKeyPath := os.Getenv("TOKEN_PRIVATE_KEY_PATH") + if err := os.Setenv("TOKEN_PRIVATE_KEY_PATH", ""); err != nil { + t.Fatalf("failed to set env %s: %v", "TOKEN_PRIVATE_KEY_PATH", err) + } + defer os.Setenv("TOKEN_PRIVATE_KEY_PATH", oriKeyPath) if err := Init(); err != nil { t.Fatalf("failed to initialize configurations: %v", err) diff --git a/src/core/filter/security.go b/src/core/filter/security.go index 2374bdc65..e7a380ca8 100644 --- a/src/core/filter/security.go +++ b/src/core/filter/security.go @@ -22,18 +22,23 @@ import ( beegoctx "github.com/astaxie/beego/context" "github.com/docker/distribution/reference" + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/common/models" secstore "github.com/goharbor/harbor/src/common/secret" "github.com/goharbor/harbor/src/common/security" admr "github.com/goharbor/harbor/src/common/security/admiral" "github.com/goharbor/harbor/src/common/security/admiral/authcontext" "github.com/goharbor/harbor/src/common/security/local" + robotCtx "github.com/goharbor/harbor/src/common/security/robot" "github.com/goharbor/harbor/src/common/security/secret" + "github.com/goharbor/harbor/src/common/token" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/auth" "github.com/goharbor/harbor/src/core/config" "github.com/goharbor/harbor/src/core/promgr" "github.com/goharbor/harbor/src/core/promgr/pmsdriver/admiral" + "strings" ) // ContextValueKey for content value @@ -95,6 +100,7 @@ func Init() { // standalone reqCtxModifiers = []ReqCtxModifier{ &secretReqCtxModifier{config.SecretStore}, + &robotAuthReqCtxModifier{}, &basicAuthReqCtxModifier{}, &sessionReqCtxModifier{}, &unauthorizedReqCtxModifier{}} @@ -147,6 +153,47 @@ func (s *secretReqCtxModifier) Modify(ctx *beegoctx.Context) bool { return true } +type robotAuthReqCtxModifier struct{} + +func (r *robotAuthReqCtxModifier) Modify(ctx *beegoctx.Context) bool { + robotName, robotTk, ok := ctx.Request.BasicAuth() + if !ok { + return false + } + if !strings.HasPrefix(robotName, common.RobotPrefix) { + return false + } + rClaims := &token.RobotClaims{} + htk, err := token.ParseWithClaims(robotTk, rClaims) + if err != nil { + log.Errorf("failed to decrypt robot token, %v", err) + return false + } + // Do authn for robot account, as Harbor only stores the token ID, just validate the ID and disable. + robot, err := dao.GetRobotByID(htk.Claims.(*token.RobotClaims).TokenID) + if err != nil { + log.Errorf("failed to get robot %s: %v", robotName, err) + return false + } + if robot == nil { + log.Error("the token provided doesn't exist.") + return false + } + if robotName != robot.Name { + log.Errorf("failed to authenticate : %v", robotName) + return false + } + if robot.Disabled { + log.Errorf("the robot account %s is disabled", robot.Name) + return false + } + log.Debug("creating robot account security context...") + pm := config.GlobalProjectMgr + securCtx := robotCtx.NewSecurityContext(robot, pm, htk.Claims.(*token.RobotClaims).Access) + setSecurCtxAndPM(ctx.Request, securCtx, pm) + return true +} + type basicAuthReqCtxModifier struct{} func (b *basicAuthReqCtxModifier) Modify(ctx *beegoctx.Context) bool { diff --git a/src/core/filter/security_test.go b/src/core/filter/security_test.go index 3512c61a2..403c76954 100644 --- a/src/core/filter/security_test.go +++ b/src/core/filter/security_test.go @@ -122,6 +122,23 @@ func TestSecretReqCtxModifier(t *testing.T) { assert.NotNil(t, projectManager(ctx)) } +func TestRobotReqCtxModifier(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, + "http://127.0.0.1/api/projects/", nil) + if err != nil { + t.Fatalf("failed to create request: %v", req) + } + req.SetBasicAuth("robot$test1", "Harbor12345") + ctx, err := newContext(req) + if err != nil { + t.Fatalf("failed to crate context: %v", err) + } + + modifier := &robotAuthReqCtxModifier{} + modified := modifier.Modify(ctx) + assert.False(t, modified) +} + func TestBasicAuthReqCtxModifier(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/api/projects/", nil) diff --git a/src/core/main.go b/src/core/main.go index a34878668..b9c2dea66 100644 --- a/src/core/main.go +++ b/src/core/main.go @@ -28,6 +28,7 @@ import ( "github.com/goharbor/harbor/src/common/utils" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/api" + _ "github.com/goharbor/harbor/src/core/auth/authproxy" _ "github.com/goharbor/harbor/src/core/auth/db" _ "github.com/goharbor/harbor/src/core/auth/ldap" _ "github.com/goharbor/harbor/src/core/auth/uaa" diff --git a/src/core/router.go b/src/core/router.go index 2c629ba01..d793ffe72 100644 --- a/src/core/router.go +++ b/src/core/router.go @@ -46,6 +46,7 @@ func initRouters() { beego.Router("/api/users/:id", &api.UserAPI{}, "get:Get;delete:Delete;put:Put") beego.Router("/api/users", &api.UserAPI{}, "get:List;post:Post") beego.Router("/api/users/:id([0-9]+)/password", &api.UserAPI{}, "put:ChangePassword") + beego.Router("/api/users/:id/permissions", &api.UserAPI{}, "get:ListUserPermissions") beego.Router("/api/users/:id/sysadmin", &api.UserAPI{}, "put:ToggleUserAdminRole") beego.Router("/api/usergroups/?:ugid([0-9]+)", &api.UserGroupAPI{}) beego.Router("/api/ldap/ping", &api.LdapAPI{}, "post:Ping") @@ -56,6 +57,7 @@ func initRouters() { } // API + beego.Router("/api/health", &api.HealthAPI{}, "get:CheckHealth") beego.Router("/api/ping", &api.SystemInfoAPI{}, "get:Ping") beego.Router("/api/search", &api.SearchAPI{}) beego.Router("/api/projects/", &api.ProjectAPI{}, "get:List;post:Post") @@ -64,6 +66,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") diff --git a/src/core/service/token/creator.go b/src/core/service/token/creator.go index c84798001..fbd229783 100644 --- a/src/core/service/token/creator.go +++ b/src/core/service/token/creator.go @@ -22,6 +22,7 @@ import ( "github.com/docker/distribution/registry/auth/token" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/common/security" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/config" @@ -158,24 +159,25 @@ func (rep repositoryFilter) filter(ctx security.Context, pm promgr.ProjectManage if err != nil { return err } - project := img.namespace + projectName := img.namespace permission := "" - exist, err := pm.Exists(project) + exist, err := pm.Exists(projectName) if err != nil { return err } if !exist { - log.Debugf("project %s does not exist, set empty permission", project) + log.Debugf("project %s does not exist, set empty permission", projectName) a.Actions = []string{} return nil } - if ctx.HasAllPerm(project) { + resource := rbac.NewProjectNamespace(projectName).Resource(rbac.ResourceRepository) + if ctx.Can(rbac.ActionPush, resource) && ctx.Can(rbac.ActionPull, resource) { permission = "RWM" - } else if ctx.HasWritePerm(project) { + } else if ctx.Can(rbac.ActionPush, resource) { permission = "RW" - } else if ctx.HasReadPerm(project) { + } else if ctx.Can(rbac.ActionPull, resource) { permission = "R" } diff --git a/src/core/service/token/token_test.go b/src/core/service/token/token_test.go index 5c0ca8f18..7337357fd 100644 --- a/src/core/service/token/token_test.go +++ b/src/core/service/token/token_test.go @@ -30,6 +30,7 @@ import ( "testing" "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/common/utils/test" "github.com/goharbor/harbor/src/core/config" ) @@ -251,13 +252,7 @@ func (f *fakeSecurityContext) IsSysAdmin() bool { func (f *fakeSecurityContext) IsSolutionUser() bool { return false } -func (f *fakeSecurityContext) HasReadPerm(projectIDOrName interface{}) bool { - return false -} -func (f *fakeSecurityContext) HasWritePerm(projectIDOrName interface{}) bool { - return false -} -func (f *fakeSecurityContext) HasAllPerm(projectIDOrName interface{}) bool { +func (f *fakeSecurityContext) Can(action rbac.Action, resource rbac.Resource) bool { return false } func (f *fakeSecurityContext) GetMyProjects() ([]*models.Project, error) { diff --git a/src/portal/lib/src/_mixin.scss b/src/portal/lib/src/_mixin.scss index ea15edcc6..a29a9c325 100644 --- a/src/portal/lib/src/_mixin.scss +++ b/src/portal/lib/src/_mixin.scss @@ -12,7 +12,7 @@ @include text-overflow; } -@mixin grid-left-top-pos{ +@mixin grid-right-top-pos{ position: absolute; z-index: 100; right: 35px; diff --git a/src/portal/src/app/config/gc/gc.api.repository.ts b/src/portal/lib/src/config/gc/gc.api.repository.ts similarity index 53% rename from src/portal/src/app/config/gc/gc.api.repository.ts rename to src/portal/lib/src/config/gc/gc.api.repository.ts index 49cfe7e18..5cc5bbf7b 100644 --- a/src/portal/src/app/config/gc/gc.api.repository.ts +++ b/src/portal/lib/src/config/gc/gc.api.repository.ts @@ -1,47 +1,62 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Inject } from '@angular/core'; import { Http } from '@angular/http'; import { throwError as observableThrowError, Observable } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; +import { SERVICE_CONFIG, IServiceConfig } from "../../service.config"; +export abstract class GcApiRepository { + abstract postSchedule(param): Observable; + + abstract putSchedule(param): Observable; + + abstract getSchedule(): Observable; + + abstract getLog(id): Observable; + + abstract getStatus(id): Observable; + + abstract getJobs(): Observable; +} @Injectable() -export class GcApiRepository { - +export class GcApiDefaultRepository extends GcApiRepository { constructor( private http: Http, + @Inject(SERVICE_CONFIG) private config: IServiceConfig ) { + super(); } public postSchedule(param): Observable { - return this.http.post("/api/system/gc/schedule", param) + return this.http.post(`${this.config.gcEndpoint}/schedule`, param) .pipe(catchError(error => observableThrowError(error))); } public putSchedule(param): Observable { - return this.http.put("/api/system/gc/schedule", param) + return this.http.put(`${this.config.gcEndpoint}/schedule`, param) .pipe(catchError(error => observableThrowError(error))); } public getSchedule(): Observable { - return this.http.get("/api/system/gc/schedule") + return this.http.get(`${this.config.gcEndpoint}/schedule`) .pipe(catchError(error => observableThrowError(error))) .pipe(map(response => response.json())); } public getLog(id): Observable { - return this.http.get("/api/system/gc/" + id + "/log") + return this.http.get(`${this.config.gcEndpoint}/${id}/log`) .pipe(catchError(error => observableThrowError(error))); } public getStatus(id): Observable { - return this.http.get("/api/system/gc/" + id) + return this.http.get(`${this.config.gcEndpoint}/id`) .pipe(catchError(error => observableThrowError(error))) .pipe(map(response => response.json())); } public getJobs(): Observable { - return this.http.get("/api/system/gc") + return this.http.get(`${this.config.gcEndpoint}`) .pipe(catchError(error => observableThrowError(error))) .pipe(map(response => response.json())); } diff --git a/src/portal/src/app/config/gc/gc.component.html b/src/portal/lib/src/config/gc/gc.component.html similarity index 100% rename from src/portal/src/app/config/gc/gc.component.html rename to src/portal/lib/src/config/gc/gc.component.html diff --git a/src/portal/src/app/config/gc/gc.component.scss b/src/portal/lib/src/config/gc/gc.component.scss similarity index 100% rename from src/portal/src/app/config/gc/gc.component.scss rename to src/portal/lib/src/config/gc/gc.component.scss diff --git a/src/portal/lib/src/config/gc/gc.component.spec.ts b/src/portal/lib/src/config/gc/gc.component.spec.ts new file mode 100644 index 000000000..0c655d0a7 --- /dev/null +++ b/src/portal/lib/src/config/gc/gc.component.spec.ts @@ -0,0 +1,44 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { GcComponent } from './gc.component'; +import { SERVICE_CONFIG, IServiceConfig } from '../../service.config'; +import { GcApiRepository, GcApiDefaultRepository} from './gc.api.repository'; +import { GcRepoService } from './gc.service'; +import { SharedModule } from "../../shared/shared.module"; +import { ErrorHandler } from '../../error-handler/error-handler'; +import { GcViewModelFactory } from './gc.viewmodel.factory'; +import { GcUtility } from './gc.utility'; + +describe('GcComponent', () => { + let component: GcComponent; + let fixture: ComponentFixture; + let config: IServiceConfig = { + systemInfoEndpoint: "/api/system/gc" + }; + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + SharedModule + ], + declarations: [ GcComponent ], + providers: [ + { provide: GcApiRepository, useClass: GcApiDefaultRepository }, + { provide: SERVICE_CONFIG, useValue: config }, + GcRepoService, + ErrorHandler, + GcViewModelFactory, + GcUtility + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GcComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/portal/src/app/config/gc/gc.component.ts b/src/portal/lib/src/config/gc/gc.component.ts similarity index 98% rename from src/portal/src/app/config/gc/gc.component.ts rename to src/portal/lib/src/config/gc/gc.component.ts index a0e5c6cca..f8e5a6c71 100644 --- a/src/portal/src/app/config/gc/gc.component.ts +++ b/src/portal/lib/src/config/gc/gc.component.ts @@ -5,7 +5,7 @@ import { GcViewModelFactory } from "./gc.viewmodel.factory"; import { GcRepoService } from "./gc.service"; import { WEEKDAYS, SCHEDULE_TYPE, ONE_MINITUE, THREE_SECONDS} from './gc.const'; import { GcUtility } from './gc.utility'; -import { ErrorHandler } from '@harbor/ui'; +import { ErrorHandler } from '../../error-handler/index'; @Component({ selector: 'gc-config', diff --git a/src/portal/src/app/config/gc/gc.const.ts b/src/portal/lib/src/config/gc/gc.const.ts similarity index 100% rename from src/portal/src/app/config/gc/gc.const.ts rename to src/portal/lib/src/config/gc/gc.const.ts diff --git a/src/portal/src/app/config/gc/gc.service.ts b/src/portal/lib/src/config/gc/gc.service.ts similarity index 96% rename from src/portal/src/app/config/gc/gc.service.ts rename to src/portal/lib/src/config/gc/gc.service.ts index 05611f8a1..126ad7580 100644 --- a/src/portal/src/app/config/gc/gc.service.ts +++ b/src/portal/lib/src/config/gc/gc.service.ts @@ -3,9 +3,10 @@ import { Http } from '@angular/http'; import { Observable, Subscription, Subject, of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { GcApiRepository } from './gc.api.repository'; -import { ErrorHandler } from '@harbor/ui'; +import { ErrorHandler } from '../../error-handler/index'; import { GcJobData } from './gcLog'; + @Injectable() export class GcRepoService { diff --git a/src/portal/src/app/config/gc/gc.utility.ts b/src/portal/lib/src/config/gc/gc.utility.ts similarity index 100% rename from src/portal/src/app/config/gc/gc.utility.ts rename to src/portal/lib/src/config/gc/gc.utility.ts diff --git a/src/portal/src/app/config/gc/gc.viewmodel.factory.ts b/src/portal/lib/src/config/gc/gc.viewmodel.factory.ts similarity index 100% rename from src/portal/src/app/config/gc/gc.viewmodel.factory.ts rename to src/portal/lib/src/config/gc/gc.viewmodel.factory.ts diff --git a/src/portal/src/app/config/gc/gcLog.ts b/src/portal/lib/src/config/gc/gcLog.ts similarity index 100% rename from src/portal/src/app/config/gc/gcLog.ts rename to src/portal/lib/src/config/gc/gcLog.ts diff --git a/src/portal/lib/src/config/gc/index.ts b/src/portal/lib/src/config/gc/index.ts new file mode 100644 index 000000000..8bc26568d --- /dev/null +++ b/src/portal/lib/src/config/gc/index.ts @@ -0,0 +1,7 @@ +export * from "./gc.component"; +export * from "./gc.const"; +export * from "./gc.api.repository"; +export * from "./gc.service"; +export * from "./gc.utility"; +export * from "./gc.viewmodel.factory"; +export * from "./gcLog"; diff --git a/src/portal/lib/src/config/index.ts b/src/portal/lib/src/config/index.ts index 0d9671725..af469edc3 100644 --- a/src/portal/lib/src/config/index.ts +++ b/src/portal/lib/src/config/index.ts @@ -4,15 +4,19 @@ import { ReplicationConfigComponent } from './replication/replication-config.com import { SystemSettingsComponent } from './system/system-settings.component'; import { VulnerabilityConfigComponent } from './vulnerability/vulnerability-config.component'; import { RegistryConfigComponent } from './registry-config.component'; +import { GcComponent } from './gc/gc.component'; + export * from './config'; export * from './replication/replication-config.component'; export * from './system/system-settings.component'; export * from './vulnerability/vulnerability-config.component'; export * from './registry-config.component'; +export * from './gc/index'; export const CONFIGURATION_DIRECTIVES: Type[] = [ ReplicationConfigComponent, + GcComponent, SystemSettingsComponent, VulnerabilityConfigComponent, RegistryConfigComponent diff --git a/src/portal/lib/src/config/registry-config.component.html b/src/portal/lib/src/config/registry-config.component.html index 01849568c..bc1d3afec 100644 --- a/src/portal/lib/src/config/registry-config.component.html +++ b/src/portal/lib/src/config/registry-config.component.html @@ -13,5 +13,11 @@ + + + + + + \ No newline at end of file diff --git a/src/portal/lib/src/config/registry-config.component.spec.ts b/src/portal/lib/src/config/registry-config.component.spec.ts index 5e8ceca45..a3a183702 100644 --- a/src/portal/lib/src/config/registry-config.component.spec.ts +++ b/src/portal/lib/src/config/registry-config.component.spec.ts @@ -8,6 +8,7 @@ import { SystemSettingsComponent } from './system/system-settings.component'; import { VulnerabilityConfigComponent } from './vulnerability/vulnerability-config.component'; import { RegistryConfigComponent } from './registry-config.component'; import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; +import { GcComponent } from './gc/gc.component'; import { ConfigurationService, @@ -62,7 +63,8 @@ describe('RegistryConfigComponent (inline template)', () => { SystemSettingsComponent, VulnerabilityConfigComponent, RegistryConfigComponent, - ConfirmationDialogComponent + ConfirmationDialogComponent, + GcComponent ], providers: [ ErrorHandler, diff --git a/src/portal/lib/src/config/registry-config.component.ts b/src/portal/lib/src/config/registry-config.component.ts index f7f8acd1b..c1aeb9eae 100644 --- a/src/portal/lib/src/config/registry-config.component.ts +++ b/src/portal/lib/src/config/registry-config.component.ts @@ -13,7 +13,7 @@ import { clone } from '../utils'; import { ErrorHandler } from '../error-handler/index'; -import { SystemSettingsComponent, VulnerabilityConfigComponent} from './index'; +import { SystemSettingsComponent, VulnerabilityConfigComponent, GcComponent} from './index'; import { Configuration } from './config'; @Component({ @@ -30,6 +30,7 @@ export class RegistryConfigComponent implements OnInit { @ViewChild("systemSettings") systemSettings: SystemSettingsComponent; @ViewChild("vulnerabilityConfig") vulnerabilityCfg: VulnerabilityConfigComponent; + @ViewChild("gc") gc: GcComponent; @ViewChild("cfgConfirmationDialog") confirmationDlg: ConfirmationDialogComponent; constructor( diff --git a/src/portal/lib/src/config/system/system-settings.component.ts b/src/portal/lib/src/config/system/system-settings.component.ts index 410a97277..8fc0ea1f8 100644 --- a/src/portal/lib/src/config/system/system-settings.component.ts +++ b/src/portal/lib/src/config/system/system-settings.component.ts @@ -1,7 +1,7 @@ import { Component, Input, Output, EventEmitter, ViewChild, Inject, OnChanges, SimpleChanges } from '@angular/core'; import { NgForm } from '@angular/forms'; import { Configuration, StringValueItem } from '../config'; -import { SERVICE_CONFIG, IServiceConfig, downloadUrl } from '../../service.config'; +import { SERVICE_CONFIG, IServiceConfig } from '../../service.config'; import { clone, isEmpty, getChanges, toPromise } from '../../utils'; import { ErrorHandler } from '../../error-handler/index'; import { ConfirmationMessage } from '../../confirmation-dialog/confirmation-message'; @@ -23,7 +23,7 @@ export class SystemSettingsComponent implements OnChanges { config: Configuration = new Configuration(); onGoing = false; private originalConfig: Configuration; - downloadLink: string = downloadUrl; + downloadLink: string; @Output() configChange: EventEmitter = new EventEmitter(); @Output() readOnlyChange: EventEmitter = new EventEmitter(); @Output() reloadSystemConfig: EventEmitter = new EventEmitter(); diff --git a/src/portal/lib/src/endpoint/endpoint.component.ts b/src/portal/lib/src/endpoint/endpoint.component.ts index ad5213810..fd77076ff 100644 --- a/src/portal/lib/src/endpoint/endpoint.component.ts +++ b/src/portal/lib/src/endpoint/endpoint.component.ts @@ -19,8 +19,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef } from "@angular/core"; -import { Subscription} from "rxjs"; -import {forkJoin} from "rxjs"; +import { Subscription } from "rxjs"; +import { forkJoin } from "rxjs"; import { TranslateService } from "@ngx-translate/core"; import { Comparator } from "../service/interface"; @@ -29,9 +29,9 @@ import { EndpointService } from "../service/endpoint.service"; import { ErrorHandler } from "../error-handler/index"; -import {ConfirmationMessage} from "../confirmation-dialog/confirmation-message"; -import {ConfirmationAcknowledgement} from "../confirmation-dialog/confirmation-state-message"; -import {ConfirmationDialogComponent} from "../confirmation-dialog/confirmation-dialog.component"; +import { ConfirmationMessage } from "../confirmation-dialog/confirmation-message"; +import { ConfirmationAcknowledgement } from "../confirmation-dialog/confirmation-state-message"; +import { ConfirmationDialogComponent } from "../confirmation-dialog/confirmation-dialog.component"; import { ConfirmationTargets, @@ -42,8 +42,9 @@ import { import { CreateEditEndpointComponent } from "../create-edit-endpoint/create-edit-endpoint.component"; import { toPromise, CustomComparator } from "../utils"; -import {operateChanges, OperateInfo, OperationState} from "../operation/operate"; -import {OperationService} from "../operation/operation.service"; +import { operateChanges, OperateInfo, OperationState } from "../operation/operate"; +import { OperationService } from "../operation/operation.service"; + @Component({ selector: "hbr-endpoint", @@ -86,10 +87,10 @@ export class EndpointComponent implements OnInit, OnDestroy { } constructor(private endpointService: EndpointService, - private errorHandler: ErrorHandler, - private translateService: TranslateService, - private operationService: OperationService, - private ref: ChangeDetectorRef) { + private errorHandler: ErrorHandler, + private translateService: TranslateService, + private operationService: OperationService, + private ref: ChangeDetectorRef) { this.forceRefreshView(1000); } @@ -208,18 +209,18 @@ export class EndpointComponent implements OnInit, OnDestroy { operateChanges(operMessage, OperationState.success); }); }).catch( - error => { - if (error && error.status === 412) { - forkJoin(this.translateService.get('BATCH.DELETED_FAILURE'), - this.translateService.get('DESTINATION.FAILED_TO_DELETE_TARGET_IN_USED')).subscribe(res => { - operateChanges(operMessage, OperationState.failure, res[1]); - }); - } else { - this.translateService.get('BATCH.DELETED_FAILURE').subscribe(res => { - operateChanges(operMessage, OperationState.failure, res); - }); - } - }); + error => { + if (error && error.status === 412) { + forkJoin(this.translateService.get('BATCH.DELETED_FAILURE'), + this.translateService.get('DESTINATION.FAILED_TO_DELETE_TARGET_IN_USED')).subscribe(res => { + operateChanges(operMessage, OperationState.failure, res[1]); + }); + } else { + this.translateService.get('BATCH.DELETED_FAILURE').subscribe(res => { + operateChanges(operMessage, OperationState.failure, res); + }); + } + }); } // Forcely refresh the view diff --git a/src/portal/lib/src/harbor-library.module.ts b/src/portal/lib/src/harbor-library.module.ts index 2c014e762..fffd8432e 100644 --- a/src/portal/lib/src/harbor-library.module.ts +++ b/src/portal/lib/src/harbor-library.module.ts @@ -29,7 +29,6 @@ import { CREATE_EDIT_LABEL_DIRECTIVES } from "./create-edit-label/index"; import { LABEL_PIECE_DIRECTIVES } from "./label-piece/index"; import { HELMCHART_DIRECTIVE } from "./helm-chart/index"; import { IMAGE_NAME_INPUT_DIRECTIVES } from "./image-name-input/index"; - import { SystemInfoService, SystemInfoDefaultService, @@ -56,8 +55,14 @@ import { HelmChartService, HelmChartDefaultService, RetagService, - RetagDefaultService + RetagDefaultService, + UserPermissionService, + UserPermissionDefaultService } from './service/index'; +import { GcRepoService } from './config/gc/gc.service'; +import { GcUtility } from './config/gc/gc.utility'; +import {GcViewModelFactory} from './config/gc/gc.viewmodel.factory'; +import {GcApiRepository, GcApiDefaultRepository} from './config/gc/gc.api.repository'; import { ErrorHandler, DefaultErrorHandler @@ -68,7 +73,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { TranslateServiceInitializer } from './i18n/index'; import { DEFAULT_LANG_COOKIE_KEY, DEFAULT_SUPPORTING_LANGS, DEFAULT_LANG } from './utils'; import { ChannelService } from './channel/index'; -import { OperationService } from './operation/operation.service'; +import { OperationService } from './operation/operation.service'; /** * Declare default service configuration; all the endpoints will be defined in @@ -97,7 +102,8 @@ export const DefaultServiceConfig: IServiceConfig = { scanJobEndpoint: "/api/jobs/scan", labelEndpoint: "/api/labels", helmChartEndpoint: "/api/chartrepo", - downloadChartEndpoint: "/chartrepo" + downloadChartEndpoint: "/chartrepo", + gcEndpoint: "/api/system/gc" }; /** @@ -151,6 +157,12 @@ export interface HarborModuleConfig { // Service implementation for helmchart helmChartService?: Provider; + // Service implementation for userPermission + userPermissionService?: Provider; + + // Service implementation for gc + gcApiRepository?: Provider; + } /** @@ -248,8 +260,10 @@ export class HarborLibraryModule { config.configService || { provide: ConfigurationService, useClass: ConfigurationDefaultService }, config.jobLogService || { provide: JobLogService, useClass: JobLogDefaultService }, config.projectPolicyService || { provide: ProjectService, useClass: ProjectDefaultService }, - config.labelService || {provide: LabelService, useClass: LabelDefaultService}, - config.helmChartService || {provide: HelmChartService, useClass: HelmChartDefaultService}, + config.labelService || { provide: LabelService, useClass: LabelDefaultService }, + config.helmChartService || { provide: HelmChartService, useClass: HelmChartDefaultService }, + config.userPermissionService || { provide: UserPermissionService, useClass: UserPermissionDefaultService }, + config.gcApiRepository || {provide: GcApiRepository, useClass: GcApiDefaultRepository}, // Do initializing TranslateServiceInitializer, { @@ -259,7 +273,10 @@ export class HarborLibraryModule { multi: true }, ChannelService, - OperationService + OperationService, + GcRepoService, + GcViewModelFactory, + GcUtility ] }; } @@ -281,10 +298,15 @@ export class HarborLibraryModule { config.configService || { provide: ConfigurationService, useClass: ConfigurationDefaultService }, config.jobLogService || { provide: JobLogService, useClass: JobLogDefaultService }, config.projectPolicyService || { provide: ProjectService, useClass: ProjectDefaultService }, - config.labelService || {provide: LabelService, useClass: LabelDefaultService}, - config.helmChartService || {provide: HelmChartService, useClass: HelmChartDefaultService}, + config.labelService || { provide: LabelService, useClass: LabelDefaultService }, + config.helmChartService || { provide: HelmChartService, useClass: HelmChartDefaultService }, + config.userPermissionService || { provide: UserPermissionService, useClass: UserPermissionDefaultService }, + config.gcApiRepository || {provide: GcApiRepository, useClass: GcApiDefaultRepository}, ChannelService, - OperationService + OperationService, + GcRepoService, + GcViewModelFactory, + GcUtility ] }; } diff --git a/src/portal/lib/src/helm-chart/helm-chart.component.html b/src/portal/lib/src/helm-chart/helm-chart.component.html index bade63a46..a9d1b75c1 100644 --- a/src/portal/lib/src/helm-chart/helm-chart.component.html +++ b/src/portal/lib/src/helm-chart/helm-chart.component.html @@ -23,14 +23,14 @@
- - - diff --git a/src/portal/lib/src/helm-chart/helm-chart.component.scss b/src/portal/lib/src/helm-chart/helm-chart.component.scss index b03ebe643..80a7590f9 100644 --- a/src/portal/lib/src/helm-chart/helm-chart.component.scss +++ b/src/portal/lib/src/helm-chart/helm-chart.component.scss @@ -12,7 +12,8 @@ $size60:60px; .toolbar { overflow: hidden; .rightPos { - @include grid-left-top-pos; + @include grid-right-top-pos; + margin-top: 20px; .filter-divider { display: inline-block; height: 16px; diff --git a/src/portal/lib/src/helm-chart/helm-chart.component.ts b/src/portal/lib/src/helm-chart/helm-chart.component.ts index f3d6832ec..792eb9333 100644 --- a/src/portal/lib/src/helm-chart/helm-chart.component.ts +++ b/src/portal/lib/src/helm-chart/helm-chart.component.ts @@ -17,9 +17,11 @@ import { SystemInfo, SystemInfoService, HelmChartItem } from "../service/index"; import { ErrorHandler } from "../error-handler/error-handler"; import { toPromise, DEFAULT_PAGE_SIZE, downloadFile } from "../utils"; import { HelmChartService } from "../service/helm-chart.service"; -import { DefaultHelmIcon} from "../shared/shared.const"; +import { DefaultHelmIcon } from "../shared/shared.const"; import { Roles } from './../shared/shared.const'; import { OperationService } from "./../operation/operation.service"; +import { UserPermissionService } from "../service/permission.service"; +import { USERSTATICPERMISSION } from "../service/permission-static"; import { OperateInfo, OperationState, @@ -45,7 +47,6 @@ export class HelmChartComponent implements OnInit { @Input() urlPrefix: string; @Input() hasSignedIn: boolean; @Input() projectRoleID = Roles.OTHER; - @Input() hasProjectAdminRole: boolean; @Output() chartClickEvt = new EventEmitter(); @Output() chartDownloadEve = new EventEmitter(); @Input() chartDefaultIcon: string = DefaultHelmIcon; @@ -76,24 +77,23 @@ export class HelmChartComponent implements OnInit { @ViewChild('chartUploadForm') uploadForm: NgForm; @ViewChild("confirmationDialog") confirmationDialog: ConfirmationDialogComponent; - + hasUploadHelmChartsPermission: boolean; + hasDownloadHelmChartsPermission: boolean; + hasDeleteHelmChartsPermission: boolean; constructor( private errorHandler: ErrorHandler, private translateService: TranslateService, private systemInfoService: SystemInfoService, private helmChartService: HelmChartService, + private userPermissionService: UserPermissionService, private operationService: OperationService, private cdr: ChangeDetectorRef, - ) {} + ) { } public get registryUrl(): string { return this.systemInfo ? this.systemInfo.registry_url : ""; } - public get developerRoleOrAbove(): boolean { - return this.projectRoleID === Roles.DEVELOPER || this.hasProjectAdminRole; - } - ngOnInit(): void { // Get system info for tag views toPromise(this.systemInfoService.getSystemInfo()) @@ -101,8 +101,21 @@ export class HelmChartComponent implements OnInit { .catch(error => this.errorHandler.error(error)); this.lastFilteredChartName = ""; this.refresh(); + this.getHelmPermissionRule(this.projectId); + } + getHelmPermissionRule(projectId: number): void { + let hasUploadHelmChartsPermission = this.userPermissionService.getPermission(projectId, + USERSTATICPERMISSION.HELM_CHART.KEY, USERSTATICPERMISSION.HELM_CHART.VALUE.UPLOAD); + let hasDownloadHelmChartsPermission = this.userPermissionService.getPermission(projectId, + USERSTATICPERMISSION.HELM_CHART.KEY, USERSTATICPERMISSION.HELM_CHART.VALUE.DOWNLOAD); + let hasDeleteHelmChartsPermission = this.userPermissionService.getPermission(projectId, + USERSTATICPERMISSION.HELM_CHART.KEY, USERSTATICPERMISSION.HELM_CHART.VALUE.DELETE); + forkJoin(hasUploadHelmChartsPermission, hasDownloadHelmChartsPermission, hasDeleteHelmChartsPermission).subscribe(permissions => { + this.hasUploadHelmChartsPermission = permissions[0] as boolean; + this.hasDownloadHelmChartsPermission = permissions[1] as boolean; + this.hasDeleteHelmChartsPermission = permissions[2] as boolean; + }, error => this.errorHandler.error(error)); } - updateFilterValue(value: string) { this.lastFilteredChartName = value; this.refresh(); @@ -111,22 +124,22 @@ export class HelmChartComponent implements OnInit { refresh() { this.loading = true; this.helmChartService - .getHelmCharts(this.projectName) - .pipe(finalize(() => { + .getHelmCharts(this.projectName) + .pipe(finalize(() => { let hnd = setInterval(() => this.cdr.markForCheck(), 100); setTimeout(() => clearInterval(hnd), 3000); this.loading = false; - })) - .subscribe( - charts => { - this.charts = charts.filter(x => x.name.includes(this.lastFilteredChartName)); - this.chartsCopy = charts.map(x => Object.assign({}, x)); - this.totalCount = charts.length; - }, - err => { - this.errorHandler.error(err); - } - ); + })) + .subscribe( + charts => { + this.charts = charts.filter(x => x.name.includes(this.lastFilteredChartName)); + this.chartsCopy = charts.map(x => Object.assign({}, x)); + this.totalCount = charts.length; + }, + err => { + this.errorHandler.error(err); + } + ); } onChartClick(item: HelmChartItem) { @@ -163,10 +176,10 @@ export class HelmChartComponent implements OnInit { this.refresh(); })) .subscribe(() => { - this.translateService - .get("HELM_CHART.FILE_UPLOADED") - .subscribe(res => this.errorHandler.info(res)); - }, + this.translateService + .get("HELM_CHART.FILE_UPLOADED") + .subscribe(res => this.errorHandler.info(res)); + }, err => this.errorHandler.error(err) ); } @@ -192,23 +205,23 @@ export class HelmChartComponent implements OnInit { this.operationService.publishInfo(operateMsg); return this.helmChartService.deleteHelmChart(this.projectName, chartName) - .pipe(map( - () => operateChanges(operateMsg, OperationState.success), - err => operateChanges(operateMsg, OperationState.failure, err) - )); + .pipe(map( + () => operateChanges(operateMsg, OperationState.success), + err => operateChanges(operateMsg, OperationState.failure, err) + )); } deleteCharts(charts: HelmChartItem[]) { if (charts && charts.length < 1) { return; } let chartsDelete$ = charts.map(chart => this.deleteChart(chart.name)); forkJoin(chartsDelete$) - .pipe( - catchError(err => throwError(err)), - finalize(() => { - this.refresh(); - this.selectedRows = []; - })) - .subscribe(() => {}); + .pipe( + catchError(err => throwError(err)), + finalize(() => { + this.refresh(); + this.selectedRows = []; + })) + .subscribe(() => { }); } downloadLatestVersion(evt?: Event, item?: HelmChartItem) { diff --git a/src/portal/lib/src/helm-chart/versions/helm-chart-version.component.html b/src/portal/lib/src/helm-chart/versions/helm-chart-version.component.html index 1cd96ab41..929cdc64e 100644 --- a/src/portal/lib/src/helm-chart/versions/helm-chart-version.component.html +++ b/src/portal/lib/src/helm-chart/versions/helm-chart-version.component.html @@ -38,18 +38,18 @@ - @@ -144,7 +144,7 @@
diff --git a/src/portal/lib/src/helm-chart/versions/helm-chart-version.component.scss b/src/portal/lib/src/helm-chart/versions/helm-chart-version.component.scss index 27a21a5d1..2459e977b 100644 --- a/src/portal/lib/src/helm-chart/versions/helm-chart-version.component.scss +++ b/src/portal/lib/src/helm-chart/versions/helm-chart-version.component.scss @@ -16,7 +16,7 @@ .toolbar { overflow: hidden; .rightPos { - @include grid-left-top-pos; + @include grid-right-top-pos; .filter-divider { display: inline-block; height: 16px; diff --git a/src/portal/lib/src/helm-chart/versions/helm-chart-version.component.ts b/src/portal/lib/src/helm-chart/versions/helm-chart-version.component.ts index 276f5dbd6..c92e724ca 100644 --- a/src/portal/lib/src/helm-chart/versions/helm-chart-version.component.ts +++ b/src/portal/lib/src/helm-chart/versions/helm-chart-version.component.ts @@ -26,6 +26,8 @@ import { ErrorHandler } from "./../../error-handler/error-handler"; import { toPromise, DEFAULT_PAGE_SIZE, downloadFile } from "../../utils"; import { OperationService } from "./../../operation/operation.service"; import { HelmChartService } from "./../../service/helm-chart.service"; +import { UserPermissionService } from "../../service/permission.service"; +import { USERSTATICPERMISSION } from "../../service/permission-static"; import { ConfirmationAcknowledgement, ConfirmationDialogComponent, ConfirmationMessage } from "./../../confirmation-dialog"; import { OperateInfo, @@ -49,13 +51,11 @@ import { }) export class ChartVersionComponent implements OnInit { signedCon: { [key: string]: any | string[] } = {}; - @Input() projectRoleID: number; @Input() projectId: number; @Input() projectName: string; @Input() chartName: string; @Input() roleName: string; @Input() hasSignedIn: boolean; - @Input() hasProjectAdminRole: boolean; @Input() chartDefaultIcon: string = DefaultHelmIcon; @Output() versionClickEvt = new EventEmitter(); @Output() backEvt = new EventEmitter(); @@ -85,12 +85,15 @@ export class ChartVersionComponent implements OnInit { @ViewChild("confirmationDialog") confirmationDialog: ConfirmationDialogComponent; - + hasAddRemoveHelmChartVersionPermission: boolean; + hasDownloadHelmChartVersionPermission: boolean; + hasDeleteHelmChartVersionPermission: boolean; constructor( private errorHandler: ErrorHandler, private systemInfoService: SystemInfoService, private helmChartService: HelmChartService, private resrouceLabelService: LabelService, + public userPermissionService: UserPermissionService, private cdr: ChangeDetectorRef, private operationService: OperationService, ) { } @@ -107,6 +110,7 @@ export class ChartVersionComponent implements OnInit { this.refresh(); this.getLabels(); this.lastFilteredVersionName = ""; + this.getHelmChartVersionPermission(this.projectId); } updateFilterValue(value: string) { @@ -326,7 +330,19 @@ export class ChartVersionComponent implements OnInit { }); } - public get developerRoleOrAbove(): boolean { - return this.projectRoleID === Roles.DEVELOPER || this.hasProjectAdminRole; + getHelmChartVersionPermission(projectId: number): void { + + let hasAddRemoveHelmChartVersionPermission = this.userPermissionService.getPermission(projectId, + USERSTATICPERMISSION.HELM_CHART_VERSION_LABEL.KEY, USERSTATICPERMISSION.HELM_CHART_VERSION_LABEL.VALUE.CREATE); + let hasDownloadHelmChartVersionPermission = this.userPermissionService.getPermission(projectId, + USERSTATICPERMISSION.HELM_CHART_VERSION.KEY, USERSTATICPERMISSION.HELM_CHART_VERSION.VALUE.READ); + let hasDeleteHelmChartVersionPermission = this.userPermissionService.getPermission(projectId, + USERSTATICPERMISSION.HELM_CHART_VERSION.KEY, USERSTATICPERMISSION.HELM_CHART_VERSION.VALUE.DELETE); + forkJoin(hasAddRemoveHelmChartVersionPermission, hasDownloadHelmChartVersionPermission, hasDeleteHelmChartVersionPermission) + .subscribe(permissions => { + this.hasAddRemoveHelmChartVersionPermission = permissions[0] as boolean; + this.hasDownloadHelmChartVersionPermission = permissions[1] as boolean; + this.hasDeleteHelmChartVersionPermission = permissions[2] as boolean; + }, error => this.errorHandler.error(error)); } } diff --git a/src/portal/lib/src/index.ts b/src/portal/lib/src/index.ts index 77c108e10..221df50f4 100644 --- a/src/portal/lib/src/index.ts +++ b/src/portal/lib/src/index.ts @@ -26,3 +26,4 @@ export * from './repository-gridview/index'; export * from './operation/index'; export * from './_animations/index'; export * from './helm-chart/index'; + diff --git a/src/portal/lib/src/label/label.component.html b/src/portal/lib/src/label/label.component.html index 7749f6bb2..717f9faf9 100644 --- a/src/portal/lib/src/label/label.component.html +++ b/src/portal/lib/src/label/label.component.html @@ -11,9 +11,9 @@
- - - + + +
diff --git a/src/portal/lib/src/label/label.component.scss b/src/portal/lib/src/label/label.component.scss index c798ac5c4..f1ec7bb47 100644 --- a/src/portal/lib/src/label/label.component.scss +++ b/src/portal/lib/src/label/label.component.scss @@ -7,7 +7,6 @@ position: absolute; z-index: 100; right: 35px; - margin-top: 14px; height: 24px; .option-right { padding-right: 16px; diff --git a/src/portal/lib/src/label/label.component.ts b/src/portal/lib/src/label/label.component.ts index 7a3f3c374..1867440d6 100644 --- a/src/portal/lib/src/label/label.component.ts +++ b/src/portal/lib/src/label/label.component.ts @@ -19,22 +19,22 @@ import { ChangeDetectorRef, Input } from "@angular/core"; -import {Label} from "../service/interface"; -import {LabelService} from "../service/label.service"; -import {toPromise} from "../utils"; -import {ErrorHandler} from "../error-handler/error-handler"; -import {CreateEditLabelComponent} from "../create-edit-label/create-edit-label.component"; -import {ConfirmationMessage} from "../confirmation-dialog/confirmation-message"; +import { Label } from "../service/interface"; +import { LabelService } from "../service/label.service"; +import { toPromise } from "../utils"; +import { ErrorHandler } from "../error-handler/error-handler"; +import { CreateEditLabelComponent } from "../create-edit-label/create-edit-label.component"; +import { ConfirmationMessage } from "../confirmation-dialog/confirmation-message"; import { ConfirmationButtons, ConfirmationState, ConfirmationTargets } from "../shared/shared.const"; -import {ConfirmationAcknowledgement} from "../confirmation-dialog/confirmation-state-message"; -import {TranslateService} from "@ngx-translate/core"; -import {ConfirmationDialogComponent} from "../confirmation-dialog/confirmation-dialog.component"; -import {operateChanges, OperateInfo, OperationState} from "../operation/operate"; -import {OperationService} from "../operation/operation.service"; +import { ConfirmationAcknowledgement } from "../confirmation-dialog/confirmation-state-message"; +import { TranslateService } from "@ngx-translate/core"; +import { ConfirmationDialogComponent } from "../confirmation-dialog/confirmation-dialog.component"; +import { operateChanges, OperateInfo, OperationState } from "../operation/operate"; +import { OperationService } from "../operation/operation.service"; @Component({ selector: "hbr-label", @@ -51,7 +51,9 @@ export class LabelComponent implements OnInit { @Input() scope: string; @Input() projectId = 0; - @Input() hasProjectAdminRole: boolean; + @Input() hasCreateLabelPermission: boolean; + @Input() hasUpdateLabelPermission: boolean; + @Input() hasDeleteLabelPermission: boolean; @ViewChild(CreateEditLabelComponent) createEditLabel: CreateEditLabelComponent; @@ -59,10 +61,10 @@ export class LabelComponent implements OnInit { confirmationDialogComponent: ConfirmationDialogComponent; constructor(private labelService: LabelService, - private errorHandler: ErrorHandler, - private translateService: TranslateService, - private operationService: OperationService, - private ref: ChangeDetectorRef) { + private errorHandler: ErrorHandler, + private translateService: TranslateService, + private operationService: OperationService, + private ref: ChangeDetectorRef) { } ngOnInit(): void { @@ -162,11 +164,11 @@ export class LabelComponent implements OnInit { operateChanges(operMessage, OperationState.success); }); }).catch( - error => { - this.translateService.get('BATCH.DELETED_FAILURE').subscribe(res => { - operateChanges(operMessage, OperationState.failure, res); + error => { + this.translateService.get('BATCH.DELETED_FAILURE').subscribe(res => { + operateChanges(operMessage, OperationState.failure, res); + }); }); - }); } // Forcely refresh the view @@ -183,4 +185,5 @@ export class LabelComponent implements OnInit { } }, duration); } + } diff --git a/src/portal/lib/src/list-replication-rule/list-replication-rule.component.html b/src/portal/lib/src/list-replication-rule/list-replication-rule.component.html index 479c5cc52..83188bda5 100644 --- a/src/portal/lib/src/list-replication-rule/list-replication-rule.component.html +++ b/src/portal/lib/src/list-replication-rule/list-replication-rule.component.html @@ -1,10 +1,10 @@
- + - - - - + + + + {{'REPLICATION.NAME' | translate}} {{'REPLICATION.STATUS' | translate}} @@ -13,7 +13,7 @@ {{'REPLICATION.DESTINATION_NAME' | translate}} {{'REPLICATION.TRIGGER_MODE' | translate}} {{'REPLICATION.PLACEHOLDER' | translate }} - + {{p.name}}
diff --git a/src/portal/lib/src/list-replication-rule/list-replication-rule.component.ts b/src/portal/lib/src/list-replication-rule/list-replication-rule.component.ts index 4107dc194..91c8820c9 100644 --- a/src/portal/lib/src/list-replication-rule/list-replication-rule.component.ts +++ b/src/portal/lib/src/list-replication-rule/list-replication-rule.component.ts @@ -24,28 +24,29 @@ import { SimpleChange, SimpleChanges } from "@angular/core"; -import { forkJoin} from "rxjs"; +import { forkJoin } from "rxjs"; import { Comparator } from "../service/interface"; import { TranslateService } from "@ngx-translate/core"; -import {ReplicationService} from "../service/replication.service"; +import { ReplicationService } from "../service/replication.service"; + import { ReplicationJob, ReplicationJobItem, ReplicationRule } from "../service/interface"; -import {ConfirmationDialogComponent} from "../confirmation-dialog/confirmation-dialog.component"; -import {ConfirmationMessage} from "../confirmation-dialog/confirmation-message"; -import {ConfirmationAcknowledgement} from "../confirmation-dialog/confirmation-state-message"; +import { ConfirmationDialogComponent } from "../confirmation-dialog/confirmation-dialog.component"; +import { ConfirmationMessage } from "../confirmation-dialog/confirmation-message"; +import { ConfirmationAcknowledgement } from "../confirmation-dialog/confirmation-state-message"; import { ConfirmationState, ConfirmationTargets, ConfirmationButtons } from "../shared/shared.const"; -import {ErrorHandler} from "../error-handler/error-handler"; -import {toPromise, CustomComparator} from "../utils"; -import {operateChanges, OperateInfo, OperationState} from "../operation/operate"; -import {OperationService} from "../operation/operation.service"; +import { ErrorHandler } from "../error-handler/error-handler"; +import { toPromise, CustomComparator } from "../utils"; +import { operateChanges, OperateInfo, OperationState } from "../operation/operate"; +import { OperationService } from "../operation/operation.service"; @Component({ @@ -58,12 +59,14 @@ export class ListReplicationRuleComponent implements OnInit, OnChanges { nullTime = "0001-01-01T00:00:00Z"; @Input() projectId: number; - @Input() isSystemAdmin: boolean; @Input() selectedId: number | string; @Input() withReplicationJob: boolean; @Input() loading = false; - + @Input() hasCreateReplicationPermission: boolean; + @Input() hasUpdateReplicationPermission: boolean; + @Input() hasDeleteReplicationPermission: boolean; + @Input() hasExecuteReplicationPermission: boolean; @Output() reload = new EventEmitter(); @Output() selectOne = new EventEmitter(); @Output() editOne = new EventEmitter(); @@ -92,10 +95,10 @@ export class ListReplicationRuleComponent implements OnInit, OnChanges { enabledComparator: Comparator = new CustomComparator("enabled", "number"); constructor(private replicationService: ReplicationService, - private translateService: TranslateService, - private errorHandler: ErrorHandler, - private operationService: OperationService, - private ref: ChangeDetectorRef) { + private translateService: TranslateService, + private errorHandler: ErrorHandler, + private operationService: OperationService, + private ref: ChangeDetectorRef) { setInterval(() => ref.markForCheck(), 500); } @@ -113,7 +116,6 @@ export class ListReplicationRuleComponent implements OnInit, OnChanges { this.retrieveRules(); } } - ngOnChanges(changes: SimpleChanges): void { let proIdChange: SimpleChange = changes["projectId"]; if (proIdChange) { @@ -156,7 +158,7 @@ export class ListReplicationRuleComponent implements OnInit, OnChanges { let count = 0; rule.filters.forEach((data: any) => { if (data.kind === 'label' && data.value.deleted) { - count ++; + count++; } }); if (count === 0) { @@ -177,8 +179,10 @@ export class ListReplicationRuleComponent implements OnInit, OnChanges { } selectRule(rule: ReplicationRule): void { - this.selectedId = rule.id || ""; - this.selectOne.emit(rule); + if (rule) { + this.selectedId = rule.id || ""; + this.selectOne.emit(rule); + } } redirectTo(rule: ReplicationRule): void { @@ -258,8 +262,8 @@ export class ListReplicationRuleComponent implements OnInit, OnChanges { if (!this.canDeleteRule) { forkJoin(this.translateService.get('BATCH.DELETED_FAILURE'), this.translateService.get('REPLICATION.DELETION_SUMMARY_FAILURE')).subscribe(res => { - operateChanges(operMessage, OperationState.failure, res[1]); - }); + operateChanges(operMessage, OperationState.failure, res[1]); + }); return null; } @@ -273,8 +277,8 @@ export class ListReplicationRuleComponent implements OnInit, OnChanges { if (error && error.status === 412) { forkJoin(this.translateService.get('BATCH.DELETED_FAILURE'), this.translateService.get('REPLICATION.FAILED_TO_DELETE_POLICY_ENABLED')).subscribe(res => { - operateChanges(operMessage, OperationState.failure, res[1]); - }); + operateChanges(operMessage, OperationState.failure, res[1]); + }); } else { this.translateService.get('BATCH.DELETED_FAILURE').subscribe(res => { operateChanges(operMessage, OperationState.failure, res); diff --git a/src/portal/lib/src/project-policy-config/project-policy-config.component.html b/src/portal/lib/src/project-policy-config/project-policy-config.component.html index c9aa2458f..17263599f 100644 --- a/src/portal/lib/src/project-policy-config/project-policy-config.component.html +++ b/src/portal/lib/src/project-policy-config/project-policy-config.component.html @@ -5,7 +5,7 @@
+ [disabled]="!hasChangeConfigRole" /> @@ -19,7 +19,7 @@
- + @@ -28,7 +28,7 @@
+ [disabled]="!hasChangeConfigRole" /> @@ -52,16 +52,16 @@
-
- - diff --git a/src/portal/lib/src/project-policy-config/project-policy-config.component.spec.ts b/src/portal/lib/src/project-policy-config/project-policy-config.component.spec.ts index 640fb6913..8b82c8e05 100644 --- a/src/portal/lib/src/project-policy-config/project-policy-config.component.spec.ts +++ b/src/portal/lib/src/project-policy-config/project-policy-config.component.spec.ts @@ -8,15 +8,18 @@ import { ProjectService, ProjectDefaultService} from '../service/project.service import { SERVICE_CONFIG, IServiceConfig} from '../service.config'; import { SystemInfo } from '../service/interface'; import { Project } from './project'; - +import { UserPermissionService, UserPermissionDefaultService } from '../service/permission.service'; +import { USERSTATICPERMISSION } from '../service/permission-static'; +import { of } from 'rxjs'; describe('ProjectPolicyConfigComponent', () => { let systemInfoService: SystemInfoService; let projectPolicyService: ProjectService; + let userPermissionService: UserPermissionService; let spySystemInfo: jasmine.Spy; let spyProjectPolicies: jasmine.Spy; - + let mockHasChangeConfigRole: boolean = true; let mockSystemInfo: SystemInfo[] = [ { 'with_clair': true, @@ -102,7 +105,8 @@ describe('ProjectPolicyConfigComponent', () => { ErrorHandler, { provide: SERVICE_CONFIG, useValue: config }, { provide: ProjectService, useClass: ProjectDefaultService }, - { provide: SystemInfoService, useClass: SystemInfoDefaultService} + { provide: SystemInfoService, useClass: SystemInfoDefaultService}, + { provide: UserPermissionService, useClass: UserPermissionDefaultService}, ] }) .compileComponents(); @@ -120,6 +124,11 @@ describe('ProjectPolicyConfigComponent', () => { spySystemInfo = spyOn(systemInfoService, 'getSystemInfo').and.returnValues(Promise.resolve(mockSystemInfo[0])); spyProjectPolicies = spyOn(projectPolicyService, 'getProject').and.returnValues(Promise.resolve(mockPorjectPolicies[0])); + userPermissionService = fixture.debugElement.injector.get(UserPermissionService); + spyOn(userPermissionService, "getPermission") + .withArgs(component.projectId, + USERSTATICPERMISSION.CONFIGURATION.KEY, USERSTATICPERMISSION.CONFIGURATION.VALUE.UPDATE ) + .and.returnValue(of(mockHasChangeConfigRole)); fixture.detectChanges(); }); diff --git a/src/portal/lib/src/project-policy-config/project-policy-config.component.ts b/src/portal/lib/src/project-policy-config/project-policy-config.component.ts index e97f4429b..650e9b5d3 100644 --- a/src/portal/lib/src/project-policy-config/project-policy-config.component.ts +++ b/src/portal/lib/src/project-policy-config/project-policy-config.component.ts @@ -13,6 +13,8 @@ import { TranslateService } from '@ngx-translate/core'; import { Project } from './project'; import {SystemInfo, SystemInfoService} from '../service/index'; +import { UserPermissionService } from '../service/permission.service'; +import { USERSTATICPERMISSION } from '../service/permission-static'; export class ProjectPolicy { Public: boolean; @@ -56,7 +58,7 @@ export class ProjectPolicyConfigComponent implements OnInit { systemInfo: SystemInfo; orgProjectPolicy = new ProjectPolicy(); projectPolicy = new ProjectPolicy(); - + hasChangeConfigRole: boolean; severityOptions = [ {severity: 'high', severityLevel: 'VULNERABILITY.SEVERITY.HIGH'}, {severity: 'medium', severityLevel: 'VULNERABILITY.SEVERITY.MEDIUM'}, @@ -69,6 +71,7 @@ export class ProjectPolicyConfigComponent implements OnInit { private translate: TranslateService, private projectService: ProjectService, private systemInfoService: SystemInfoService, + private userPermission: UserPermissionService ) {} ngOnInit(): void { @@ -85,8 +88,14 @@ export class ProjectPolicyConfigComponent implements OnInit { // retrive project level policy data this.retrieve(); + this.getPermission(); + } + private getPermission(): void { + this.userPermission.getPermission(this.projectId, + USERSTATICPERMISSION.CONFIGURATION.KEY, USERSTATICPERMISSION.CONFIGURATION.VALUE.UPDATE).subscribe(permissins => { + this.hasChangeConfigRole = permissins as boolean; + }); } - public get withNotary(): boolean { return this.systemInfo ? this.systemInfo.with_notary : false; } diff --git a/src/portal/lib/src/replication/replication.component.html b/src/portal/lib/src/replication/replication.component.html index 512d0b864..f4d9a2a6c 100644 --- a/src/portal/lib/src/replication/replication.component.html +++ b/src/portal/lib/src/replication/replication.component.html @@ -11,9 +11,14 @@
- + (reload)="reloadRules($event)" [loading]="loading" [withReplicationJob]="withReplicationJob" (redirect)="customRedirect($event)" + [hasCreateReplicationPermission]="hasCreateReplicationPermission" + [hasUpdateReplicationPermission]="hasUpdateReplicationPermission" + [hasDeleteReplicationPermission]="hasDeleteReplicationPermission" + [hasExecuteReplicationPermission]="hasExecuteReplicationPermission" + >
diff --git a/src/portal/lib/src/replication/replication.component.scss b/src/portal/lib/src/replication/replication.component.scss index c7664c936..16104b61f 100644 --- a/src/portal/lib/src/replication/replication.component.scss +++ b/src/portal/lib/src/replication/replication.component.scss @@ -16,7 +16,7 @@ .rightPos{ position: absolute; right: 35px; - margin-top: 15px; + margin-top: 20px; z-index: 100; height: 32px; } diff --git a/src/portal/lib/src/replication/replication.component.ts b/src/portal/lib/src/replication/replication.component.ts index d16bc80c3..ece3c95da 100644 --- a/src/portal/lib/src/replication/replication.component.ts +++ b/src/portal/lib/src/replication/replication.component.ts @@ -104,6 +104,10 @@ export class ReplicationComponent implements OnInit, OnDestroy { @Input() isSystemAdmin: boolean; @Input() withAdmiral: boolean; @Input() withReplicationJob: boolean; + @Input() hasCreateReplicationPermission: boolean; + @Input() hasUpdateReplicationPermission: boolean; + @Input() hasDeleteReplicationPermission: boolean; + @Input() hasExecuteReplicationPermission: boolean; @Output() redirect = new EventEmitter(); @Output() openCreateRule = new EventEmitter(); diff --git a/src/portal/lib/src/repository-gridview/repository-gridview.component.html b/src/portal/lib/src/repository-gridview/repository-gridview.component.html index 6fd137c12..52f46561e 100644 --- a/src/portal/lib/src/repository-gridview/repository-gridview.component.html +++ b/src/portal/lib/src/repository-gridview/repository-gridview.component.html @@ -7,7 +7,7 @@ {{'CONFIG.REGISTRY_CERTIFICATE' | translate | uppercase}} - + @@ -27,7 +27,7 @@ - + {{'REPOSITORY.NAME' | translate}} {{'REPOSITORY.TAGS_COUNT' | translate}} @@ -87,7 +87,7 @@