diff --git a/src/common/const.go b/src/common/const.go index 7f213b484..2a2dce854 100755 --- a/src/common/const.go +++ b/src/common/const.go @@ -187,6 +187,8 @@ const ( TraceOtelInsecure = "trace_otel_insecure" TraceOtelTimeout = "trace_otel_timeout" + GDPRDeleteUser = "gdpr_delete_user" + // These variables are temporary solution for issue: https://github.com/goharbor/harbor/issues/16039 // When user disable the pull count/time/audit log, it will decrease the database access, especially in large concurrency pull scenarios. // TODO: Once we have a complete solution, delete these variables. diff --git a/src/controller/user/controller.go b/src/controller/user/controller.go index da125a26e..f84567b04 100644 --- a/src/controller/user/controller.go +++ b/src/controller/user/controller.go @@ -16,6 +16,7 @@ package user import ( "context" + "github.com/goharbor/harbor/src/lib/config" "github.com/goharbor/harbor/src/common" commonmodels "github.com/goharbor/harbor/src/common/models" @@ -178,6 +179,13 @@ func (c *controller) Delete(ctx context.Context, id int) error { return errors.UnknownError(err).WithMessage("delete user failed, user id: %v, cannot delete oidc user, error:%v", id, err) } } + gdprSetting, err := config.GDPRSetting(ctx) + if err != nil { + return errors.UnknownError(err).WithMessage("failed to load GDPR setting: %v", err) + } + if gdprSetting.DeleteUser { + return c.mgr.DeleteGDPR(ctx, id) + } return c.mgr.Delete(ctx, id) } diff --git a/src/lib/config/metadata/metadatalist.go b/src/lib/config/metadata/metadatalist.go index 12e0d4138..1c2e7c340 100644 --- a/src/lib/config/metadata/metadatalist.go +++ b/src/lib/config/metadata/metadatalist.go @@ -53,6 +53,7 @@ const ( // Put all config items do not belong a existing group into basic BasicGroup = "basic" TrivyGroup = "trivy" + GDPRGroup = "gdpr" ) var ( @@ -187,6 +188,8 @@ var ( {Name: common.CacheEnabled, Scope: SystemScope, Group: BasicGroup, EnvKey: "CACHE_ENABLED", DefaultValue: "false", ItemType: &BoolType{}, Editable: false, Description: `Enable cache`}, {Name: common.CacheExpireHours, Scope: SystemScope, Group: BasicGroup, EnvKey: "CACHE_EXPIRE_HOURS", DefaultValue: "24", ItemType: &IntType{}, Editable: false, Description: `The expire hours for cache`}, + {Name: common.GDPRDeleteUser, Scope: SystemScope, Group: GDPRGroup, EnvKey: "GDPR_DELETE_USER", DefaultValue: "false", ItemType: &BoolType{}, Editable: false, Description: `The flag indicates if a user should be deleted compliant with GDPR.`}, + {Name: common.AuditLogForwardEndpoint, Scope: UserScope, Group: BasicGroup, EnvKey: "AUDIT_LOG_FORWARD_ENDPOINT", DefaultValue: "", ItemType: &StringType{}, Editable: false, Description: `The endpoint to forward the audit log.`}, {Name: common.SkipAuditLogDatabase, Scope: UserScope, Group: BasicGroup, EnvKey: "SKIP_LOG_AUDIT_DATABASE", DefaultValue: "false", ItemType: &BoolType{}, Editable: false, Description: `The option to skip audit log in database`}, } diff --git a/src/lib/config/models/model.go b/src/lib/config/models/model.go index ee08498b8..187219f7b 100644 --- a/src/lib/config/models/model.go +++ b/src/lib/config/models/model.go @@ -106,3 +106,7 @@ type GroupConf struct { AdminDN string `json:"ldap_group_admin_dn,omitempty"` MembershipAttribute string `json:"ldap_group_membership_attribute,omitempty"` } + +type GDPRSetting struct { + DeleteUser bool `json:"user_delete,omitempty"` +} diff --git a/src/lib/config/userconfig.go b/src/lib/config/userconfig.go index 7baaa5b15..87895dd7a 100644 --- a/src/lib/config/userconfig.go +++ b/src/lib/config/userconfig.go @@ -191,6 +191,16 @@ func OIDCSetting(ctx context.Context) (*cfgModels.OIDCSetting, error) { }, nil } +// GDPRSetting returns the setting of GDPR +func GDPRSetting(ctx context.Context) (*cfgModels.GDPRSetting, error) { + if err := DefaultMgr().Load(ctx); err != nil { + return nil, err + } + return &cfgModels.GDPRSetting{ + DeleteUser: DefaultMgr().Get(ctx, common.GDPRDeleteUser).GetBool(), + }, nil +} + // NotificationEnable returns a bool to indicates if notification enabled in harbor func NotificationEnable(ctx context.Context) bool { return DefaultMgr().Get(ctx, common.NotificationEnable).GetBool() diff --git a/src/pkg/user/manager.go b/src/pkg/user/manager.go index 6ba79aca0..3ba06a1c3 100644 --- a/src/pkg/user/manager.go +++ b/src/pkg/user/manager.go @@ -24,6 +24,7 @@ import ( "github.com/goharbor/harbor/src/lib/q" "github.com/goharbor/harbor/src/pkg/user/dao" "github.com/goharbor/harbor/src/pkg/user/models" + "hash/crc32" "strings" ) @@ -46,6 +47,8 @@ type Manager interface { Create(ctx context.Context, user *commonmodels.User) (int, error) // Delete deletes the user by updating user's delete flag and update the name and Email Delete(ctx context.Context, id int) error + // DeleteGDPR deletes the user by updating user's delete flag and replace identifiable data with its crc + DeleteGDPR(ctx context.Context, id int) error // SetSysAdminFlag sets the system admin flag of the user in local DB SetSysAdminFlag(ctx context.Context, id int, admin bool) error // UpdateProfile updates the user's profile @@ -102,6 +105,18 @@ func (m *manager) Delete(ctx context.Context, id int) error { return m.dao.Update(ctx, u, "username", "email", "deleted") } +func (m *manager) DeleteGDPR(ctx context.Context, id int) error { + u, err := m.Get(ctx, id) + if err != nil { + return err + } + u.Username = fmt.Sprintf("%s#%d", checkSum(u.Username), u.UserID) + u.Email = fmt.Sprintf("%s#%d", checkSum(u.Email), u.UserID) + u.Realname = fmt.Sprintf("%s#%d", checkSum(u.Realname), u.UserID) + u.Deleted = true + return m.dao.Update(ctx, u, "username", "email", "realname", "deleted") +} + func (m *manager) MatchLocalPassword(ctx context.Context, usernameOrEmail, password string) (*commonmodels.User, error) { l, err := m.dao.List(ctx, q.New(q.KeyWords{"username_or_email": usernameOrEmail})) if err != nil { @@ -202,3 +217,7 @@ func injectPasswd(u *commonmodels.User, password string) { u.Salt = salt u.PasswordVersion = utils.SHA256 } + +func checkSum(str string) string { + return fmt.Sprintf("%08x", crc32.Checksum([]byte(str), crc32.IEEETable)) +} diff --git a/src/pkg/user/manager_test.go b/src/pkg/user/manager_test.go index 19b9b4639..5c4560e3a 100644 --- a/src/pkg/user/manager_test.go +++ b/src/pkg/user/manager_test.go @@ -2,6 +2,7 @@ package user import ( "context" + "fmt" "testing" "github.com/goharbor/harbor/src/common/models" @@ -47,6 +48,37 @@ func (m *mgrTestSuite) TestSetAdminFlag() { m.dao.AssertExpectations(m.T()) } +func (m *mgrTestSuite) TestUserDeleteGDPR() { + existingUser := &models.User{ + UserID: 123, + Username: "existing", + Email: "existing@mytest.com", + Realname: "RealName", + } + m.dao.On("List", mock.Anything, testifymock.MatchedBy( + func(query *q.Query) bool { + return query.Keywords["user_id"] == 123 + })).Return( + []*models.User{existingUser}, nil) + + m.dao.On("Update", mock.Anything, testifymock.MatchedBy( + func(u *models.User) bool { + return u.UserID == 123 && + u.Email == fmt.Sprintf("%s#%d", checkSum("existing@mytest.com"), existingUser.UserID) && + u.Username == fmt.Sprintf("%s#%d", checkSum("existing"), existingUser.UserID) && + u.Realname == fmt.Sprintf("%s#%d", checkSum("RealName"), existingUser.UserID) && + u.Deleted == true + }), + "username", + "email", + "realname", + "deleted", + ).Return(nil) + + err := m.mgr.DeleteGDPR(context.Background(), 123) + m.Nil(err) +} + func (m *mgrTestSuite) TestOnboard() { existingUser := &models.User{ UserID: 123, diff --git a/src/testing/pkg/user/manager.go b/src/testing/pkg/user/manager.go index 27ab5b028..2d591b723 100644 --- a/src/testing/pkg/user/manager.go +++ b/src/testing/pkg/user/manager.go @@ -74,6 +74,20 @@ func (_m *Manager) Delete(ctx context.Context, id int) error { return r0 } +// DeleteGDPR provides a mock function with given fields: ctx, id +func (_m *Manager) DeleteGDPR(ctx context.Context, id int) error { + ret := _m.Called(ctx, id) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Get provides a mock function with given fields: ctx, id func (_m *Manager) Get(ctx context.Context, id int) (*models.User, error) { ret := _m.Called(ctx, id)