Skip to content
This repository has been archived by the owner on Jul 12, 2023. It is now read-only.

Purge old realmless users #1135

Merged
merged 13 commits into from
Nov 24, 2020
1 change: 1 addition & 0 deletions pkg/config/cleanup_server_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
13 changes: 13 additions & 0 deletions pkg/controller/cleanup/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions pkg/database/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this does what you think it does?

  • Realm membership changes don't change updated_at, so you could end up immediately deleting someone
  • That column is updated anytime the direct user record changes, we can happen at login with the revoke check

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latter isn't a problem - it's okay to not-delete users who are logging in to keep fresh.

I suppose we can ensure users is bumped on realm-remove

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose we can ensure users is bumped on realm-remove

I couldn't figure out a good way to do that. If we can "touch" the update_at on the user when realm membership is changed, I think this is fine (maybe add a comment).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added TestRemoveRealmUpdatesTime and I can't seem to repro your claim that modifying the realm relationship isn't bumping time

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting... what happens if the user comes serialized from the cache?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the reason is that we have wrapped calls with SaveUser here:

which always saves both the User entry and the associations (even if the table fields haven't changed)

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")
Expand Down
118 changes: 118 additions & 0 deletions pkg/database/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down