Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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 := "9c069b5e749972014da7784f5c3802745a6e903e"
PROTON_COMMIT := "c0f71519e4fafa7ca73f3552c389ccb1cedffe2b"

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 @@ -463,7 +463,7 @@ func buildAPIDependencies(
metaschemaService := metaschema.NewService(metaschemaRepository)
projectRepository := postgres.NewProjectRepository(dbc)
projectService := project.NewService(projectRepository, relationService, userService, policyService,
authnService, serviceUserService, groupService)
authnService, serviceUserService, groupService, roleService)

resourcePGRepository := postgres.NewResourceRepository(dbc)
resourceService := resource.NewService(
Expand Down
13 changes: 8 additions & 5 deletions core/project/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package project
import "errors"

var (
ErrNotExist = errors.New("project or its relations doesn't exist")
ErrInvalidUUID = errors.New("invalid syntax of uuid")
ErrInvalidID = errors.New("project id is invalid")
ErrConflict = errors.New("project already exist")
ErrInvalidDetail = errors.New("invalid project detail")
ErrNotExist = errors.New("project or its relations doesn't exist")
ErrInvalidUUID = errors.New("invalid syntax of uuid")
ErrInvalidID = errors.New("project id is invalid")
ErrConflict = errors.New("project already exist")
ErrInvalidDetail = errors.New("invalid project detail")
ErrLastOwnerRole = errors.New("cannot remove the last owner role")
ErrNotMember = errors.New("user is not a member of the project")
ErrInvalidProjectRole = errors.New("role is not valid for project scope")
)
47 changes: 47 additions & 0 deletions core/project/mocks/policy_service.go

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

95 changes: 95 additions & 0 deletions core/project/mocks/role_service.go

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

122 changes: 121 additions & 1 deletion core/project/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"context"
"errors"
"fmt"
"slices"

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

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

Expand Down Expand Up @@ -39,9 +41,14 @@ type ServiceuserService interface {
type PolicyService interface {
Create(ctx context.Context, policy policy.Policy) (policy.Policy, error)
List(ctx context.Context, flt policy.Filter) ([]policy.Policy, error)
Delete(ctx context.Context, id string) error
ProjectMemberCount(ctx context.Context, ids []string) ([]policy.MemberCount, error)
}

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

type AuthnService interface {
GetPrincipal(ctx context.Context, via ...authenticate.ClientAssertion) (authenticate.Principal, error)
}
Expand All @@ -59,11 +66,12 @@ type Service struct {
policyService PolicyService
authnService AuthnService
groupService GroupService
roleService RoleService
}

func NewService(repository Repository, relationService RelationService, userService UserService,
policyService PolicyService, authnService AuthnService, suserService ServiceuserService,
groupService GroupService) *Service {
groupService GroupService, roleService RoleService) *Service {
return &Service{
repository: repository,
relationService: relationService,
Expand All @@ -72,6 +80,7 @@ func NewService(repository Repository, relationService RelationService, userServ
authnService: authnService,
suserService: suserService,
groupService: groupService,
roleService: roleService,
}
}

Expand Down Expand Up @@ -348,6 +357,117 @@ func (s Service) Disable(ctx context.Context, id string) error {
return s.repository.SetState(ctx, id, Disabled)
}

// SetMemberRole atomically changes a user's role in a project.
// It deletes existing project-level policies and creates a new one with the specified role.
// Returns ErrLastOwnerRole if this would remove the last owner.
func (s Service) SetMemberRole(ctx context.Context, projectID, userID, newRoleID string) error {
if err := s.validateSetMemberRoleRequest(ctx, projectID, userID, newRoleID); err != nil {
return err
}

existingPolicies, err := s.getUserProjectPolicies(ctx, projectID, userID)
if err != nil {
return err
}

if len(existingPolicies) == 0 {
return ErrNotMember
}

if err := s.validateMinOwnerConstraint(ctx, projectID, newRoleID, existingPolicies); err != nil {
return err
}

return s.replaceUserProjectPolicies(ctx, projectID, userID, newRoleID, existingPolicies)
}

func (s Service) getUserProjectPolicies(ctx context.Context, projectID, userID string) ([]policy.Policy, error) {
return s.policyService.List(ctx, policy.Filter{
ProjectID: projectID,
PrincipalID: userID,
PrincipalType: schema.UserPrincipal,
})
}

func (s Service) validateMinOwnerConstraint(ctx context.Context, projectID, newRoleID string, existingPolicies []policy.Policy) error {
ownerRole, err := s.roleService.Get(ctx, schema.RoleProjectOwner)
if err != nil {
return fmt.Errorf("failed to get owner role: %w", err)
}

if newRoleID == ownerRole.ID {
return nil
}

isCurrentlyOwner := false
for _, p := range existingPolicies {
if p.RoleID == ownerRole.ID {
isCurrentlyOwner = true
break
}
}

if !isCurrentlyOwner {
return nil
}

ownerPolicies, err := s.policyService.List(ctx, policy.Filter{
ProjectID: projectID,
RoleID: ownerRole.ID,
})
if err != nil {
return err
}

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

return nil
}

func (s Service) replaceUserProjectPolicies(ctx context.Context, projectID, userID, newRoleID string, existingPolicies []policy.Policy) error {
for _, p := range existingPolicies {
if err := s.policyService.Delete(ctx, p.ID); err != nil {
return err
}
}

_, err := s.policyService.Create(ctx, policy.Policy{
RoleID: newRoleID,
ResourceID: projectID,
ResourceType: schema.ProjectNamespace,
PrincipalID: userID,
PrincipalType: schema.UserPrincipal,
})
return err
}

func (s Service) validateSetMemberRoleRequest(ctx context.Context, projectID, userID, newRoleID string) error {
_, err := s.Get(ctx, projectID)
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
}

isGlobalRole := utils.IsNullUUID(fetchedRole.OrgID)
isGlobalProjectRole := isGlobalRole && slices.Contains(fetchedRole.Scopes, schema.ProjectNamespace)
if !isGlobalProjectRole {
return ErrInvalidProjectRole
}

return nil
}

// DeleteModel doesn't delete the nested resource, only itself
func (s Service) DeleteModel(ctx context.Context, id string) error {
// delete all relations where resource is an object
Expand Down
Loading
Loading