Add user event and config event (#21455)

Signed-off-by: stonezdj <stone.zhang@broadcom.com>
This commit is contained in:
stonezdj(Daojun Zhang) 2025-02-14 14:23:14 +08:00 committed by GitHub
parent cc966435a5
commit 6965cab0c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 604 additions and 4 deletions

View File

@ -63,7 +63,9 @@ import (
_ "github.com/goharbor/harbor/src/pkg/accessory/model/sbom"
_ "github.com/goharbor/harbor/src/pkg/accessory/model/subject"
"github.com/goharbor/harbor/src/pkg/audit"
_ "github.com/goharbor/harbor/src/pkg/auditext/event/config"
_ "github.com/goharbor/harbor/src/pkg/auditext/event/login"
_ "github.com/goharbor/harbor/src/pkg/auditext/event/user"
dbCfg "github.com/goharbor/harbor/src/pkg/config/db"
_ "github.com/goharbor/harbor/src/pkg/config/inmemory"
"github.com/goharbor/harbor/src/pkg/notification"

View File

@ -0,0 +1,157 @@
// 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 event
import (
"context"
"fmt"
"net/http"
"regexp"
"time"
"slices"
ctlevent "github.com/goharbor/harbor/src/controller/event"
"github.com/goharbor/harbor/src/controller/event/metadata/commonevent"
"github.com/goharbor/harbor/src/controller/event/model"
"github.com/goharbor/harbor/src/lib/config"
"github.com/goharbor/harbor/src/pkg/notifier/event"
)
const (
createOp = "create"
updateOp = "update"
deleteOp = "delete"
resourceIDPattern = `^%v/(\d+)$`
)
// ResolveIDToNameFunc is the function to resolve the resource name from resource id
type ResolveIDToNameFunc func(string) string
type Resolver struct {
BaseURLPattern string
ResourceType string
SucceedCodes []int
// SensitiveAttributes is the attributes that need to be redacted
SensitiveAttributes []string
// HasResourceName indicates if the resource has name, if true, need to resolve the resource name before delete
ShouldResolveName bool
// IDToNameFunc is used to resolve the resource name from resource id
IDToNameFunc ResolveIDToNameFunc
}
// PreCheck check if the event should be captured and resolve the resource name if needed, if need to resolve the resource name, return the resource name
func (e *Resolver) PreCheck(ctx context.Context, url string, method string) (capture bool, resourceName string) {
capture = config.AuditLogEventEnabled(ctx, fmt.Sprintf("%v_%v", MethodToOperation(method), e.ResourceType))
if !capture {
return false, ""
}
// for delete operation on a resource has name, need to resolve the resource id to resource name before delete
resName := ""
if capture && method == http.MethodDelete && e.ShouldResolveName {
re := regexp.MustCompile(fmt.Sprintf(resourceIDPattern, e.BaseURLPattern))
m := re.FindStringSubmatch(url)
if len(m) == 2 && e.IDToNameFunc != nil {
resName = e.IDToNameFunc(m[1])
}
}
return true, resName
}
// Resolve ...
func (e *Resolver) Resolve(ce *commonevent.Metadata, event *event.Event) error {
if ce.RequestMethod != http.MethodPost && ce.RequestMethod != http.MethodDelete && ce.RequestMethod != http.MethodPut {
return nil
}
evt := &model.CommonEvent{
Operator: ce.Username,
ResourceType: e.ResourceType,
OcurrAt: time.Now(),
IsSuccessful: true,
}
resourceName := ""
operation := MethodToOperation(ce.RequestMethod)
if len(operation) == 0 {
return nil
}
evt.Operation = operation
switch evt.Operation {
case createOp:
if len(ce.ResponseLocation) > 0 {
// extract resource id from response location
re := regexp.MustCompile(fmt.Sprintf(resourceIDPattern, e.BaseURLPattern))
m := re.FindStringSubmatch(ce.ResponseLocation)
if len(m) != 2 {
return nil
}
evt.ResourceName = m[1]
if e.IDToNameFunc != nil {
resourceName = e.IDToNameFunc(m[1])
}
}
if e.ShouldResolveName && resourceName != "" {
evt.ResourceName = resourceName
}
case deleteOp:
re := regexp.MustCompile(fmt.Sprintf(resourceIDPattern, e.BaseURLPattern))
m := re.FindStringSubmatch(ce.RequestURL)
if len(m) != 2 {
return nil
}
evt.ResourceName = m[1]
if e.ShouldResolveName && ce.ResourceName != "" {
evt.ResourceName = ce.ResourceName
}
case updateOp:
re := regexp.MustCompile(fmt.Sprintf(resourceIDPattern, e.BaseURLPattern))
m := re.FindStringSubmatch(ce.RequestURL)
if len(m) != 2 {
return nil
}
evt.ResourceName = m[1]
if e.IDToNameFunc != nil {
resourceName = e.IDToNameFunc(m[1])
}
if e.ShouldResolveName && resourceName != "" {
evt.ResourceName = resourceName
}
}
evt.OperationDescription = fmt.Sprintf("%s %s with name: %s", evt.Operation, e.ResourceType, evt.ResourceName)
if !slices.Contains(e.SucceedCodes, ce.ResponseCode) {
evt.IsSuccessful = false
}
event.Topic = ctlevent.TopicCommonEvent
event.Data = evt
return nil
}
// MethodToOperation converts HTTP method to operation
func MethodToOperation(method string) string {
switch method {
case http.MethodPost:
return createOp
case http.MethodDelete:
return deleteOp
case http.MethodPut:
return updateOp
}
return ""
}

View File

@ -0,0 +1,66 @@
// 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 event
import (
"context"
"testing"
)
func TestEventResolver_PreCheck(t *testing.T) {
type fields struct {
BaseURLPattern string
ResourceType string
SucceedCodes []int
SensitiveAttributes []string
ShouldResolveName bool
IDToNameFunc ResolveIDToNameFunc
}
type args struct {
ctx context.Context
url string
method string
}
tests := []struct {
name string
fields fields
args args
wantCapture bool
wantResourceName string
}{
{"test normal", fields{BaseURLPattern: `/api/v2.0/tests`, ResourceType: "test", SucceedCodes: []int{200}, ShouldResolveName: true, IDToNameFunc: func(string) string { return "test" }}, args{context.Background(), "/api/v2.0/tests/123", "DELETE"}, true, "test"},
{"test resource name", fields{BaseURLPattern: `/api/v2.0/tests`, ResourceType: "test", SucceedCodes: []int{200}, ShouldResolveName: true, IDToNameFunc: func(string) string { return "test_resource_name" }}, args{context.Background(), "/api/v2.0/tests/234", "DELETE"}, true, "test_resource_name"},
{"test no resource name", fields{BaseURLPattern: `/api/v2.0/tests`, ResourceType: "test", SucceedCodes: []int{200}, ShouldResolveName: true}, args{context.Background(), "/api/v2.0/tests/234", "GET"}, true, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := &Resolver{
BaseURLPattern: tt.fields.BaseURLPattern,
ResourceType: tt.fields.ResourceType,
SucceedCodes: tt.fields.SucceedCodes,
SensitiveAttributes: tt.fields.SensitiveAttributes,
ShouldResolveName: tt.fields.ShouldResolveName,
IDToNameFunc: tt.fields.IDToNameFunc,
}
gotCapture, gotResourceName := e.PreCheck(tt.args.ctx, tt.args.url, tt.args.method)
if gotCapture != tt.wantCapture {
t.Errorf("EventResolver.PreCheck() gotCapture = %v, want %v", gotCapture, tt.wantCapture)
}
if gotResourceName != tt.wantResourceName {
t.Errorf("EventResolver.PreCheck() gotResourceName = %v, want %v", gotResourceName, tt.wantResourceName)
}
})
}
}

View File

@ -0,0 +1,71 @@
// 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 config
import (
"context"
"fmt"
"net/http"
"time"
"github.com/goharbor/harbor/src/common/rbac"
ctlevent "github.com/goharbor/harbor/src/controller/event"
"github.com/goharbor/harbor/src/controller/event/metadata/commonevent"
"github.com/goharbor/harbor/src/controller/event/model"
"github.com/goharbor/harbor/src/lib/config"
ext "github.com/goharbor/harbor/src/pkg/auditext/event"
"github.com/goharbor/harbor/src/pkg/notifier/event"
)
func init() {
var configureEventResolver = &resolver{
SensitiveAttributes: []string{"ldap_password", "oidc_client_secret"}, // all user config items with PasswordType defined in the metadatalist.go should be defined in SensitiveAttributes
}
commonevent.RegisterResolver(`/api/v2.0/configurations`, configureEventResolver)
}
const payloadSizeLimit = 450
// resolver used to resolve the configuration event
type resolver struct {
SensitiveAttributes []string
}
func (c *resolver) Resolve(ce *commonevent.Metadata, evt *event.Event) error {
e := &model.CommonEvent{}
e.Operation = "update"
e.Operator = ce.Username
e.ResourceType = rbac.ResourceConfiguration.String()
e.ResourceName = rbac.ResourceConfiguration.String()
e.Payload = ext.Redact(ce.RequestPayload, c.SensitiveAttributes)
e.OcurrAt = time.Now()
if len(ce.RequestPayload) > payloadSizeLimit {
ce.RequestPayload = fmt.Sprintf("%v...", ce.RequestPayload[:payloadSizeLimit])
}
e.OperationDescription = fmt.Sprintf("update configuration: %v", ce.RequestPayload)
if ce.ResponseCode == http.StatusOK {
e.IsSuccessful = true
}
evt.Topic = ctlevent.TopicCommonEvent
evt.Data = e
return nil
}
func (c *resolver) PreCheck(ctx context.Context, _ string, method string) (bool, string) {
if method != http.MethodPut {
return false, ""
}
return config.AuditLogEventEnabled(ctx, fmt.Sprintf("%v_%v", ext.MethodToOperation(method), rbac.ResourceConfiguration.String())), ""
}

View File

@ -0,0 +1,113 @@
// 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 config
import (
"context"
"testing"
"github.com/goharbor/harbor/src/controller/event/metadata/commonevent"
"github.com/goharbor/harbor/src/controller/event/model"
"github.com/goharbor/harbor/src/pkg/notifier/event"
)
func TestConfigureEventResolver_Resolve(t *testing.T) {
type fields struct {
SensitiveAttributes []string
}
type args struct {
ce *commonevent.Metadata
evt *event.Event
}
tests := []struct {
name string
fields fields
args args
wantErr bool
wantOperation string
wantResourceType string
wantOperationDescription string
}{
{"test normal", fields{[]string{}}, args{
ce: &commonevent.Metadata{
Username: "test",
RequestURL: "/api/v2.0/configurations",
RequestMethod: "PUT",
}, evt: &event.Event{}}, false, "update", "configuration", "update configuration"},
{"test error", fields{[]string{}}, args{ce: &commonevent.Metadata{
Username: "test",
RequestURL: "/api/v2.0/configurations",
RequestMethod: "POST",
}, evt: &event.Event{}}, false, "", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &resolver{
SensitiveAttributes: tt.fields.SensitiveAttributes,
}
if err := c.Resolve(tt.args.ce, tt.args.evt); (err != nil) != tt.wantErr {
t.Errorf("ConfigureEventResolver.Resolve() error = %v, wantErr %v", err, tt.wantErr)
if tt.args.evt.Data != nil {
data := tt.args.evt.Data.(*model.CommonEvent)
if data.Operation != tt.wantOperation {
t.Errorf("ConfigureEventResolver.Resolve() Operation = %v, want %v", data.Operation, tt.wantOperation)
}
if data.ResourceType != tt.wantResourceType {
t.Errorf("ConfigureEventResolver.Resolve() ResourceType = %v, want %v", data.ResourceType, tt.wantResourceType)
}
if data.OperationDescription != tt.wantOperationDescription {
t.Errorf("ConfigureEventResolver.Resolve() OperationDescription = %v, want %v", data.OperationDescription, tt.wantOperationDescription)
}
}
}
})
}
}
func TestConfigureEventResolver_PreCheck(t *testing.T) {
type fields struct {
SensitiveAttributes []string
}
type args struct {
ctx context.Context
url string
method string
}
tests := []struct {
name string
fields fields
args args
want bool
wantTopic string
}{
{"test normal", fields{[]string{}}, args{context.Background(), "/api/v2.0/configurations", "PUT"}, true, ""},
{"test error", fields{[]string{}}, args{context.Background(), "/api/v2.0/configurations", "POST"}, false, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &resolver{
SensitiveAttributes: tt.fields.SensitiveAttributes,
}
got, got1 := c.PreCheck(tt.args.ctx, tt.args.url, tt.args.method)
if got != tt.want {
t.Errorf("ConfigureEventResolver.PreCheck() got = %v, want %v", got, tt.want)
}
if got1 != tt.wantTopic {
t.Errorf("ConfigureEventResolver.PreCheck() got1 = %v, want %v", got1, tt.wantTopic)
}
})
}
}

View File

@ -22,7 +22,7 @@ import (
"time"
"github.com/goharbor/harbor/src/common/rbac"
ctlEvent "github.com/goharbor/harbor/src/controller/event"
ctlevent "github.com/goharbor/harbor/src/controller/event"
"github.com/goharbor/harbor/src/controller/event/metadata/commonevent"
"github.com/goharbor/harbor/src/controller/event/model"
"github.com/goharbor/harbor/src/lib/config"
@ -69,7 +69,7 @@ func (l *loginResolver) Resolve(ce *commonevent.Metadata, event *event.Event) er
if ce.ResponseCode != http.StatusOK {
e.IsSuccessful = false
}
event.Topic = ctlEvent.TopicCommonEvent
event.Topic = ctlevent.TopicCommonEvent
event.Data = e
return nil
}

View File

@ -21,7 +21,7 @@ import (
"time"
"github.com/goharbor/harbor/src/common/rbac"
ctlEvent "github.com/goharbor/harbor/src/controller/event"
ctlevent "github.com/goharbor/harbor/src/controller/event"
"github.com/goharbor/harbor/src/controller/event/metadata/commonevent"
"github.com/goharbor/harbor/src/controller/event/model"
"github.com/goharbor/harbor/src/lib/config"
@ -44,7 +44,7 @@ func (l *logoutResolver) Resolve(ce *commonevent.Metadata, event *event.Event) e
if ce.ResponseCode != http.StatusOK {
e.IsSuccessful = false
}
event.Topic = ctlEvent.TopicCommonEvent
event.Topic = ctlevent.TopicCommonEvent
event.Data = e
return nil
}

