Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add user event and config event #21455

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/core/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
203 changes: 203 additions & 0 deletions src/pkg/auditext/event/basic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// 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"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"time"

"slices"

event2 "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 (
create = "create"
update = "update"
delete = "delete"
resourceIDPattern = `^%v/(\d+)$`
)

type ResolveIDToNameFunc func(string) string

type EventResolver 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
HasResourceName 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 *EventResolver) 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.HasResourceName {
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
}

func (e *EventResolver) 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 create:
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.HasResourceName && resourceName != "" {
evt.ResourceName = resourceName
}

case delete:
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.HasResourceName && ce.ResourceName != "" {
evt.ResourceName = ce.ResourceName
}

case update:
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.HasResourceName && 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 = event2.TopicCommonEvent
event.Data = evt
return nil
}

// MethodToOperation converts HTTP method to operation
func MethodToOperation(method string) string {
switch method {
case http.MethodPost:
return create
case http.MethodDelete:
return delete
case http.MethodPut:
return update
}
return ""
}

// replacePassword recursively replaces "user_password" values with "***"
func replacePassword(data map[string]interface{}, maskAttributes []string) {
for key, value := range data {
if inAttributes(key, maskAttributes) {
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)
}
}
}
}
}

func inAttributes(key string, maskAttributes []string) bool {
for _, attribute := range maskAttributes {
if key == attribute {
return true
}
}
return false
}

func RedactPayload(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 ""
}
// Replace user_password values
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)
}
66 changes: 66 additions & 0 deletions src/pkg/auditext/event/basic_test.go
Original file line number Diff line number Diff line change
@@ -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
HasResourceName 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}, HasResourceName: 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}, HasResourceName: 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}, HasResourceName: true}, args{context.Background(), "/api/v2.0/tests/234", "GET"}, true, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := &EventResolver{
BaseURLPattern: tt.fields.BaseURLPattern,
ResourceType: tt.fields.ResourceType,
SucceedCodes: tt.fields.SucceedCodes,
SensitiveAttributes: tt.fields.SensitiveAttributes,
HasResourceName: tt.fields.HasResourceName,
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)
}
})
}
}
74 changes: 74 additions & 0 deletions src/pkg/auditext/event/config/config.go
Original file line number Diff line number Diff line change
@@ -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 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"},
}
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.SourceIP = ce.IPAddress
e.Payload = ext.RedactPayload(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)
e.IsSuccessful = true
e.ResourceType = rbac.ResourceConfiguration.String()
if ce.ResponseCode != http.StatusOK {
e.IsSuccessful = false
}
evt.Topic = ctlEvent.TopicCommonEvent
evt.Data = e
return nil
}

func (c *resolver) PreCheck(ctx context.Context, url 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())), ""
}
Loading
Loading