Problem
policy.AssignRole writes three SpiceDB tuples per policy, but policy.Delete / policy.DeleteWithMinRoleGuard only clean up two of them. Every call to delete a policy leaks the third tuple.
Service-level write/delete audit
policy.Create writes
-
Policy row via repository.Upsert — INSERT ON CONFLICT UPDATE on (role_id, resource_id, resource_type, principal_id, principal_type). Returns same ID on conflict and updates grant_relation, metadata, updated_at.
-
Three SpiceDB tuples via AssignRole:
T1: rolebinding:<polID>#role_bearer @ <principal_type>:<principal_id>[#member]
T2: rolebinding:<polID>#role @ role:<role_id>
T3: <resource_type>:<resource_id>#<grant_relation> @ rolebinding:<polID>
T1, T2 have the rolebinding as Object. T3 has it as Subject (the resource is the Object).
policy.Delete deletes
relationService.Delete(Relation{Object: rolebinding:<polID>}) — wildcard by Object: catches T1, T2; misses T3.
repository.Delete(polID) — drops the policy row.
DeleteWithMinRoleGuard has the same gap.
Asymmetries
A1 — T3 always leaks (primary, real)
T3 has the rolebinding as Subject, not Object, so the wildcard-by-Object delete never matches it. Every removal path leaks T3:
membership.RemoveOrganizationMember / RemoveProjectMember / RemoveGroupMember
membership.RemoveAllGroupMembers, removeGroupAsPrincipalPolicies
project.UpdateOwner, project.RemovePrincipalPolicies
Today group.DeleteModel and project.DeleteModel paper over it with a wildcard Delete(Object: <resource>) sweep at resource-delete time — but:
- The sweep only catches T3 where the deleted resource is the object. T3 on other resources (e.g. group-as-principal on a project) still leaks.
- Per-member removals (not full resource delete) leak T3 forever.
A2 — Upsert replay can leave a stale T3 (theoretical)
Create calls repository.Upsert. On conflict (same unique key) it updates grant_relation and returns the SAME polID. AssignRole then re-writes T1/T2/T3 with the NEW grant_relation. SpiceDB writes use OPERATION_TOUCH (idempotent for identical tuples), so T1 and T2 are fine. But the OLD T3 — <resource>#<old_grant>@rolebinding:<polID> — is never deleted: a new tuple at <resource>#<new_grant>@rolebinding:<polID> is written alongside it.
Not currently exploitable in practice — grant_relation is one of two fixed values (granted / pat_granted) tied to principal_type, which is part of the unique key, so the Upsert branch can never observe a grant_relation change. Worth fixing if grant_relation ever becomes meaningfully variable.
Proposed fix (A1)
Make Delete symmetric with AssignRole — also remove T3 by deleting where the rolebinding is the Subject:
func (s Service) Delete(ctx context.Context, id string) error {
// T1, T2: rolebinding as Object
if err := s.relationService.Delete(ctx, relation.Relation{
Object: relation.Object{ID: id, Namespace: schema.RoleBindingNamespace},
}); err != nil { return err }
// T3: rolebinding as Subject (resource-grant tuple)
if err := s.relationService.Delete(ctx, relation.Relation{
Subject: relation.Subject{ID: id, Namespace: schema.RoleBindingNamespace},
}); err != nil && !errors.Is(err, relation.ErrNotExist) { return err }
return s.repository.Delete(ctx, id)
}
Same change in DeleteWithMinRoleGuard.
Once landed, the wildcard sweeps in group.DeleteModel / project.DeleteModel become dead code and can be removed in a followup.
Problem
policy.AssignRolewrites three SpiceDB tuples per policy, butpolicy.Delete/policy.DeleteWithMinRoleGuardonly clean up two of them. Every call to delete a policy leaks the third tuple.Service-level write/delete audit
policy.CreatewritesPolicy row via
repository.Upsert— INSERT ON CONFLICT UPDATE on(role_id, resource_id, resource_type, principal_id, principal_type). Returns same ID on conflict and updatesgrant_relation,metadata,updated_at.Three SpiceDB tuples via
AssignRole:T1, T2 have the rolebinding as Object. T3 has it as Subject (the resource is the Object).
policy.DeletedeletesrelationService.Delete(Relation{Object: rolebinding:<polID>})— wildcard by Object: catches T1, T2; misses T3.repository.Delete(polID)— drops the policy row.DeleteWithMinRoleGuardhas the same gap.Asymmetries
A1 — T3 always leaks (primary, real)
T3 has the rolebinding as Subject, not Object, so the wildcard-by-Object delete never matches it. Every removal path leaks T3:
membership.RemoveOrganizationMember/RemoveProjectMember/RemoveGroupMembermembership.RemoveAllGroupMembers,removeGroupAsPrincipalPoliciesproject.UpdateOwner,project.RemovePrincipalPoliciesToday
group.DeleteModelandproject.DeleteModelpaper over it with a wildcardDelete(Object: <resource>)sweep at resource-delete time — but:A2 — Upsert replay can leave a stale T3 (theoretical)
Createcallsrepository.Upsert. On conflict (same unique key) it updatesgrant_relationand returns the SAMEpolID.AssignRolethen re-writes T1/T2/T3 with the NEWgrant_relation. SpiceDB writes useOPERATION_TOUCH(idempotent for identical tuples), so T1 and T2 are fine. But the OLD T3 —<resource>#<old_grant>@rolebinding:<polID>— is never deleted: a new tuple at<resource>#<new_grant>@rolebinding:<polID>is written alongside it.Not currently exploitable in practice —
grant_relationis one of two fixed values (granted/pat_granted) tied toprincipal_type, which is part of the unique key, so the Upsert branch can never observe agrant_relationchange. Worth fixing ifgrant_relationever becomes meaningfully variable.Proposed fix (A1)
Make
Deletesymmetric withAssignRole— also remove T3 by deleting where the rolebinding is the Subject:Same change in
DeleteWithMinRoleGuard.Once landed, the wildcard sweeps in
group.DeleteModel/project.DeleteModelbecome dead code and can be removed in a followup.