-
Notifications
You must be signed in to change notification settings - Fork 44
Remove redundant direct SpiceDB relations for organization and group membership #1478
Description
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:
- Remove
relationServicecalls inside the membership package - Update SpiceDB schema (remove
+ owner, narrowmember) - 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.