Skip to content

feat(deleter): user delete checks for blockers before deleting anything - #2027

Open
AmanGIT07 wants to merge 3 commits into
mainfrom
feat/user-delete-check
Open

AmanGIT07 wants to merge 3 commits into
mainfrom
feat/user-delete-check

Conversation

@AmanGIT07

@AmanGIT07 AmanGIT07 commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

DeleteUser now checks what blocks the delete before it changes anything. It refuses while the user is the only owner of an org that has other members, and while an org that would be deleted with the user has blockers of its own. An org where the user is the only member is now deleted with the user. CheckUserDelete gives the same answer without deleting anything. Nothing calls it yet.

Changes

  • core/deleter/service.go:
    • CheckUserDelete goes through each org where the user has an org-level policy and puts it in one of two lists:

      • orgs to delete, where the user is the only member. Each one carries the blockers its own delete would hit, from billing and resources.
      • orgs to leave, where there are other members. Each one carries the user's role title, and a SOLE_OWNER_WITH_MEMBERS blocker when the user is the only owner.

      It also counts the user's PATs.

    • DeleteUser runs the same check first. If anything blocks, it returns every blocker at once and changes nothing. Otherwise it deletes the orgs where the user is the only member and removes the user from the other orgs. Then it deletes the remaining policies, the PATs and the user, as before.

    • CheckOrganizationDelete and the user check share the org's billing and resource check, orgDeleteBlockers.

  • core/deleter/deleter.go: the SOLE_OWNER_WITH_MEMBERS blocker type with its organization subject type, and UserDeleteCheck, OrganizationToDelete and OrganizationToLeave. BlockedError gains UserID, and its message then starts with "user cannot be deleted yet".
  • internal/api/v1beta1connect/user.go: a blocked DeleteUser returns FAILED_PRECONDITION with one violation per blocker, like the org and project deletes. This also covers an org delete's blockers passed up through the user delete.
  • core/user, internal/store/postgres/user_repository.go: GetByIDsIncludingDisabled, the same lookup as GetByIDs without the filter that hides disabled users.
  • core/userpat/service.go: CountByUser.
  • Mocks regenerated for the new interface methods.

