Context
The core/membership/ package centralizes member access management across organizations, projects, and groups (#1478). Today the private core functions (replacePolicy, replaceRelation, removeAllPolicies) are shared, but each public function still decides whether to call relation operations or not. When someone adds group support, they have to know which relations to manage — the same scattered-knowledge problem from the original design, just one level deeper.
The problem in detail
Frontier manages access through two mechanisms:
- Policies — role bindings stored in Postgres (e.g., "user X has role
app_project_viewer on project Y")
- Relations — explicit SpiceDB tuples (e.g.,
organization:acme#owner@user:alice)
Not every resource type uses both. This is the source of bugs:
| Resource |
Policies |
Relations |
What happens on role change today |
| Organization |
Yes |
Yes (owner, member) |
Policy is replaced, but relation is NOT updated — this is the stale-permission bug |
| Project |
Yes |
No |
Policy is replaced. Clean. |
| Group |
Yes |
Yes (owner, member) |
Same risk as org |
Today, even inside the membership package, the public functions encode this knowledge procedurally:
// org — caller knows to call replaceRelation
func (s *Service) SetOrganizationMemberRole(...) {
s.replacePolicy(...)
s.replaceRelation(...) // caller remembers to do this
}
// project — caller knows NOT to call replaceRelation
func (s *Service) SetProjectMemberRole(...) {
s.replacePolicy(...)
// no replaceRelation — caller knows project is clean
}
If a future developer copies the project pattern for groups, they'll forget the relation step and reintroduce the bug. The knowledge of "which resources need relations" is implicit in code flow, not declared.
Proposal
Member struct
Replaces the 4-string tuple (resourceID, resourceType, principalID, principalType) passed everywhere:
type Member struct {
ResourceID string
ResourceType string
PrincipalID string
PrincipalType string
}
resourceSpec — declaring what membership means for a resource type
This is the key abstraction. Instead of each public function deciding what to do, we declare what membership operations mean for each resource type.
A resourceSpec is a direct reflection of the SpiceDB schema (base_schema.zed). The schema defines which relations exist on each resource type, and the spec encodes that into Go so the membership package can manage them automatically. Any change to base_schema.zed that adds, removes, or renames a relation on org/project/group MUST be reflected in the corresponding resourceSpec. If the schema says organization has owner and member relations, the spec must list both in allRelations and map roles to them in roleToRelation. A mismatch means stale relations or missing access — the exact class of bugs this package exists to prevent.
type resourceSpec struct {
// policyFilter builds the correct policy.Filter for listing/querying policies.
// Each resource type uses a different filter field (OrgID vs ProjectID vs GroupID)
// because policy.Filter has separate fields per resource type.
policyFilter func(m Member) policy.Filter
// roleToRelation maps a role to the SpiceDB relation name that should be created.
// This MUST match the relations defined in base_schema.zed for this resource type.
//
// For orgs: owner role → "owner" relation, everything else → "member" relation.
// For projects: nil — projects don't have membership relations in SpiceDB.
// For groups: owner role → "owner" relation, member role → "member" relation.
//
// When nil, upsertMembership/removeMembership skip all relation operations.
// This is what makes the abstraction safe — you can't forget to handle relations
// because the spec declares whether they exist.
roleToRelation func(role.Role) string
// allRelations lists every possible membership relation name for this resource type,
// as defined in base_schema.zed. On any mutation (add, role change, remove), ALL old
// relations are deleted first, then the correct one is created. This prevents stale
// relations.
//
// For orgs: []string{"owner", "member"} — matches base_schema.zed organization definition
// For projects: nil (no relations)
// For groups: []string{"owner", "member"} — matches base_schema.zed group definition
//
// Empty/nil means no relation cleanup is needed.
allRelations []string
// multiRole controls whether a principal can hold multiple roles simultaneously
// on this resource type. Default (false) = single role, upsert semantics.
// See "Future: multi-role support" section below.
multiRole bool
}
Why each field exists:
-
policyFilter — The policy.Filter struct has separate fields (OrgID, ProjectID, GroupID) instead of a generic ResourceID. Each resource type needs to set the right field. Without this, every function that lists policies needs a switch statement on resource type.
-
roleToRelation — This encodes the mapping from role → SpiceDB relation, derived from base_schema.zed. For orgs, the owner role maps to the owner relation, while viewer/manager map to member. For projects, this is nil because projects don't have SpiceDB membership relations. When this is nil, the generic core skips all relation operations entirely — not because the caller forgot, but because the spec says they don't apply.
-
allRelations — On every mutation, we must delete ALL existing relations before creating the new one. If you demote an org owner to viewer, you must delete both owner and member relations (in case either exists), then create member. This list tells the core what to clean up. Without it, you'd need to know which relations could exist — the exact knowledge that was scattered before.
Relationship to base_schema.zed
The resourceSpec declarations are a Go-side mirror of base_schema.zed. The schema is the source of truth for what relations exist:
// base_schema.zed (simplified)
definition organization {
relation owner: user | serviceuser
relation member: user | serviceuser | group#member
// ...
}
definition project {
// no owner/member relations — access is purely via policies
relation granted: role#bearer
// ...
}
definition group {
relation owner: user
relation member: user
// ...
}
The specs must stay in sync:
// If base_schema.zed adds a new relation to organization (e.g., "admin"),
// orgSpec.allRelations must be updated to include "admin", and
// orgSpec.roleToRelation must map the corresponding role to "admin".
// Failure to do this = stale relations on role change/removal.
var orgSpec = resourceSpec{
allRelations: []string{"owner", "member"}, // ← must match base_schema.zed
roleToRelation: orgRoleToRelation, // ← must map all roles to their relations
// ...
}
Rule: When modifying base_schema.zed, check every resourceSpec in the membership package. If a membership-related relation is added, removed, or renamed, the corresponding spec must be updated in the same PR. Consider adding a code comment in base_schema.zed pointing to the spec declarations, and vice versa.
Declared once per resource type
var orgSpec = resourceSpec{
policyFilter: func(m Member) policy.Filter {
return policy.Filter{OrgID: m.ResourceID, PrincipalID: m.PrincipalID, PrincipalType: m.PrincipalType}
},
roleToRelation: func(r role.Role) string {
if r.Name == schema.RoleOrganizationOwner {
return "owner"
}
return "member"
},
allRelations: []string{"owner", "member"},
}
var projectSpec = resourceSpec{
policyFilter: func(m Member) policy.Filter {
return policy.Filter{ProjectID: m.ResourceID, PrincipalID: m.PrincipalID, PrincipalType: m.PrincipalType}
},
roleToRelation: nil, // projects don't have membership relations — policy only
allRelations: nil,
}
// Future: adding group is just declaring a new spec
var groupSpec = resourceSpec{
policyFilter: func(m Member) policy.Filter {
return policy.Filter{GroupID: m.ResourceID, PrincipalID: m.PrincipalID, PrincipalType: m.PrincipalType}
},
roleToRelation: func(r role.Role) string {
if r.Name == schema.GroupOwnerRole {
return "owner"
}
return "member"
},
allRelations: []string{"owner", "member"},
}
Generic core — 2 functions that handle everything
These read the spec to decide what to do. The caller never decides.
// upsertMembership replaces the policy (always) and — if the resource spec
// declares relations — replaces the relation too. The caller can't forget
// the relation step because it's driven by the spec, not by the caller.
func (s *Service) upsertMembership(ctx context.Context, spec resourceSpec, m Member, fetchedRole role.Role, existing []policy.Policy) error {
// skip if already has exactly this role
if len(existing) == 1 && existing[0].RoleID == fetchedRole.ID {
return nil
}
// always replace policy
if err := s.replacePolicy(ctx, m, fetchedRole.ID, existing); err != nil {
return err
}
// replace relation ONLY if this resource type has relations
if spec.roleToRelation != nil {
newRelation := spec.roleToRelation(fetchedRole)
if err := s.replaceRelation(ctx, m, spec.allRelations, newRelation); err != nil {
return err
}
}
return nil
}
// removeMembership deletes all policies (always) and — if the resource spec
// declares relations — deletes all relations too.
func (s *Service) removeMembership(ctx context.Context, spec resourceSpec, m Member) (int, error) {
removed, err := s.removeAllPolicies(ctx, spec, m)
if err != nil {
return 0, err
}
if len(spec.allRelations) > 0 {
if err := s.removeAllRelations(ctx, m, spec.allRelations); err != nil {
return removed, err
}
}
return removed, nil
}
Public functions become thin validation wrappers
The public API stays the same. The only change is internal — validation + delegate to generic core:
func (s *Service) SetProjectMemberRole(ctx, projectID, principalID, principalType, roleID string) error {
fetchedRole, err := s.validateProjectRole(ctx, roleID)
if err != nil { return err }
prj, err := s.projectService.Get(ctx, projectID)
if err != nil { return err }
if err := s.validateOrgMembership(ctx, prj.Organization.ID, principalID, principalType); err != nil {
return err
}
m := Member{ResourceID: projectID, ResourceType: schema.ProjectNamespace,
PrincipalID: principalID, PrincipalType: principalType}
existing, err := s.policyService.List(ctx, projectSpec.policyFilter(m))
if err != nil { return err }
return s.upsertMembership(ctx, projectSpec, m, fetchedRole, existing)
// ^ no relation code here — projectSpec.roleToRelation is nil, so upsertMembership skips it
}
func (s *Service) SetOrganizationMemberRole(ctx, orgID, principalID, principalType, roleID string) error {
// ... validate org, principal, role, min owner constraint ...
m := Member{ResourceID: orgID, ResourceType: schema.OrganizationNamespace,
PrincipalID: principalID, PrincipalType: principalType}
existing, err := s.policyService.List(ctx, orgSpec.policyFilter(m))
if err != nil { return err }
return s.upsertMembership(ctx, orgSpec, m, fetchedRole, existing)
// ^ orgSpec.roleToRelation is set, so upsertMembership handles relations automatically
}
Future: multi-role support
The current design assumes single role per principal per resource — each mutation is an upsert that replaces the existing role. This works for all current resource types where a user is either an owner, manager, or viewer on an org/project/group.
If a future requirement needs multiple concurrent roles (e.g., a user is both project_viewer and project_billing_admin on a project), the resourceSpec can be extended with a multiRole bool field:
var projectSpec = resourceSpec{
// ...
multiRole: true, // allow multiple roles per principal per resource
}
What changes with multiRole: true:
| Behavior |
Single role (default) |
Multi-role |
upsertMembership |
Delete all existing policies → create one |
Create new policy if role not already held, leave others |
| "skip if same" check |
len(existing) == 1 && existing[0].RoleID == roleID |
slices.ContainsFunc(existing, matchesRole) |
removeMembership |
Delete all policies + all relations |
Same — removes principal entirely |
New: removeRole |
Not needed — upsert handles it |
New function: delete the specific policy for one role, keep others |
| Relation mapping |
1 role → 1 relation |
Highest-privilege role wins (e.g., if user has both owner and viewer roles, SpiceDB relation is owner) |
The key constraint with multi-role + relations: SpiceDB relations are not role-aware — a principal either has owner or member relation, not both. If a principal holds multiple roles, roleToRelation must resolve to the highest-privilege relation across all held roles. This means:
// For multi-role, roleToRelation changes from "map this role" to
// "given all roles this principal holds, what relation should exist?"
rolesToRelation func(heldRoles []role.Role) string
Example: user holds org_viewer + org_billing_admin → the relation should be member (since neither is owner). If they also hold org_owner → relation becomes owner.
We don't need this today. All current resource types are single-role. This note is here so the design isn't painted into a corner — the extension path is clear when the need arises.
What this achieves
| Concern |
Before |
After |
| Does this resource need relations? |
Each public function decides |
Declared in resourceSpec (mirrors base_schema.zed) |
| Can a developer forget relations? |
Yes — copy project pattern for groups |
No — spec drives behavior |
| Schema change safety |
Grep for relation names across scattered call sites |
Update the one resourceSpec that corresponds to the changed schema definition |
| Adding a new resource type |
Write full function with correct policy+relation calls |
Declare spec + write validation wrapper |
| Reading a public function |
Need to understand policy AND relation mechanics |
Just validation logic; upsertMembership is a black box |
| Multi-role extension |
Would require rewriting every public function |
Flip multiRole: true on the spec, adjust core |
File layout
| File |
Contents |
membership.go |
Member, resourceSpec, spec declarations (orgSpec, projectSpec, groupSpec) |
service.go |
Service struct, constructor, upsertMembership, removeMembership, replacePolicy, replaceRelation |
org_membership.go |
Org public functions + org-specific validation (validateOrgRole, min owner constraint) |
project_membership.go |
Project public functions + project-specific validation (validateProjectRole, validateOrgMembership) |
principal.go |
validatePrincipal, principalInfo struct |
Depends on
Should be done after the basic structure is merged and stable.
Context
The
core/membership/package centralizes member access management across organizations, projects, and groups (#1478). Today the private core functions (replacePolicy,replaceRelation,removeAllPolicies) are shared, but each public function still decides whether to call relation operations or not. When someone adds group support, they have to know which relations to manage — the same scattered-knowledge problem from the original design, just one level deeper.The problem in detail
Frontier manages access through two mechanisms:
app_project_vieweron project Y")organization:acme#owner@user:alice)Not every resource type uses both. This is the source of bugs:
owner,member)owner,member)Today, even inside the membership package, the public functions encode this knowledge procedurally:
If a future developer copies the project pattern for groups, they'll forget the relation step and reintroduce the bug. The knowledge of "which resources need relations" is implicit in code flow, not declared.
Proposal
MemberstructReplaces the 4-string tuple
(resourceID, resourceType, principalID, principalType)passed everywhere:resourceSpec— declaring what membership means for a resource typeThis is the key abstraction. Instead of each public function deciding what to do, we declare what membership operations mean for each resource type.
A
resourceSpecis a direct reflection of the SpiceDB schema (base_schema.zed). The schema defines which relations exist on each resource type, and the spec encodes that into Go so the membership package can manage them automatically. Any change tobase_schema.zedthat adds, removes, or renames a relation on org/project/group MUST be reflected in the correspondingresourceSpec. If the schema saysorganizationhasownerandmemberrelations, the spec must list both inallRelationsand map roles to them inroleToRelation. A mismatch means stale relations or missing access — the exact class of bugs this package exists to prevent.Why each field exists:
policyFilter— Thepolicy.Filterstruct has separate fields (OrgID,ProjectID,GroupID) instead of a genericResourceID. Each resource type needs to set the right field. Without this, every function that lists policies needs a switch statement on resource type.roleToRelation— This encodes the mapping from role → SpiceDB relation, derived frombase_schema.zed. For orgs, the owner role maps to theownerrelation, while viewer/manager map tomember. For projects, this is nil because projects don't have SpiceDB membership relations. When this is nil, the generic core skips all relation operations entirely — not because the caller forgot, but because the spec says they don't apply.allRelations— On every mutation, we must delete ALL existing relations before creating the new one. If you demote an org owner to viewer, you must delete bothownerandmemberrelations (in case either exists), then createmember. This list tells the core what to clean up. Without it, you'd need to know which relations could exist — the exact knowledge that was scattered before.Relationship to
base_schema.zedThe
resourceSpecdeclarations are a Go-side mirror ofbase_schema.zed. The schema is the source of truth for what relations exist:The specs must stay in sync:
Declared once per resource type
Generic core — 2 functions that handle everything
These read the spec to decide what to do. The caller never decides.
Public functions become thin validation wrappers
The public API stays the same. The only change is internal — validation + delegate to generic core:
Future: multi-role support
The current design assumes single role per principal per resource — each mutation is an upsert that replaces the existing role. This works for all current resource types where a user is either an owner, manager, or viewer on an org/project/group.
If a future requirement needs multiple concurrent roles (e.g., a user is both
project_viewerandproject_billing_adminon a project), theresourceSpeccan be extended with amultiRole boolfield:What changes with
multiRole: true:upsertMembershiplen(existing) == 1 && existing[0].RoleID == roleIDslices.ContainsFunc(existing, matchesRole)removeMembershipremoveRoleownerandviewerroles, SpiceDB relation isowner)The key constraint with multi-role + relations: SpiceDB relations are not role-aware — a principal either has
ownerormemberrelation, not both. If a principal holds multiple roles,roleToRelationmust resolve to the highest-privilege relation across all held roles. This means:Example: user holds
org_viewer+org_billing_admin→ the relation should bemember(since neither isowner). If they also holdorg_owner→ relation becomesowner.We don't need this today. All current resource types are single-role. This note is here so the design isn't painted into a corner — the extension path is clear when the need arises.
What this achieves
resourceSpec(mirrorsbase_schema.zed)resourceSpecthat corresponds to the changed schema definitionupsertMembershipis a black boxmultiRole: trueon the spec, adjust coreFile layout
membership.goMember,resourceSpec, spec declarations (orgSpec,projectSpec,groupSpec)service.goupsertMembership,removeMembership,replacePolicy,replaceRelationorg_membership.govalidateOrgRole, min owner constraint)project_membership.govalidateProjectRole,validateOrgMembership)principal.govalidatePrincipal,principalInfostructDepends on
Should be done after the basic structure is merged and stable.