Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dc05755
chore: update proton commit for SetOrganizationMemberRole RPC
whoAbhishekSah Mar 23, 2026
aaa0520
feat: implement SetOrganizationMemberRole RPC
whoAbhishekSah Mar 23, 2026
aa8461a
docs: add rationale for org-only policy filter in SetMemberRole
whoAbhishekSah Mar 23, 2026
139bba1
chore: update proton commit after rebase
whoAbhishekSah Mar 23, 2026
7f7aa9b
chore: remove unintended docs files
whoAbhishekSah Mar 23, 2026
9d74518
fix: SetOrganizationMemberRole validation and owner check
whoAbhishekSah Mar 24, 2026
df5ff66
style: fix indentation in service_test.go
whoAbhishekSah Mar 24, 2026
f5560d7
refactor: simplify RoleService by using role.Role directly
whoAbhishekSah Mar 24, 2026
8e21f03
style: improve error handling readability in SetMemberRole
whoAbhishekSah Mar 24, 2026
cdf9491
refactor: rename roleID to newRoleID for clarity in SetMemberRole
whoAbhishekSah Mar 24, 2026
9e3922b
refactor: split SetMemberRole into smaller functions
whoAbhishekSah Mar 24, 2026
4e6b4cd
refactor: move role validation from handler to service
whoAbhishekSah Mar 24, 2026
c071f6a
refactor: consolidate validation into validateSetMemberRoleRequest
whoAbhishekSah Mar 24, 2026
24a6202
test: add table-driven tests for SetOrganizationMemberRole handler
whoAbhishekSah Mar 24, 2026
3691a82
test: add unit tests for SetMemberRole service function
whoAbhishekSah Mar 24, 2026
5c4dc8b
fix: validate user_id is a valid UUID in SetOrganizationMemberRole
whoAbhishekSah Mar 24, 2026
488fb21
fix: use distinct error for last owner role constraint
whoAbhishekSah Mar 24, 2026
02a3bc2
feat: use proto UUID validation for SetOrganizationMemberRole
whoAbhishekSah Mar 25, 2026
5cc97fc
refactor: address review feedback on SetMemberRole
whoAbhishekSah Mar 25, 2026
bb411e6
fix: require user to be org member before changing role
whoAbhishekSah Mar 25, 2026
9973227
feat: validate role is valid for organization scope
whoAbhishekSah Mar 25, 2026
6979c5b
fix: handle nil UUID for global role org_id check
whoAbhishekSah Mar 25, 2026
eae912d
chore: update proton commit and simplify role validation
whoAbhishekSah Mar 25, 2026
39f8e94
chore: regenerate proto with SearchCurrentUserPATsRequest rename
whoAbhishekSah Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
VERSION := $(shell git describe --tags ${TAG})
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto admin-app compose-up-dev
.DEFAULT_GOAL := build
PROTON_COMMIT := "b891167da54d774968538c9261091899026e1825"
PROTON_COMMIT := "dcca57385227df0690df54d66f73b3baacfc580d"

admin-app:
@echo " > generating admin build"
Expand Down
2 changes: 1 addition & 1 deletion cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ func buildAPIDependencies(
postgres.NewFlowRepository(logger, dbc), mailDialer, tokenService, sessionService, userService, serviceUserService, webAuthConfig, patValidator)
groupService := group.NewService(groupRepository, relationService, authnService, policyService)
organizationService := organization.NewService(organizationRepository, relationService, userService,
authnService, policyService, preferenceService, auditRecordRepository)
authnService, policyService, preferenceService, auditRecordRepository, roleService)

userPATService := userpat.NewService(logger, userPATRepo, cfg.App.PAT, organizationService, roleService, policyService, auditRecordRepository)

Expand Down
15 changes: 9 additions & 6 deletions core/organization/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package organization
import "errors"

var (
ErrNotExist = errors.New("org doesn't exist")
ErrInvalidUUID = errors.New("invalid syntax of uuid")
ErrInvalidID = errors.New("org id is invalid")
ErrConflict = errors.New("org already exist")
ErrInvalidDetail = errors.New("invalid org detail")
ErrDisabled = errors.New("org is disabled")
ErrNotExist = errors.New("org doesn't exist")
ErrInvalidUUID = errors.New("invalid syntax of uuid")
ErrInvalidID = errors.New("org id is invalid")
ErrConflict = errors.New("org already exist")
ErrInvalidDetail = errors.New("invalid org detail")
ErrDisabled = errors.New("org is disabled")
ErrLastOwnerRole = errors.New("cannot remove the last owner role")
ErrNotMember = errors.New("user is not a member of the organization")
ErrInvalidOrgRole = errors.New("role is not valid for organization scope")
)
95 changes: 95 additions & 0 deletions core/organization/mocks/role_service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

158 changes: 157 additions & 1 deletion core/organization/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"time"

