// SPDX-License-Identifier: MPL-2.0 package access import ( "context" "errors" "sort" ) var ErrRoleSetUnsupported = errors.New("access: atomic role sets are unsupported") // RoleSetRepository commits every replacement and the audit atomically. There // is no sequence of individual Grant/Revoke calls as a fallback. type RoleSetRepository interface { ReplaceOrganizationUserRoles(context.Context, []string, []Binding, string, AuditEvent) error } type OrganizationUserRolesChange struct { OrganizationID, UserID, ActorUserID, RequestID string Roles, ExpectedBindingIDs []string } // ReplaceOrganizationUserRoles replaces the direct organization-wide role set // for one active member. Team and narrower grants are unaffected. This bulk // operation requires a current direct owner inside the write transaction; // applications still authorize their customer/merchant and allowed-role boundary. func (service *Service) ReplaceOrganizationUserRoles(ctx context.Context, input OrganizationUserRolesChange) ([]Binding, error) { repository, ok := service.repository.(RoleSetRepository) if !ok { return nil, ErrRoleSetUnsupported } if service.ownerRole == "" || !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || !text(input.RequestID, 128, true) || len(input.Roles) < 1 || len(input.Roles) > 16 { return nil, errors.New("access: invalid organization role set") } roles := append([]string(nil), input.Roles...) sort.Strings(roles) for i, role := range roles { if _, exists := service.policy.Roles[role]; !exists || i > 0 && roles[i-1] == role { return nil, errors.New("access: unknown or duplicate role") } } expected, err := canonicalBindingIDs(input.ExpectedBindingIDs) if err != nil { return nil, err } now := service.now().UTC() bindings := make([]Binding, 0, len(roles)) for _, role := range roles { id, err := randomID(service.random) if err != nil { return nil, err } bindings = append(bindings, Binding{ID: id, SubjectKind: User, SubjectID: input.UserID, Role: role, Scope: Scope{OrganizationID: input.OrganizationID}, GrantedBy: input.ActorUserID, GrantedAt: now}) } id, err := randomID(service.random) if err != nil { return nil, err } audit := AuditEvent{ID: id, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.role.replace", ResourceType: "user", ResourceID: input.UserID, RequestID: input.RequestID, Summary: "Direct organization roles replaced", CreatedAt: now} if err := repository.ReplaceOrganizationUserRoles(ctx, expected, bindings, service.ownerRole, audit); err != nil { return nil, err } return bindings, nil }