Skip to content

bug(serviceuser): SU→org identity-link relation tuple leaks on DeleteServiceUser when no org policies remain #1629

@whoAbhishekSah

Description

@whoAbhishekSah

Summary

serviceuser.Service.Delete leaks the app/serviceuser:<su>#org@app/organization:<org> "identity link" relation tuple in SpiceDB whenever the service user has no remaining org policies at delete time.

Concretely: if all policies with the SU as principal have been removed before DeleteServiceUser is called, the SU row is dropped from postgres and the SU is gone from the API surface, but the identity-link tuple remains live in SpiceDB forever.

Repro

# 1. Fresh org and SU
ORG=$(create_org)
SU=$(create_serviceuser org=$ORG)

# 2. Create a policy granting SU some org role
POL=$(create_policy role=org_viewer resource=app/organization:$ORG principal=app/serviceuser:$SU)

# 3. Delete the policy BEFORE deleting the SU
delete_policy $POL

# 4. Delete the SU
delete_serviceuser $SU

# 5. Query SpiceDB
SELECT * FROM relation_tuple
WHERE deleted_xid = '9223372036854775807'::xid8
  AND (object_id = '<SU>' OR userset_object_id = '<SU>');
-- Expected: 0 rows
-- Actual:   app/serviceuser:<SU>#org@app/organization:<ORG>

If step 3 is skipped (i.e. there's at least one SU-principal org policy at delete time), no leak — the auto-cascade path picks up the identity link.

Root cause

serviceuser.Service.Delete (core/serviceuser/service.go:142) calls membershipService.RemoveOrganizationMember "best-effort" and logs/swallows errors. RemoveOrganizationMember (core/membership/service.go:253) returns ErrNotMember early when the principal has no org policies (core/membership/service.go:273-275), so it never reaches cascadeRemovePrincipal, which is where the SU↔org identity-link cleanup actually lives (core/membership/service.go:397-407).

The follow-up relationService.Delete(Subject={SU}) in serviceuser.Service.Delete only cleans tuples where the SU is the subject of the relation. The identity-link tuple has the SU as the object, so it's missed.

Why this matters

Suggested fix

In serviceuser.Service.Delete, after the existing Subject-side wildcard, also issue an Object-side wildcard for the SU:

// existing: relations where SU is the Subject
if err := s.relationService.Delete(ctx, relation.Relation{
    Subject: relation.Subject{ID: id, Namespace: schema.ServiceUserPrincipal},
}); err != nil {
    return err
}

// new: relations where SU is the Object (e.g. SU#org@org)
if err := s.relationService.Delete(ctx, relation.Relation{
    Object: relation.Object{ID: id, Namespace: schema.ServiceUserPrincipal},
}); err != nil && !errors.Is(err, relation.ErrNotExist) {
    return err
}

Alternative: have RemoveOrganizationMember clean the identity-link unconditionally (move the block out from under the cascadeRemovePrincipal early-return), but this is broader in scope.

Test plan

  • Unit test in core/serviceuser covering the "no remaining policies" path.
  • Integration / manual: same repro above; assert post-delete tuple count for the SU is 0.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions