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

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions