Merge pull request #1 from goharbor/master

update
This commit is contained in:
Chenyu Zhang 2019-07-12 09:50:47 +08:00 committed by GitHub
commit 1e2660f060
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
288 changed files with 38718 additions and 1887 deletions

View File

@ -292,7 +292,7 @@ compile_notary_migrate_patch:
compile: check_environment versions_prepare compile_core compile_jobservice compile_registryctl compile_notary_migrate_patch
update_prepare_version:
@echo "substitude the prepare version tag in prepare file..."
@echo "substitute the prepare version tag in prepare file..."
@$(SEDCMD) -i -e 's/goharbor\/prepare:.*[[:space:]]\+/goharbor\/prepare:$(VERSIONTAG) /' $(MAKEPATH)/prepare ;
prepare: update_prepare_version
@ -416,7 +416,7 @@ start:
@echo "Start complete. You can visit harbor now."
down:
@echo "Please make sure to set -e NOTARYFLAG=true/CLAIRFLAG=true/CHARTFLAG=true if you are using Notary/CLAIR/Chartmuseum in Harbor, otherwise the Notary/CLAIR/Chartmuseum containers cannot be stop automaticlly."
@echo "Please make sure to set -e NOTARYFLAG=true/CLAIRFLAG=true/CHARTFLAG=true if you are using Notary/CLAIR/Chartmuseum in Harbor, otherwise the Notary/CLAIR/Chartmuseum containers cannot be stopped automatically."
@while [ -z "$$CONTINUE" ]; do \
read -r -p "Type anything but Y or y to exit. [Y/N]: " CONTINUE; \
done ; \

View File

@ -44,25 +44,25 @@ You can compile the code by one of the three approaches:
* Get official Golang image from docker hub:
```sh
$ docker pull golang:1.11.2
$ docker pull golang:1.12.5
```
* Build, install and bring up Harbor without Notary:
```sh
$ make install GOBUILDIMAGE=golang:1.11.2 COMPILETAG=compile_golangimage
$ make install GOBUILDIMAGE=golang:1.12.5 COMPILETAG=compile_golangimage
```
* Build, install and bring up Harbor with Notary:
```sh
$ make install GOBUILDIMAGE=golang:1.11.2 COMPILETAG=compile_golangimage NOTARYFLAG=true
$ make install GOBUILDIMAGE=golang:1.12.5 COMPILETAG=compile_golangimage NOTARYFLAG=true
```
* Build, install and bring up Harbor with Clair:
```sh
$ make install GOBUILDIMAGE=golang:1.11.2 COMPILETAG=compile_golangimage CLAIRFLAG=true
$ make install GOBUILDIMAGE=golang:1.12.5 COMPILETAG=compile_golangimage CLAIRFLAG=true
```
#### II. Compile code with your own Golang environment, then build Harbor

View File