View File

@ -0,0 +1,64 @@
// 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 user
import (
"net/http"
"strconv"
"github.com/goharbor/harbor/src/common/rbac"
"github.com/goharbor/harbor/src/controller/event/metadata/commonevent"
"github.com/goharbor/harbor/src/lib/log"
"github.com/goharbor/harbor/src/lib/orm"
"github.com/goharbor/harbor/src/pkg/auditext/event"
pkgUser "github.com/goharbor/harbor/src/pkg/user"
)
func init() {
var userResolver = &userEventResolver{
Resolver: event.Resolver{
BaseURLPattern: "/api/v2.0/users",
ResourceType: rbac.ResourceUser.String(),
SucceedCodes: []int{http.StatusCreated, http.StatusOK},
SensitiveAttributes: []string{"password"},
ShouldResolveName: true,
IDToNameFunc: userIDToName,
},
}
commonevent.RegisterResolver(`/api/v2.0/users$`, userResolver)
commonevent.RegisterResolver(`^/api/v2.0/users/\d+/password$`, userResolver)
commonevent.RegisterResolver(`^/api/v2.0/users/\d+/sysadmin$`, userResolver)
commonevent.RegisterResolver(`^/api/v2.0/users/\d+$`, userResolver)
}
type userEventResolver struct {
event.Resolver
}
// userIDToName convert user id to user name
func userIDToName(userID string) string {
id, err := strconv.ParseInt(userID, 10, 32)
if err != nil {
log.Errorf("failed to parse userID: %v to int", userID)
return ""
}
// use different context to so that the user is visible before the transaction is committed
user, err := pkgUser.Mgr.Get(orm.Context(), int(id))
if err != nil {
log.Errorf("failed to parse userID: %v to int, err %v", userID, err)
return ""
}
return user.Username
}

