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.
Summary
serviceuser.Service.Deleteleaks theapp/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
DeleteServiceUseris 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
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) callsmembershipService.RemoveOrganizationMember"best-effort" and logs/swallows errors.RemoveOrganizationMember(core/membership/service.go:253) returnsErrNotMemberearly when the principal has no org policies (core/membership/service.go:273-275), so it never reachescascadeRemovePrincipal, which is where the SU↔org identity-link cleanup actually lives (core/membership/service.go:397-407).The follow-up
relationService.Delete(Subject={SU})inserviceuser.Service.Deleteonly 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
app/serviceusersee stale rows. Over time this is harder to reason about and harder to back-test relation invariants against.Suggested fix
In
serviceuser.Service.Delete, after the existing Subject-side wildcard, also issue an Object-side wildcard for the SU:Alternative: have
RemoveOrganizationMemberclean the identity-link unconditionally (move the block out from under thecascadeRemovePrincipalearly-return), but this is broader in scope.Test plan
core/serviceusercovering the "no remaining policies" path.