Skip to content

policy.Delete is asymmetric with AssignRole: resource-grant tuple leaks on every policy delete #1617

@whoAbhishekSah

Description

@whoAbhishekSah

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

  1. 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.

  2. 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

  1. relationService.Delete(Relation{Object: rolebinding:<polID>}) — wildcard by Object: catches T1, T2; misses T3.
  2. 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:

  1. 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.
  2. 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.

Metadata

Metadata

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