Skip to content

Commit a8a19f3

Browse files
feat: move SetOrganizationMemberRole to membership package (#1541)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 04ecbe8 commit a8a19f3

9 files changed

Lines changed: 600 additions & 571 deletions

File tree

core/membership/service.go

Lines changed: 238 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package membership
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"slices"
78
"time"
@@ -76,27 +77,21 @@ func NewService(
7677
}
7778
}
7879

79-
// AddOrganizationMember adds a new member to an organization with an explicit role.
80-
// Returns ErrAlreadyMember if the principal already has a policy on this org.
81-
// Only user principals are supported — serviceusers are bound to orgs at creation time.
80+
// AddOrganizationMember adds a new user to an organization with an explicit role,
81+
// bypassing the invitation flow. Only user principals are supported — this is a
82+
// direct-add operation for superadmins.
83+
// Returns ErrAlreadyMember if the user already has a policy on this org.
8284
func (s *Service) AddOrganizationMember(ctx context.Context, orgID, principalID, principalType, roleID string) error {
83-
if principalType != schema.UserPrincipal {
84-
return ErrInvalidPrincipal
85-
}
86-
8785
// orgService.Get returns ErrDisabled for disabled orgs
8886
org, err := s.orgService.Get(ctx, orgID)
8987
if err != nil {
9088
return err
9189
}
9290

93-
usr, err := s.userService.GetByID(ctx, principalID)
91+
principal, err := s.validatePrincipal(ctx, principalID, principalType)
9492
if err != nil {
9593
return err
9694
}
97-
if usr.State == user.Disabled {
98-
return user.ErrDisabled
99-
}
10095

10196
fetchedRole, err := s.validateOrgRole(ctx, roleID, orgID)
10297
if err != nil {
@@ -136,8 +131,168 @@ func (s *Service) AddOrganizationMember(ctx context.Context, orgID, principalID,
136131
}
137132

138133
// audit logging
139-
s.auditOrgMemberAdded(ctx, org, usr, roleID)
134+
s.auditOrgMemberAdded(ctx, org, principal, roleID)
135+
136+
return nil
137+
}
138+
139+
// SetOrganizationMemberRole changes an existing member's role in an organization.
140+
// SetOrganizationMemberRole skips the write if the member already has exactly the requested role.
141+
// Currently only user principals are supported. May be extended to service users
142+
// in the future to give them org-level roles (see #1544).
143+
func (s *Service) SetOrganizationMemberRole(ctx context.Context, orgID, principalID, principalType, roleID string) error {
144+
org, err := s.orgService.Get(ctx, orgID)
145+
if err != nil {
146+
return err
147+
}
148+
149+
principal, err := s.validatePrincipal(ctx, principalID, principalType)
150+
if err != nil {
151+
return err
152+
}
153+
154+
fetchedRole, err := s.validateOrgRole(ctx, roleID, orgID)
155+
if err != nil {
156+
return err
157+
}
158+
159+
// use the canonical UUID from the fetched role for all comparisons and writes
160+
resolvedRoleID := fetchedRole.ID
161+
162+
existing, err := s.policyService.List(ctx, policy.Filter{
163+
OrgID: orgID,
164+
PrincipalID: principalID,
165+
PrincipalType: principalType,
166+
})
167+
if err != nil {
168+
return fmt.Errorf("list existing policies: %w", err)
169+
}
170+
if len(existing) == 0 {
171+
return ErrNotMember
172+
}
173+
174+
// skip if the user already has exactly this role
175+
if len(existing) == 1 && existing[0].RoleID == resolvedRoleID {
176+
return nil
177+
}
178+
179+
if err := s.validateMinOwnerConstraint(ctx, orgID, resolvedRoleID, existing); err != nil {
180+
return err
181+
}
140182

183+
if err := s.replacePolicy(ctx, orgID, schema.OrganizationNamespace, principalID, principalType, resolvedRoleID, existing); err != nil {
184+
return err
185+
}
186+
187+
newRelation := orgRoleToRelation(fetchedRole)
188+
oldRelations := []string{schema.OwnerRelationName, schema.MemberRelationName}
189+
err = s.replaceRelation(ctx, orgID, schema.OrganizationNamespace, principalID, principalType, oldRelations, newRelation)
190+
if err != nil {
191+
s.log.Error("membership state inconsistent: policy replaced but relation update failed, needs manual fix",
192+
"org_id", orgID,
193+
"principal_id", principalID,
194+
"principal_type", principalType,
195+
"new_role_id", resolvedRoleID,
196+
"expected_relation", newRelation,
197+
"error", err,
198+
)
199+
return err
200+
}
201+
202+
s.auditOrgMemberRoleChanged(ctx, org, principal, resolvedRoleID)
203+
return nil
204+
}
205+
206+
// validateMinOwnerConstraint ensures the org always has at least one owner after a role change.
207+
func (s *Service) validateMinOwnerConstraint(ctx context.Context, orgID, newRoleID string, existing []policy.Policy) error {
208+
ownerRole, err := s.roleService.Get(ctx, schema.RoleOrganizationOwner)
209+
if err != nil {
210+
return fmt.Errorf("get owner role: %w", err)
211+
}
212+
213+
// no constraint if promoting to owner
214+
if newRoleID == ownerRole.ID {
215+
return nil
216+
}
217+
218+
// no constraint if user is not currently an owner
219+
isCurrentlyOwner := false
220+
for _, p := range existing {
221+
if p.RoleID == ownerRole.ID {
222+
isCurrentlyOwner = true
223+
break
224+
}
225+
}
226+
if !isCurrentlyOwner {
227+
return nil
228+
}
229+
230+
// user is owner, being demoted — make sure at least one other owner remains
231+
ownerPolicies, err := s.policyService.List(ctx, policy.Filter{
232+
OrgID: orgID,
233+
RoleID: ownerRole.ID,
234+
})
235+
if err != nil {
236+
return fmt.Errorf("list owner policies: %w", err)
237+
}
238+
if len(ownerPolicies) <= 1 {
239+
return ErrLastOwnerRole
240+
}
241+
return nil
242+
}
243+
244+
// replacePolicy deletes the given existing policies and creates a new one with the new role.
245+
func (s *Service) replacePolicy(ctx context.Context, resourceID, resourceType, principalID, principalType, roleID string, existing []policy.Policy) error {
246+
for _, p := range existing {
247+
err := s.policyService.Delete(ctx, p.ID)
248+
if err != nil {
249+
return fmt.Errorf("delete policy %s: %w", p.ID, err)
250+
}
251+
}
252+
253+
_, err := s.createPolicy(ctx, resourceID, resourceType, principalID, principalType, roleID)
254+
if err != nil {
255+
s.log.Error("membership state inconsistent: old policies deleted but new policy creation failed, needs manual fix",
256+
"resource_id", resourceID,
257+
"resource_type", resourceType,
258+
"principal_id", principalID,
259+
"principal_type", principalType,
260+
"role_id", roleID,
261+
"error", err,
262+
)
263+
return err
264+
}
265+
return nil
266+
}
267+
268+
// replaceRelation deletes the given old relations for the principal on the resource,
269+
// then creates a new relation with the given name.
270+
// Only relation.ErrNotExist is ignored on delete — any other error is returned.
271+
func (s *Service) replaceRelation(ctx context.Context, resourceID, resourceType, principalID, principalType string, oldRelations []string, newRelationName string) error {
272+
obj := relation.Object{ID: resourceID, Namespace: resourceType}
273+
sub := relation.Subject{ID: principalID, Namespace: principalType}
274+
275+
for _, name := range oldRelations {
276+
err := s.relationService.Delete(ctx, relation.Relation{Object: obj, Subject: sub, RelationName: name})
277+
if err != nil && !errors.Is(err, relation.ErrNotExist) {
278+
return fmt.Errorf("delete relation %s: %w", name, err)
279+
}
280+
}
281+
282+
_, err := s.relationService.Create(ctx, relation.Relation{
283+
Object: obj, Subject: sub, RelationName: newRelationName,
284+
})
285+
if err != nil {
286+
s.log.Error("membership state inconsistent: old relations deleted but new relation creation failed, needs manual fix",
287+
"resource_id", resourceID,
288+
"resource_type", resourceType,
289+
"principal_id", principalID,
290+
"principal_type", principalType,
291+
"expected_relation", newRelationName,
292+
"error", err,
293+
)
294+
return fmt.Errorf("create relation: %w", err)
295+
}
141296
return nil
142297
}
143298

@@ -169,6 +324,42 @@ func (s *Service) validateOrgRole(ctx context.Context, roleID, orgID string) (ro
169324
return role.Role{}, ErrInvalidOrgRole
170325
}
171326

327+
// principalInfo holds validated principal details for audit and downstream use.
328+
type principalInfo struct {
329+
ID string
330+
Type string
331+
Name string
332+
Email string
333+
}
334+
335+
// validatePrincipal checks that the principal exists and is active.
336+
// To add support for a new principal type (e.g., service user), add a case here
337+
// and add the corresponding service dependency to the Service struct.
338+
func (s *Service) validatePrincipal(ctx context.Context, principalID, principalType string) (principalInfo, error) {
339+
switch principalType {
340+
case schema.UserPrincipal:
341+
usr, err := s.userService.GetByID(ctx, principalID)
342+
if err != nil {
343+
return principalInfo{}, err
344+
}
345+
if usr.State == user.Disabled {
346+
return principalInfo{}, user.ErrDisabled
347+
}
348+
return principalInfo{
349+
ID: usr.ID,
350+
Type: schema.UserPrincipal,
351+
Name: usr.Title,
352+
Email: usr.Email,
353+
}, nil
354+
// To support service users in the future, add:
355+
// case schema.ServiceUserPrincipal:
356+
// su, err := s.serviceUserService.Get(ctx, principalID)
357+
// ...
358+
default:
359+
return principalInfo{}, ErrInvalidPrincipal
360+
}
361+
}
362+
172363
// orgRoleToRelation maps an org role to the corresponding SpiceDB relation name.
173364
// Owner role gets "owner" relation, everything else gets "member" relation.
174365
func orgRoleToRelation(r role.Role) string {
@@ -203,7 +394,36 @@ func (s *Service) createRelation(ctx context.Context, resourceID, resourceType,
203394
return nil
204395
}
205396

206-
func (s *Service) auditOrgMemberAdded(ctx context.Context, org organization.Organization, usr user.User, roleID string) {
397+
func (s *Service) auditOrgMemberRoleChanged(ctx context.Context, org organization.Organization, p principalInfo, roleID string) {
398+
s.auditRecordRepository.Create(ctx, auditrecord.AuditRecord{
399+
Event: pkgAuditRecord.OrganizationMemberRoleChangedEvent,
400+
Resource: auditrecord.Resource{
401+
ID: org.ID,
402+
Type: pkgAuditRecord.OrganizationType,
403+
Name: org.Title,
404+
},
405+
Target: &auditrecord.Target{
406+
ID: p.ID,
407+
Type: pkgAuditRecord.UserType,
408+
Name: p.Name,
409+
Metadata: map[string]any{
410+
"email": p.Email,
411+
"role_id": roleID,
412+
},
413+
},
414+
OrgID: org.ID,
415+
OccurredAt: time.Now(),
416+
})
417+
418+
audit.GetAuditor(ctx, org.ID).LogWithAttrs(audit.OrgMemberRoleChangedEvent, audit.Target{
419+
ID: p.ID,
420+
Type: p.Type,
421+
}, map[string]string{
422+
"role_id": roleID,
423+
})
424+
}
425+
426+
func (s *Service) auditOrgMemberAdded(ctx context.Context, org organization.Organization, p principalInfo, roleID string) {
207427
s.auditRecordRepository.Create(ctx, auditrecord.AuditRecord{
208428
Event: pkgAuditRecord.OrganizationMemberAddedEvent,
209429
Resource: auditrecord.Resource{
@@ -212,11 +432,11 @@ func (s *Service) auditOrgMemberAdded(ctx context.Context, org organization.Orga
212432
Name: org.Title,
213433
},
214434
Target: &auditrecord.Target{
215-
ID: usr.ID,
435+
ID: p.ID,
216436
Type: pkgAuditRecord.UserType,
217-
Name: usr.Title,
437+
Name: p.Name,
218438
Metadata: map[string]any{
219-
"email": usr.Email,
439+
"email": p.Email,
220440
"role_id": roleID,
221441
},
222442
},
@@ -225,8 +445,8 @@ func (s *Service) auditOrgMemberAdded(ctx context.Context, org organization.Orga
225445
})
226446

227447
audit.GetAuditor(ctx, org.ID).LogWithAttrs(audit.OrgMemberCreatedEvent, audit.Target{
228-
ID: usr.ID,
229-
Type: schema.UserPrincipal,
448+
ID: p.ID,
449+
Type: p.Type,
230450
}, map[string]string{
231451
"role_id": roleID,
232452
})

0 commit comments

Comments
 (0)