Skip to content

Remove redundant direct SpiceDB relations for organization and group membership #1478

@whoAbhishekSah

Description

@whoAbhishekSah

Problem

When users are added to organizations or groups, we create both a policy (role binding) and a direct SpiceDB relation. The direct relations are redundant and cause bugs - for example, an org creator demoted from owner to member retains owner permissions because their direct owner relation persists.

Current State vs Proposed

Resource Action Role (via policy) Direct Relation (current) Direct Relation (proposed)
Organization Add user as owner Owner (app_organization_owner) organization#owner@user None
Organization Add user as admin Admin (app_organization_manager) organization#member@user None
Organization Add user as member Member (app_organization_viewer) organization#member@user None
Organization Add serviceuser (same as user) organization#member@serviceuser None
Organization Add group - organization#member@group#member Keep
Group Add user as owner Team Owner (app_group_owner) group#owner@user None
Group Add user as member Team Member (app_group_member) group#member@user Keep
Project Add user as owner Project Owner (app_project_owner) None None
Project Add user as manager Project Manager (app_project_manager) None None
Project Add user as viewer Project Viewer (app_project_viewer) None None

Project is the clean model - only policies, no direct relations. We want org and group to follow this pattern.

Why keep these relations?

Relation Reason
organization#member@group#member Gives group members automatic org visibility
group#member@user Needed for SpiceDB to resolve group-level policies

Recommended Approach: Membership Package

Instead of modifying the SpiceDB schema directly, introduce a membership package that centralises all policy and relation management. This gives us immediate bug fixes without schema changes or data migration.

Why this approach

  • Fixes bugs now — reads move to policy-based listing immediately, so stale direct relations stop causing permission issues
  • Single place to debug — all membership logic (add, remove, role change, listing) lives in one package instead of being scattered across org, group, and project services. When the next issue shows up, there's one place to look and one place to fix
  • No migration needed — no SpiceDB schema changes, no data cleanup, single code deploy
  • Easy rollback — if something breaks, revert one package
  • Sets up full cleanup — once all call sites go through the package, removing direct relations becomes a one-file change inside the package (no external callers affected)

Package interface

// core/membership/service.go

// SetRole - add or change a principal's role on a resource
// Creates policy + direct relation (for backward compat)
func (s *Service) SetRole(ctx, resourceID, resourceType, principalID, principalType, roleID string) error

// Remove - remove a principal from a resource
// Deletes policies + direct relation
func (s *Service) Remove(ctx, resourceID, resourceType, principalID, principalType string) error

// ListByResource - list principals on a resource (always via policies)
func (s *Service) ListByResource(ctx, resourceID, resourceType string) ([]policy.Policy, error)

// ListByPrincipal - list resources a principal belongs to (replaces LookupResources)
func (s *Service) ListByPrincipal(ctx, principalID, principalType, resourceType string) ([]string, error)

Call sites to update (9 total)

# Current Replace with
1 organization.AddMember (policy + direct relation) membership.SetRole
2 organization.ListByUser (SpiceDB LookupResources) membership.ListByPrincipal
3 organization.RemoveUsers (policy delete + relation delete) membership.Remove
4 group.addMember (policy + direct relation) membership.SetRole
5 group.addOwner (policy + direct relation) membership.SetRole
6 group.ListByUser (SpiceDB LookupResources) membership.ListByPrincipal
7 group.RemoveMembers (relation delete) membership.Remove
8 serviceuser.Create (direct member relation on org) membership.SetRole
9 organization.SetMemberRole (already policy-only) membership.SetRole

What does NOT change

  • Hierarchy relations (project#org, group#org, serviceuser#org) — these are entity links, not membership
  • Policy/rolebinding system (policy.AssignRole) — stays in policy service
  • Role definitions (role.createRolePermissionRelation) — stays in role service
  • Platform relations (user sudo) — stays in user service
  • Permission checks (CheckPermission, BatchCheck) — unchanged

Impact on Project Inherited Permissions

Projects inherit permissions from organizations via the org relation:

permission delete = org->project_delete + granted->app_project_administer + ...
permission project_delete = ... + granted->app_organization_administer + owner

The app_organization_owner role includes app_organization_administer permission, so the inheritance still works via the policy path:

project#delete 
  → org#project_delete 
    → granted->app_organization_administer  ✅ (owner role has this)
User State Current Access After Change
Has policy + relation ✅ via both paths ✅ via policy path
Has only policy ✅ via policy ✅ via policy
Has only relation (bug state) ✅ via relation ❌ loses access

The third case (only relation, no policy) is the bug we're fixing.


Benefits

Benefit Description
Fixes permission bug Role changes will actually work - no more stale permissions from direct relations
Code simplicity One package for all membership operations instead of scattered logic
SDK simplicity SDK only deals with role-based operations (assign role, change role, remove role). Policies and relations are internal constructs - SDK consumers don't need to know about them
Consistency Same pattern for org, group, and project membership
Single source of truth Reads always use policies - easier to debug and audit
No migration risk No schema changes, no SpiceDB data migration needed

Potential Downsides

Concern Impact Mitigation
Two systems remain Direct relations still created alongside policies Package ensures they stay in sync; can remove later
Package enforcement New code must use the package Code review + documentation

Future: Full Cleanup (Phase 2)

Once the membership package is in place and stable, removing direct relations becomes a contained change:

  1. Remove relationService calls inside the membership package
  2. Update SpiceDB schema (remove + owner, narrow member)
  3. Run migration to clean up stale relations

No external callers change — only the internals of the membership package.

See #1479 for the additional improvement of using computed permissions for group membership.

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