View File

@ -0,0 +1,59 @@
// Copyright Project Harbor Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package event
import (
"encoding/json"
"slices"
"github.com/goharbor/harbor/src/lib/log"
)
// Redact replaces sensitive attributes in the JSON payload with "***"
func Redact(payload string, sensitiveAttributes []string) string {
if len(payload) == 0 {
return ""
}
var jsonData map[string]interface{}
if err := json.Unmarshal([]byte(payload), &jsonData); err != nil {
log.Fatalf("Error parsing JSON: %v", err)
return ""
}
replacePassword(jsonData, sensitiveAttributes)
// Convert the modified map back to JSON
modifiedJSON, err := json.MarshalIndent(jsonData, "", " ")
if err != nil {
log.Fatalf("Error converting to JSON: %v", err)
return ""
}
return string(modifiedJSON)
}
// replacePassword recursively replaces attribute in maskAttributes's value with "***"
func replacePassword(data map[string]interface{}, maskAttributes []string) {
for key, value := range data {
if slices.Contains(maskAttributes, key) {
data[key] = "***"
} else if nestedMap, ok := value.(map[string]interface{}); ok {
replacePassword(nestedMap, maskAttributes)
} else if nestedArray, ok := value.([]interface{}); ok {
for _, item := range nestedArray {
if itemMap, ok := item.(map[string]interface{}); ok {
replacePassword(itemMap, maskAttributes)
}
}
}
}
}

View File

@ -0,0 +1,68 @@
// 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 event
import (
"testing"
)
func TestRedact(t *testing.T) {
type args struct {
payload string
sensitiveAttributes []string
}
tests := []struct {
name string
args args
want string
}{
{"normal case", args{`{"password":"123456"}`, []string{"password"}}, "{\n \"password\": \"***\"\n}"},
{"no sensitive case", args{`{"ldap_base_dn":"dc=example,dc=com"}`, []string{"password"}}, "{\n \"ldap_base_dn\": \"dc=example,dc=com\"\n}"},
{"empty case", args{"", []string{"password"}}, ""},
{"mixed attribute", args{`{"ldap_base_dn":"dc=example,dc=com", "ldap_search_passwd": "admin"}`, []string{"ldap_search_passwd"}}, "{\n \"ldap_base_dn\": \"dc=example,dc=com\",\n \"ldap_search_passwd\": \"***\"\n}"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Redact(tt.args.payload, tt.args.sensitiveAttributes); got != tt.want {
t.Errorf("Redact() = %v, want %v", got, tt.want)
}
})
}
}
func Test_replacePassword(t *testing.T) {
type args struct {
data map[string]interface{}
maskAttributes []string
}
tests := []struct {
name string
args args
}{
{"normal case", args{map[string]interface{}{"password": "123456"}, []string{"password"}}},
{"nested case", args{map[string]interface{}{"master": map[string]interface{}{"password": "123456"}}, []string{"password"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
replacePassword(tt.args.data, tt.args.maskAttributes)
if val, ok := tt.args.data["password"]; ok && val != nil && tt.args.data["password"] != "***" {
t.Errorf("replacePassword() = %v, want %v", tt.args.data["password"], "***")
}
if val, ok := tt.args.data["master"]; ok && val != nil && tt.args.data["master"].(map[string]interface{})["password"] != "***" {
t.Errorf("replacePassword() = %v, want %v", tt.args.data["master"].(map[string]interface{})["password"], "***")
}
})
}
}