Technical Details

  • Only users count as members. An org whose only other member is a service user is deleted with the user.
  • A disabled user counts as a member. Otherwise, deleting the only owner would leave the disabled members in an org with no owner. The member list RPCs hide disabled users (fix: member list RPCs hide disabled users and groups but still return their role pairs #1967), so such an org can look like it has no other members while the delete is blocked.
  • A policy whose user is deleted does not count as a member.
  • Two changes for an admin deleting a user:
    • When the user is the only owner of an org with other members, the delete is refused. Before, the user was removed and the org was left with no owner.
    • When the user is the only member of an org, the org is deleted with its billing accounts, projects and resources. Before, the org was left with no members.
  • CheckUserDelete reads invoices from the local rows, like CheckOrganizationDelete. Those rows sync from the billing provider on a timer, so the check can be a few minutes behind. DeleteUser reads the invoices from the provider for every org it would delete, before it deletes any of them. So the check can allow the delete, and DeleteUser can still refuse it because of an unpaid invoice that hasn't synced yet.
  • A refused user delete puts the blockers of every org in one list. So each blocker of an org that would be deleted starts with organization[<name>]: , followed by its message, or by its type when it has no message. A blocker of an org the user leaves already names its org. The check keeps each org's blockers under that org, without the label.
  • If an org delete fails after that org's policies are gone, a retry of the user delete no longer finds the org. The error names the org id, so the org delete can be run again on its own.

Test Plan

  • core/deleter/service_test.go TestCheckUserDelete: each org goes to the right list. A disabled member counts, and a member without a user row does not. An org that goes with the user carries its blockers, judged on the local invoice rows. Nothing blocks when every org the user leaves keeps an owner. An org that no longer exists is skipped, and a failed read stops the check.
  • TestDeleteUser: the steps run in order: orgs that go with the user, orgs the user leaves, remaining policies, PATs, user. A blocked delete returns every blocker, with invoices judged on provider data, before it changes anything. Each blocker of an org that would be deleted is labeled with the org's name, so the same blocker in two orgs stays apart. An org deleted since the check does not stop the delete. A failure at any step stops the delete before the next step.
  • TestUserDeleteCheck_CanDelete: a blocker in any of the three places means the user cannot be deleted.
  • internal/api/v1beta1connect/user_test.go: a blocked user delete returns FAILED_PRECONDITION with one violation per blocker. So does an org delete's blocker passed up through the user delete.
  • core/userpat/service_test.go TestService_CountByUser and internal/store/postgres/user_repository_test.go TestGetByIDsIncludingDisabled.
  • Breaking any one rule makes a test fail. For example: counting only enabled members, reading the local invoice rows in the delete, ignoring the blockers, leaving orgs before deleting the orgs where the user is the only member, or dropping the org label.
  • go test ./core/deleter/... ./core/user/... ./core/userpat/... ./internal/api/v1beta1connect/... passes.
  • go test ./test/e2e/... passes. End-to-end tests for these rules are in test(e2e): cover the user delete check end to end #2028.
  • make lint reports no issues.

SQL Safety

  • Values flow through goqu.Ex{}: GetByIDsIncludingDisabled uses the same goqu.Ex{"id": goqu.Op{"in": userIDs}} as GetByIDs.
  • ToSQL() callers capture and forward params (query, params, err := stmt.ToSQL(); db.…Context(ctx, …, query, params...)). Never query, _, err := ….
  • No ? placeholders inside single-quoted SQL literals in goqu.L.
  • No new //nolint:forbidigo or // #nosec G20x annotations.

@vercel

vercel Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
frontier Ready Ready Preview Oct 28, 2026 1:00pm UTC

@coderabbitai

coderabbitai Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 1d4bf3c and 332e38b.

Files selected for processing: 5
  • core/deleter/deleter.go
  • core/deleter/service.go
  • core/deleter/service_test.go
  • internal/api/v1beta1connect/user_test.go
  • internal/store/postgres/user_repository_test.go

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

📝 Summary

Summary by CodeRabbit

  • New Features
    • User deletion checks now show which organizations will be deleted or left, identify blockers, and report the user’s personal access token count.
    • Deletion is blocked when the user is the sole owner of an organization with other members. Blocked deletions return details about the organization and reason.
    • Disabled accounts are considered when checking organization membership; soft-deleted accounts remain excluded.
  • Bug Fixes
    • User deletion now handles organization cleanup and stops when checks or cleanup steps fail.

User deletion now checks organization memberships and blockers before cleanup. The check classifies organizations for deletion or leaving and reports the user’s PAT count. Blocked deletions return failed-precondition API responses with structured blocker details.

Changes: User deletion flow

Layer / File(s) Summary
User and PAT lookup support
core/user/user.go, core/user/service.go, internal/store/postgres/user_repository.go, core/user/mocks/repository.go, core/deleter/service.go, core/deleter/mocks/*, core/user/mocks/repository.go, core/userpat/service.go, core/userpat/service_test.go, internal/store/postgres/user_repository_test.go
The user repository and service add lookup of disabled users. The PostgreSQL lookup continues to exclude soft-deleted users. The PAT service adds a per-user PAT count. Deleter interfaces and mocks expose the lookup methods.
Deletion assessment and cleanup
core/deleter/deleter.go, core/deleter/service.go, core/deleter/service_test.go
The deleter adds user-deletion check data and a sole-owner blocker. It classifies organizations for deletion or leaving, checks blockers before cleanup, and tests the assessment and deletion sequence.
Blocked deletion API response
internal/api/v1beta1connect/deleter.go, internal/api/v1beta1connect/user.go, internal/api/v1beta1connect/user_test.go
The API maps blocked user deletions to failed-precondition responses and includes the user ID in blocked-deletion logs. Tests check the response code and structured precondition details.

Priority: Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Suggested reviewers: kushsharma, whoabhisheksah, rohilsurana

Merge Risk: Minimal · up to 332e3

User deletion now checks blockers before changing anything and returns structured failed-precondition responses when blocked. No concrete merge-blocking issue remains in the supplied context. Known non-atomic deletion edge cases have been acknowledged and deferred.

Pre-merge checks: Passed 2
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coveralls

coveralls commented Oct 26, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 40692417526

Coverage increased (+0.1%) to 55.922%

Details

  • Coverage increased (+0.1%) from the base build.
  • Patch coverage: 10 uncovered changes across 3 files (138 of 148 lines covered, 93.24%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
core/deleter/service.go 98 92 93.88%
core/user/service.go 3 0 0.0%
internal/api/v1beta1connect/deleter.go 2 1 50.0%
Total (7 files) 148 138 93.24%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 42082
Covered Lines: 23533
Line Coverage: 55.92%
Coverage Strength: 19.67 hits per line

💛 - Coveralls

Base automatically changed from fix/user-delete-removes-every-policy to main October 27, 2026 11:48
CheckUserDelete lists the orgs where the user is the only member, which are deleted with the user, the orgs the user leaves, what blocks the delete, and the user's PAT count. DeleteUser runs the same check first, with invoices read from the billing provider, and refuses with every blocker before changing anything. Otherwise it deletes the orgs where the user is the only member and removes the user from the rest. A blocked delete returns FAILED_PRECONDITION with one violation per blocker.
Comment thread core/deleter/service.go
if err != nil {
return err
}
if blockers := check.allBlockers(); len(blockers) > 0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The flat list loses which org each blocker came from, so two sole member orgs with resources give two identical RESOURCES_EXIST violations. Can we add the org id or name to each blocker?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Each blocker of an org that would be deleted now starts with organization[<name>]: in a refused delete, followed by its message, or by its type when it has no message. So the two entries read organization[acme]: RESOURCES_EXIST and organization[globex]: RESOURCES_EXIST. SOLE_OWNER_WITH_MEMBERS already names its org, and the check keeps each org's blockers under that org, so neither changes. Done in 1ecf313.

Comment thread core/deleter/deleter.go
}

type UserDeleteCheck struct {
Blockers []Blocker

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nothing sets UserDeleteCheck.Blockers, so the top level blockers in #2031's response are always empty. Should SOLE_OWNER_WITH_MEMBERS go here too, or can we drop the field?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SOLE_OWNER_WITH_MEMBERS belongs to one org, so it sits under that org in organizations_to_leave. Putting it in the top-level list too would show it twice.

The top-level list is for any blocker that doesn't belong to one org. Nothing adds one yet, so it's empty for now. We'll keep the field rather than drop it.

Comment thread core/deleter/service.go
return &BlockedError{UserID: userID, Blockers: blockers}
}
for _, o := range check.OrganizationsToDelete {
if err := d.DeleteOrganization(ctx, o.Organization.ID); err != nil && !errors.Is(err, organization.ErrNotExist) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If someone joins a sole member org between the check and this loop, the org is deleted with them in it. Is that fine until we have transactions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, it's fine for now. The user was told this org would be deleted with their account and confirmed it, so deleting it is what they asked for.

To get in, someone has to accept an invite the user sent earlier, join through the org's verified domain, or be added by a superuser, all in the short gap between the check and that org's delete. A second member check just before the delete would only make the gap smaller. Closing it needs the check and the delete in one transaction across Postgres and SpiceDB, which we don't have yet.

Comment thread core/deleter/service.go
}
for _, o := range check.OrganizationsToDelete {
if err := d.DeleteOrganization(ctx, o.Organization.ID); err != nil && !errors.Is(err, organization.ErrNotExist) {
return fmt.Errorf("failed to delete org[%s]: %w", o.Organization.ID, err)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If an org delete fails after its policies are gone, the retry won't find the org and the user delete goes through, leaving a half deleted org. Do we have a follow up ticket for this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We have other cases like this one, where a step fails after an earlier step has already changed something. A plain org delete has this exact gap today. Reordering the steps would only move the problem to another step, so these will be fixed together once we have transaction support.

Until then, a failed step returns an error and raises an alert, so a developer can look at it and clean up if needed.

Comment thread core/deleter/service.go
// otherwise userService.Delete would leave orphan policy rows behind.
func (d Service) DeleteUser(ctx context.Context, userID string) error {
func (d Service) CheckUserDelete(ctx context.Context, userID string) (UserDeleteCheck, error) {
check, err := d.checkUser(ctx, userID, false)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The check reads local invoice rows but the delete asks the provider, so can_delete=true can still end in a refused delete. Can we say this in the proto doc or the PR body?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added to the PR description, next to where it says what each one reads: the check can allow the delete, and DeleteUser can still refuse it because of an unpaid invoice that hasn't synced yet.

Comment thread core/deleter/service.go
default:
toLeave := OrganizationToLeave{Organization: org}
if len(self.Roles) > 0 {
toLeave.RoleTitle = self.Roles[0].Title

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: Roles[0] has no fixed order, so a user who is both owner and viewer can show as Member. Prefer the owner role title.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A user holds one org role at a time. Adding a member refuses when the user already has a role, and changing a member's role replaces the old one instead of adding a second. So Roles[0] is the user's role.

Comment thread core/deleter/service.go Outdated
toLeave.Blockers = []Blocker{{
Type: BlockerSoleOwnerWithMembers,
Subject: org.ID,
SubjectType: schema.OrganizationNamespace,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: the other subject types are plain words like invoice, so a SubjectOrganization = "organization" constant would fit better.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added SubjectOrganization = "organization", and the SOLE_OWNER_WITH_MEMBERS blocker uses it. Done in 332e38b.

This branch was successfully deployed

1 active deployment
Preview — 332e38b0 Deployed Oct 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants