diff --git a/pkg/config/cleanup_server_config.go b/pkg/config/cleanup_server_config.go index 796ef6880..b8696a781 100644 --- a/pkg/config/cleanup_server_config.go +++ b/pkg/config/cleanup_server_config.go @@ -44,6 +44,7 @@ type CleanupConfig struct { AuthorizedAppMaxAge time.Duration `env:"AUTHORIZED_APP_MAX_AGE, default=336h"` CleanupPeriod time.Duration `env:"CLEANUP_PERIOD, default=15m"` MobileAppMaxAge time.Duration `env:"MOBILE_APP_MAX_AGE, default=168h"` + UserPurgeMaxAge time.Duration `env:"USER_PURGE_MAX_AGE, default=720h"` // VerificationCodeMaxAge is the period in which the full code should be available. // After this time it will be recycled. The code will be zeroed out, but its status persist. VerificationCodeMaxAge time.Duration `env:"VERIFICATION_CODE_MAX_AGE, default=48h"` diff --git a/pkg/controller/cleanup/cleanup.go b/pkg/controller/cleanup/cleanup.go index 94bffafb3..9a32cb8bf 100644 --- a/pkg/controller/cleanup/cleanup.go +++ b/pkg/controller/cleanup/cleanup.go @@ -172,6 +172,19 @@ func (c *Controller) HandleCleanup() http.Handler { } }() + // Users + func() { + defer observability.RecordLatency(&ctx, time.Now(), mLatencyMs, &result, &item) + item = tag.Upsert(itemTagKey, "USER") + if count, err := c.db.PurgeUsers(c.config.UserPurgeMaxAge); err != nil { + merr = multierror.Append(merr, fmt.Errorf("failed to purge users: %w", err)) + result = observability.ResultError("FAILED") + } else { + logger.Infow("purged user entries", "count", count) + result = observability.ResultOK() + } + }() + // If there are any errors, return them if merr != nil { if errs := merr.WrappedErrors(); len(errs) > 0 { diff --git a/pkg/database/user.go b/pkg/database/user.go index 6cd400fee..92c57e870 100644 --- a/pkg/database/user.go +++ b/pkg/database/user.go @@ -339,6 +339,21 @@ func (db *Database) DeleteUser(u *User, actor Auditable) error { }) } +// PurgeUsers will delete users who are not a system admin, not a member of any realms +// and have not been modified before the expiry time. +func (db *Database) PurgeUsers(maxAge time.Duration) (int64, error) { + if maxAge > 0 { + maxAge = -1 * maxAge + } + deleteBefore := time.Now().UTC().Add(maxAge) + // Delete users who were created/updated before the expiry time. + rtn := db.db.Unscoped(). + Where("users.system_admin = false AND users.created_at < ? AND users.updated_at < ?", deleteBefore, deleteBefore). + Where("NOT EXISTS(SELECT 1 FROM user_realms WHERE user_realms.user_id = users.id)"). // delete where no realm association exists. + Delete(&User{}) + return rtn.RowsAffected, rtn.Error +} + func (db *Database) SaveUser(u *User, actor Auditable) error { if u == nil { return fmt.Errorf("provided user is nil") diff --git a/pkg/database/user_test.go b/pkg/database/user_test.go index c0a5b201e..3d03a64a5 100644 --- a/pkg/database/user_test.go +++ b/pkg/database/user_test.go @@ -97,6 +97,124 @@ func TestUserLifecycle(t *testing.T) { } } +func TestPurgeUsers(t *testing.T) { + t.Parallel() + + db := NewTestDatabase(t) + + email := "purge@example.com" + user := User{ + Email: email, + Name: "Dr Delete", + SystemAdmin: true, + } + + if err := db.SaveUser(&user, System); err != nil { + t.Fatalf("error creating user: %v", err) + } + expectExists(t, db, user.ID) + + // is admin + if _, err := db.PurgeUsers(time.Duration(0)); err != nil { + t.Fatal(err) + } + expectExists(t, db, user.ID) + + // Update an attribute + user.SystemAdmin = false + realm := NewRealmWithDefaults("test") + user.AddRealm(realm) + if err := db.SaveUser(&user, System); err != nil { + t.Fatal(err) + } + // has a realm + if _, err := db.PurgeUsers(time.Duration(0)); err != nil { + t.Fatal(err) + } + + user.RemoveRealm(realm) + if err := db.SaveUser(&user, System); err != nil { + t.Fatal(err) + } + + // not old enough + if _, err := db.PurgeUsers(time.Hour); err != nil { + t.Fatal(err) + } + expectExists(t, db, user.ID) + + db.PurgeUsers(time.Duration(0)) + + // Find user by ID - Expect deleted + { + got, err := db.FindUser(user.ID) + if err != nil && !IsNotFound(err) { + t.Fatalf("expected user to be deleted. got: %v", err) + } + if got != nil { + t.Fatalf("expected user to be deleted. got: %v", got) + } + } +} + +func TestRemoveRealmUpdatesTime(t *testing.T) { + t.Parallel() + + db := NewTestDatabase(t) + realm := NewRealmWithDefaults("test") + + email := "purge@example.com" + user := User{ + Email: email, + Name: "Dr Delete", + } + user.AddRealm(realm) + + if err := db.SaveUser(&user, System); err != nil { + t.Fatalf("error creating user: %v", err) + } + got, err := db.FindUser(user.ID) + if err != nil { + t.Fatal(err) + } + + if got, want := got.ID, user.ID; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + + time.Sleep(time.Second) // in case this executes in under a nanosecond. + + originalTime := got.Model.UpdatedAt + user.RemoveRealm(realm) + if err := db.SaveUser(&user, System); err != nil { + t.Fatal(err) + } + + got, err = db.FindUser(user.ID) + if err != nil { + t.Fatal(err) + } + + if got, want := got.ID, user.ID; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + // Assert that the user time was updated. + if originalTime == got.Model.UpdatedAt { + t.Errorf("expected user time to be updated. Got %#v", originalTime.Format(time.RFC3339)) + } +} + +func expectExists(t *testing.T, db *Database, id uint) { + got, err := db.FindUser(id) + if err != nil { + t.Fatal(err) + } + + if got, want := got.ID, id; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } +} + func TestUserNotFound(t *testing.T) { t.Parallel()