@ -17,18 +17,23 @@ This guide provides instructions to manage roles by LDAP/AD group. You can impor
Besides **[basic LDAP configure parameters](https://github.com/vmware/harbor/blob/master/docs/installation_guide.md#optional-parameters)** , LDAP group related configure parameters should be configured, they can be configured before or after installation
1. Configure parameters in harbor.cfg before installation
1. Configure LDAP parameters via API, refer to **[Config Harbor user settings by command line](configure_user_settings.md)**
For example:
```
curl -X PUT -u "<username>:<password>" -H "Content-Type: application/json" -ki https://harbor.sample.domain/api/configurations -d'{"ldap_group_basedn":"ou=groups,dc=example,dc=com"}'
```
The following parameters are related to LDAP group configuration.
* ldap_group_basedn -- The base DN from which to lookup a group in LDAP/AD, for example: ou=groups,dc=example,dc=com
* ldap_group_filter -- The filter to search LDAP/AD group, for example: objectclass=groupOfNames
* ldap_group_gid -- The attribute used to name an LDAP/AD group, for example: cn
* ldap_group_scope -- The scope to search for LDAP/AD groups. 0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE
2. Or Change configure parameter in web console after installation. Go to "Administration" -> "Configuration" -> "Authentication" and change following settings.
- LDAP Group Base DN -- ldap_group_basedn in harbor.cfg
- LDAP Group Filter -- ldap_group_filter in harbor.cfg
- LDAP Group GID -- ldap_group_gid in harbor.cfg
- LDAP Group Scope -- ldap_group_scope in harbor.cfg
2. Or change configure parameter in web console after installation. Go to "Administration" -> "Configuration" -> "Authentication" and change following settings.
- LDAP Group Base DN -- ldap_group_basedn in the Harbor user settings
- LDAP Group Filter -- ldap_group_filter in the Harbor user settings
- LDAP Group GID -- ldap_group_gid in the Harbor user settings
- LDAP Group Scope -- ldap_group_scope in the Harbor user settings
- LDAP Groups With Admin Privilege -- Specify an LDAP/AD group DN, all LDAPA/AD users in this group have harbor admin privileges.
![Screenshot of LDAP group config](img/group/ldap_group_config.png)

View File

@ -2,7 +2,7 @@ swagger: '2.0'
info:
title: Harbor API
description: These APIs provide services for manipulating Harbor project.
version: 1.8.0
version: 1.9.0
host: localhost
schemes:
- http
@ -3478,6 +3478,44 @@ paths:
description: The robot account is not found.
'500':
description: Unexpected internal errors.
'/system/CVEWhitelist':
get:
summary: Get the system level whitelist of CVE.
description: Get the system level whitelist of CVE. This API can be called by all authenticated users.
tags:
- Products
- System
responses:
'200':
description: Successfully retrieved the CVE whitelist.
schema:
$ref: "#/definitions/CVEWhitelist"
'401':
description: User is not authenticated.
'500':
description: Unexpected internal errors.
put:
summary: Update the system level whitelist of CVE.
description: This API overwrites the system level whitelist of CVE with the list in request body. Only system Admin
has permission to call this API.
tags:
- Products
- System
parameters:
- in: body
name: whitelist
description: The whitelist with new content
schema:
$ref: "#/definitions/CVEWhitelist"
responses:
'200':
description: Successfully updated the CVE whitelist.
'401':
description: User is not authenticated.
'403':
description: User does not have permission to call this API.
'500':
description: Unexpected internal errors.
responses:
OK:
description: 'Success'
@ -3601,6 +3639,9 @@ definitions:
metadata:
description: The metadata of the project.
$ref: '#/definitions/ProjectMetadata'
cve_whitelist:
description: The CVE whitelist of this project.
$ref: '#/definitions/CVEWhitelist'
ProjectMetadata:
type: object
properties:
@ -5069,4 +5110,28 @@ definitions:
description: The name of namespace
metadata:
type: object
description: The metadata of namespace
description: The metadata of namespace
CVEWhitelist:
type: object
description: The CVE Whitelist for system or project
properties:
id:
type: integer
description: ID of the whitelist
project_id:
type: integer
description: ID of the project which the whitelist belongs to. For system level whitelist this attribute is zero.
expires_at:
type: integer
description: the time for expiration of the whitelist, in the form of seconds since epoch. This is an optional attribute, if it's not set the CVE whitelist does not expire.
items:
type: array
items:
$ref: "#/definitions/CVEWhitelistItem"
CVEWhitelistItem:
type: object
description: The item in CVE whitelist
properties:
cve_id:
type: string
description: The ID of the CVE, such as "CVE-2019-10164"

View File

@ -36,10 +36,10 @@ version | set harbor version
#### EXAMPLE:
#### Build and run harbor from source code.
make install GOBUILDIMAGE=golang:1.11.2 COMPILETAG=compile_golangimage NOTARYFLAG=true
make install GOBUILDIMAGE=golang:1.12.5 COMPILETAG=compile_golangimage NOTARYFLAG=true
### Package offline installer
make package_offline GOBUILDIMAGE=golang:1.11.2 COMPILETAG=compile_golangimage NOTARYFLAG=true
make package_offline GOBUILDIMAGE=golang:1.12.5 COMPILETAG=compile_golangimage NOTARYFLAG=true
### Start harbor with notary
make -e NOTARYFLAG=true start

View File

@ -0,0 +1,10 @@
/* add table for CVE whitelist */
CREATE TABLE cve_whitelist (
id SERIAL PRIMARY KEY NOT NULL,
project_id int,
creation_time timestamp default CURRENT_TIMESTAMP,
update_time timestamp default CURRENT_TIMESTAMP,
expires_at bigint,
items text NOT NULL,
UNIQUE (project_id)
);

View File

@ -8,5 +8,6 @@ RUN mkdir /harbor/ \
COPY ./make/photon/jobservice/start.sh ./make/photon/jobservice/harbor_jobservice /harbor/
RUN chmod u+x /harbor/harbor_jobservice /harbor/start.sh
RUN mkdir -p /var/log/jobs
WORKDIR /harbor/
ENTRYPOINT ["/harbor/start.sh"]

View File

@ -0,0 +1,74 @@
// 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 dao
import (
"encoding/json"
"fmt"
"github.com/goharbor/harbor/src/common/models"
"github.com/goharbor/harbor/src/common/utils/log"
)
// UpdateCVEWhitelist Updates the vulnerability white list to DB
func UpdateCVEWhitelist(l models.CVEWhitelist) (int64, error) {
o := GetOrmer()
itemsBytes, _ := json.Marshal(l.Items)
l.ItemsText = string(itemsBytes)
id, err := o.InsertOrUpdate(&l, "project_id")
return id, err
}
// GetSysCVEWhitelist Gets the system level vulnerability white list from DB
func GetSysCVEWhitelist() (*models.CVEWhitelist, error) {
return GetCVEWhitelist(0)
}
// UpdateSysCVEWhitelist updates the system level CVE whitelist
/*
func UpdateSysCVEWhitelist(l models.CVEWhitelist) error {
if l.ProjectID != 0 {
return fmt.Errorf("system level CVE whitelist cannot set project ID")
}
l.ProjectID = -1
_, err := UpdateCVEWhitelist(l)
return err
}
*/
// GetCVEWhitelist Gets the CVE whitelist of the project based on the project ID in parameter
func GetCVEWhitelist(pid int64) (*models.CVEWhitelist, error) {
o := GetOrmer()
qs := o.QueryTable(&models.CVEWhitelist{})
qs = qs.Filter("ProjectID", pid)
r := []*models.CVEWhitelist{}
_, err := qs.All(&r)
if err != nil {
return nil, fmt.Errorf("failed to get CVE whitelist for project %d, error: %v", pid, err)
}
if len(r) == 0 {
log.Infof("No CVE whitelist found for project %d, returning empty list.", pid)
return &models.CVEWhitelist{ProjectID: pid, Items: []models.CVEWhitelistItem{}}, nil
} else if len(r) > 1 {
log.Infof("Multiple CVE whitelists found for project %d, length: %d, returning first element.", pid, len(r))
}
items := []models.CVEWhitelistItem{}
err = json.Unmarshal([]byte(r[0].ItemsText), &items)
if err != nil {
log.Errorf("Failed to decode item list, err: %v, text: %s", err, r[0].ItemsText)
return nil, err
}
r[0].Items = items
return r[0], nil
}

View File

@ -0,0 +1,72 @@
// 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 dao
import (
"github.com/goharbor/harbor/src/common/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestUpdateAndGetCVEWhitelist(t *testing.T) {
require.Nil(t, ClearTable("cve_whitelist"))
l, err := GetSysCVEWhitelist()
assert.Nil(t, err)
assert.Equal(t, models.CVEWhitelist{ProjectID: 0, Items: []models.CVEWhitelistItem{}}, *l)
l2, err := GetCVEWhitelist(5)
assert.Nil(t, err)
assert.Equal(t, models.CVEWhitelist{ProjectID: 5, Items: []models.CVEWhitelistItem{}}, *l2)
longList := []models.CVEWhitelistItem{}
for i := 0; i < 50; i++ {
longList = append(longList, models.CVEWhitelistItem{CVEID: "CVE-1999-0067"})
}
e := int64(1573254000)
in1 := models.CVEWhitelist{ProjectID: 3, Items: longList, ExpiresAt: &e}
_, err = UpdateCVEWhitelist(in1)
require.Nil(t, err)
// assert.Equal(t, int64(1), n)
out1, err := GetCVEWhitelist(3)
require.Nil(t, err)
assert.Equal(t, int64(3), out1.ProjectID)
assert.Equal(t, longList, out1.Items)
assert.Equal(t, e, *out1.ExpiresAt)
in2 := models.CVEWhitelist{ProjectID: 3, Items: []models.CVEWhitelistItem{}}
_, err = UpdateCVEWhitelist(in2)
require.Nil(t, err)
// assert.Equal(t, int64(1), n2)
out2, err := GetCVEWhitelist(3)
require.Nil(t, err)
assert.Equal(t, int64(3), out2.ProjectID)
assert.Equal(t, []models.CVEWhitelistItem{}, out2.Items)
sysCVEs := []models.CVEWhitelistItem{
{CVEID: "CVE-2019-10164"},
{CVEID: "CVE-2017-12345"},
}
in3 := models.CVEWhitelist{Items: sysCVEs}
_, err = UpdateCVEWhitelist(in3)
require.Nil(t, err)
// assert.Equal(t, int64(1), n3)
sysList, err := GetSysCVEWhitelist()
require.Nil(t, err)
assert.Equal(t, int64(0), sysList.ProjectID)
assert.Equal(t, sysCVEs, sysList.Items)
// require.Nil(t, ClearTable("cve_whitelist"))
}

View File

@ -15,10 +15,8 @@
package group
import (
"strings"
"time"
"github.com/goharbor/harbor/src/common"
"github.com/goharbor/harbor/src/common/utils"
"github.com/goharbor/harbor/src/common/dao"
@ -139,20 +137,3 @@ func OnBoardUserGroup(g *models.UserGroup, keyAttribute string, combinedKeyAttri
return nil
}
// GetGroupDNQueryCondition get the part of IN ('XXX', 'XXX') condition
func GetGroupDNQueryCondition(userGroupList []*models.UserGroup) string {
result := make([]string, 0)
count := 0
for _, userGroup := range userGroupList {
if userGroup.GroupType == common.LdapGroupType {
result = append(result, "'"+userGroup.LdapGroupDN+"'")
count++
}
}
// No LDAP Group found
if count == 0 {
return ""
}
return strings.Join(result, ",")
}

View File

@ -47,6 +47,8 @@ func TestMain(m *testing.M) {
initSqls := []string{
"insert into harbor_user (username, email, password, realname) values ('member_test_01', 'member_test_01@example.com', '123456', 'member_test_01')",
"insert into project (name, owner_id) values ('member_test_01', 1)",
`insert into project (name, owner_id) values ('group_project2', 1)`,
`insert into project (name, owner_id) values ('group_project_private', 1)`,
"insert into user_group (group_name, group_type, ldap_group_dn) values ('test_group_01', 1, 'cn=harbor_users,ou=sample,ou=vmware,dc=harbor,dc=com')",
"update project set owner_id = (select user_id from harbor_user where username = 'member_test_01') where name = 'member_test_01'",
"insert into project_member (project_id, entity_id, entity_type, role) values ( (select project_id from project where name = 'member_test_01') , (select user_id from harbor_user where username = 'member_test_01'), 'u', 1)",
@ -55,6 +57,8 @@ func TestMain(m *testing.M) {
clearSqls := []string{
"delete from project where name='member_test_01'",
"delete from project where name='group_project2'",
"delete from project where name='group_project_private'",
"delete from harbor_user where username='member_test_01' or username='pm_sample'",
"delete from user_group",
"delete from project_member",
@ -175,7 +179,7 @@ func TestUpdateUserGroup(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fmt.Printf("id=%v", createdUserGroupID)
fmt.Printf("id=%v\n", createdUserGroupID)
if err := UpdateUserGroupName(tt.args.id, tt.args.groupName); (err != nil) != tt.wantErr {
t.Errorf("UpdateUserGroup() error = %v, wantErr %v", err, tt.wantErr)
userGroup, err := GetUserGroup(tt.args.id)
@ -249,40 +253,6 @@ func TestOnBoardUserGroup(t *testing.T) {
}
}
func TestGetGroupDNQueryCondition(t *testing.T) {
userGroupList := []*models.UserGroup{
{
GroupName: "sample1",
GroupType: 1,
LdapGroupDN: "cn=sample1_users,ou=groups,dc=example,dc=com",
},
{
GroupName: "sample2",
GroupType: 1,
LdapGroupDN: "cn=sample2_users,ou=groups,dc=example,dc=com",
},
{
GroupName: "sample3",
GroupType: 0,
LdapGroupDN: "cn=sample3_users,ou=groups,dc=example,dc=com",
},
}
groupQueryConditions := GetGroupDNQueryCondition(userGroupList)
expectedConditions := `'cn=sample1_users,ou=groups,dc=example,dc=com','cn=sample2_users,ou=groups,dc=example,dc=com'`
if groupQueryConditions != expectedConditions {
t.Errorf("Failed to GetGroupDNQueryCondition, expected %v, actual %v", expectedConditions, groupQueryConditions)
}
var userGroupList2 []*models.UserGroup
groupQueryCondition2 := GetGroupDNQueryCondition(userGroupList2)
if len(groupQueryCondition2) > 0 {
t.Errorf("Failed to GetGroupDNQueryCondition, expected %v, actual %v", "", groupQueryCondition2)
}
groupQueryCondition3 := GetGroupDNQueryCondition(nil)
if len(groupQueryCondition3) > 0 {
t.Errorf("Failed to GetGroupDNQueryCondition, expected %v, actual %v", "", groupQueryCondition3)
}
}
func TestGetGroupProjects(t *testing.T) {
userID, err := dao.Register(models.User{
Username: "grouptestu09",
@ -322,8 +292,7 @@ func TestGetGroupProjects(t *testing.T) {
})
defer project.DeleteProjectMemberByID(pmid)
type args struct {
groupDNCondition string
query *models.ProjectQueryParam
query *models.ProjectQueryParam
}
member := &models.MemberQuery{
Name: "grouptestu09",
@ -335,19 +304,17 @@ func TestGetGroupProjects(t *testing.T) {
wantErr bool
}{
{"Query with group DN",
args{"'cn=harbor_users,ou=groups,dc=example,dc=com'",
&models.ProjectQueryParam{
Member: member,
}},
args{&models.ProjectQueryParam{
Member: member,
}},
1, false},
{"Query without group DN",
args{"",
&models.ProjectQueryParam{}},
args{&models.ProjectQueryParam{}},
1, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := dao.GetGroupProjects(tt.args.groupDNCondition, tt.args.query)
got, err := dao.GetGroupProjects([]int{groupID}, tt.args.query)
if (err != nil) != tt.wantErr {
t.Errorf("GetGroupProjects() error = %v, wantErr %v", err, tt.wantErr)
return
@ -392,8 +359,7 @@ func TestGetTotalGroupProjects(t *testing.T) {
})
defer project.DeleteProjectMemberByID(pmid)
type args struct {
groupDNCondition string
query *models.ProjectQueryParam
query *models.ProjectQueryParam
}
tests := []struct {
name string
@ -401,18 +367,16 @@ func TestGetTotalGroupProjects(t *testing.T) {
wantSize int
wantErr bool
}{
{"Query with group DN",
args{"'cn=harbor_users,ou=groups,dc=example,dc=com'",
&models.ProjectQueryParam{}},
{"Query with group ID",
args{&models.ProjectQueryParam{}},
1, false},
{"Query without group DN",
args{"",
&models.ProjectQueryParam{}},
{"Query without group ID",
args{&models.ProjectQueryParam{}},
1, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := dao.GetTotalGroupProjects(tt.args.groupDNCondition, tt.args.query)
got, err := dao.GetTotalGroupProjects([]int{groupID}, tt.args.query)
if (err != nil) != tt.wantErr {
t.Errorf("GetGroupProjects() error = %v, wantErr %v", err, tt.wantErr)
return
@ -423,3 +387,44 @@ func TestGetTotalGroupProjects(t *testing.T) {
})
}
}
func TestGetRolesByLDAPGroup(t *testing.T) {
userGroupList, err := QueryUserGroup(models.UserGroup{LdapGroupDN: "cn=harbor_users,ou=sample,ou=vmware,dc=harbor,dc=com", GroupType: 1})
if err != nil || len(userGroupList) < 1 {
t.Errorf("failed to query user group, err %v", err)
}
project, err := dao.GetProjectByName("member_test_01")
if err != nil {
t.Errorf("Error occurred when Get project by name: %v", err)
}
privateProject, err := dao.GetProjectByName("group_project_private")
if err != nil {
t.Errorf("Error occurred when Get project by name: %v", err)
}
type args struct {
projectID int64
groupIDs []int
}
tests := []struct {
name string
args args
wantSize int
wantErr bool
}{
{"Check normal", args{projectID: project.ProjectID, groupIDs: []int{userGroupList[0].ID}}, 1, false},
{"Check non exist", args{projectID: privateProject.ProjectID, groupIDs: []int{9999}}, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := dao.GetRolesByGroupID(tt.args.projectID, tt.args.groupIDs)
if (err != nil) != tt.wantErr {
t.Errorf("TestGetRolesByLDAPGroup() error = %v, wantErr %v", err, tt.wantErr)
return
}
if len(got) != tt.wantSize {
t.Errorf("TestGetRolesByLDAPGroup() = %v, want %v", len(got), tt.wantSize)
}
})
}
}

View File

@ -156,18 +156,19 @@ func GetProjects(query *models.ProjectQueryParam) ([]*models.Project, error) {
// GetGroupProjects - Get user's all projects, including user is the user member of this project
// and the user is in the group which is a group member of this project.
func GetGroupProjects(groupDNCondition string, query *models.ProjectQueryParam) ([]*models.Project, error) {
func GetGroupProjects(groupIDs []int, query *models.ProjectQueryParam) ([]*models.Project, error) {
sql, params := projectQueryConditions(query)
sql = `select distinct p.project_id, p.name, p.owner_id,
p.creation_time, p.update_time ` + sql
if len(groupDNCondition) > 0 {
groupIDCondition := JoinNumberConditions(groupIDs)
if len(groupIDs) > 0 {
sql = fmt.Sprintf(
`%s union select distinct p.project_id, p.name, p.owner_id, p.creation_time, p.update_time
from project p
left join project_member pm on p.project_id = pm.project_id
left join user_group ug on ug.id = pm.entity_id and pm.entity_type = 'g' and ug.group_type = 1
where ug.ldap_group_dn in ( %s ) order by name`,
sql, groupDNCondition)
left join user_group ug on ug.id = pm.entity_id and pm.entity_type = 'g'
where ug.id in ( %s ) order by name`,
sql, groupIDCondition)
}
sqlStr, queryParams := CreatePagination(query, sql, params)
log.Debugf("query sql:%v", sql)
@ -178,10 +179,11 @@ func GetGroupProjects(groupDNCondition string, query *models.ProjectQueryParam)
// GetTotalGroupProjects - Get the total count of projects, including user is the member of this project and the
// user is in the group, which is the group member of this project.
func GetTotalGroupProjects(groupDNCondition string, query *models.ProjectQueryParam) (int, error) {
func GetTotalGroupProjects(groupIDs []int, query *models.ProjectQueryParam) (int, error) {
var sql string
sqlCondition, params := projectQueryConditions(query)
if len(groupDNCondition) == 0 {
groupIDCondition := JoinNumberConditions(groupIDs)
if len(groupIDs) == 0 {
sql = `select count(1) ` + sqlCondition
} else {
sql = fmt.Sprintf(
@ -189,9 +191,9 @@ func GetTotalGroupProjects(groupDNCondition string, query *models.ProjectQueryPa
from ( select p.project_id %s union select p.project_id
from project p
left join project_member pm on p.project_id = pm.project_id
left join user_group ug on ug.id = pm.entity_id and pm.entity_type = 'g' and ug.group_type = 1
where ug.ldap_group_dn in ( %s )) t`,
sqlCondition, groupDNCondition)
left join user_group ug on ug.id = pm.entity_id and pm.entity_type = 'g'
where ug.id in ( %s )) t`,
sqlCondition, groupIDCondition)
}
log.Debugf("query sql:%v", sql)
var count int
@ -291,24 +293,24 @@ func DeleteProject(id int64) error {
return err
}
// GetRolesByLDAPGroup - Get Project roles of the
// specified group DN is a member of current project
func GetRolesByLDAPGroup(projectID int64, groupDNCondition string) ([]int, error) {
// GetRolesByGroupID - Get Project roles of the
// specified group is a member of current project
func GetRolesByGroupID(projectID int64, groupIDs []int) ([]int, error) {
var roles []int
if len(groupDNCondition) == 0 {
if len(groupIDs) == 0 {
return roles, nil
}
groupIDCondition := JoinNumberConditions(groupIDs)
o := GetOrmer()
// Because an LDAP user can be memberof multiple groups,
// 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
where ug.ldap_group_dn in ( %s ) and pm.project_id = ? `,
groupDNCondition)
where ug.id in ( %s ) and pm.project_id = ?`,
groupIDCondition)
log.Debugf("sql:%v", sql)
if _, err := o.Raw(sql, projectID).QueryRows(&roles); err != nil {
log.Warningf("Error in GetRolesByLDAPGroup, error: %v", err)
log.Warningf("Error in GetRolesByGroupID, error: %v", err)
return nil, err
}
// If there is no row selected, the min returns an empty row, to avoid return 0 as role

View File

@ -148,16 +148,3 @@ func SearchMemberByName(projectID int64, entityName string) ([]*models.Member, e
_, err := o.Raw(sql, queryParam).QueryRows(&members)
return members, err
}
// GetRolesByGroup -- Query group roles
func GetRolesByGroup(projectID int64, groupDNCondition string) []int {
var roles []int
o := dao.GetOrmer()
sql := `select role from project_member pm
left join user_group ug on pm.project_id = ?
where ug.group_type = 1 and ug.ldap_group_dn in (` + groupDNCondition + `)`
if _, err := o.Raw(sql, projectID).QueryRows(&roles); err != nil {
return roles
}
return roles
}

View File

@ -305,30 +305,3 @@ func PrepareGroupTest() {
}
dao.PrepareTestData(clearSqls, initSqls)
}
func TestGetRolesByGroup(t *testing.T) {
PrepareGroupTest()
project, err := dao.GetProjectByName("group_project")
if err != nil {
t.Errorf("Error occurred when GetProjectByName : %v", err)
}
type args struct {
projectID int64
groupDNCondition string
}
tests := []struct {
name string
args args
want []int
}{
{"Query group with role", args{project.ProjectID, "'cn=harbor_user,dc=example,dc=com'"}, []int{2}},
{"Query group no role", args{project.ProjectID, "'cn=another_user,dc=example,dc=com'"}, []int{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetRolesByGroup(tt.args.projectID, tt.args.groupDNCondition); !dao.ArrayEqual(got, tt.want) {
t.Errorf("GetRolesByGroup() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -118,36 +118,6 @@ func Test_projectQueryConditions(t *testing.T) {
}
}
func TestGetGroupProjects(t *testing.T) {
prepareGroupTest()
query := &models.ProjectQueryParam{Member: &models.MemberQuery{Name: "sample_group"}}
type args struct {
groupDNCondition string
query *models.ProjectQueryParam
}
tests := []struct {
name string
args args
wantSize int
wantErr bool
}{
{"Verify correct sql", args{groupDNCondition: "'cn=harbor_user,dc=example,dc=com'", query: query}, 1, false},
{"Verify missed sql", args{groupDNCondition: "'cn=another_user,dc=example,dc=com'", query: query}, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetGroupProjects(tt.args.groupDNCondition, tt.args.query)
if (err != nil) != tt.wantErr {
t.Errorf("GetGroupProjects() error = %v, wantErr %v", err, tt.wantErr)
return
}
if len(got) != tt.wantSize {
t.Errorf("GetGroupProjects() = %v, want %v", got, tt.wantSize)
}
})
}
}
func prepareGroupTest() {
initSqls := []string{
`insert into user_group (group_name, group_type, ldap_group_dn) values ('harbor_group_01', 1, 'cn=harbor_user,dc=example,dc=com')`,
@ -169,73 +139,6 @@ func prepareGroupTest() {
PrepareTestData(clearSqls, initSqls)
}
func TestGetTotalGroupProjects(t *testing.T) {
prepareGroupTest()
query := &models.ProjectQueryParam{Member: &models.MemberQuery{Name: "sample_group"}}
type args struct {
groupDNCondition string
query *models.ProjectQueryParam
}
tests := []struct {
name string
args args
want int
wantErr bool
}{
{"Verify correct sql", args{groupDNCondition: "'cn=harbor_user,dc=example,dc=com'", query: query}, 1, false},
{"Verify missed sql", args{groupDNCondition: "'cn=another_user,dc=example,dc=com'", query: query}, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetTotalGroupProjects(tt.args.groupDNCondition, tt.args.query)
if (err != nil) != tt.wantErr {
t.Errorf("GetTotalGroupProjects() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("GetTotalGroupProjects() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetRolesByLDAPGroup(t *testing.T) {
prepareGroupTest()
project, err := GetProjectByName("group_project")
if err != nil {
t.Errorf("Error occurred when Get project by name: %v", err)
}
privateProject, err := GetProjectByName("group_project_private")
if err != nil {
t.Errorf("Error occurred when Get project by name: %v", err)
}
type args struct {
projectID int64
groupDNCondition string
}
tests := []struct {
name string
args args
wantSize int
wantErr bool
}{
{"Check normal", args{project.ProjectID, "'cn=harbor_user,dc=example,dc=com'"}, 1, false},
{"Check non exist", args{privateProject.ProjectID, "'cn=not_harbor_user,dc=example,dc=com'"}, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetRolesByLDAPGroup(tt.args.projectID, tt.args.groupDNCondition)
if (err != nil) != tt.wantErr {
t.Errorf("TestGetRolesByLDAPGroup() error = %v, wantErr %v", err, tt.wantErr)
return
}
if len(got) != tt.wantSize {
t.Errorf("TestGetRolesByLDAPGroup() = %v, want %v", len(got), tt.wantSize)
}
})
}
}
func TestProjetExistsByName(t *testing.T) {
name := "project_exist_by_name_test"
exist := ProjectExistsByName(name)

View File

@ -15,12 +15,11 @@
package dao
import (
"encoding/json"
"fmt"
"github.com/astaxie/beego/orm"
"github.com/goharbor/harbor/src/common/models"
"github.com/goharbor/harbor/src/common/utils/log"
"encoding/json"
"fmt"
"time"
)

11
src/common/dao/utils.go Normal file
View File

@ -0,0 +1,11 @@
package dao
import (
"fmt"
"strings"
)
// JoinNumberConditions - To join number condition into string,used in sql query
func JoinNumberConditions(ids []int) string {
return strings.Trim(strings.Replace(fmt.Sprint(ids), " ", ",", -1), "[]")
}

View File

@ -0,0 +1,24 @@
package dao
import "testing"
func TestJoinNumberConditions(t *testing.T) {
type args struct {
ids []int
}
tests := []struct {
name string
args args
want string
}{
{name: "normal test", args: args{[]int{1, 2, 3}}, want: "1,2,3"},
{name: "dummy test", args: args{[]int{}}, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := JoinNumberConditions(tt.args.ids); got != tt.want {
t.Errorf("JoinNumberConditions() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -36,5 +36,6 @@ func init() {
new(AdminJob),
new(JobLog),
new(Robot),
new(OIDCUser))
new(OIDCUser),
new(CVEWhitelist))
}

View File

@ -0,0 +1,55 @@
// 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 models
import "time"
// CVEWhitelist defines the data model for a CVE whitelist
type CVEWhitelist struct {
ID int64 `orm:"pk;auto;column(id)" json:"id"`
ProjectID int64 `orm:"column(project_id)" json:"project_id"`
ExpiresAt *int64 `orm:"column(expires_at)" json:"expires_at,omitempty"`
Items []CVEWhitelistItem `orm:"-" json:"items"`
ItemsText string `orm:"column(items)" json:"-"`
CreationTime time.Time `orm:"column(creation_time);auto_now_add" json:"creation_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now" json:"update_time"`
}
// CVEWhitelistItem defines one item in the CVE whitelist
type CVEWhitelistItem struct {
CVEID string `json:"cve_id"`
}
// TableName ...
func (c *CVEWhitelist) TableName() string {
return "cve_whitelist"
}
// CVESet returns the set of CVE id of the items in the whitelist to help filter the vulnerability list
func (c *CVEWhitelist) CVESet() map[string]struct{} {
r := map[string]struct{}{}
for _, it := range c.Items {
r[it.CVEID] = struct{}{}
}
return r
}
// IsExpired returns whether the whitelist is expired
func (c *CVEWhitelist) IsExpired() bool {
if c.ExpiresAt == nil {
return false
}
return time.Now().Unix() >= *c.ExpiresAt
}

View File

@ -0,0 +1,72 @@
// 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 models
import (
"github.com/stretchr/testify/assert"
"reflect"
"testing"
"time"
)
func TestCVEWhitelist_All(t *testing.T) {
future := int64(4411494000)
now := time.Now().Unix()
cases := []struct {
input CVEWhitelist
cveset map[string]struct{}
expired bool
}{
{
input: CVEWhitelist{
ID: 1,
ProjectID: 0,
Items: []CVEWhitelistItem{},
},
cveset: map[string]struct{}{},
expired: false,
},
{
input: CVEWhitelist{
ID: 1,
ProjectID: 0,
Items: []CVEWhitelistItem{},
ExpiresAt: &now,
},
cveset: map[string]struct{}{},
expired: true,
},
{
input: CVEWhitelist{
ID: 2,
ProjectID: 3,
Items: []CVEWhitelistItem{
{CVEID: "CVE-1999-0067"},
{CVEID: "CVE-2016-7654321"},
},
ExpiresAt: &future,
},
cveset: map[string]struct{}{
"CVE-1999-0067": {},
"CVE-2016-7654321": {},
},
expired: false,
},
}
for _, c := range cases {
assert.Equal(t, c.expired, c.input.IsExpired())
assert.True(t, reflect.DeepEqual(c.cveset, c.input.CVESet()))
}
}

View File

@ -20,16 +20,17 @@ import (
// keys of project metadata and severity values
const (
ProMetaPublic = "public"
ProMetaEnableContentTrust = "enable_content_trust"
ProMetaPreventVul = "prevent_vul" // prevent vulnerable images from being pulled
ProMetaSeverity = "severity"
ProMetaAutoScan = "auto_scan"
SeverityNone = "negligible"
SeverityLow = "low"
SeverityMedium = "medium"
SeverityHigh = "high"
SeverityCritical = "critical"
ProMetaPublic = "public"
ProMetaEnableContentTrust = "enable_content_trust"
ProMetaPreventVul = "prevent_vul" // prevent vulnerable images from being pulled
ProMetaSeverity = "severity"
ProMetaAutoScan = "auto_scan"
ProMetaReuseSysCVEWhitelist = "reuse_sys_cve_whitelist"
SeverityNone = "negligible"
SeverityLow = "low"
SeverityMedium = "medium"
SeverityHigh = "high"
SeverityCritical = "critical"
)
// ProjectMetadata holds the metadata of a project.

View File

@ -36,6 +36,7 @@ type Project struct {
RepoCount int64 `orm:"-" json:"repo_count"`
ChartCount uint64 `orm:"-" json:"chart_count"`
Metadata map[string]string `orm:"-" json:"metadata"`
CVEWhitelist CVEWhitelist `orm:"-" json:"cve_whitelist"`
}
// GetMetadata ...
@ -83,6 +84,15 @@ func (p *Project) VulPrevented() bool {
return isTrue(prevent)
}
// ReuseSysCVEWhitelist ...
func (p *Project) ReuseSysCVEWhitelist() bool {
r, ok := p.GetMetadata(ProMetaReuseSysCVEWhitelist)
if !ok {
return true
}
return isTrue(r)
}
// Severity ...
func (p *Project) Severity() string {
severity, exist := p.GetMetadata(ProMetaSeverity)
@ -128,9 +138,9 @@ type ProjectQueryParam struct {
// MemberQuery filter by member's username and role
type MemberQuery struct {
Name string // the username of member
Role int // the role of the member has to the project
GroupList []*UserGroup // the group list of current user
Name string // the username of member
Role int // the role of the member has to the project
GroupIDs []int // the group ID of current user belongs to
}
// Pagination ...
@ -154,9 +164,10 @@ type BaseProjectCollection struct {
// ProjectRequest holds informations that need for creating project API
type ProjectRequest struct {
Name string `json:"project_name"`
Public *int `json:"public"` // deprecated, reserved for project creation in replication
Metadata map[string]string `json:"metadata"`
Name string `json:"project_name"`
Public *int `json:"public"` // deprecated, reserved for project creation in replication
Metadata map[string]string `json:"metadata"`
CVEWhitelist CVEWhitelist `json:"cve_whitelist"`
}
// ProjectQueryResult ...

View File

@ -34,31 +34,6 @@ type ScanJob struct {
UpdateTime time.Time `orm:"column(update_time);auto_now" json:"update_time"`
}
// Severity represents the severity of a image/component in terms of vulnerability.
type Severity int64
// Sevxxx is the list of severity of image after scanning.
const (
_ Severity = iota
SevNone
SevUnknown
SevLow
SevMedium
SevHigh
)
// String is the output function for sererity variable
func (sev Severity) String() string {
name := []string{"negligible", "unknown", "low", "medium", "high"}
i := int64(sev)
switch {
case i >= 1 && i <= int64(SevHigh):
return name[i-1]
default:
return "unknown"
}
}
// TableName is required by by beego orm to map ScanJob to table img_scan_job
func (s *ScanJob) TableName() string {
return ScanJobTable
@ -101,17 +76,6 @@ type ImageScanReq struct {
Tag string `json:"tag"`
}
// VulnerabilityItem is an item in the vulnerability result returned by vulnerability details API.
type VulnerabilityItem struct {
ID string `json:"id"`
Severity Severity `json:"severity"`
Pkg string `json:"package"`
Version string `json:"version"`
Description string `json:"description"`
Link string `json:"link"`
Fixed string `json:"fixedVersion,omitempty"`
}
// ScanAllPolicy is represent the json request and object for scan all policy, the parm is het
type ScanAllPolicy struct {
Type string `json:"type"`

26
src/common/models/sev.go Normal file
View File

@ -0,0 +1,26 @@
package models
// Severity represents the severity of a image/component in terms of vulnerability.
type Severity int64
// Sevxxx is the list of severity of image after scanning.
const (
_ Severity = iota
SevNone
SevUnknown
SevLow
SevMedium
SevHigh
)
// String is the output function for severity variable
func (sev Severity) String() string {
name := []string{"negligible", "unknown", "low", "medium", "high"}
i := int64(sev)
switch {
case i >= 1 && i <= int64(SevHigh):
return name[i-1]
default:
return "unknown"
}
}

View File

@ -35,13 +35,13 @@ type User struct {
// to it.
Role int `orm:"-" json:"role_id"`
// RoleList []Role `json:"role_list"`
HasAdminRole bool `orm:"column(sysadmin_flag)" json:"has_admin_role"`
ResetUUID string `orm:"column(reset_uuid)" json:"reset_uuid"`
Salt string `orm:"column(salt)" json:"-"`
CreationTime time.Time `orm:"column(creation_time);auto_now_add" json:"creation_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now" json:"update_time"`
GroupList []*UserGroup `orm:"-" json:"-"`
OIDCUserMeta *OIDCUser `orm:"-" json:"oidc_user_meta,omitempty"`
HasAdminRole bool `orm:"column(sysadmin_flag)" json:"has_admin_role"`
ResetUUID string `orm:"column(reset_uuid)" json:"reset_uuid"`
Salt string `orm:"column(salt)" json:"-"`
CreationTime time.Time `orm:"column(creation_time);auto_now_add" json:"creation_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now" json:"update_time"`
GroupIDs []int `orm:"-" json:"-"`
OIDCUserMeta *OIDCUser `orm:"-" json:"oidc_user_meta,omitempty"`
}
// UserQuery ...

View File

@ -54,6 +54,7 @@ var (
{Resource: rbac.ResourceSelf, Action: rbac.ActionDelete},
{Resource: rbac.ResourceMember, Action: rbac.ActionCreate},
{Resource: rbac.ResourceMember, Action: rbac.ActionRead},
{Resource: rbac.ResourceMember, Action: rbac.ActionUpdate},
{Resource: rbac.ResourceMember, Action: rbac.ActionDelete},
{Resource: rbac.ResourceMember, Action: rbac.ActionList},

View File

@ -27,6 +27,7 @@ var (
{Resource: rbac.ResourceSelf, Action: rbac.ActionDelete},
{Resource: rbac.ResourceMember, Action: rbac.ActionCreate},
{Resource: rbac.ResourceMember, Action: rbac.ActionRead},
{Resource: rbac.ResourceMember, Action: rbac.ActionUpdate},
{Resource: rbac.ResourceMember, Action: rbac.ActionDelete},
{Resource: rbac.ResourceMember, Action: rbac.ActionList},
@ -105,6 +106,7 @@ var (
"master": {
{Resource: rbac.ResourceSelf, Action: rbac.ActionRead},
{Resource: rbac.ResourceMember, Action: rbac.ActionRead},
{Resource: rbac.ResourceMember, Action: rbac.ActionList},
{Resource: rbac.ResourceMetadata, Action: rbac.ActionCreate},
@ -172,6 +174,7 @@ var (
"developer": {
{Resource: rbac.ResourceSelf, Action: rbac.ActionRead},
{Resource: rbac.ResourceMember, Action: rbac.ActionRead},
{Resource: rbac.ResourceMember, Action: rbac.ActionList},
{Resource: rbac.ResourceLog, Action: rbac.ActionList},
@ -221,6 +224,7 @@ var (
"guest": {
{Resource: rbac.ResourceSelf, Action: rbac.ActionRead},
{Resource: rbac.ResourceMember, Action: rbac.ActionRead},
{Resource: rbac.ResourceMember, Action: rbac.ActionList},
{Resource: rbac.ResourceLog, Action: rbac.ActionList},

View File

@ -17,7 +17,6 @@ package local
import (
"github.com/goharbor/harbor/src/common"
"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"
@ -140,12 +139,11 @@ func (s *SecurityContext) GetRolesByGroup(projectIDOrName interface{}) []int {
user := s.user
project, err := s.pm.Get(projectIDOrName)
// No user, group or project info
if err != nil || project == nil || user == nil || len(user.GroupList) == 0 {
if err != nil || project == nil || user == nil || len(user.GroupIDs) == 0 {
return roles
}
// Get role by LDAP group
groupDNConditions := group.GetGroupDNQueryCondition(user.GroupList)
roles, err = dao.GetRolesByLDAPGroup(project.ProjectID, groupDNConditions)
// Get role by Group ID
roles, err = dao.GetRolesByGroupID(project.ProjectID, user.GroupIDs)
if err != nil {
return nil
}
@ -157,8 +155,8 @@ func (s *SecurityContext) GetMyProjects() ([]*models.Project, error) {
result, err := s.pm.List(
&models.ProjectQueryParam{
Member: &models.MemberQuery{
Name: s.GetUsername(),
GroupList: s.user.GroupList,
Name: s.GetUsername(),
GroupIDs: s.user.GroupIDs,
},
})
if err != nil {

View File

@ -20,6 +20,7 @@ import (
"github.com/goharbor/harbor/src/common"
"github.com/goharbor/harbor/src/common/dao"
"github.com/goharbor/harbor/src/common/dao/group"
"github.com/goharbor/harbor/src/common/dao/project"
"github.com/goharbor/harbor/src/common/models"
"github.com/goharbor/harbor/src/common/rbac"
@ -253,9 +254,16 @@ func TestHasPushPullPermWithGroup(t *testing.T) {
if err != nil {
t.Errorf("Error occurred when GetUser: %v", err)
}
developer.GroupList = []*models.UserGroup{
{GroupName: "test_group", GroupType: 1, LdapGroupDN: "cn=harbor_user,dc=example,dc=com"},
userGroups, err := group.QueryUserGroup(models.UserGroup{GroupType: common.LdapGroupType, LdapGroupDN: "cn=harbor_user,dc=example,dc=com"})
if err != nil {
t.Errorf("Failed to query user group %v", err)
}
if len(userGroups) < 1 {
t.Errorf("Failed to retrieve user group")
}
developer.GroupIDs = []int{userGroups[0].ID}
resource := rbac.NewProjectNamespace(project.Name).Resource(rbac.ResourceRepository)
@ -332,9 +340,15 @@ func TestSecurityContext_GetRolesByGroup(t *testing.T) {
if err != nil {
t.Errorf("Error occurred when GetUser: %v", err)
}
developer.GroupList = []*models.UserGroup{
{GroupName: "test_group", GroupType: 1, LdapGroupDN: "cn=harbor_user,dc=example,dc=com"},
userGroups, err := group.QueryUserGroup(models.UserGroup{GroupType: common.LdapGroupType, LdapGroupDN: "cn=harbor_user,dc=example,dc=com"})
if err != nil {
t.Errorf("Failed to query user group %v", err)
}
if len(userGroups) < 1 {
t.Errorf("Failed to retrieve user group")
}
developer.GroupIDs = []int{userGroups[0].ID}
type fields struct {
user *models.User
pm promgr.ProjectManager

View File

@ -35,20 +35,14 @@ const googleEndpoint = "https://accounts.google.com"
type providerHelper struct {
sync.Mutex
ep endpoint
instance atomic.Value
setting atomic.Value
}
type endpoint struct {
url string
VerifyCert bool
instance atomic.Value
setting atomic.Value
creationTime time.Time
}
func (p *providerHelper) get() (*gooidc.Provider, error) {
if p.instance.Load() != nil {
s := p.setting.Load().(models.OIDCSetting)
if s.Endpoint != p.ep.url || s.VerifyCert != p.ep.VerifyCert { // relevant settings have changed, need to re-create provider.
if time.Now().Sub(p.creationTime) > 3*time.Second {
if err := p.create(); err != nil {
return nil, err
}
@ -57,7 +51,7 @@ func (p *providerHelper) get() (*gooidc.Provider, error) {
p.Lock()
defer p.Unlock()
if p.instance.Load() == nil {
if err := p.reload(); err != nil {
if err := p.reloadSetting(); err != nil {
return nil, err
}
if err := p.create(); err != nil {
@ -65,7 +59,7 @@ func (p *providerHelper) get() (*gooidc.Provider, error) {
}
go func() {
for {
if err := p.reload(); err != nil {
if err := p.reloadSetting(); err != nil {
log.Warningf("Failed to refresh configuration, error: %v", err)
}
time.Sleep(3 * time.Second)
@ -73,10 +67,11 @@ func (p *providerHelper) get() (*gooidc.Provider, error) {
}()
}
}
return p.instance.Load().(*gooidc.Provider), nil
}
func (p *providerHelper) reload() error {
func (p *providerHelper) reloadSetting() error {
conf, err := config.OIDCSetting()
if err != nil {
return fmt.Errorf("failed to load OIDC setting: %v", err)
@ -96,10 +91,7 @@ func (p *providerHelper) create() error {
return fmt.Errorf("failed to create OIDC provider, error: %v", err)
}
p.instance.Store(provider)
p.ep = endpoint{
url: s.Endpoint,
VerifyCert: s.VerifyCert,
}
p.creationTime = time.Now()
return nil
}

View File

@ -49,21 +49,20 @@ func TestMain(m *testing.M) {
func TestHelperLoadConf(t *testing.T) {
testP := &providerHelper{}
assert.Nil(t, testP.setting.Load())
err := testP.reload()
err := testP.reloadSetting()
assert.Nil(t, err)
assert.Equal(t, "test", testP.setting.Load().(models.OIDCSetting).Name)
assert.Equal(t, endpoint{}, testP.ep)
}
func TestHelperCreate(t *testing.T) {
testP := &providerHelper{}
err := testP.reload()
err := testP.reloadSetting()
assert.Nil(t, err)
assert.Nil(t, testP.instance.Load())
err = testP.create()
assert.Nil(t, err)
assert.EqualValues(t, "https://accounts.google.com", testP.ep.url)
assert.NotNil(t, testP.instance.Load())
assert.True(t, time.Now().Sub(testP.creationTime) < 2*time.Second)
}
func TestHelperGet(t *testing.T) {

View File

@ -0,0 +1,114 @@
// 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 redis
import (
"errors"
"github.com/garyburd/redigo/redis"
"github.com/goharbor/harbor/src/common/utils"
"time"
)
var (
// ErrUnLock ...
ErrUnLock = errors.New("error to release the redis lock")
)
const (
unlockScript = `
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
`
defaultDelay = 5 * time.Second
defaultMaxRetry = 5
defaultExpiry = 600 * time.Second
)
// Mutex ...
type Mutex struct {
Conn redis.Conn
key string
value string
opts Options
}
// New ...
func New(conn redis.Conn, key, value string) *Mutex {
o := *DefaultOptions()
if value == "" {
value = utils.GenerateRandomString()
}
return &Mutex{conn, key, value, o}
}
// Require retry to require the lock
func (rm *Mutex) Require() (bool, error) {
var isRequired bool
var err error
for i := 0; i < rm.opts.maxRetry; i++ {
isRequired, err = rm.require()
if isRequired {
break
}
if err != nil || !isRequired {
time.Sleep(rm.opts.retryDelay)
}
}
return isRequired, err
}
// require get the redis lock, for details, just refer to https://redis.io/topics/distlock
func (rm *Mutex) require() (bool, error) {
reply, err := redis.String(rm.Conn.Do("SET", rm.key, rm.value, "NX", "PX", int(rm.opts.expiry/time.Millisecond)))
if err != nil {
return false, err
}
return reply == "OK", nil
}
// Free releases the lock, for details, just refer to https://redis.io/topics/distlock
func (rm *Mutex) Free() (bool, error) {
script := redis.NewScript(1, unlockScript)
resp, err := redis.Int(script.Do(rm.Conn, rm.key, rm.value))
if err != nil {
return false, err
}
if resp == 0 {
return false, ErrUnLock
}
return true, nil
}
// Options ...
type Options struct {
retryDelay time.Duration
expiry time.Duration
maxRetry int
}
// DefaultOptions ...
func DefaultOptions() *Options {
opt := &Options{
retryDelay: defaultDelay,
expiry: defaultExpiry,
maxRetry: defaultMaxRetry,
}
return opt
}

View File

@ -0,0 +1,62 @@
// 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 redis
import (
"fmt"
"github.com/garyburd/redigo/redis"
"github.com/stretchr/testify/assert"
"os"
"testing"
"time"
)
const testingRedisHost = "REDIS_HOST"
func TestRedisLock(t *testing.T) {
con, err := redis.Dial(
"tcp",
fmt.Sprintf("%s:%d", getRedisHost(), 6379),
redis.DialConnectTimeout(30*time.Second),
redis.DialReadTimeout(time.Minute+10*time.Second),
redis.DialWriteTimeout(10*time.Second),
)
assert.Nil(t, err)
defer con.Close()
rm := New(con, "test-redis-lock", "test-value")
successLock, err := rm.Require()
assert.Nil(t, err)
assert.True(t, successLock)
time.Sleep(2 * time.Second)
_, err = rm.Require()
assert.NotNil(t, err)
successUnLock, err := rm.Free()
assert.Nil(t, err)
assert.True(t, successUnLock)
}
func getRedisHost() string {
redisHost := os.Getenv(testingRedisHost)
if redisHost == "" {
redisHost = "127.0.0.1" // for local test
}
return redisHost
}

View File

@ -22,8 +22,6 @@ import (
"net/url"
"strings"
// "time"
commonhttp "github.com/goharbor/harbor/src/common/http"
"github.com/goharbor/harbor/src/common/utils"
)
@ -130,9 +128,18 @@ func (r *Registry) Catalog() ([]string, error) {
return repos, nil
}
// Ping ...
// Ping checks by Head method
func (r *Registry) Ping() error {
req, err := http.NewRequest(http.MethodHead, buildPingURL(r.Endpoint.String()), nil)
return r.ping(http.MethodHead)
}
// PingGet checks by Get method
func (r *Registry) PingGet() error {
return r.ping(http.MethodGet)
}
func (r *Registry) ping(method string) error {
req, err := http.NewRequest(method, buildPingURL(r.Endpoint.String()), nil)
if err != nil {
return err
}

View File

@ -211,7 +211,7 @@ func (r *Repository) PushManifest(reference, mediaType string, payload []byte) (
defer resp.Body.Close()
if resp.StatusCode == http.StatusCreated {
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
digest = resp.Header.Get(http.CanonicalHeaderKey("Docker-Content-Digest"))
return
}

View File

@ -144,6 +144,7 @@ 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/system/scanAll/schedule", &ScanAllAPI{}, "get:Get;put:Put;post:Post")
beego.Router("/api/system/CVEWhitelist", &SysCVEWhitelistAPI{}, "get:Get;put:Put")
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")

View File

@ -212,10 +212,10 @@ func jobserviceHealthChecker() health.Checker {
}
func registryHealthChecker() health.Checker {
url := getRegistryURL() + "/v2"
url := getRegistryURL() + "/"
timeout := 60 * time.Second
period := 10 * time.Second
checker := HTTPStatusCodeHealthChecker(http.MethodGet, url, nil, timeout, http.StatusUnauthorized)
checker := HTTPStatusCodeHealthChecker(http.MethodGet, url, nil, timeout, http.StatusOK)
return PeriodicHealthChecker(checker, period)
}

View File

@ -158,6 +158,7 @@ func (p *ProjectAPI) Post() {
if _, ok := pro.Metadata[models.ProMetaPublic]; !ok {
pro.Metadata[models.ProMetaPublic] = strconv.FormatBool(false)
}
// populate
owner := p.SecurityCtx.GetUsername()
// set the owner as the system admin when the API being called by replication
@ -460,7 +461,8 @@ func (p *ProjectAPI) Put() {
if err := p.ProjectMgr.Update(p.project.ProjectID,
&models.Project{
Metadata: req.Metadata,
Metadata: req.Metadata,
CVEWhitelist: req.CVEWhitelist,
}); err != nil {
p.ParseAndHandleError(fmt.Sprintf("failed to update project %d",
p.project.ProjectID), err)

View File

@ -52,6 +52,15 @@ func TestProjectMemberAPI_Get(t *testing.T) {
},
code: http.StatusBadRequest,
},
// 200
{
request: &testingRequest{
method: http.MethodGet,
url: fmt.Sprintf("/api/projects/1/members/%d", projAdminPMID),
credential: admin,
},
code: http.StatusOK,
},
// 404
{
request: &testingRequest{

View File

@ -49,6 +49,7 @@ func (t *RegistryAPI) Ping() {
ID *int64 `json:"id"`
Type *string `json:"type"`
URL *string `json:"url"`
Region *string `json:"region"`
CredentialType *string `json:"credential_type"`
AccessKey *string `json:"access_key"`
AccessSecret *string `json:"access_secret"`

View File

@ -17,6 +17,7 @@ package api
import (
"encoding/json"
"fmt"
"github.com/goharbor/harbor/src/pkg/scan"
"io/ioutil"
"net/http"
"sort"
@ -34,7 +35,6 @@ import (
"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"
"github.com/goharbor/harbor/src/common/utils/notary"
"github.com/goharbor/harbor/src/common/utils/registry"
@ -332,7 +332,7 @@ func (ra *RepositoryAPI) Delete() {
go func(tag string) {
e := &event.Event{
Type: event.EventTypeImagePush,
Type: event.EventTypeImageDelete,
Resource: &model.Resource{
Type: model.ResourceTypeImage,
Metadata: &model.ResourceMetadata{
@ -1036,21 +1036,9 @@ func (ra *RepositoryAPI) VulnerabilityDetails() {
ra.SendForbiddenError(errors.New(ra.SecurityCtx.GetUsername()))
return
}
res := []*models.VulnerabilityItem{}
overview, err := dao.GetImgScanOverview(digest)
res, err := scan.VulnListByDigest(digest)
if err != nil {
ra.SendInternalServerError(fmt.Errorf("failed to get the scan overview, error: %v", err))
return
}
if overview != nil && len(overview.DetailsKey) > 0 {
clairClient := clair.NewClient(config.ClairEndpoint(), nil)
log.Debugf("The key for getting details: %s", overview.DetailsKey)
details, err := clairClient.GetResult(overview.DetailsKey)
if err != nil {
ra.SendInternalServerError(fmt.Errorf("Failed to get scan details from Clair, error: %v", err))
return
}
res = transformVulnerabilities(details)
log.Errorf("Failed to get vulnerability list for image: %s:%s", repository, tag)
}
ra.Data["json"] = res
ra.ServeJSON()

View File

@ -0,0 +1,81 @@
// 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 api
import (
"errors"
"fmt"
"github.com/goharbor/harbor/src/common/models"
"github.com/goharbor/harbor/src/common/utils/log"
"github.com/goharbor/harbor/src/pkg/scan/whitelist"
"net/http"
)
// SysCVEWhitelistAPI Handles the requests to manage system level CVE whitelist
type SysCVEWhitelistAPI struct {
BaseController
manager whitelist.Manager
}
// Prepare validates the request initially
func (sca *SysCVEWhitelistAPI) Prepare() {
sca.BaseController.Prepare()
if !sca.SecurityCtx.IsAuthenticated() {
sca.SendUnAuthorizedError(errors.New("Unauthorized"))
return
}
if !sca.SecurityCtx.IsSysAdmin() && sca.Ctx.Request.Method != http.MethodGet {
msg := fmt.Sprintf("only system admin has permission issue %s request to this API", sca.Ctx.Request.Method)
log.Errorf(msg)
sca.SendForbiddenError(errors.New(msg))
return
}
sca.manager = whitelist.NewDefaultManager()
}
// Get handles the GET request to retrieve the system level CVE whitelist
func (sca *SysCVEWhitelistAPI) Get() {
l, err := sca.manager.GetSys()
if err != nil {
sca.SendInternalServerError(err)
return
}
sca.WriteJSONData(l)
}
// Put handles the PUT request to update the system level CVE whitelist
func (sca *SysCVEWhitelistAPI) Put() {
var l models.CVEWhitelist
if err := sca.DecodeJSONReq(&l); err != nil {
log.Errorf("Failed to decode JSON array from request")
sca.SendBadRequestError(err)
return
}
if l.ProjectID != 0 {
msg := fmt.Sprintf("Non-zero project ID for system CVE whitelist: %d.", l.ProjectID)
log.Error(msg)
sca.SendBadRequestError(errors.New(msg))
return
}
if err := sca.manager.SetSys(l); err != nil {
if whitelist.IsInvalidErr(err) {
log.Errorf("Invalid CVE whitelist: %v", err)
sca.SendBadRequestError(err)
return
}
sca.SendInternalServerError(err)
return
}
}

View File

@ -0,0 +1,126 @@
// 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 api
import (
"github.com/goharbor/harbor/src/common/models"
"net/http"
"testing"
)
func TestSysCVEWhitelistAPIGet(t *testing.T) {
url := "/api/system/CVEWhitelist"
cases := []*codeCheckingCase{
// 401
{
request: &testingRequest{
method: http.MethodGet,
url: url,
},
code: http.StatusUnauthorized,
},
// 200
{
request: &testingRequest{
method: http.MethodGet,
url: url,
credential: nonSysAdmin,
},
code: http.StatusOK,
},
}
runCodeCheckingCases(t, cases...)
}
func TestSysCVEWhitelistAPIPut(t *testing.T) {
url := "/api/system/CVEWhitelist"
s := int64(1573254000)
cases := []*codeCheckingCase{
// 401
{
request: &testingRequest{
method: http.MethodPut,
url: url,
},
code: http.StatusUnauthorized,
},
// 403
{
request: &testingRequest{
method: http.MethodPut,
url: url,
credential: nonSysAdmin,
},
code: http.StatusForbidden,
},
// 400
{
request: &testingRequest{
method: http.MethodPut,
url: url,
bodyJSON: []string{"CVE-1234-1234"},
credential: sysAdmin,
},
code: http.StatusBadRequest,
},
// 400
{
request: &testingRequest{
method: http.MethodPut,
url: url,
bodyJSON: models.CVEWhitelist{
ExpiresAt: &s,
Items: []models.CVEWhitelistItem{
{CVEID: "CVE-2019-12310"},
},
ProjectID: 2,
},
credential: sysAdmin,
},
code: http.StatusBadRequest,
},
// 400
{
request: &testingRequest{
method: http.MethodPut,
url: url,
bodyJSON: models.CVEWhitelist{
ExpiresAt: &s,
Items: []models.CVEWhitelistItem{
{CVEID: "CVE-2019-12310"},
{CVEID: "CVE-2019-12310"},
},
},
credential: sysAdmin,
},
code: http.StatusBadRequest,
},
// 200
{
request: &testingRequest{
method: http.MethodPut,
url: url,
bodyJSON: models.CVEWhitelist{
ExpiresAt: &s,
Items: []models.CVEWhitelistItem{
{CVEID: "CVE-2019-12310"},
},
},
credential: sysAdmin,
},
code: http.StatusOK,
},
}
runCodeCheckingCases(t, cases...)
}

View File

@ -24,7 +24,6 @@ import (
commonhttp "github.com/goharbor/harbor/src/common/http"
"github.com/goharbor/harbor/src/common/models"
"github.com/goharbor/harbor/src/common/utils"
"github.com/goharbor/harbor/src/common/utils/clair"
"github.com/goharbor/harbor/src/common/utils/log"
"github.com/goharbor/harbor/src/common/utils/registry"
"github.com/goharbor/harbor/src/common/utils/registry/auth"
@ -279,35 +278,3 @@ func repositoryExist(name string, client *registry.Repository) (bool, error) {
}
return len(tags) != 0, nil
}
// transformVulnerabilities transforms the returned value of Clair API to a list of VulnerabilityItem
func transformVulnerabilities(layerWithVuln *models.ClairLayerEnvelope) []*models.VulnerabilityItem {
res := []*models.VulnerabilityItem{}
l := layerWithVuln.Layer
if l == nil {
return res
}
features := l.Features
if features == nil {
return res
}
for _, f := range features {
vulnerabilities := f.Vulnerabilities
if vulnerabilities == nil {
continue
}
for _, v := range vulnerabilities {
vItem := &models.VulnerabilityItem{
ID: v.Name,
Pkg: f.Name,
Version: f.Version,
Severity: clair.ParseClairSev(v.Severity),
Fixed: v.FixedBy,
Link: v.Link,
Description: v.Description,
}
res = append(res, vItem)
}
}
return res
}

View File

@ -20,11 +20,11 @@ import (
"strings"
"github.com/goharbor/harbor/src/common"
"github.com/goharbor/harbor/src/common/dao"
"github.com/goharbor/harbor/src/common/dao/group"
"github.com/goharbor/harbor/src/common/utils"
goldap "gopkg.in/ldap.v2"
"github.com/goharbor/harbor/src/common/dao"
"github.com/goharbor/harbor/src/common/dao/group"
"github.com/goharbor/harbor/src/common/models"
ldapUtils "github.com/goharbor/harbor/src/common/utils/ldap"
"github.com/goharbor/harbor/src/common/utils/log"
@ -79,7 +79,7 @@ func (l *Auth) Authenticate(m models.AuthModel) (*models.User, error) {
u.Username = ldapUsers[0].Username
u.Email = strings.TrimSpace(ldapUsers[0].Email)
u.Realname = ldapUsers[0].Realname
userGroups := make([]*models.UserGroup, 0)
ugIDs := []int{}
dn := ldapUsers[0].DN
if err = ldapSession.Bind(dn, m.Password); err != nil {
@ -95,6 +95,7 @@ func (l *Auth) Authenticate(m models.AuthModel) (*models.User, error) {
for _, groupDN := range ldapUsers[0].GroupDNList {
groupDN = utils.TrimLower(groupDN)
// Attach LDAP group admin
if len(groupAdminDN) > 0 && groupAdminDN == groupDN {
u.HasAdminRole = true
}
@ -103,16 +104,16 @@ func (l *Auth) Authenticate(m models.AuthModel) (*models.User, error) {
GroupType: 1,
LdapGroupDN: groupDN,
}
userGroupList, err := group.QueryUserGroup(userGroupQuery)
userGroups, err := group.QueryUserGroup(userGroupQuery)
if err != nil {
continue
}
if len(userGroupList) == 0 {
if len(userGroups) == 0 {
continue
}
userGroups = append(userGroups, userGroupList[0])
ugIDs = append(ugIDs, userGroups[0].ID)
}
u.GroupList = userGroups
u.GroupIDs = ugIDs
return &u, nil
}

View File

@ -229,8 +229,10 @@ type oidcCliReqCtxModifier struct{}
func (oc *oidcCliReqCtxModifier) Modify(ctx *beegoctx.Context) bool {
path := ctx.Request.URL.Path
if path != "/service/token" && !strings.HasPrefix(path, "/chartrepo/") {
log.Debug("OIDC CLI modifer only handles request by docker CLI or helm CLI")
if path != "/service/token" &&
!strings.HasPrefix(path, "/chartrepo/") &&
!strings.HasPrefix(path, "/api/chartrepo/") {
log.Debug("OIDC CLI modifier only handles request by docker CLI or helm CLI")
return false
}
if ctx.Request.Context().Value(AuthModeKey).(string) != common.OIDCAuth {

View File

@ -20,7 +20,6 @@ import (
"time"
"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/utils"
errutil "github.com/goharbor/harbor/src/common/utils/error"
@ -132,19 +131,16 @@ func (d *driver) Update(projectIDOrName interface{},
func (d *driver) List(query *models.ProjectQueryParam) (*models.ProjectQueryResult, error) {
var total int64
var projects []*models.Project
var groupDNCondition string
// List with LDAP group projects
var groupIDs []int
if query != nil && query.Member != nil {
groupDNCondition = group.GetGroupDNQueryCondition(query.Member.GroupList)
groupIDs = query.Member.GroupIDs
}
count, err := dao.GetTotalGroupProjects(groupDNCondition, query)
count, err := dao.GetTotalGroupProjects(groupIDs, query)
if err != nil {
return nil, err
}
total = int64(count)
projects, err = dao.GetGroupProjects(groupDNCondition, query)
projects, err = dao.GetGroupProjects(groupIDs, query)
if err != nil {
return nil, err
}

View File

@ -16,6 +16,7 @@ package promgr
import (
"fmt"
"github.com/goharbor/harbor/src/pkg/scan/whitelist"
"strconv"
"github.com/goharbor/harbor/src/common/models"
@ -44,6 +45,7 @@ type defaultProjectManager struct {
pmsDriver pmsdriver.PMSDriver
metaMgrEnabled bool // if metaMgrEnabled is enabled, metaMgr will be used to CURD metadata
metaMgr metamgr.ProjectMetadataManager
whitelistMgr whitelist.Manager
}
// NewDefaultProjectManager returns an instance of defaultProjectManager,
@ -56,6 +58,7 @@ func NewDefaultProjectManager(driver pmsdriver.PMSDriver, metaMgrEnabled bool) P
}
if metaMgrEnabled {
mgr.metaMgr = metamgr.NewDefaultProjectMetadataManager()
mgr.whitelistMgr = whitelist.NewDefaultManager()
}
return mgr
}
@ -77,6 +80,11 @@ func (d *defaultProjectManager) Get(projectIDOrName interface{}) (*models.Projec
for k, v := range meta {
project.Metadata[k] = v
}
wl, err := d.whitelistMgr.Get(project.ProjectID)
if err != nil {
return nil, err
}
project.CVEWhitelist = *wl
}
return project, nil
}
@ -85,9 +93,12 @@ func (d *defaultProjectManager) Create(project *models.Project) (int64, error) {
if err != nil {
return 0, err
}
if len(project.Metadata) > 0 && d.metaMgrEnabled {
if err = d.metaMgr.Add(id, project.Metadata); err != nil {
log.Errorf("failed to add metadata for project %s: %v", project.Name, err)
if d.metaMgrEnabled {
d.whitelistMgr.CreateEmpty(project.ProjectID)
if len(project.Metadata) > 0 {
if err = d.metaMgr.Add(id, project.Metadata); err != nil {
log.Errorf("failed to add metadata for project %s: %v", project.Name, err)
}
}
}
return id, nil
@ -110,37 +121,40 @@ func (d *defaultProjectManager) Delete(projectIDOrName interface{}) error {
}
func (d *defaultProjectManager) Update(projectIDOrName interface{}, project *models.Project) error {
if len(project.Metadata) > 0 && d.metaMgrEnabled {
pro, err := d.Get(projectIDOrName)
if err != nil {
pro, err := d.Get(projectIDOrName)
if err != nil {
return err
}
if pro == nil {
return fmt.Errorf("project %v not found", projectIDOrName)
}
// TODO transaction?
if d.metaMgrEnabled {
if err := d.whitelistMgr.Set(pro.ProjectID, project.CVEWhitelist); err != nil {
return err
}
if pro == nil {
return fmt.Errorf("project %v not found", projectIDOrName)
}
// TODO transaction?
metaNeedUpdated := map[string]string{}
metaNeedCreated := map[string]string{}
if pro.Metadata == nil {
pro.Metadata = map[string]string{}
}
for key, value := range project.Metadata {
_, exist := pro.Metadata[key]
if exist {
metaNeedUpdated[key] = value
} else {
metaNeedCreated[key] = value
if len(project.Metadata) > 0 {
metaNeedUpdated := map[string]string{}
metaNeedCreated := map[string]string{}
if pro.Metadata == nil {
pro.Metadata = map[string]string{}
}
for key, value := range project.Metadata {
_, exist := pro.Metadata[key]
if exist {
metaNeedUpdated[key] = value
} else {
metaNeedCreated[key] = value
}
}
if err = d.metaMgr.Add(pro.ProjectID, metaNeedCreated); err != nil {
return err
}
if err = d.metaMgr.Update(pro.ProjectID, metaNeedUpdated); err != nil {
return err
}
}
if err = d.metaMgr.Add(pro.ProjectID, metaNeedCreated); err != nil {
return err
}
if err = d.metaMgr.Update(pro.ProjectID, metaNeedUpdated); err != nil {
return err
}
}
return d.pmsDriver.Update(projectIDOrName, project)
}
@ -179,6 +193,7 @@ func (d *defaultProjectManager) List(query *models.ProjectQueryParam) (*models.P
project.Metadata = meta
}
}
// the whitelist is not populated deliberately
return result, nil
}

View File

@ -166,9 +166,10 @@ func TestPMSPolicyChecker(t *testing.T) {
Name: name,
OwnerID: 1,
Metadata: map[string]string{
models.ProMetaEnableContentTrust: "true",
models.ProMetaPreventVul: "true",
models.ProMetaSeverity: "low",
models.ProMetaEnableContentTrust: "true",
models.ProMetaPreventVul: "true",
models.ProMetaSeverity: "low",
models.ProMetaReuseSysCVEWhitelist: "false",
},
})
require.Nil(t, err)
@ -180,9 +181,10 @@ func TestPMSPolicyChecker(t *testing.T) {
contentTrustFlag := getPolicyChecker().contentTrustEnabled("project_for_test_get_sev_low")
assert.True(t, contentTrustFlag)
projectVulnerableEnabled, projectVulnerableSeverity := getPolicyChecker().vulnerablePolicy("project_for_test_get_sev_low")
projectVulnerableEnabled, projectVulnerableSeverity, wl := getPolicyChecker().vulnerablePolicy("project_for_test_get_sev_low")
assert.True(t, projectVulnerableEnabled)
assert.Equal(t, projectVulnerableSeverity, models.SevLow)
assert.Empty(t, wl.Items)
}
func TestMatchNotaryDigest(t *testing.T) {

View File

@ -2,7 +2,6 @@ package proxy
import (
"encoding/json"
"github.com/goharbor/harbor/src/common/dao"
"github.com/goharbor/harbor/src/common/models"
"github.com/goharbor/harbor/src/common/utils/clair"
@ -11,6 +10,8 @@ import (
"github.com/goharbor/harbor/src/core/config"
"github.com/goharbor/harbor/src/core/promgr"
coreutils "github.com/goharbor/harbor/src/core/utils"
"github.com/goharbor/harbor/src/pkg/scan"
"github.com/goharbor/harbor/src/pkg/scan/whitelist"
"context"
"fmt"
@ -82,7 +83,7 @@ type policyChecker interface {
// contentTrustEnabled returns whether a project has enabled content trust.
contentTrustEnabled(name string) bool
// vulnerablePolicy returns whether a project has enabled vulnerable, and the project's severity.
vulnerablePolicy(name string) (bool, models.Severity)
vulnerablePolicy(name string) (bool, models.Severity, models.CVEWhitelist)
}
type pmsPolicyChecker struct {
@ -97,13 +98,28 @@ func (pc pmsPolicyChecker) contentTrustEnabled(name string) bool {
}
return project.ContentTrustEnabled()
}
func (pc pmsPolicyChecker) vulnerablePolicy(name string) (bool, models.Severity) {
func (pc pmsPolicyChecker) vulnerablePolicy(name string) (bool, models.Severity, models.CVEWhitelist) {
project, err := pc.pm.Get(name)
wl := models.CVEWhitelist{}
if err != nil {
log.Errorf("Unexpected error when getting the project, error: %v", err)
return true, models.SevUnknown
return true, models.SevUnknown, wl
}
return project.VulPrevented(), clair.ParseClairSev(project.Severity())
mgr := whitelist.NewDefaultManager()
if project.ReuseSysCVEWhitelist() {
w, err := mgr.GetSys()
if err != nil {
return project.VulPrevented(), clair.ParseClairSev(project.Severity()), wl
}
wl = *w
} else {
w, err := mgr.Get(project.ProjectID)
if err != nil {
return project.VulPrevented(), clair.ParseClairSev(project.Severity()), wl
}
wl = *w
}
return project.VulPrevented(), clair.ParseClairSev(project.Severity()), wl
}
// newPMSPolicyChecker returns an instance of an pmsPolicyChecker
@ -298,32 +314,39 @@ func (vh vulnerableHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request)
vh.next.ServeHTTP(rw, req)
return
}
projectVulnerableEnabled, projectVulnerableSeverity := getPolicyChecker().vulnerablePolicy(img.projectName)
projectVulnerableEnabled, projectVulnerableSeverity, wl := getPolicyChecker().vulnerablePolicy(img.projectName)
if !projectVulnerableEnabled {
vh.next.ServeHTTP(rw, req)
return
}
overview, err := dao.GetImgScanOverview(img.digest)
vl, err := scan.VulnListByDigest(img.digest)
if err != nil {
log.Errorf("failed to get ImgScanOverview with repo: %s, reference: %s, digest: %s. Error: %v", img.repository, img.reference, img.digest, err)
http.Error(rw, marshalError("PROJECT_POLICY_VIOLATION", "Failed to get ImgScanOverview."), http.StatusPreconditionFailed)
log.Errorf("Failed to get the vulnerability list, error: %v", err)
http.Error(rw, marshalError("PROJECT_POLICY_VIOLATION", "Failed to get vulnerabilities."), http.StatusPreconditionFailed)
return
}
// severity is 0 means that the image fails to scan or not scanned successfully.
if overview == nil || overview.Sev == 0 {
log.Debugf("cannot get the image scan overview info, failing the response.")
http.Error(rw, marshalError("PROJECT_POLICY_VIOLATION", "Cannot get the image severity."), http.StatusPreconditionFailed)
return
}
imageSev := overview.Sev
if imageSev >= int(projectVulnerableSeverity) {
log.Debugf("the image severity: %q is higher then project setting: %q, failing the response.", models.Severity(imageSev), projectVulnerableSeverity)
http.Error(rw, marshalError("PROJECT_POLICY_VIOLATION", fmt.Sprintf("The severity of vulnerability of the image: %q is equal or higher than the threshold in project setting: %q.", models.Severity(imageSev), projectVulnerableSeverity)), http.StatusPreconditionFailed)
filtered := vl.ApplyWhitelist(wl)
msg := vh.filterMsg(img, filtered)
log.Info(msg)
if int(vl.Severity()) >= int(projectVulnerableSeverity) {
log.Debugf("the image severity: %q is higher then project setting: %q, failing the response.", vl.Severity(), projectVulnerableSeverity)
http.Error(rw, marshalError("PROJECT_POLICY_VIOLATION", fmt.Sprintf("The severity of vulnerability of the image: %q is equal or higher than the threshold in project setting: %q.", vl.Severity(), projectVulnerableSeverity)), http.StatusPreconditionFailed)
return
}
vh.next.ServeHTTP(rw, req)
}
func (vh vulnerableHandler) filterMsg(img imageInfo, filtered scan.VulnerabilityList) string {
filterMsg := fmt.Sprintf("Image: %s/%s:%s, digest: %s, vulnerabilities fitered by whitelist:", img.projectName, img.repository, img.reference, img.digest)
if len(filtered) == 0 {
filterMsg = fmt.Sprintf("%s none.", filterMsg)
}
for _, v := range filtered {
filterMsg = fmt.Sprintf("%s ID: %s, severity: %s;", filterMsg, v.ID, v.Severity)
}
return filterMsg
}
func matchNotaryDigest(img imageInfo) (bool, error) {
if NotaryEndpoint == "" {
NotaryEndpoint = config.InternalNotaryEndpoint()

View File

@ -38,7 +38,15 @@ func Init(urls ...string) error {
return err
}
Proxy = httputil.NewSingleHostReverseProxy(targetURL)
handlers = handlerChain{head: readonlyHandler{next: urlHandler{next: multipleManifestHandler{next: listReposHandler{next: contentTrustHandler{next: vulnerableHandler{next: Proxy}}}}}}}
handlers = handlerChain{
head: readonlyHandler{
next: urlHandler{
next: multipleManifestHandler{
next: listReposHandler{
next: contentTrustHandler{
next: vulnerableHandler{
next: Proxy,
}}}}}}}
return nil
}

View File

@ -96,6 +96,7 @@ func initRouters() {
beego.Router("/api/system/gc/:id([0-9]+)/log", &api.GCAPI{}, "get:GetLog")
beego.Router("/api/system/gc/schedule", &api.GCAPI{}, "get:Get;put:Put;post:Post")
beego.Router("/api/system/scanAll/schedule", &api.ScanAllAPI{}, "get:Get;put:Put;post:Post")
beego.Router("/api/system/CVEWhitelist", &api.SysCVEWhitelistAPI{}, "get:Get;put:Put")
beego.Router("/api/logs", &api.LogAPI{})

View File

@ -10,6 +10,7 @@ require (
github.com/Unknwon/goconfig v0.0.0-20160216183935-5f601ca6ef4d // indirect
github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 // indirect
github.com/astaxie/beego v1.9.0
github.com/aws/aws-sdk-go v1.19.47
github.com/beego/i18n v0.0.0-20140604031826-e87155e8f0c0
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 // indirect
github.com/bitly/go-simplejson v0.5.0 // indirect

View File

@ -25,6 +25,8 @@ github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/astaxie/beego v1.9.0 h1:tPzS+D1oCLi+SEb/TLNRNYpCjaMVfAGoy9OTLwS5ul4=
github.com/astaxie/beego v1.9.0/go.mod h1:0R4++1tUqERR0WYFWdfkcrsyoVBCG4DgpDGokT3yb+U=
github.com/aws/aws-sdk-go v1.19.47 h1:ZEze0mpk8Fttrsz6UNLqhH/jRGYbMPfWFA2ILas4AmM=
github.com/aws/aws-sdk-go v1.19.47/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/beego/i18n v0.0.0-20140604031826-e87155e8f0c0 h1:fQaDnUQvBXHHQdGBu9hz8nPznB4BeiPQokvmQVjmNEw=
github.com/beego/i18n v0.0.0-20140604031826-e87155e8f0c0/go.mod h1:KLeFCpAMq2+50NkXC8iiJxLLiiTfTqrGtKEVm+2fk7s=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@ -158,6 +160,8 @@ github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a h1:eeaG9XMUvRBYX
github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.0.0 h1:6WV8LvwPpDhKjo5U9O6b4+xdG/jTXNPwlDme/MTo8Ns=
github.com/jinzhu/now v1.0.0/go.mod h1:oHTiXerJ20+SfYcrdlBO7rzZRJWGwSTQ0iUY2jI6Gfc=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=

View File

@ -34,7 +34,7 @@ func RedisKeyScheduled(namespace string) string {
// RedisKeyLastPeriodicEnqueue returns key of timestamp if last periodic enqueue.
func RedisKeyLastPeriodicEnqueue(namespace string) string {
return RedisNamespacePrefix(namespace) + "last_periodic_enqueue"
return RedisNamespacePrefix(namespace) + "last_periodic_enqueue_h"
}
// KeyNamespacePrefix returns the based key based on the namespace.

View File

@ -34,6 +34,12 @@ import (
_ "github.com/goharbor/harbor/src/replication/adapter/native"
// register the Huawei adapter
_ "github.com/goharbor/harbor/src/replication/adapter/huawei"
// register the Google Gcr adapter
_ "github.com/goharbor/harbor/src/replication/adapter/googlegcr"
// register the AwsEcr adapter
_ "github.com/goharbor/harbor/src/replication/adapter/awsecr"
// register the AzureAcr adapter
_ "github.com/goharbor/harbor/src/replication/adapter/azurecr"
)
// Replication implements the job interface

View File

@ -16,6 +16,12 @@ package period
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/pkg/errors"
"github.com/goharbor/harbor/src/jobservice/common/rds"
"github.com/goharbor/harbor/src/jobservice/common/utils"
"github.com/goharbor/harbor/src/jobservice/env"
@ -26,9 +32,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"sync"
"testing"
"time"
)
// EnqueuerTestSuite tests functions of enqueuer
@ -89,19 +92,30 @@ func (suite *EnqueuerTestSuite) TestEnqueuer() {
suite.enqueuer.stopChan <- true
}()
<-time.After(1 * time.Second)
key := rds.RedisKeyScheduled(suite.namespace)
conn := suite.pool.Get()
defer func() {
_ = conn.Close()
}()
count, err := redis.Int(conn.Do("ZCARD", key))
require.Nil(suite.T(), err, "count scheduled: nil error expected but got %s", err)
assert.Condition(suite.T(), func() bool {
return count > 0
}, "count of scheduled jobs should be greater than 0 but got %d", count)
tk := time.NewTicker(500 * time.Millisecond)
defer tk.Stop()
for {
select {
case <-tk.C:
count, err := redis.Int(conn.Do("ZCARD", key))
require.Nil(suite.T(), err, "count scheduled: nil error expected but got %s", err)
if assert.Condition(suite.T(), func() (success bool) {
return count > 0
}, "at least one job should be scheduled for the periodic job policy") {
return
}
case <-time.After(15 * time.Second):
require.NoError(suite.T(), errors.New("timeout (15s): expect at 1 scheduled job but still get nothing"))
return
}
}
}()
err := suite.enqueuer.start()
@ -112,7 +126,7 @@ func (suite *EnqueuerTestSuite) prepare() {
now := time.Now()
minute := now.Minute()
coreSpec := fmt.Sprintf("30,50 %d * * * *", minute+2)
coreSpec := fmt.Sprintf("0-59 %d * * * *", minute)
// Prepare one
p := &Policy{

136
src/pkg/scan/vuln.go Normal file
View File

@ -0,0 +1,136 @@
// 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 scan
import (
"fmt"
"github.com/goharbor/harbor/src/common/dao"
"github.com/goharbor/harbor/src/common/models"
"github.com/goharbor/harbor/src/common/utils/clair"
"github.com/goharbor/harbor/src/common/utils/log"
"github.com/goharbor/harbor/src/core/config"
"reflect"
)
// VulnerabilityItem represents a vulnerability reported by scanner
type VulnerabilityItem struct {
ID string `json:"id"`
Severity models.Severity `json:"severity"`
Pkg string `json:"package"`
Version string `json:"version"`
Description string `json:"description"`
Link string `json:"link"`
Fixed string `json:"fixedVersion,omitempty"`
}
// VulnerabilityList is a list of vulnerabilities, which should be scanner-agnostic
type VulnerabilityList []VulnerabilityItem
// ApplyWhitelist filters out the CVE defined in the whitelist in the parm.
// It returns the items that are filtered for the caller to track or log.
func (vl *VulnerabilityList) ApplyWhitelist(whitelist models.CVEWhitelist) VulnerabilityList {
filtered := VulnerabilityList{}
if whitelist.IsExpired() {
log.Info("The input whitelist is expired, skip filtering")
return filtered
}
s := whitelist.CVESet()
r := (*vl)[:0]
for _, v := range *vl {
if _, ok := s[v.ID]; ok {
log.Debugf("Filtered Vulnerability in whitelist, CVE ID: %s, severity: %s", v.ID, v.Severity)
filtered = append(filtered, v)
} else {
r = append(r, v)
}
}
val := reflect.ValueOf(vl)
val.Elem().SetLen(len(r))
return filtered
}
// Severity returns the highest severity of the vulnerabilities in the list
func (vl *VulnerabilityList) Severity() models.Severity {
s := models.SevNone
for _, v := range *vl {
if v.Severity > s {
s = v.Severity
}
}
return s
}
// HasCVE returns whether the vulnerability list has the vulnerability with CVE ID in the parm
func (vl *VulnerabilityList) HasCVE(id string) bool {
for _, v := range *vl {
if v.ID == id {
return true
}
}
return false
}
// VulnListFromClairResult transforms the returned value of Clair API to a VulnerabilityList
func VulnListFromClairResult(layerWithVuln *models.ClairLayerEnvelope) VulnerabilityList {
res := VulnerabilityList{}
if layerWithVuln == nil {
return res
}
l := layerWithVuln.Layer
if l == nil {
return res
}
features := l.Features
if features == nil {
return res
}
for _, f := range features {
vulnerabilities := f.Vulnerabilities
if vulnerabilities == nil {
continue
}
for _, v := range vulnerabilities {
vItem := VulnerabilityItem{
ID: v.Name,
Pkg: f.Name,
Version: f.Version,
Severity: clair.ParseClairSev(v.Severity),
Fixed: v.FixedBy,
Link: v.Link,
Description: v.Description,
}
res = append(res, vItem)
}
}
return res
}
// VulnListByDigest returns the VulnerabilityList based on the scan result of artifact with the digest in the parm
func VulnListByDigest(digest string) (VulnerabilityList, error) {
var res VulnerabilityList
overview, err := dao.GetImgScanOverview(digest)
if err != nil {
return res, err
}
if overview == nil || len(overview.DetailsKey) == 0 {
return res, fmt.Errorf("unable to get the scan result for digest: %s, the artifact is not scanned", digest)
}
c := clair.NewClient(config.ClairEndpoint(), nil)
clairRes, err := c.GetResult(overview.DetailsKey)
if err != nil {
return res, fmt.Errorf("failed to get scan result from Clair, error: %v", err)
}
return VulnListFromClairResult(clairRes), nil
}

178
src/pkg/scan/vuln_test.go Normal file
View File

@ -0,0 +1,178 @@
// 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 scan
import (
"github.com/goharbor/harbor/src/common/dao"
"github.com/goharbor/harbor/src/common/models"
"github.com/stretchr/testify/assert"
"os"
"testing"
)
var (
past = int64(1561967574)
vulnList1 = VulnerabilityList{}
vulnList2 = VulnerabilityList{
{ID: "CVE-2018-10754",
Severity: models.SevLow,
Pkg: "ncurses",
Version: "6.0+20161126-1+deb9u2",
},
{
ID: "CVE-2018-6485",
Severity: models.SevHigh,
Pkg: "glibc",
Version: "2.24-11+deb9u4",
},
}
whiteList1 = models.CVEWhitelist{
ExpiresAt: &past,
Items: []models.CVEWhitelistItem{
{CVEID: "CVE-2018-6485"},
},
}
whiteList2 = models.CVEWhitelist{
Items: []models.CVEWhitelistItem{
{CVEID: "CVE-2018-6485"},
},
}
whiteList3 = models.CVEWhitelist{
Items: []models.CVEWhitelistItem{
{CVEID: "CVE-2018-6485"},
{CVEID: "CVE-2018-10754"},
{CVEID: "CVE-2019-12817"},
},
}
)
func TestMain(m *testing.M) {
dao.PrepareTestForPostgresSQL()
os.Exit(m.Run())
}
func TestVulnerabilityList_HasCVE(t *testing.T) {
cases := []struct {
input VulnerabilityList
cve string
result bool
}{
{
input: vulnList1,
cve: "CVE-2018-10754",
result: false,
},
{
input: vulnList2,
cve: "CVE-2018-10754",
result: true,
},
}
for _, c := range cases {
assert.Equal(t, c.result, c.input.HasCVE(c.cve))
}
}
func TestVulnerabilityList_Severity(t *testing.T) {
cases := []struct {
input VulnerabilityList
expect models.Severity
}{
{
input: vulnList1,
expect: models.SevNone,
}, {
input: vulnList2,
expect: models.SevHigh,
},
}
for _, c := range cases {
assert.Equal(t, c.expect, c.input.Severity())
}
}
func TestVulnerabilityList_ApplyWhitelist(t *testing.T) {
cases := []struct {
vl VulnerabilityList
wl models.CVEWhitelist
expectFiltered VulnerabilityList
expectSev models.Severity
}{
{
vl: vulnList2,
wl: whiteList1,
expectFiltered: VulnerabilityList{},
expectSev: models.SevHigh,
},
{
vl: vulnList2,
wl: whiteList2,
expectFiltered: VulnerabilityList{
{
ID: "CVE-2018-6485",
Severity: models.SevHigh,
Pkg: "glibc",
Version: "2.24-11+deb9u4",
},
},
expectSev: models.SevLow,
},
{
vl: vulnList1,
wl: whiteList3,
expectFiltered: VulnerabilityList{},
expectSev: models.SevNone,
},
{
vl: vulnList2,
wl: whiteList3,
expectFiltered: VulnerabilityList{
{ID: "CVE-2018-10754",
Severity: models.SevLow,
Pkg: "ncurses",
Version: "6.0+20161126-1+deb9u2",
},
{
ID: "CVE-2018-6485",
Severity: models.SevHigh,
Pkg: "glibc",
Version: "2.24-11+deb9u4",
},
},
expectSev: models.SevNone,
},
}
for _, c := range cases {
filtered := c.vl.ApplyWhitelist(c.wl)
assert.Equal(t, c.expectFiltered, filtered)
assert.Equal(t, c.vl.Severity(), c.expectSev)
}
}
func TestVulnListByDigest(t *testing.T) {
_, err := VulnListByDigest("notexist")
assert.NotNil(t, err)
}
func TestVulnListFromClairResult(t *testing.T) {
l := VulnListFromClairResult(nil)
assert.Equal(t, VulnerabilityList{}, l)
lv := &models.ClairLayerEnvelope{
Layer: nil,
Error: nil,
}
l2 := VulnListFromClairResult(lv)
assert.Equal(t, VulnerabilityList{}, l2)
}

View File

@ -0,0 +1,79 @@
// 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 whitelist
import (
"github.com/goharbor/harbor/src/common/dao"
"github.com/goharbor/harbor/src/common/models"
"github.com/goharbor/harbor/src/jobservice/logger"
)
// Manager defines the interface of CVE whitelist manager, it support both system level and project level whitelists
type Manager interface {
// CreateEmpty creates empty whitelist for given project
CreateEmpty(projectID int64) error
// Set sets the whitelist for given project (create or update)
Set(projectID int64, list models.CVEWhitelist) error
// Get gets the whitelist for given project
Get(projectID int64) (*models.CVEWhitelist, error)
// SetSys sets system level whitelist
SetSys(list models.CVEWhitelist) error
// GetSys gets system level whitelist
GetSys() (*models.CVEWhitelist, error)
}
type defaultManager struct{}
// CreateEmpty creates empty whitelist for given project
func (d *defaultManager) CreateEmpty(projectID int64) error {
l := models.CVEWhitelist{
ProjectID: projectID,
}
_, err := dao.UpdateCVEWhitelist(l)
if err != nil {
logger.Errorf("Failed to create empty CVE whitelist for project: %d, error: %v", projectID, err)
}
return err
}
// Set sets the whitelist for given project (create or update)
func (d *defaultManager) Set(projectID int64, list models.CVEWhitelist) error {
list.ProjectID = projectID
if err := Validate(list); err != nil {
return err
}
_, err := dao.UpdateCVEWhitelist(list)
return err
}
// Get gets the whitelist for given project
func (d *defaultManager) Get(projectID int64) (*models.CVEWhitelist, error) {
return dao.GetCVEWhitelist(projectID)
}
// SetSys sets the system level whitelist
func (d *defaultManager) SetSys(list models.CVEWhitelist) error {
return d.Set(0, list)
}
// GetSys gets the system level whitelist
func (d *defaultManager) GetSys() (*models.CVEWhitelist, error) {
return d.Get(0)
}
// NewDefaultManager return a new instance of defaultManager
func NewDefaultManager() Manager {
return &defaultManager{}
}

View File

@ -0,0 +1,60 @@
// 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 whitelist
import (
"fmt"
"github.com/goharbor/harbor/src/common/models"
"regexp"
)
type invalidErr struct {
msg string
}
func (ie *invalidErr) Error() string {
return ie.msg
}
// NewInvalidErr ...
func NewInvalidErr(s string) error {
return &invalidErr{
msg: s,
}
}
// IsInvalidErr checks if the error is an invalidErr
func IsInvalidErr(err error) bool {
_, ok := err.(*invalidErr)
return ok
}
const cveIDPattern = `^CVE-\d{4}-\d+$`
// Validate help validates the CVE whitelist, to ensure the CVE ID is valid and there's no duplication
func Validate(wl models.CVEWhitelist) error {
m := map[string]struct{}{}
re := regexp.MustCompile(cveIDPattern)
for _, it := range wl.Items {
if !re.MatchString(it.CVEID) {
return &invalidErr{fmt.Sprintf("invalid CVE ID: %s", it.CVEID)}
}
if _, ok := m[it.CVEID]; ok {
return &invalidErr{fmt.Sprintf("duplicate CVE ID in whitelist: %s", it.CVEID)}
}
m[it.CVEID] = struct{}{}
}
return nil
}

View File

@ -0,0 +1,102 @@
// 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 whitelist
import (
"fmt"
"github.com/goharbor/harbor/src/common/models"
"github.com/stretchr/testify/assert"
"testing"
)
func TestIsInvalidErr(t *testing.T) {
cases := []struct {
instance error
expect bool
}{
{
instance: nil,
expect: false,
},
{
instance: fmt.Errorf("whatever"),
expect: false,
},
{
instance: NewInvalidErr("This is true"),
expect: true,
},
}
for n, c := range cases {
t.Logf("Executing TestIsInvalidErr case: %d\n", n)
assert.Equal(t, c.expect, IsInvalidErr(c.instance))
}
}
func TestValidate(t *testing.T) {
cases := []struct {
l models.CVEWhitelist
noError bool
}{
{
l: models.CVEWhitelist{
Items: nil,
},
noError: true,
},
{
l: models.CVEWhitelist{
Items: []models.CVEWhitelistItem{},
},
noError: true,
},
{
l: models.CVEWhitelist{
Items: []models.CVEWhitelistItem{
{CVEID: "breakit"},
},
},
noError: false,
},
{
l: models.CVEWhitelist{
Items: []models.CVEWhitelistItem{
{CVEID: "CVE-2014-456132"},
{CVEID: "CVE-2014-7654321"},
},
},
noError: true,
},
{
l: models.CVEWhitelist{
Items: []models.CVEWhitelistItem{
{CVEID: "CVE-2014-456132"},
{CVEID: "CVE-2014-456132"},
{CVEID: "CVE-2014-7654321"},
},
},
noError: false,
},
}
for n, c := range cases {
t.Logf("Executing TestValidate case: %d\n", n)
e := Validate(c.l)
assert.Equal(t, c.noError, e == nil)
if e != nil {
assert.True(t, IsInvalidErr(e))
}
}
}

View File

@ -44,11 +44,15 @@
<div class="form-group">
<label for="destination_url" class="col-md-4 form-group-label-override required">{{ 'DESTINATION.URL' |
translate }}</label>
<label class="col-md-8" for="destination_url" aria-haspopup="true" role="tooltip" [class.invalid]="targetEndpoint.errors && (targetEndpoint.dirty || targetEndpoint.touched)"
[class.valid]="targetEndpoint.valid" class="tooltip tooltip-validation tooltip-sm tooltip-bottom-left">
<input type="text" id="destination_url" [disabled]="testOngoing || urlDisabled" [readonly]="!editable" [(ngModel)]="target.url"
size="25" name="endpointUrl" #targetEndpoint="ngModel" required placeholder="http(s)://192.168.1.1">
<span class="tooltip-content" *ngIf="targetEndpoint.errors && targetEndpoint.errors.required && (targetEndpoint.dirty || targetEndpoint.touched)">
<label class="col-md-8" for="destination_url" aria-haspopup="true" role="tooltip" [class.invalid]="targetEndpoint?.errors && (targetEndpoint?.dirty || targetEndpoint?.touched)"
[class.valid]="targetEndpoint?.valid" class="tooltip tooltip-validation tooltip-sm tooltip-bottom-left">
<input *ngIf="!endpointList.length" type="text" id="destination_url" [disabled]="testOngoing || urlDisabled" [readonly]="!editable" [(ngModel)]="target.url"
size="25" name="endpointUrl" #targetEndpoint="ngModel" required placeholder="http(s)://192.168.1.1">
<select *ngIf="endpointList.length" [(ngModel)]="target.url" name="endpointUrl" #targetEndpoint="ngModel">
<option class="display-none" value=""></option>
<option *ngFor="let endpoint of endpointList" value="{{endpoint.value}}">{{endpoint.key}}</option>
</select>
<span class="tooltip-content" *ngIf="targetEndpoint?.errors && targetEndpoint.errors.required && (targetEndpoint.dirty || targetEndpoint.touched)">
{{ 'DESTINATION.URL_IS_REQUIRED' | translate }}
</span>
</label>
@ -57,15 +61,19 @@
<div class="form-group">
<label for="destination_access_key" class="col-md-4 form-group-label-override">{{ 'DESTINATION.ACCESS_ID' |
translate }}</label>
<input type="text" placeholder="Access ID" class="col-md-8" id="destination_access_key" [disabled]="testOngoing" [readonly]="!editable"
<input type="text" placeholder="Access ID" class="col-md-8" id="destination_access_key" [disabled]="testOngoing" [readonly]="target.type ==='google-gcr' || !editable"
[(ngModel)]="target.credential.access_key" size="28" name="access_key" #access_key="ngModel">
</div>
<!-- access_secret -->
<div class="form-group">
<label for="destination_password" class="col-md-4 form-group-label-override">{{ 'DESTINATION.ACCESS_SECRET' |
translate }}</label>
<input type="password" placeholder="Access Secret" class="col-md-8" id="destination_password" [disabled]="testOngoing" [readonly]="!editable"
[(ngModel)]="target.credential.access_secret" size="28" name="access_secret" #access_secret="ngModel">
<input *ngIf="target.type !=='google-gcr';else gcr_secret" type="password" placeholder="Access Secret" class="col-md-8" id="destination_password" [disabled]="testOngoing" [readonly]="!editable"
[(ngModel)]="target.credential.access_secret" size="28" name="access_secret" #access_secret="ngModel">
<ng-template #gcr_secret>
<textarea type="text" row="3" placeholder="Json Secret" class="inputWidth" id="destination_password" [disabled]="testOngoing" [readonly]="!editable"
[(ngModel)]="target.credential.access_secret" name="access_secret" #access_secret="ngModel"></textarea>
</ng-template>
</div>
<!-- Verify Remote Cert -->
<div class="form-group">
@ -88,7 +96,7 @@
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" (click)="testConnection()" [disabled]="inProgress || targetEndpoint.errors">{{
<button type="button" class="btn btn-outline" (click)="testConnection()" [disabled]="inProgress || (targetEndpoint?.errors)">{{
'DESTINATION.TEST_CONNECTION' | translate }}</button>
<button type="button" class="btn btn-outline" (click)="onCancel()" [disabled]="inProgress">{{ 'BUTTON.CANCEL' |
translate }}</button>

View File

@ -15,3 +15,6 @@
.inputWidth {
width: 216px;
}
.display-none {
display: none
}

View File

@ -32,6 +32,7 @@ import { Endpoint, PingEndpoint } from "../service/interface";
import { clone, compareValue, isEmptyObject } from "../utils";
const FAKE_PASSWORD = "rjGcfuRu";
const FAKE_JSON_KEY = "No Change";
const DOCKERHUB_URL = "https://hub.docker.com";
@Component({
selector: "hbr-create-edit-endpoint",
@ -49,12 +50,13 @@ export class CreateEditEndpointComponent
closable: boolean = false;
editable: boolean;
adapterList: string[];
endpointList: object[] = [];
target: Endpoint = this.initEndpoint();
selectedType: string;
initVal: Endpoint;
targetForm: NgForm;
@ViewChild("targetForm") currentForm: NgForm;
targetEndpoint;
testOngoing: boolean;
onGoing: boolean;
endpointId: number | string;
@ -188,8 +190,8 @@ export class CreateEditEndpointComponent
this.urlDisabled = this.target.type === 'docker-hub' ? true : false;
// Keep data cache
this.initVal = clone(target);
this.initVal.credential.access_secret = FAKE_PASSWORD;
this.target.credential.access_secret = FAKE_PASSWORD;
this.initVal.credential.access_secret = this.target.type === 'google-gcr' ? FAKE_JSON_KEY : FAKE_PASSWORD;
this.target.credential.access_secret = this.target.type === 'google-gcr' ? FAKE_JSON_KEY : FAKE_PASSWORD;
// Open the modal now
this.open();
@ -219,6 +221,104 @@ export class CreateEditEndpointComponent
this.urlDisabled = false;
this.targetForm.controls.endpointUrl.setValue("");
}
if (selectValue === 'google-gcr') {
this.targetForm.controls.access_key.setValue("_json_key");
} else {
this.targetForm.controls.access_key.setValue("");
}
if (selectValue === 'google-gcr') {
this.endpointList = [
{
key: "gcr.io",
value: "https://gcr.io"
},
{
key: "us.gcr.io",
value: "https://us.gcr.io"
},
{
key: "eu.gcr.io",
value: "https://eu.gcr.io"
},
{
key: "asia.gcr.io",
value: "https://asia.gcr.io"
}
];
} else if (selectValue === 'aws-ecr') {
this.endpointList = [
{
key: "ap-northeast-1",
value: "https://api.ecr.ap-northeast-1.amazonaws.com"
},
{
key: "us-east-1",
value: "https://api.ecr.us-east-1.amazonaws.com"
},
{
key: "us-east-2",
value: "https://api.ecr.us-east-2.amazonaws.com"
},
{
key: "us-west-1",
value: "https://api.ecr.us-west-1.amazonaws.com"
},
{
key: "us-west-2",
value: "https://api.ecr.us-west-2.amazonaws.com"
},
{
key: "ap-east-1",
value: "https://api.ecr.ap-east-1.amazonaws.com"
},
{
key: "ap-south-1",
value: "https://api.ecr.ap-south-1.amazonaws.com"
},
{
key: "ap-northeast-2",
value: "https://api.ecr.ap-northeast-2.amazonaws.com"
},
{
key: "ap-southeast-1",
value: "https://api.ecr.ap-southeast-1.amazonaws.com"
},
{
key: "ap-southeast-2",
value: "https://api.ecr.ap-southeast-2.amazonaws.com"
},
{
key: "ca-central-1",
value: "https://api.ecr.ca-central-1.amazonaws.com"
},
{
key: "eu-central-1",
value: "https://api.ecr.eu-central-1.amazonaws.com"
},
{
key: "eu-west-1",
value: "https://api.ecr.eu-west-1.amazonaws.com"
},
{
key: "eu-west-2",
value: "https://api.ecr.eu-west-2.amazonaws.com"
},
{
key: "eu-west-3",
value: "https://api.ecr.eu-west-3.amazonaws.com"
},
{
key: "eu-north-1",
value: "https://api.ecr.eu-north-1.amazonaws.com"
},
{
key: "sa-east-1",
value: "https://api.ecr.sa-east-1.amazonaws.com"
}
];
} else {
this.endpointList = [];
}
}
testConnection() {

View File

@ -62,8 +62,8 @@
<span class="spinner spinner-inline spinner-position" [hidden]="onGoing === false"></span>
<div formArrayName="filters">
<div class="filterSelect" *ngFor="let filter of filters.controls; let i=index">
<div [formGroupName]="i">
<div class="width-70">
<div [formGroupName]="i" *ngIf="supportedFilters[i]?.type !=='label' || (supportedFilters[i]?.type==='label' && supportedFilterLabels?.length)">
<div class="width-70" >
<label>{{"REPLICATION." + supportedFilters[i]?.type.toUpperCase() | translate}}:</label>
</div>
<label *ngIf="supportedFilters[i]?.style==='input'" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-bottom-left"
@ -77,6 +77,27 @@
<option *ngFor="let value of supportedFilters[i]?.values;" value="{{value}}">{{value}}</option>
</select>
</div>
<div class="select resource-box" *ngIf="supportedFilters[i]?.type==='label'&& supportedFilters[i]?.style==='list'">
<div class="dropdown width-100" formArrayName="value">
<clr-dropdown class="width-100">
<button type="button" class="width-100 dropdown-toggle btn btn-link statistic-data label-text" clrDropdownTrigger>
<ng-template ngFor let-label [ngForOf]="filter.value.value" let-m="index">
<span class="label" *ngIf="m<1"> {{label}} </span>
</ng-template>
<span class="ellipsis" *ngIf="filter.value.value.length>1">···</span>
<div *ngFor="let label1 of filter.value.value;let k = index" hidden="true">
<input type="text" [formControlName]="k" #labelValue id="{{'label_'+ supportedFilters[i]?.type + '_' + label1}}" name="{{'label_'+ supportedFilters[i]?.type + '_' + label1}}" placeholder="select labels" >
</div>
</button>
<clr-dropdown-menu class="width-100" clrPosition="bottom-left" *clrIfOpen>
<button type="button" class="dropdown-item" *ngFor="let value of supportedFilterLabels" (click)="stickLabel(value,i)">
<clr-icon shape="check" [hidden]="!value.select" class='pull-left'></clr-icon>
<div class='labelDiv'><hbr-label-piece [label]="value" [labelWidth]="130"></hbr-label-piece></div>
</button>
</clr-dropdown-menu>
</clr-dropdown>
</div>
</div>
<div class="resource-box" *ngIf="supportedFilters[i]?.style==='radio' && supportedFilters[i]?.values.length <= 1">
<span>{{supportedFilters[i]?.values}}</span>
</div>
@ -85,7 +106,8 @@
<clr-tooltip-content clrPosition="top-left" clrSize="md" *clrIfOpen>
<span class="tooltip-content" *ngIf="supportedFilters[i]?.type==='name'">{{'TOOLTIP.NAME_FILTER' | translate}}</span>
<span class="tooltip-content" *ngIf="supportedFilters[i]?.type==='tag'">{{'TOOLTIP.TAG_FILTER' | translate}}</span>
<span class="tooltip-content" *ngIf="supportedFilters[i]?.type==='resource'">{{'TOOLTIP.RESOURCE_FILTER' | translate}}</span>
<span class="tooltip-content" *ngIf="supportedFilters[i]?.type==='label'">{{'TOOLTIP.LABEL_FILTER' | translate}}</span>
<span class="tooltip-content" *ngIf="supportedFilters[i]?.type==='resource'">{{'TOOLTIP.RESOURCE_FILTER' | translate}}</span>
</clr-tooltip-content>
</clr-tooltip>
</div>

View File

@ -268,4 +268,29 @@ clr-modal {
.display-none{
display: none;
}
.width-100 {
width: 100%;
}
.label-text {
text-transform: none;
letter-spacing: normal;
font-size: 13px;
font-weight: 400;
color: #000;
height: 1.2rem;
margin: 0 !important;
line-height: 1rem;
text-align: left;
padding-left: 6px;
outline: none;
border-bottom: 1px solid rgb(154, 154, 154);
}
.labelDiv {
padding-left: 26px;
}
.ellipsis {
margin-left: 0.2rem;
font-size: 16px;
font-weight: 700;
}

View File

@ -32,6 +32,7 @@ import { ErrorHandler } from "../error-handler/error-handler";
import { TranslateService } from "@ngx-translate/core";
import { EndpointService } from "../service/endpoint.service";
import { cronRegex } from "../utils";
import { FilterType } from "../shared/shared.const";
@Component({
@ -67,6 +68,8 @@ export class CreateEditRuleComponent implements OnInit, OnDestroy {
cronString: string;
supportedTriggers: string[];
supportedFilters: Filter[];
supportedFilterLabels: { name: string; color: string; select: boolean; scope: string; }[] = [];
@Input() withAdmiral: boolean;
@Output() goToRegistry = new EventEmitter<any>();
@ -92,6 +95,8 @@ export class CreateEditRuleComponent implements OnInit, OnDestroy {
this.supportedFilters = adapter.supported_resource_filters;
this.supportedFilters.forEach(element => {
this.filters.push(this.initFilter(element.type));
// get supportedFilterLabels labels from supportedFilters
this.getLabelListFromAdapter(element);
});
this.supportedTriggers = adapter.supported_triggers;
this.ruleForm.get("trigger").get("type").setValue(this.supportedTriggers[0]);
@ -264,12 +269,29 @@ export class CreateEditRuleComponent implements OnInit, OnDestroy {
return this.ruleForm.get("filters") as FormArray;
}
setFilter(filters: Filter[]) {
const filterFGs = filters.map(filter => this.fb.group(filter));
const filterFGs = filters.map(filter => {
if (filter.type === FilterType.LABEL) {
let fbLabel = this.fb.group({
type: FilterType.LABEL
});
let filterLabel = this.fb.array(filter.value);
fbLabel.setControl('value', filterLabel);
return fbLabel;
} else {
return this.fb.group(filter);
}
});
const filterFormArray = this.fb.array(filterFGs);
this.ruleForm.setControl("filters", filterFormArray);
}
initFilter(name: string) {
if (name === FilterType.LABEL) {
const labelArray = this.fb.array([]);
const labelControl = this.fb.group({type: name});
labelControl.setControl('value', labelArray);
return labelControl;
}
return this.fb.group({
type: name,
value: ''
@ -314,7 +336,8 @@ export class CreateEditRuleComponent implements OnInit, OnDestroy {
let filters: any = copyRuleForm.filters;
// remove the filters which user not set.
for (let i = filters.length - 1; i >= 0; i--) {
if (filters[i].value === "") {
if (filters[i].value === "" || (filters[i].value instanceof Array
&& filters[i].value.length === 0)) {
copyRuleForm.filters.splice(i, 1);
}
}
@ -356,6 +379,8 @@ export class CreateEditRuleComponent implements OnInit, OnDestroy {
this.inlineAlert.close();
this.noSelectedEndpoint = true;
this.isRuleNameValid = true;
this.supportedFilterLabels = [];
this.policyId = -1;
this.createEditRuleOpened = true;
@ -373,7 +398,7 @@ export class CreateEditRuleComponent implements OnInit, OnDestroy {
this.repService.getRegistryInfo(srcRegistryId)
.pipe(finalize(() => (this.onGoing = false)))
.subscribe(adapter => {
this.setFilterAndTrigger(adapter);
this.setFilterAndTrigger(adapter, ruleInfo);
this.updateRuleFormAndCopyUpdateForm(ruleInfo);
}, (error: any) => {
this.inlineAlert.showInlineError(error);
@ -397,17 +422,63 @@ export class CreateEditRuleComponent implements OnInit, OnDestroy {
}
}
setFilterAndTrigger(adapter) {
setFilterAndTrigger(adapter, ruleInfo?) {
this.supportedFilters = adapter.supported_resource_filters;
this.setFilter([]);
this.supportedFilters.forEach(element => {
this.filters.push(this.initFilter(element.type));
// get supportedFilterLabels labels from supportedFilters
this.getLabelListFromAdapter(element);
// only when edit replication rule
if (ruleInfo && this.supportedFilterLabels.length) {
this.getLabelListFromRuleInfo(ruleInfo);
}
});
this.supportedTriggers = adapter.supported_triggers;
this.ruleForm.get("trigger").get("type").setValue(this.supportedTriggers[0]);
}
getLabelListFromAdapter(supportedFilter) {
if (supportedFilter.type === FilterType.LABEL && supportedFilter.values) {
this.supportedFilterLabels = [];
supportedFilter.values.forEach( value => {
this.supportedFilterLabels.push({
name: value,
color: '#fff',
select: false,
scope: 'g'
});
});
}
}
getLabelListFromRuleInfo(ruleInfo) {
let labelValueObj = ruleInfo.filters.find((currentValue) => {
return currentValue.type === FilterType.LABEL;
});
if (labelValueObj) {
for (const labelValue of labelValueObj.value) {
let flagLabel = this.supportedFilterLabels.every((currentValue) => {
return currentValue.name !== labelValue;
});
if (flagLabel) {
this.supportedFilterLabels = [
{
name: labelValue,
color: '#fff',
select: true,
scope: 'g'
}, ...this.supportedFilterLabels];
}
//
for (const labelObj of this.supportedFilterLabels) {
if (labelObj.name === labelValue) {
labelObj.select = true;
}
}
}
}
}
close(): void {
this.createEditRuleOpened = false;
}
@ -462,7 +533,11 @@ export class CreateEditRuleComponent implements OnInit, OnDestroy {
}
if (!findTag) {
filtersArray.push({ type: this.supportedFilters[i].type, value: "" });
if (this.supportedFilters[i].type === FilterType.LABEL) {
filtersArray.push({ type: this.supportedFilters[i].type, value: [] });
} else {
filtersArray.push({ type: this.supportedFilters[i].type, value: "" });
}
}
}
@ -482,4 +557,20 @@ export class CreateEditRuleComponent implements OnInit, OnDestroy {
}
return trigger_settingsControls.controls.cron.touched || trigger_settingsControls.controls.cron.dirty;
}
stickLabel(value, index) {
value.select = !value.select;
let filters = this.ruleForm.get('filters') as FormArray;
let fromIndex = filters.controls[index] as FormGroup;
let labelValue = this.supportedFilterLabels.reduce( (cumulatedSelectedArrs, currentValue) => {
if (currentValue.select) {
if (!cumulatedSelectedArrs.length) {
return [currentValue.name];
}
return [...cumulatedSelectedArrs, currentValue.name];
}
return cumulatedSelectedArrs;
}, []);
fromIndex.setControl('value', this.fb.array(labelValue));
}
}

View File

@ -130,7 +130,7 @@ export interface ReplicationRule extends Base {
export class Filter {
type: string;
value?: string;
value?: any;
constructor(type: string) {
this.type = type;
}
@ -434,3 +434,8 @@ export interface HttpOptionTextInterface {
withCredentials?: boolean;
}
export interface ProjectRootInterface {
NAME: string;
VALUE: number;
LABEL: string;
}

View File

@ -61,6 +61,12 @@ export const CommonRoutes = {
export const enum ConfirmationState {
NA, CONFIRMED, CANCEL
}
export const FilterType = {
NAME: "name",
TAG: "tag",
LABEL: "label",
RESOURCE: "resource"
};
export const enum ConfirmationButtons {
CONFIRM_CANCEL, YES_NO, DELETE_CANCEL, CLOSE, REPLICATE_CANCEL, STOP_CANCEL
@ -84,3 +90,35 @@ export const LabelColor = [
{ 'color': '#F52F52', 'textColor': 'black' }, { 'color': '#FF5501', 'textColor': 'black' },
{ 'color': '#F57600', 'textColor': 'black' }, { 'color': '#FFDC0B', 'textColor': 'black' },
];
export const CONFIG_AUTH_MODE = {
HTTP_AUTH: "http_auth",
LDAP_AUTH: "ldap_auth"
};
export const PROJECT_ROOTS = [
{
NAME: "admin",
VALUE: 1,
LABEL: "GROUP.PROJECT_ADMIN"
},
{
NAME: "master",
VALUE: 4,
LABEL: "GROUP.PROJECT_MASTER"
},
{
NAME: "developer",
VALUE: 2,
LABEL: "GROUP.DEVELOPER"
},
{
NAME: "guest",
VALUE: 3,
LABEL: "GROUP.GUEST"
}
];
export enum GroupType {
LDAP_TYPE = 1,
HTTP_TYPE = 2
}

View File

@ -1,11 +1,11 @@
<div class="tip-wrapper tip-position" [style.width]='maxWidth'>
<clr-tooltip>
<div clrTooltipTrigger>
<div class="tip-wrapper tip-block bar-block-high" [style.width]='tipWidth(5)'></div>
<div class="tip-wrapper tip-block bar-block-medium" [style.width]='tipWidth(4)'></div>
<div class="tip-wrapper tip-block bar-block-low" [style.width]='tipWidth(3)'></div>
<div class="tip-wrapper tip-block bar-block-unknown" [style.width]='tipWidth(2)'></div>
<div class="tip-wrapper tip-block bar-block-none" [style.width]='tipWidth(1)'></div>
<div clrTooltipTrigger class="tip-block">
<div class="tip-wrapper bar-block-high" [style.width]='tipWidth(5)'></div>
<div class="tip-wrapper bar-block-medium" [style.width]='tipWidth(4)'></div>
<div class="tip-wrapper bar-block-low" [style.width]='tipWidth(3)'></div>
<div class="tip-wrapper bar-block-unknown" [style.width]='tipWidth(2)'></div>
<div class="tip-wrapper bar-block-none" [style.width]='tipWidth(1)'></div>
</div>
<clr-tooltip-content [clrPosition]="'right'" [clrSize]="'lg'" *clrIfOpen>
<div [ngSwitch]="scanLevel" class="bar-tooltip-font-larger">

View File

@ -19,7 +19,7 @@ import { CookieService } from 'ngx-cookie';
import { AppConfig } from './app-config';
import { CookieKeyOfAdmiral, HarborQueryParamKey } from './shared/shared.const';
import { maintainUrlQueryParmas } from './shared/shared.utils';
import { HTTP_GET_OPTIONS} from '@harbor/ui';
import { HTTP_GET_OPTIONS , CONFIG_AUTH_MODE} from '@harbor/ui';
import { map, catchError } from "rxjs/operators";
import { Observable, throwError as observableThrowError } from "rxjs";
export const systemInfoEndpoint = "/api/systeminfo";
@ -67,7 +67,10 @@ export class AppConfigService {
}
public isLdapMode(): boolean {
return this.configurations && this.configurations.auth_mode === 'ldap_auth';
return this.configurations && this.configurations.auth_mode === CONFIG_AUTH_MODE.LDAP_AUTH;
}
public isHttpAuthMode(): boolean {
return this.configurations && this.configurations.auth_mode === CONFIG_AUTH_MODE.HTTP_AUTH;
}
// Return the reconstructed admiral url

View File

@ -28,7 +28,7 @@
<clr-icon shape="users" clrVerticalNavIcon></clr-icon>
{{'SIDE_NAV.SYSTEM_MGMT.USER' | translate}}
</a>
<a *ngIf='isLdapMode' clrVerticalNavLink routerLink="/harbor/groups" routerLinkActive="active">
<a *ngIf='isLdapMode || isHttpAuthMode' clrVerticalNavLink routerLink="/harbor/groups" routerLinkActive="active">
<clr-icon shape="users" clrVerticalNavIcon></clr-icon>
{{'SIDE_NAV.SYSTEM_MGMT.GROUP' | translate}}
</a>

View File

@ -54,6 +54,8 @@ export class HarborShellComponent implements OnInit, OnDestroy {
searchSub: Subscription;
searchCloseSub: Subscription;
isLdapMode: boolean;
isHttpAuthMode: boolean;
constructor(
private route: ActivatedRoute,
@ -63,6 +65,11 @@ export class HarborShellComponent implements OnInit, OnDestroy {
private appConfigService: AppConfigService) { }
ngOnInit() {
if (this.appConfigService.isLdapMode()) {
this.isLdapMode = true;
} else if (this.appConfigService.isHttpAuthMode()) {
this.isHttpAuthMode = true;
}
this.searchSub = this.searchTrigger.searchTriggerChan$.subscribe(searchEvt => {
if (searchEvt && searchEvt.trim() !== "") {
this.isSearchResultsOpened = true;
@ -70,7 +77,7 @@ export class HarborShellComponent implements OnInit, OnDestroy {
});
this.searchCloseSub = this.searchTrigger.searchCloseChan$.subscribe(close => {
this.isSearchResultsOpened = false;
this.isSearchResultsOpened = false;
});
}
@ -97,11 +104,6 @@ export class HarborShellComponent implements OnInit, OnDestroy {
return account != null && account.has_admin_role;
}
public get isLdapMode(): boolean {
let appConfig = this.appConfigService.getConfig();
return appConfig.auth_mode === 'ldap_auth';
}
public get isUserExisting(): boolean {
let account = this.session.getCurrentUser();
return account != null;

View File

@ -1,11 +1,12 @@
<clr-modal [(clrModalOpen)]="opened" [clrModalStaticBackdrop]="true" [clrModalClosable]="false">
<h3 class="modal-title" *ngIf="mode === 'create'">{{'GROUP.IMPORT_LDAP_GROUP' | translate}}</h3>
<h3 class="modal-title" *ngIf="mode === 'create' && isLdapMode">{{'GROUP.IMPORT_LDAP_GROUP' | translate}}</h3>
<h3 class="modal-title" *ngIf="mode === 'create' && isHttpAuthMode">{{'GROUP.IMPORT_HTTP_GROUP' | translate}}</h3>
<h3 class="modal-title" *ngIf="mode !== 'create'">{{'GROUP.EDIT' | translate}}</h3>
<div class="modal-body">
<form class="form" #groupForm="ngForm">
<section class="form-block">
<div class="form-group">
<div class="form-group" *ngIf="isLdapMode">
<label for="ldap_group_dn" class="required">{{ 'GROUP.GROUP_DN' | translate}}</label>
<label for="ldap_group_dn"
aria-haspopup="true"
@ -22,7 +23,7 @@
</span>
</label>
</div>
<div class="form-group">
<div class="form-group" *ngIf="isLdapMode">
<label for="type">{{'GROUP.TYPE' | translate}}</label>
<label id="type">LDAP</label>
</div>

View File

@ -1,13 +1,15 @@
import {finalize} from 'rxjs/operators';
import { finalize } from 'rxjs/operators';
import { Subscription } from "rxjs";
import { Component, OnInit, EventEmitter, Output, ChangeDetectorRef, OnDestroy, ViewChild } from "@angular/core";
import { NgForm } from "@angular/forms";
import { GroupType } from "@harbor/ui";
import { GroupService } from "../group.service";
import { MessageHandlerService } from "./../../shared/message-handler/message-handler.service";
import { SessionService } from "./../../shared/session.service";
import { UserGroup } from "./../group";
import { AppConfigService } from "../../app-config.service";
@Component({
selector: "hbr-add-group-modal",
@ -19,7 +21,7 @@ export class AddGroupModalComponent implements OnInit, OnDestroy {
mode = "create";
dnTooltip = 'TOOLTIP.ITEM_REQUIRED';
group: UserGroup = new UserGroup();
group: UserGroup;
formChangeSubscription: Subscription;
@ -30,25 +32,36 @@ export class AddGroupModalComponent implements OnInit, OnDestroy {
@Output() dataChange = new EventEmitter();
isLdapMode: boolean;
isHttpAuthMode: boolean;
constructor(
private session: SessionService,
private msgHandler: MessageHandlerService,
private appConfigService: AppConfigService,
private groupService: GroupService,
private cdr: ChangeDetectorRef
) {}
) { }
ngOnInit() { }
ngOnInit() {
if (this.appConfigService.isLdapMode()) {
this.isLdapMode = true;
}
if (this.appConfigService.isHttpAuthMode()) {
this.isHttpAuthMode = true;
}
this.group = new UserGroup(this.isLdapMode ? GroupType.LDAP_TYPE : GroupType.HTTP_TYPE);
}
ngOnDestroy() { }
public get isDNInvalid(): boolean {
let dnControl = this.groupForm.controls['ldap_group_dn'];
return dnControl && dnControl.invalid && (dnControl.dirty || dnControl.touched);
return dnControl && dnControl.invalid && (dnControl.dirty || dnControl.touched);
}
public get isNameInvalid(): boolean {
let dnControl = this.groupForm.controls['group_name'];
return dnControl && dnControl.invalid && (dnControl.dirty || dnControl.touched);
return dnControl && dnControl.invalid && (dnControl.dirty || dnControl.touched);
}
public get isFormValid(): boolean {
@ -83,7 +96,7 @@ export class AddGroupModalComponent implements OnInit, OnDestroy {
let groupCopy = Object.assign({}, this.group);
this.groupService
.createGroup(groupCopy).pipe(
finalize(() => this.close()))
finalize(() => this.close()))
.subscribe(
res => {
this.msgHandler.showSuccess("GROUP.ADD_GROUP_SUCCESS");
@ -97,7 +110,7 @@ export class AddGroupModalComponent implements OnInit, OnDestroy {
let groupCopy = Object.assign({}, this.group);
this.groupService
.editGroup(groupCopy).pipe(
finalize(() => this.close()))
finalize(() => this.close()))
.subscribe(
res => {
this.msgHandler.showSuccess("GROUP.EDIT_GROUP_SUCCESS");
@ -108,7 +121,7 @@ export class AddGroupModalComponent implements OnInit, OnDestroy {
}
resetGroup() {
this.group = new UserGroup();
this.group = new UserGroup(this.isLdapMode ? GroupType.LDAP_TYPE : GroupType.HTTP_TYPE);
this.groupForm.reset();
}
}

View File

@ -15,18 +15,18 @@
<clr-icon shape="plus" size="15"></clr-icon>&nbsp;{{'GROUP.ADD' | translate}}</button>
<button type="button" class="btn btn-sm btn-secondary" (click)="editGroup()" [disabled]="!canEditGroup">
<clr-icon shape="pencil" size="15"></clr-icon>&nbsp;{{'GROUP.EDIT' | translate}}</button>
<button type="button" class="btn btn-sm btn-secondary" (click)="openDeleteConfirmationDialog()" [disabled]="!canEditGroup">
<button type="button" class="btn btn-sm btn-secondary" (click)="openDeleteConfirmationDialog()" [disabled]="!canDeleteGroup">
<clr-icon shape="times" size="15"></clr-icon>&nbsp;{{'GROUP.DELETE' | translate}}</button>
</clr-dg-action-bar>
<clr-dg-column>{{'GROUP.NAME' | translate}}</clr-dg-column>
<clr-dg-column>{{'GROUP.TYPE' | translate}}</clr-dg-column>
<clr-dg-column>{{'GROUP.DN' | translate}}</clr-dg-column>
<clr-dg-column *ngIf="isLdapMode">{{'GROUP.DN' | translate}}</clr-dg-column>
<clr-dg-row *clrDgItems="let group of groups" [clrDgItem]="group">
<clr-dg-cell>{{group.group_name}}</clr-dg-cell>
<clr-dg-cell>{{groupToSring(group.group_type) | translate}}</clr-dg-cell>
<clr-dg-cell>{{group.ldap_group_dn}}</clr-dg-cell>
<clr-dg-cell *ngIf="isLdapMode">{{group.ldap_group_dn}}</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination #pagination [clrDgPageSize]="15">

View File

@ -4,7 +4,7 @@ import { flatMap, catchError } from "rxjs/operators";
import { SessionService } from "./../shared/session.service";
import { TranslateService } from "@ngx-translate/core";
import { Component, OnInit, ViewChild, OnDestroy } from "@angular/core";
import { operateChanges, OperateInfo, OperationService, OperationState, errorHandler as errorHandFn } from "@harbor/ui";
import { operateChanges, OperateInfo, OperationService, OperationState, errorHandler as errorHandFn, GroupType } from "@harbor/ui";
import {
ConfirmationTargets,
@ -19,6 +19,8 @@ import { UserGroup } from "./group";
import { GroupService } from "./group.service";
import { MessageHandlerService } from "../shared/message-handler/message-handler.service";
import { throwError as observableThrowError } from "rxjs";
import { AppConfigService } from '../app-config.service';
@Component({
selector: "app-group",
templateUrl: "./group.component.html",
@ -35,6 +37,7 @@ export class GroupComponent implements OnInit, OnDestroy {
delSub: Subscription;
batchOps = 'idle';
batchInfos = new Map();
isLdapMode: boolean;
@ViewChild(AddGroupModalComponent) newGroupModal: AddGroupModalComponent;
@ -46,10 +49,14 @@ export class GroupComponent implements OnInit, OnDestroy {
private msgHandler: MessageHandlerService,
private session: SessionService,
private translateService: TranslateService,
private appConfigService: AppConfigService
) { }
ngOnInit() {
this.loadData();
if (this.appConfigService.isLdapMode()) {
this.isLdapMode = true;
}
this.delSub = this.operateDialogService.confirmationConfirm$.subscribe(
message => {
if (
@ -150,7 +157,13 @@ export class GroupComponent implements OnInit, OnDestroy {
}
groupToSring(type: number) {
if (type === 1) { return 'GROUP.LDAP_TYPE'; } else { return 'UNKNOWN'; }
if (type === GroupType.LDAP_TYPE) {
return 'GROUP.LDAP_TYPE';
} else if (type === GroupType.HTTP_TYPE) {
return 'GROUP.HTTP_TYPE';
} else {
return 'UNKNOWN';
}
}
doFilter(groupName: string): void {
@ -162,6 +175,12 @@ export class GroupComponent implements OnInit, OnDestroy {
}
get canEditGroup(): boolean {
return (
this.selectedGroups.length === 1 &&
this.session.currentUser.has_admin_role && this.isLdapMode
);
}
get canDeleteGroup(): boolean {
return (
this.selectedGroups.length === 1 &&
this.session.currentUser.has_admin_role

View File

@ -4,9 +4,9 @@ export class UserGroup {
group_type: number;
ldap_group_dn?: string;
constructor() {
constructor(groupType) {
{
this.group_type = 1;
this.group_type = groupType;
}
}
}

View File

@ -30,7 +30,7 @@ export class AddGroupComponent implements OnInit {
currentTerm = '';
selectedRole = 1;
group = new UserGroup();
group = new UserGroup(1);
selectedGroups: UserGroup[] = [];
groups: UserGroup[] = [];
totalCount = 0;
@ -89,7 +89,7 @@ export class AddGroupComponent implements OnInit {
resetModaldata() {
this.createGroupMode = false;
this.group = new UserGroup();
this.group = new UserGroup(1);
this.selectedRole = 1;
this.selectedGroups = [];
this.groups = [];

View File

@ -0,0 +1,36 @@
<clr-modal [(clrModalOpen)]="addHttpAuthOpened" [clrModalStaticBackdrop]="staticBackdrop" [clrModalClosable]="closable">
<h3 class="modal-title">{{'GROUP.NEW_MEMBER' | translate}}</h3>
<inline-alert class="modal-title padding-0"></inline-alert>
<div class="modal-body">
<label>{{ 'GROUP.NEW_USER_INFO' | translate}}</label>
<form #memberForm="ngForm">
<section class="form-block">
<div class="form-group">
<label for="member_name" class="col-md-4 form-group-label-override required">{{'GROUP.GROUP' | translate}} {{'GROUP.NAME' | translate}}</label>
<label for="member_name" aria-haspopup="true" role="tooltip"
class="tooltip tooltip-validation tooltip-sm tooltip-bottom-left">
<input type="text" id="member_name" [(ngModel)]="member_group.group_name"
name="member_name"
size="20"
minlength="3"
#memberName="ngModel"
required autocomplete="off">
</label>
<span class="spinner spinner-inline" [hidden]="!checkOnGoing"></span>
</div>
<div class="form-group">
<label class="col-md-4 form-group-label-override">{{'GROUP.ROLE' | translate}}</label>
<div class="radio" *ngFor="let projectRoot of projectRoots">
<input type="radio" name="member_role" id="{{'check_root_project_' + projectRoot.NAME}}" [value]="projectRoot.VALUE" [(ngModel)]="role_id">
<label for="{{'check_root_project_' + projectRoot.NAME}}">{{ projectRoot.LABEL | translate}}</label>
</div>
</div>
</section>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" (click)="onCancel()">{{'BUTTON.CANCEL' | translate}}</button>
<button type="button" class="btn btn-primary" [disabled]="!isValid" (click)="onSubmit()">{{'BUTTON.OK' | translate}}</button>
</div>
</clr-modal>

View File

@ -0,0 +1,8 @@
.form-group-label-override {
font-size: 14px;
font-weight: 400;
}
.padding-0 {
padding: 0;
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AddHttpAuthGroupComponent } from './add-http-auth-group.component';
describe('AddHttpAuthGroupComponent', () => {
let component: AddHttpAuthGroupComponent;
let fixture: ComponentFixture<AddHttpAuthGroupComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AddHttpAuthGroupComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AddHttpAuthGroupComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,120 @@
import { finalize } from 'rxjs/operators';
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
//
// 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.
import {
Component,
Input,
EventEmitter,
Output,
ViewChild,
OnInit
} from '@angular/core';
import { NgForm } from '@angular/forms';
import { TranslateService } from '@ngx-translate/core';
import { InlineAlertComponent } from '../../../shared/inline-alert/inline-alert.component';
import { UserService } from '../../../user/user.service';
import { errorHandler as errorHandFn, PROJECT_ROOTS, ProjectRootInterface } from "@harbor/ui";
import { MemberService } from '../member.service';
import { UserGroup } from "./../../../group/group";
@Component({
selector: 'add-http-auth-group',
templateUrl: './add-http-auth-group.component.html',
styleUrls: ['./add-http-auth-group.component.scss'],
providers: [UserService]
})
export class AddHttpAuthGroupComponent implements OnInit {
projectRoots: ProjectRootInterface[];
member_group: UserGroup = { group_name: '', group_type: 2 };
role_id: number;
addHttpAuthOpened: boolean;
memberForm: NgForm;
staticBackdrop: boolean = true;
closable: boolean = false;
@ViewChild('memberForm')
currentForm: NgForm;
@ViewChild(InlineAlertComponent)
inlineAlert: InlineAlertComponent;
@Input() projectId: number;
@Output() added = new EventEmitter<boolean>();
checkOnGoing: boolean = false;
constructor(private memberService: MemberService,
private translateService: TranslateService) { }
ngOnInit(): void {
this.projectRoots = PROJECT_ROOTS;
}
createGroupAsMember() {
this.checkOnGoing = true;
this.memberService.addGroupMember(this.projectId, this.member_group, this.role_id)
.pipe(
finalize(() => {
this.checkOnGoing = false;
}
))
.subscribe(
res => {
this.role_id = null;
this.addHttpAuthOpened = false;
this.added.emit(true);
},
err => {
let errorMessageKey: string = errorHandFn(err);
this.translateService
.get(errorMessageKey)
.subscribe(errorMessage => this.inlineAlert.showInlineError(errorMessage));
this.added.emit(false);
}
);
}
onSubmit(): void {
this.createGroupAsMember();
}
onCancel() {
this.role_id = null;
this.addHttpAuthOpened = false;
}
openAddMemberModal(): void {
this.currentForm.reset();
this.addHttpAuthOpened = true;
this.role_id = 1;
}
public get isValid(): boolean {
return this.currentForm &&
this.currentForm.valid &&
!this.checkOnGoing;
}
}

View File

@ -16,7 +16,7 @@
<button class="btn btn-sm btn-secondary" (click)="openAddMemberModal()" [disabled]="!hasCreateMemberPermission">
<span><clr-icon shape="plus" size="16"></clr-icon>&nbsp;{{'MEMBER.USER' | translate }}</span>
</button>
<button class="btn btn-sm btn-secondary" (click)="openAddGroupModal()" [disabled]="!hasCreateMemberPermission || !isLdapMode">
<button class="btn btn-sm btn-secondary" (click)="openAddGroupModal()" [disabled]="!hasCreateMemberPermission || !(isLdapMode || isHttpAuthMode)">
<span><clr-icon shape="plus" size="16"></clr-icon>&nbsp;{{'MEMBER.LDAP_GROUP' | translate}}</span>
</button>
<clr-dropdown id='member-action' [clrCloseMenuOnItemClick]="false" class="btn btn-sm btn-link" clrDropdownTrigger>
@ -53,4 +53,5 @@
</div>
<add-member [projectId]="projectId" [memberList]="members" (added)="addedMember($event)"></add-member>
<add-group [projectId]="projectId" [memberList]="members" (added)="addedGroup($event)"></add-group>
<add-http-auth-group [projectId]="projectId" (added)="addedGroup($event)"></add-http-auth-group>
</div>

View File

@ -17,8 +17,10 @@ import { Component, OnInit, ViewChild, OnDestroy, ChangeDetectionStrategy, Chang
import { ActivatedRoute, Router } from "@angular/router";
import { Subscription, forkJoin, Observable } from "rxjs";
import { TranslateService } from "@ngx-translate/core";
import { operateChanges, OperateInfo, OperationService, OperationState, UserPermissionService, USERSTATICPERMISSION, ErrorHandler
, errorHandler as errorHandFn } from "@harbor/ui";
import {
operateChanges, OperateInfo, OperationService, OperationState, UserPermissionService, USERSTATICPERMISSION, ErrorHandler
, errorHandler as errorHandFn
} from "@harbor/ui";
import { MessageHandlerService } from "../../shared/message-handler/message-handler.service";
import { ConfirmationTargets, ConfirmationState, ConfirmationButtons } from "../../shared/shared.const";
@ -30,6 +32,7 @@ import { Project } from "../../project/project";
import { Member } from "./member";
import { SessionUser } from "../../shared/session-user";
import { AddGroupComponent } from './add-group/add-group.component';
import { AddHttpAuthGroupComponent } from './add-http-auth-group/add-http-auth-group.component';
import { MemberService } from "./member.service";
import { AddMemberComponent } from "./add-member/add-member.component";
import { AppConfigService } from "../../app-config.service";
@ -56,16 +59,18 @@ export class MemberComponent implements OnInit, OnDestroy {
isDelete = false;
isChangeRole = false;
loading = false;
isLdapMode: boolean = false;
isChangingRole = false;
batchChangeRoleInfos = {};
isLdapMode: boolean;
isHttpAuthMode: boolean;
@ViewChild(AddMemberComponent)
addMemberComponent: AddMemberComponent;
@ViewChild(AddGroupComponent)
addGroupComponent: AddGroupComponent;
@ViewChild(AddHttpAuthGroupComponent)
addHttpAuthGroupComponent: AddHttpAuthGroupComponent;
hasCreateMemberPermission: boolean;
hasUpdateMemberPermission: boolean;
hasDeleteMemberPermission: boolean;
@ -108,13 +113,15 @@ export class MemberComponent implements OnInit, OnDestroy {
// Get current user from registered resolver.
this.currentUser = this.session.getCurrentUser();
this.retrieve(this.projectId, "");
// get member permission rule
this.getMemberPermissionRule(this.projectId);
if (this.appConfigService.isLdapMode()) {
this.isLdapMode = true;
}
// get member permission rule
this.getMemberPermissionRule(this.projectId);
if (this.appConfigService.isHttpAuthMode()) {
this.isHttpAuthMode = true;
}
}
doSearch(searchMember: string) {
this.searchMember = searchMember;
this.retrieve(this.projectId, this.searchMember);
@ -172,7 +179,11 @@ export class MemberComponent implements OnInit, OnDestroy {
// Add group
openAddGroupModal() {
this.addGroupComponent.open();
if (this.isLdapMode) {
this.addGroupComponent.open();
} else {
this.addHttpAuthGroupComponent.openAddMemberModal();
}
}
addedGroup(result: boolean) {
this.searchMember = "";
@ -188,10 +199,10 @@ export class MemberComponent implements OnInit, OnDestroy {
return this.memberService
.changeMemberRole(projectId, member.id, roleId)
.pipe(map(() => this.batchChangeRoleInfos[member.id] = 'done')
, catchError(error => {
this.messageHandlerService.handleError(error + ": " + member.entity_name);
return observableThrowError(error);
}));
, catchError(error => {
this.messageHandlerService.handleError(error + ": " + member.entity_name);
return observableThrowError(error);
}));
};
// Preparation for members role change

View File

@ -38,6 +38,7 @@ import { ProjectLabelComponent } from "../project/project-label/project-label.co
import { HelmChartModule } from './helm-chart/helm-chart.module';
import { RobotAccountComponent } from './robot-account/robot-account.component';
import { AddRobotComponent } from './robot-account/add-robot/add-robot.component';
import { AddHttpAuthGroupComponent } from './member/add-http-auth-group/add-http-auth-group.component';
@NgModule({
imports: [
@ -59,7 +60,8 @@ import { AddRobotComponent } from './robot-account/add-robot/add-robot.component
ProjectLabelComponent,
AddGroupComponent,
RobotAccountComponent,
AddRobotComponent
AddRobotComponent,
AddHttpAuthGroupComponent
],
exports: [ProjectComponent, ListProjectComponent],
providers: [ProjectRoutingResolver, MemberService, RobotService]

View File

@ -1,130 +1,129 @@
<clr-modal [(clrModalOpen)]="addRobotOpened"
[clrModalStaticBackdrop]="staticBackdrop" [clrModalClosable]="closable">
<h3 class="modal-title">{{'ROBOT_ACCOUNT.CREAT_ROBOT_ACCOUNT' | translate}}</h3>
<inline-alert #copyAlert class="modal-title"></inline-alert>
<div class="modal-body">
<form #robotForm="ngForm">
<section class="form-block">
<div class="form-group">
<label class="col-md-3
[clrModalStaticBackdrop]="staticBackdrop" [clrModalClosable]="closable">
<h3 class="modal-title">{{'ROBOT_ACCOUNT.CREAT_ROBOT_ACCOUNT' | translate}}</h3>
<inline-alert #copyAlert class="modal-title"></inline-alert>
<div class="modal-body">
<form #robotForm="ngForm">
<section class="form-block">
<div class="form-group padding-left-120">
<label class="col-md-3
form-group-label-override required" for="robot_name">
{{'ROBOT_ACCOUNT.NAME' | translate}}
</label>
<label aria-haspopup="true" role="tooltip" class="tooltip
{{'ROBOT_ACCOUNT.NAME' | translate}}
</label>
<label aria-haspopup="true" role="tooltip" class="tooltip
tooltip-validation
tooltip-md tooltip-bottom-left" for="robot_name"
[class.invalid]="!isRobotNameValid">
<input type="text"
[(ngModel)]="robot.name"
size="30" class="input-width"
name="robot_name"
id="robot_name"
#robotName="ngModel"
required
pattern='[^" ~#$%]+'
maxLengthExt="255"
autocomplete="off"
(keyup)='handleValidation()'>
<span class="tooltip-content">
[class.invalid]="!isRobotNameValid">
<input type="text"
[(ngModel)]="robot.name"
size="30" class="input-width"
name="robot_name"
id="robot_name"
#robotName="ngModel"
required
pattern='[^" ~#$%]+'
maxLengthExt="255"
autocomplete="off"
(keyup)='handleValidation()'>
<span class="tooltip-content">
{{ nameTooltipText | translate }}
</span>
</label>
<span class="spinner spinner-inline" [hidden]="!checkOnGoing"></span>
</div>
<div class="form-group">
<label class="form-group-label-override">{{'REPLICATION.DESCRIPTION' |
translate}}</label>
<input type="text" size="255" class="input-width"
[(ngModel)]="robot.description"
name="robot_desc" id="robot_desc">
</div>
<div class="form-group">
<label class="col-md-3">
{{'ROBOT_ACCOUNT.PERMISSIONS' | translate}}
</label>
<label class="clr-col-md-8 no-margin padding-left-0">
<clr-checkbox-wrapper>
<input type="checkbox" clrCheckbox [checked]="true"
[(ngModel)]="robot.access.isPullImage" name="isPullImage"
id="permission-pull" class="clr-checkbox">
<label for="permission-pull" class="clr-control-label">
{{'ROBOT_ACCOUNT.PULL_PERMISSION' | translate}}
</label>
</clr-checkbox-wrapper>
</label>
<label class="clr-col-md-8 no-margin padding-left-0">
<clr-checkbox-wrapper>
<input type="checkbox" clrCheckbox [checked]="true"
[(ngModel)]="robot.access.isPushOrPullImage" name="isPushOrPullImage"
id="permission-push" class="clr-checkbox">
<label for="permission-push" class="clr-control-label">
{{'ROBOT_ACCOUNT.PULL_PUSH_PERMISSION' | translate}}
</label>
</clr-checkbox-wrapper>
</label>
<label class="clr-col-md-8 no-margin padding-left-0">
<clr-checkbox-wrapper>
<input type="checkbox" clrCheckbox [checked]="true"
[(ngModel)]="robot.access.isPushChart" name="isPushChart"
id="permission-push-chart" class="clr-checkbox">
<label for="permission-push-chart" class="clr-control-label">
{{'ROBOT_ACCOUNT.PUSH_CHART_PERMISSION' | translate}}
</label>
</clr-checkbox-wrapper>
</label>
<label class="clr-col-md-8 no-margin padding-left-0">
<clr-checkbox-wrapper>
<input type="checkbox" clrCheckbox [checked]="true"
[(ngModel)]="robot.access.isPullChart" name="isPullChart"
id="permission-pull-chart" class="clr-checkbox">
<label for="permission-pull-chart" class="clr-control-label">
{{'ROBOT_ACCOUNT.PULL_CHART_PERMISSION' | translate}}
</label>
</clr-checkbox-wrapper>
</label>
</div>
</section>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" (click)="onCancel()">{{'BUTTON.CANCEL'
| translate}}</button>
<button type="button" [disabled]="shouldDisable" class="btn btn-primary"
(click)="onSubmit()">{{'BUTTON.SAVE'
| translate}}</button>
</div>
</label>
<span class="spinner spinner-inline" [hidden]="!checkOnGoing"></span>
</div>
<div class="form-group padding-left-120">
<label class="form-group-label-override">{{'REPLICATION.DESCRIPTION' |
translate}}</label>
<input type="text" size="255" class="input-width"
[(ngModel)]="robot.description"
name="robot_desc" id="robot_desc">
</div>
<div class="clr-row">
<div class="clr-col-3 permission">
<label class="col-md-3">
{{'ROBOT_ACCOUNT.PERMISSIONS' | translate}}
</label>
</div>
<div class="clr-col">
<div class="form-group padding-left-120">
<label>{{'ROBOT_ACCOUNT.PERMISSIONS_IMAGE' | translate}}</label>
<div class="radio-inline">
<input type="radio" name="image-permission"
id="image-permission-pull"
value="pull"
[(ngModel)]="imagePermission">
<label for="image-permission-pull">{{'ROBOT_ACCOUNT.PULL' | translate}}</label>
</div>
<div class="radio-inline">
<input type="radio" name="image-permission"
id="image-permission-push-and-pull"
value="push-and-pull"
[(ngModel)]="imagePermission">
<label for="image-permission-push-and-pull">{{'ROBOT_ACCOUNT.PUSH' | translate}}
& {{'ROBOT_ACCOUNT.PULL' | translate}}</label>
</div>
</div>
<div class="form-group padding-left-120">
<label>{{'ROBOT_ACCOUNT.PERMISSIONS_HELMCHART' | translate}}</label>
<div class="checkbox-inline">
<input type="checkbox" id="helm-permission-push"
[checked]="robot.access.isPushChart"
[(ngModel)]="robot.access.isPushChart"
name="helm-permission">
<label for="helm-permission-push">{{'ROBOT_ACCOUNT.PUSH' | translate}}</label>
</div>
<div class="checkbox-inline">
<input type="checkbox" id="helm-permission-pull"
[checked]="robot.access.isPullChart"
[(ngModel)]="robot.access.isPullChart"
name="helm-permission">
<label for="helm-permission-pull">{{'ROBOT_ACCOUNT.PULL' | translate}}</label>
</div>
</div>
</div>
</div>
</section>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" (click)="onCancel()">{{'BUTTON.CANCEL'
| translate}}</button>
<button type="button" [disabled]="shouldDisable" class="btn btn-primary"
(click)="onSubmit()">{{'BUTTON.SAVE'
| translate}}</button>
</div>
</clr-modal>
<clr-modal [(clrModalOpen)]="copyToken" class="copy-token"
[clrModalStaticBackdrop]="staticBackdrop" [clrModalClosable]="closable">
<div class="modal-title">
<h3 class="modal-title">
<clr-icon class="alert-icon success-icon" shape="check-circle" size="50"></clr-icon>
{{ createSuccess | translate}}</h3>
<div class="alert alert-info" role="alert">
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<clr-icon class="alert-icon" shape="info-circle"></clr-icon>
</div>
<span class="alert-text">{{'ROBOT_ACCOUNT.ALERT_TEXT' | translate}}</span>
[clrModalStaticBackdrop]="staticBackdrop" [clrModalClosable]="closable">
<div class="modal-title">
<h3 class="modal-title">
<clr-icon class="alert-icon success-icon" shape="check-circle" size="50"></clr-icon>
{{ createSuccess | translate}}</h3>
<div class="alert alert-info" role="alert">
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<clr-icon class="alert-icon" shape="info-circle"></clr-icon>
</div>
<span class="alert-text">{{'ROBOT_ACCOUNT.ALERT_TEXT' | translate}}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-body">
<section class="form-block show-info">
<div class="form-group robot-name">
<label class="form-group-label-override">{{'ROBOT_ACCOUNT.NAME'
| translate}}</label>
<span>{{robotAccount}}</span>
</div>
<div class="form-group robot-token">
<label class="form-group-label-override">{{'ROBOT_ACCOUNT.TOKEN' |
translate}}</label>
<hbr-copy-input (onCopySuccess)="onCpSuccess($event)"
(onCopyError)="onCpError($event)" inputSize="50" headerTitle=""
defaultValue="{{robotToken}}" class="copy-input"></hbr-copy-input>
</div>
</section>
</div>
<div class="modal-body">
<section class="form-block show-info">
<div class="form-group robot-name">
<label class="form-group-label-override">{{'ROBOT_ACCOUNT.NAME'
| translate}}</label>
<span>{{robotAccount}}</span>
</div>
<div class="form-group robot-token">
<label class="form-group-label-override">{{'ROBOT_ACCOUNT.TOKEN' |
translate}}</label>
<hbr-copy-input (onCopySuccess)="onCpSuccess($event)"
(onCopyError)="onCpError($event)" inputSize="50" headerTitle=""
defaultValue="{{robotToken}}" class="copy-input"></hbr-copy-input>
</div>
</section>
</div>
</clr-modal>

View File

@ -3,7 +3,7 @@
}
.input-width {
width: 200px;
width: 300px;
}
.copy-token {
@ -35,3 +35,12 @@
.no-margin {
margin: 0;
}
.permission{
padding-top: 5px;
color: #000000;
}
.padding-left-120{
padding-left: 120px;
}

View File

@ -38,17 +38,18 @@ export class AddRobotComponent implements OnInit, OnDestroy {
robotNameChecker: Subject<string> = new Subject<string>();
nameTooltipText = "ROBOT_ACCOUNT.ROBOT_NAME";
robotForm: NgForm;
imagePermission: string = "push-and-pull";
@Input() projectId: number;
@Input() projectName: string;
@Output() create = new EventEmitter<boolean>();
@ViewChild("robotForm") currentForm: NgForm;
@ViewChild("copyAlert") copyAlert: InlineAlertComponent;
constructor(
private robotService: RobotService,
private translate: TranslateService,
private errorHandler: ErrorHandler,
private cdr: ChangeDetectorRef,
private messageHandlerService: MessageHandlerService
private robotService: RobotService,
private translate: TranslateService,
private errorHandler: ErrorHandler,
private cdr: ChangeDetectorRef,
private messageHandlerService: MessageHandlerService
) {}
ngOnInit(): void {
@ -59,31 +60,31 @@ export class AddRobotComponent implements OnInit, OnDestroy {
if (this.isRobotNameValid) {
this.checkOnGoing = true;
this.robotService
.listRobotAccount(this.projectId)
.pipe(
finalize(() => {
this.checkOnGoing = false;
let hnd = setInterval(() => this.cdr.markForCheck(), 100);
setTimeout(() => clearInterval(hnd), 2000);
})
)
.subscribe(
response => {
if (response && response.length) {
if (
response.find(target => {
return target.name === "robot$" + cont.value;
})
) {
this.isRobotNameValid = false;
this.nameTooltipText = "ROBOT_ACCOUNT.ACCOUNT_EXISTING";
.listRobotAccount(this.projectId)
.pipe(
finalize(() => {
this.checkOnGoing = false;
let hnd = setInterval(() => this.cdr.markForCheck(), 100);
setTimeout(() => clearInterval(hnd), 2000);
})
)
.subscribe(
response => {
if (response && response.length) {
if (
response.find(target => {
return target.name === "robot$" + cont.value;
})
) {
this.isRobotNameValid = false;
this.nameTooltipText = "ROBOT_ACCOUNT.ACCOUNT_EXISTING";
}
}
},
error => {
this.errorHandler.error(error);
}
}
},
error => {
this.errorHandler.error(error);
}
);
);
} else {
this.nameTooltipText = "ROBOT_ACCOUNT.ROBOT_NAME";
}
@ -116,49 +117,57 @@ export class AddRobotComponent implements OnInit, OnDestroy {
if (this.isSubmitOnGoing) {
return;
}
// set value to robot.access.isPullImage and robot.access.isPushOrPullImage when submit
if ( this.imagePermission === 'pull' ) {
this.robot.access.isPullImage = true;
this.robot.access.isPushOrPullImage = false;
} else {
this.robot.access.isPullImage = false;
this.robot.access.isPushOrPullImage = true;
}
this.isSubmitOnGoing = true;
this.robotService
.addRobotAccount(
this.projectId,
this.robot,
this.projectName
)
.subscribe(
response => {
this.isSubmitOnGoing = false;
this.robotToken = response.token;
this.robotAccount = response.name;
this.copyToken = true;
this.create.emit(true);
this.translate
.get("ROBOT_ACCOUNT.CREATED_SUCCESS", { param: this.robotAccount })
.subscribe((res: string) => {
this.createSuccess = res;
});
this.addRobotOpened = false;
},
error => {
this.isSubmitOnGoing = false;
this.copyAlert.showInlineError(error);
}
);
.addRobotAccount(
this.projectId,
this.robot,
this.projectName
)
.subscribe(
response => {
this.isSubmitOnGoing = false;
this.robotToken = response.token;
this.robotAccount = response.name;
this.copyToken = true;
this.create.emit(true);
this.translate
.get("ROBOT_ACCOUNT.CREATED_SUCCESS", { param: this.robotAccount })
.subscribe((res: string) => {
this.createSuccess = res;
});
this.addRobotOpened = false;
},
error => {
this.isSubmitOnGoing = false;
this.copyAlert.showInlineError(error);
}
);
}
isValid(): boolean {
return (
this.currentForm &&
this.currentForm.valid &&
!this.isSubmitOnGoing &&
this.isRobotNameValid &&
!this.checkOnGoing
this.currentForm &&
this.currentForm.valid &&
!this.isSubmitOnGoing &&
this.isRobotNameValid &&
!this.checkOnGoing
);
}
get shouldDisable(): boolean {
if (this.robot && this.robot.access) {
return (
!this.isValid() ||
(!this.robot.access.isPushOrPullImage && !this.robot.access.isPullImage
&& !this.robot.access.isPullChart && !this.robot.access.isPushChart)
!this.isValid() ||
(!this.robot.access.isPushOrPullImage && !this.robot.access.isPullImage
&& !this.robot.access.isPullChart && !this.robot.access.isPushChart)
);
}
}
@ -180,9 +189,9 @@ export class AddRobotComponent implements OnInit, OnDestroy {
onCpSuccess($event: any): void {
this.copyToken = false;
this.translate
.get("ROBOT_ACCOUNT.COPY_SUCCESS", { param: this.robotAccount })
.subscribe((res: string) => {
this.messageHandlerService.showSuccess(res);
});
.get("ROBOT_ACCOUNT.COPY_SUCCESS", { param: this.robotAccount })
.subscribe((res: string) => {
this.messageHandlerService.showSuccess(res);
});
}
}

View File

@ -16,7 +16,7 @@ export class Robot {
constructor () {
this.access = <any>{};
// this.access[0].action = true;
this.access.isPullImage = true;
this.access.isPullImage = false;
this.access.isPushOrPullImage = true;
this.access.isPushChart = false;
this.access.isPullChart = false;

View File

@ -58,6 +58,7 @@
"TOOLTIP": {
"NAME_FILTER": "Filter the name of the resource. Leave empty or use '**' to match all. 'library/**' only matches resources under 'library'. For more patterns, please refer to the user guide.",
"TAG_FILTER": "Filter the tag/version part of the resources. Leave empty or use '**' to match all. '1.0*' only matches the tags that starts with '1.0'. For more patterns, please refer to the user guide.",
"LABEL_FILTER": "Filter the resources according to labels.",
"RESOURCE_FILTER": "Filter the type of resources.",
"PUSH_BASED": "Push the resources from the local Harbor to the remote registry.",
"PULL_BASED": "Pull the resources from the remote registry to the local Harbor.",
@ -310,10 +311,10 @@
"ENABLE_ACCOUNT": "Enable Account",
"DELETE": "Delete",
"CREAT_ROBOT_ACCOUNT": "Creat Robot Account",
"PULL_PERMISSION": "Image pull",
"PULL_PUSH_PERMISSION": "Image pull / push",
"PUSH_CHART_PERMISSION": "Helm chart push",
"PULL_CHART_PERMISSION": "Helm chart pull",
"PERMISSIONS_IMAGE": "Image",
"PERMISSIONS_HELMCHART": "Helm Chart",
"PUSH": "Push",
"PULL": "Pull",
"FILTER_PLACEHOLDER": "Filter Robot Accounts",
"ROBOT_NAME": "Cannot contain special characters(~#$%) and maximum length should be 255 characters.",
"ACCOUNT_EXISTING": "Robot Account is already exists.",
@ -327,6 +328,7 @@
"GROUP": "Group",
"GROUPS": "Groups",
"IMPORT_LDAP_GROUP": "Import LDAP Group",
"IMPORT_HTTP_GROUP": "New HTTP Group",
"ADD": "New Group",
"EDIT": "Edit",
"DELETE": "Delete",
@ -339,8 +341,17 @@
"ADD_GROUP_SUCCESS": "Add group success",
"EDIT_GROUP_SUCCESS": "Edit group success",
"LDAP_TYPE": "LDAP",
"HTTP_TYPE": "HTTP",
"OF": "of",
"ITEMS": "items"
"ITEMS": "items",
"NEW_MEMBER": "New Group Member",
"NEW_USER_INFO": "Add a group to be a member of this project with specified role",
"ROLE": "Role",
"SYS_ADMIN": "System Admin",
"PROJECT_ADMIN": "Project Admin",
"PROJECT_MASTER": "Master",
"DEVELOPER": "Developer",
"GUEST": "Guest"
},
"AUDIT_LOG": {
"USERNAME": "Username",

View File

@ -58,6 +58,7 @@
"TOOLTIP": {
"NAME_FILTER": "Filter the name of the resource. Leave empty or use '**' to match all. 'library/**' only matches resources under 'library'. For more patterns, please refer to the user guide.",
"TAG_FILTER": "Filter the tag/version part of the resources. Leave empty or use '**' to match all. '1.0*' only matches the tags that starts with '1.0'. For more patterns, please refer to the user guide.",
"LABEL_FILTER": "Filter the resources according to labels.",
"RESOURCE_FILTER": "Filter the type of resources.",
"PUSH_BASED": "Push the resources from the local Harbor to the remote registry.",
"PULL_BASED": "Pull the resources from the remote registry to the local Harbor.",
@ -311,10 +312,10 @@
"ENABLE_ACCOUNT": "Enable Account",
"DELETE": "Delete",
"CREAT_ROBOT_ACCOUNT": "Creat Robot Account",
"PULL_PERMISSION": "Image pull",
"PULL_PUSH_PERMISSION": "Image pull / push",
"PUSH_CHART_PERMISSION": "Helm chart push",
"PULL_CHART_PERMISSION": "Helm chart pull",
"PERMISSIONS_IMAGE": "Image",
"PERMISSIONS_HELMCHART": "Helm Chart",
"PUSH": "Push",
"PULL": "Pull",
"FILTER_PLACEHOLDER": "Filter Robot Accounts",
"ROBOT_NAME": "Cannot contain special characters(~#$%) and maximum length should be 255 characters.",
"ACCOUNT_EXISTING": "Robot Account is already exists.",
@ -328,6 +329,7 @@
"GROUP": "Group",
"GROUPS": "Groups",
"IMPORT_LDAP_GROUP": "Import LDAP Group",
"IMPORT_HTTP_GROUP": "New HTTP Group",
"ADD": "Add",
"EDIT": "Edit",
"DELETE": "Delete",
@ -339,8 +341,17 @@
"ADD_GROUP_SUCCESS": "Add group success",
"EDIT_GROUP_SUCCESS": "Edit group success",
"LDAP_TYPE": "LDAP",
"HTTP_TYPE": "HTTP",
"OF": "of",
"ITEMS": "items"
"ITEMS": "items",
"NEW_MEMBER": "New Group Member",
"NEW_USER_INFO": "Add a group to be a member of this project with specified role",
"ROLE": "Role",
"SYS_ADMIN": "System Admin",
"PROJECT_ADMIN": "Project Admin",
"PROJECT_MASTER": "Master",
"DEVELOPER": "Developer",
"GUEST": "Guest"
},
"AUDIT_LOG": {
"USERNAME": "Nombre de usuario",

View File

@ -55,6 +55,7 @@
"TOOLTIP": {
"NAME_FILTER": "Filter the name of the resource. Leave empty or use '**' to match all. 'library/**' only matches resources under 'library'. For more patterns, please refer to the user guide.",
"TAG_FILTER": "Filter the tag/version part of the resources. Leave empty or use '**' to match all. '1.0*' only matches the tags that starts with '1.0'. For more patterns, please refer to the user guide.",
"LABEL_FILTER": "Filter the resources according to labels.",
"RESOURCE_FILTER": "Filter the type of resources.",
"PUSH_BASED": "Push the resources from the local Harbor to the remote registry.",
"PULL_BASED": "Pull the resources from the remote registry to the local Harbor.",
@ -302,10 +303,11 @@
"ENABLE_ACCOUNT": "permettre à compte ",
"DELETE": "Supprimer",
"CREAT_ROBOT_ACCOUNT": "créat robot compte ",
"PULL_PERMISSION": "Image pull",
"PULL_PUSH_PERMISSION": "Image pull / push",
"PUSH_CHART_PERMISSION": "Helm chart push",
"PULL_CHART_PERMISSION": "Helm chart pull",
"PERMISSIONS_IMAGE": "Image",
"PERMISSIONS_HELMCHART": "Helm Chart",
"PUSH": "Push",
"PULL": "Pull",
"FILTER_PLACEHOLDER": "Filter Robot Accounts",
"ROBOT_NAME": "ne peut pas contenir de caractères spéciaux(~#$%) et la longueur maximale devrait être de 255 caractères.",
"ACCOUNT_EXISTING": "le robot est existe déjà.",
@ -319,6 +321,7 @@
"Group": "Group",
"GROUPS": "Groups",
"IMPORT_LDAP_GROUP": "Import LDAP Group",
"IMPORT_HTTP_GROUP": "New HTTP Group",
"ADD": "Add",
"EDIT": "Edit",
"DELETE": "Delete",
@ -331,8 +334,17 @@
"ADD_GROUP_SUCCESS": "Add group success",
"EDIT_GROUP_SUCCESS": "Edit group success",
"LDAP_TYPE": "LDAP",
"HTTP_TYPE": "HTTP",
"OF": "of",
"ITEMS": "items"
"ITEMS": "items",
"NEW_MEMBER": "New Group Member",
"NEW_USER_INFO": "Add a group to be a member of this project with specified role",
"ROLE": "Role",
"SYS_ADMIN": "System Admin",
"PROJECT_ADMIN": "Project Admin",
"PROJECT_MASTER": "Master",
"DEVELOPER": "Developer",
"GUEST": "Guest"
},
"AUDIT_LOG": {
"USERNAME": "Nom d'utilisateur",

View File

@ -58,6 +58,7 @@
"TOOLTIP": {
"NAME_FILTER": "Filter the name of the resource. Leave empty or use '**' to match all. 'library/**' only matches resources under 'library'. For more patterns, please refer to the user guide.",
"TAG_FILTER": "Filter the tag/version part of the resources. Leave empty or use '**' to match all. '1.0*' only matches the tags that starts with '1.0'. For more patterns, please refer to the user guide.",
"LABEL_FILTER": "Filter the resources according to labels.",
"RESOURCE_FILTER": "Filter the type of resources.",
"PUSH_BASED": "Push the resources from the local Harbor to the remote registry.",
"PULL_BASED": "Pull the resources from the remote registry to the local Harbor.",
@ -308,10 +309,10 @@
"ENABLE_ACCOUNT": "Ativar conta",
"DELETE": "Remover",
"CREAT_ROBOT_ACCOUNT": "CRIA robô conta",
"PULL_PERMISSION": "Image pull",
"PULL_PUSH_PERMISSION": "Image pull / push",
"PUSH_CHART_PERMISSION": "Helm chart push",
"PULL_CHART_PERMISSION": "Helm chart pull",
"PERMISSIONS_IMAGE": "Image",
"PERMISSIONS_HELMCHART": "Helm Chart",
"PUSH": "Push",
"PULL": "Pull",
"FILTER_PLACEHOLDER": "Filtro robot accounts",
"ROBOT_NAME": "Não Pode conter caracteres especiais(~#$%) e comprimento máximo deveria ser 255 caracteres.",
"ACCOUNT_EXISTING": "Robô conta já existe.",
@ -325,6 +326,7 @@
"GROUP": "Grupo",
"GROUPS": "Grupos",
"IMPORT_LDAP_GROUP": "Importar grupo do LDAP",
"IMPORT_HTTP_GROUP": "New HTTP Group",
"ADD": "Novo Grupo",
"EDIT": "Editar",
"DELETE": "Remover",
@ -337,8 +339,17 @@
"ADD_GROUP_SUCCESS": "Grupo adicionado com sucesso",
"EDIT_GROUP_SUCCESS": "Grupo editado com sucesso",
"LDAP_TYPE": "LDAP",
"HTTP_TYPE": "HTTP",
"OF": "de",
"ITEMS": "itens"
"ITEMS": "itens",
"NEW_MEMBER": "New Group Member",
"NEW_USER_INFO": "Add a group to be a member of this project with specified role",
"ROLE": "Role",
"SYS_ADMIN": "System Admin",
"PROJECT_ADMIN": "Project Admin",
"PROJECT_MASTER": "Master",
"DEVELOPER": "Developer",
"GUEST": "Guest"
},
"AUDIT_LOG": {
"USERNAME": "Nome do usuário",

View File

@ -58,6 +58,7 @@
"TOOLTIP": {
"NAME_FILTER": "过滤资源的名字。不填或者“”匹配所有资源“library/”只匹配“library”下的资源。更多的匹配模式请参考用户手册。",
"TAG_FILTER": "过滤资源的tag/version。不填或者“”匹配所有“1.0*”只匹配以“1.0”开头的tag/version。",
"LABEL_FILTER": "根据标签筛选资源。",
"RESOURCE_FILTER": "过滤资源的类型。",
"PUSH_BASED": "把资源由本地Harbor推送到远端仓库。",
"PULL_BASED": "把资源由远端仓库拉取到本地Harbor。",
@ -86,8 +87,8 @@
"NONEMPTY": "不能为空",
"ENDPOINT_FORMAT": "Endpoint必须以http://或https://开头。",
"OIDC_ENDPOIT_FORMAT": "Endpoint必须以https://开头。",
"OIDC_NAME": "OIDC提供商的名称.",
"OIDC_ENDPOINT": "OIDC服务器的地址.",
"OIDC_NAME": "OIDC提供商的名称",
"OIDC_ENDPOINT": "OIDC服务器的地址",
"OIDC_SCOPE": "在身份验证期间发送到OIDC服务器的scope。它必须包含“openid”和“offline_access”。如果您使用Google请从此字段中删除“脱机访问”。",
"OIDC_VERIFYCERT": "如果您的OIDC服务器是通过自签名证书托管的请取消选中此框。"
},
@ -298,7 +299,7 @@
"NEW_ROBOT_ACCOUNT": "添加机器人账户",
"ENABLED_STATE": "启用状态",
"EXPIRATION": "过期时间",
"NUMBER_REQUIRED":"此项为必填项且为不为0的整数.",
"NUMBER_REQUIRED":"此项为必填项且为不为0的整数",
"TOKEN_EXPIRATION":"机器人账户令牌过期时间(天)",
"DESCRIPTION": "描述",
"ACTION": "操作",
@ -309,15 +310,15 @@
"ENABLE_ACCOUNT": "启用账户",
"DELETE": "删除",
"CREAT_ROBOT_ACCOUNT": "创建机器人账户",
"PULL_PERMISSION": "Pull 镜像",
"PULL_PUSH_PERMISSION": "Push和Pull 镜像",
"PUSH_CHART_PERMISSION": "推送Chart",
"PULL_CHART_PERMISSION": "拉取Chart",
"PERMISSIONS_IMAGE": "镜像",
"PERMISSIONS_HELMCHART": "Helm Chart",
"PUSH": "推送",
"PULL": "拉取",
"FILTER_PLACEHOLDER": "过滤机器人账户",
"ROBOT_NAME": "不能包含特殊字符(~#$%)且长度不能超过255.",
"ACCOUNT_EXISTING": "机器人账户已经存在.",
"ROBOT_NAME": "不能包含特殊字符(~#$%)且长度不能超过255",
"ACCOUNT_EXISTING": "机器人账户已经存在",
"ALERT_TEXT": "这是唯一一次复制您的个人访问令牌的机会",
"CREATED_SUCCESS": "创建账户 '{{param}}' 成功.",
"CREATED_SUCCESS": "创建账户 '{{param}}' 成功",
"COPY_SUCCESS": "成功复制 '{{param}}' 的令牌",
"DELETION_TITLE": "删除账户确认",
"DELETION_SUMMARY": "你确认删除机器人账户 {{param}}?"
@ -326,6 +327,7 @@
"GROUP": "组",
"GROUPS": "组",
"IMPORT_LDAP_GROUP": "导入LDAP组",
"IMPORT_HTTP_GROUP": "新建HTTP组",
"ADD": "新增",
"EDIT": "编辑",
"DELETE": "删除",
@ -338,8 +340,17 @@
"ADD_GROUP_SUCCESS": "添加组成功",
"EDIT_GROUP_SUCCESS": "修改组成功",
"LDAP_TYPE": "LDAP",
"HTTP_TYPE": "HTTP",
"OF": "共计",
"ITEMS": "条记录"
"ITEMS": "条记录",
"NEW_MEMBER": "新建组成员",
"NEW_USER_INFO": "添加一个组作为具有指定角色的此项目的成员",
"ROLE": "权限",
"SYS_ADMIN": "系统管理员",
"PROJECT_ADMIN": "项目管理员",
"PROJECT_MASTER": "维护人员",
"DEVELOPER": "开发者",
"GUEST": "访客"
},
"AUDIT_LOG": {
"USERNAME": "用户名",
@ -727,7 +738,7 @@
"TOKEN_EXPIRATION": "由令牌服务创建的令牌的过期时间分钟默认为30分钟。",
"ROBOT_TOKEN_EXPIRATION": "机器人账户的令牌的过期时间默认为30天,显示的结果为分钟转化的天数并向下取整。",
"PRO_CREATION_RESTRICTION": "用来确定哪些用户有权限创建项目,默认为’所有人‘,设置为’仅管理员‘则只有管理员可以创建项目。",
"ROOT_CERT_DOWNLOAD": "下载镜像库根证书.",
"ROOT_CERT_DOWNLOAD": "下载镜像库根证书",
"SCANNING_POLICY": "基于不同需求设置镜像扫描策略。‘无’:不设置任何策略;‘每日定时’:每天在设置的时间定时执行扫描。",
"VERIFY_CERT": "检查来自LDAP服务端的证书",
"READONLY_TOOLTIP": "选中,表示正在维护状态,不可删除仓库及标签,也不可以推送镜像。",
@ -859,8 +870,8 @@
},
"CHART": {
"SCANNING_TIME": "扫描完成时间:",
"TOOLTIPS_TITLE": "{{totalPackages}}个{{package}}中的{{totalVulnerability}}个含有{{vulnerability}}.",
"TOOLTIPS_TITLE_SINGULAR": "{{totalPackages}}个{{package}}中的{{totalVulnerability}}个含有{{vulnerability}}.",
"TOOLTIPS_TITLE": "{{totalPackages}}个{{package}}中的{{totalVulnerability}}个含有{{vulnerability}}",
"TOOLTIPS_TITLE_SINGULAR": "{{totalPackages}}个{{package}}中的{{totalVulnerability}}个含有{{vulnerability}}",
"TOOLTIPS_TITLE_ZERO": "没有发现可识别的漏洞包"
},
"SEVERITY": {

View File

@ -86,4 +86,8 @@ body {
.color-red {
color: red;
}
}
.datagrid-table,.datagrid-header{
position: inherit !important;
}

View File

@ -17,10 +17,18 @@ package adapter
import (
"errors"
"fmt"
"io"
"github.com/docker/distribution"
"github.com/goharbor/harbor/src/replication/filter"
"github.com/goharbor/harbor/src/replication/model"
)
// const definition
const (
UserAgentReplication = "harbor-replication-service"
)
var registry = map[model.RegistryType]Factory{}
// Factory creates a specific Adapter according to the params
@ -37,6 +45,81 @@ type Adapter interface {
HealthCheck() (model.HealthStatus, error)
}
// ImageRegistry defines the capabilities that an image registry should have
type ImageRegistry interface {
FetchImages(filters []*model.Filter) ([]*model.Resource, error)
ManifestExist(repository, reference string) (exist bool, digest string, err error)
PullManifest(repository, reference string, accepttedMediaTypes []string) (manifest distribution.Manifest, digest string, err error)
PushManifest(repository, reference, mediaType string, payload []byte) error
// the "reference" can be "tag" or "digest", the function needs to handle both
DeleteManifest(repository, reference string) error
BlobExist(repository, digest string) (exist bool, err error)
PullBlob(repository, digest string) (size int64, blob io.ReadCloser, err error)
PushBlob(repository, digest string, size int64, blob io.Reader) error
}
// ChartRegistry defines the capabilities that a chart registry should have
type ChartRegistry interface {
FetchCharts(filters []*model.Filter) ([]*model.Resource, error)
ChartExist(name, version string) (bool, error)
DownloadChart(name, version string) (io.ReadCloser, error)
UploadChart(name, version string, chart io.Reader) error
DeleteChart(name, version string) error
}
// Repository defines an repository object, it can be image repository, chart repository and etc.
type Repository struct {
ResourceType string `json:"resource_type"`
Name string `json:"name"`
}
// GetName returns the name
func (r *Repository) GetName() string {
return r.Name
}
// GetFilterableType returns the filterable type
func (r *Repository) GetFilterableType() filter.FilterableType {
return filter.FilterableTypeRepository
}
// GetResourceType returns the resource type
func (r *Repository) GetResourceType() string {
return r.ResourceType
}
// GetLabels returns the labels
func (r *Repository) GetLabels() []string {
return nil
}
// VTag defines an vTag object, it can be image tag, chart version and etc.
type VTag struct {
ResourceType string `json:"resource_type"`
Name string `json:"name"`
Labels []string `json:"labels"`
}
// GetFilterableType returns the filterable type
func (v *VTag) GetFilterableType() filter.FilterableType {
return filter.FilterableTypeVTag
}
// GetResourceType returns the resource type
func (v *VTag) GetResourceType() string {
return v.ResourceType
}
// GetName returns the name
func (v *VTag) GetName() string {
return v.Name
}
// GetLabels returns the labels
func (v *VTag) GetLabels() []string {
return v.Labels
}
// RegisterFactory registers one adapter factory to the registry
func RegisterFactory(t model.RegistryType, factory Factory) error {
if len(t) == 0 {

View File

@ -0,0 +1,176 @@
// 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 awsecr
import (
"errors"
"net/http"
"regexp"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
awsecrapi "github.com/aws/aws-sdk-go/service/ecr"
"github.com/goharbor/harbor/src/common/utils/log"
"github.com/goharbor/harbor/src/common/utils/registry"
adp "github.com/goharbor/harbor/src/replication/adapter"
"github.com/goharbor/harbor/src/replication/adapter/native"
"github.com/goharbor/harbor/src/replication/model"
)
func init() {
if err := adp.RegisterFactory(model.RegistryTypeAwsEcr, func(registry *model.Registry) (adp.Adapter, error) {
return newAdapter(registry)
}); err != nil {
log.Errorf("failed to register factory for %s: %v", model.RegistryTypeAwsEcr, err)
return
}
log.Infof("the factory for adapter %s registered", model.RegistryTypeAwsEcr)
}
func newAdapter(registry *model.Registry) (*adapter, error) {
region, err := parseRegion(registry.URL)
if err != nil {
return nil, err
}
authorizer := NewAuth(region, registry.Credential.AccessKey, registry.Credential.AccessSecret, registry.Insecure)
dockerRegistry, err := native.NewAdapterWithCustomizedAuthorizer(registry, authorizer)
if err != nil {
return nil, err
}
return &adapter{
registry: registry,
Adapter: dockerRegistry,
region: region,
}, nil
}
func parseRegion(url string) (string, error) {
pattern := "https://(?:api|\\d+\\.dkr)\\.ecr\\.([\\w\\-]+)\\.amazonaws\\.com"
rs := regexp.MustCompile(pattern).FindStringSubmatch(url)
if rs == nil {
return "", errors.New("Bad aws url")
}
return rs[1], nil
}
type adapter struct {
*native.Adapter
registry *model.Registry
region string
forceEndpoint *string
}
func (*adapter) Info() (info *model.RegistryInfo, err error) {
return &model.RegistryInfo{
Type: model.RegistryTypeAwsEcr,
SupportedResourceTypes: []model.ResourceType{
model.ResourceTypeImage,
},
SupportedResourceFilters: []*model.FilterStyle{
{
Type: model.FilterTypeName,
Style: model.FilterStyleTypeText,
},
{
Type: model.FilterTypeTag,
Style: model.FilterStyleTypeText,
},
},
SupportedTriggers: []model.TriggerType{
model.TriggerTypeManual,
model.TriggerTypeScheduled,
},
}, nil
}
// HealthCheck checks health status of a registry
func (a *adapter) HealthCheck() (model.HealthStatus, error) {
if a.registry.Credential == nil ||
len(a.registry.Credential.AccessKey) == 0 || len(a.registry.Credential.AccessSecret) == 0 {
log.Errorf("no credential to ping registry %s", a.registry.URL)
return model.Unhealthy, nil
}
if err := a.PingGet(); err != nil {
log.Errorf("failed to ping registry %s: %v", a.registry.URL, err)
return model.Unhealthy, nil
}
return model.Healthy, nil
}
// PrepareForPush nothing need to do.
func (a *adapter) PrepareForPush(resources []*model.Resource) error {
for _, resource := range resources {
if resource == nil {
return errors.New("the resource cannot be nil")
}
if resource.Metadata == nil {
return errors.New("the metadata of resource cannot be nil")
}
if resource.Metadata.Repository == nil {
return errors.New("the namespace of resource cannot be nil")
}
if len(resource.Metadata.Repository.Name) == 0 {
return errors.New("the name of the namespace cannot be nil")
}
err := a.createRepository(resource.Metadata.Repository.Name)
if err != nil {
return err
}
}
return nil
}
func (a *adapter) createRepository(repository string) error {
if a.registry.Credential == nil ||
len(a.registry.Credential.AccessKey) == 0 || len(a.registry.Credential.AccessSecret) == 0 {
return errors.New("no credential ")
}
cred := credentials.NewStaticCredentials(
a.registry.Credential.AccessKey,
a.registry.Credential.AccessSecret,
"")
if a.region == "" {
return errors.New("no region parsed")
}
config := &aws.Config{
Credentials: cred,
Region: &a.region,
HTTPClient: &http.Client{
Transport: registry.GetHTTPTransport(a.registry.Insecure),
},
}
if a.forceEndpoint != nil {
config.Endpoint = a.forceEndpoint
}
sess := session.Must(session.NewSession(config))
svc := awsecrapi.New(sess)
_, err := svc.CreateRepository(&awsecrapi.CreateRepositoryInput{
RepositoryName: &repository,
})
if err != nil {
if e, ok := err.(awserr.Error); ok {
if e.Code() == awsecrapi.ErrCodeRepositoryAlreadyExistsException {
return nil
}
}
return err
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More