"github.com/raystack/frontier/core/audit"
Expand All @@ -12,6 +13,7 @@ import (
"github.com/raystack/frontier/core/preference"

"github.com/raystack/frontier/core/policy"
"github.com/raystack/frontier/core/role"

"github.com/raystack/frontier/core/authenticate"

Expand Down Expand Up @@ -68,6 +70,10 @@ type AuditRecordRepository interface {
Create(ctx context.Context, auditRecord auditrecord.AuditRecord) (auditrecord.AuditRecord, error)
}

type RoleService interface {
Get(ctx context.Context, idOrName string) (role.Role, error)
}

type Service struct {
repository Repository
relationService RelationService
Expand All @@ -76,11 +82,13 @@ type Service struct {
policyService PolicyService
prefService PreferencesService
auditRecordRepository AuditRecordRepository
roleService RoleService
}

func NewService(repository Repository, relationService RelationService,
userService UserService, authnService AuthnService, policyService PolicyService,
prefService PreferencesService, auditRecordRepository AuditRecordRepository) *Service {
prefService PreferencesService, auditRecordRepository AuditRecordRepository,
roleService RoleService) *Service {
return &Service{
repository: repository,
relationService: relationService,
Expand All @@ -89,6 +97,7 @@ func NewService(repository Repository, relationService RelationService,
policyService: policyService,
prefService: prefService,
auditRecordRepository: auditRecordRepository,
roleService: roleService,
}
}

Expand Down Expand Up @@ -349,6 +358,153 @@ func (s Service) AddUsers(ctx context.Context, orgID string, userIDs []string) e
return err
}

// SetMemberRole atomically changes a user's role in an organization.
// It deletes existing org-level policies and creates a new one with the specified role.
// Returns ErrLastOwnerRole if this would remove the last owner.
//
// Note: This assumes one role per user per org. If multiple roles need to be supported,
// consider accepting a list of roles or providing separate Add/Remove methods.
func (s Service) SetMemberRole(ctx context.Context, orgID, userID, newRoleID string) error {
err := s.validateSetMemberRoleRequest(ctx, orgID, userID, newRoleID)
Comment thread
whoAbhishekSah marked this conversation as resolved.
if err != nil {
return err
}

// get user's current org-level policies
existingPolicies, err := s.getUserOrgPolicies(ctx, orgID, userID)
if err != nil {
return err
}

// user must already be a member (have at least one org-level policy)
if len(existingPolicies) == 0 {
return ErrNotMember
}

// check minimum owner constraint
err = s.validateMinOwnerConstraint(ctx, orgID, newRoleID, existingPolicies)
if err != nil {
return err
}

// delete existing policies and create new one
err = s.replaceUserOrgPolicies(ctx, orgID, userID, newRoleID, existingPolicies)
if err != nil {
return err
}

return nil
}

// getUserOrgPolicies returns the user's org-level policies.
// Project and group policies are intentionally not included because:
// - Org owner/admin get implicit project access via SpiceDB (org->project_get)
// - Explicit project policies are for users who need project-specific access
func (s Service) getUserOrgPolicies(ctx context.Context, orgID, userID string) ([]policy.Policy, error) {
// policy service returns empty list if no policies found, not an error
return s.policyService.List(ctx, policy.Filter{
OrgID: orgID,
PrincipalID: userID,
PrincipalType: schema.UserPrincipal,
})
}

// validateMinOwnerConstraint ensures org always has at least 1 owner after role change
func (s Service) validateMinOwnerConstraint(ctx context.Context, orgID, newRoleID string, existingPolicies []policy.Policy) error {
ownerRole, err := s.roleService.Get(ctx, schema.RoleOrganizationOwner)
if err != nil {
return fmt.Errorf("failed to get owner role: %w", err)
}

// if assigning owner role, no constraint to check
if newRoleID == ownerRole.ID {
return nil
}

// check if user currently has owner role
isCurrentlyOwner := false
for _, p := range existingPolicies {
if p.RoleID == ownerRole.ID {
isCurrentlyOwner = true
break
}
}

Comment thread
whoAbhishekSah marked this conversation as resolved.
// if user is not currently an owner, changing their role won't reduce owner count
if !isCurrentlyOwner {
return nil
}

// count current owners - if this is the only owner, reject the change
ownerPolicies, err := s.policyService.List(ctx, policy.Filter{
OrgID: orgID,
RoleID: ownerRole.ID,
})
if err != nil {
return err
}

if len(ownerPolicies) <= 1 {
return ErrLastOwnerRole
}

return nil
}

// replaceUserOrgPolicies deletes existing policies and creates a new one with the given role
func (s Service) replaceUserOrgPolicies(ctx context.Context, orgID, userID, newRoleID string, existingPolicies []policy.Policy) error {
// delete existing policies
for _, p := range existingPolicies {
err := s.policyService.Delete(ctx, p.ID)
if err != nil {
return err
}
}

// create new policy with new role
_, err := s.policyService.Create(ctx, policy.Policy{
RoleID: newRoleID,
ResourceID: orgID,
ResourceType: schema.OrganizationNamespace,
PrincipalID: userID,
PrincipalType: schema.UserPrincipal,
})
if err != nil {
return err
}

return nil
}

// validateSetMemberRoleRequest validates that org, user, and role exist
func (s Service) validateSetMemberRoleRequest(ctx context.Context, orgID, userID, newRoleID string) error {
_, err := s.Get(ctx, orgID)
if err != nil {
return err
}

_, err = s.userService.GetByID(ctx, userID)
if err != nil {
return err
}

fetchedRole, err := s.roleService.Get(ctx, newRoleID)
if err != nil {
return err
}

// validate role is valid for organization scope
// role must be either: a global org role OR an org-specific role for this org
isGlobalRole := utils.IsNullUUID(fetchedRole.OrgID)
isGlobalOrgRole := isGlobalRole && slices.Contains(fetchedRole.Scopes, schema.OrganizationNamespace)
isOrgSpecificRole := fetchedRole.OrgID == orgID
if !isGlobalOrgRole && !isOrgSpecificRole {
return ErrInvalidOrgRole
}

return nil
}

// RemoveUsers removes users from an organization as members
// it doesn't remove user access to projects or other resources provided
// by policies, don't call directly, use cascade deleter
Expand Down
Loading
Loading