@@ -2,6 +2,7 @@ package membership
22
33import (
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.
8284func (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.
174365func 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