diff --git a/cmd/serve.go b/cmd/serve.go index 342eed92b..bcfc1dde6 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -434,7 +434,7 @@ func buildAPIDependencies( projectService := project.NewService(projectRepository, relationService, userService, policyService, authnService, serviceUserService, groupService, roleService) - membershipService := membership.NewService(logger, policyService, relationService, roleService, organizationService, userService, projectService, groupService, auditRecordRepository) + membershipService := membership.NewService(logger, policyService, relationService, roleService, organizationService, userService, projectService, groupService, serviceUserService, auditRecordRepository) // Setter injection: org → membership is circular (membership needs org for validation, // org needs membership for Create/AdminCreate). Break the cycle with a post-init setter. organizationService.SetMembershipService(membershipService) diff --git a/core/membership/errors.go b/core/membership/errors.go index 01eb2e235..0e01732ea 100644 --- a/core/membership/errors.go +++ b/core/membership/errors.go @@ -9,4 +9,6 @@ var ( ErrLastOwnerRole = errors.New("cannot change role: this is the last owner of the organization") ErrInvalidPrincipal = errors.New("only user principals are supported") ErrInvalidPrincipalType = errors.New("unsupported principal type") + ErrNotOrgMember = errors.New("principal is not a member of the organization") + ErrInvalidProjectRole = errors.New("role is not valid for project scope") ) diff --git a/core/membership/mocks/group_service.go b/core/membership/mocks/group_service.go index 844ff8c8e..2e84c6765 100644 --- a/core/membership/mocks/group_service.go +++ b/core/membership/mocks/group_service.go @@ -23,6 +23,63 @@ func (_m *GroupService) EXPECT() *GroupService_Expecter { return &GroupService_Expecter{mock: &_m.Mock} } +// Get provides a mock function with given fields: ctx, idOrName +func (_m *GroupService) Get(ctx context.Context, idOrName string) (group.Group, error) { + ret := _m.Called(ctx, idOrName) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 group.Group + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (group.Group, error)); ok { + return rf(ctx, idOrName) + } + if rf, ok := ret.Get(0).(func(context.Context, string) group.Group); ok { + r0 = rf(ctx, idOrName) + } else { + r0 = ret.Get(0).(group.Group) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, idOrName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GroupService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type GroupService_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - idOrName string +func (_e *GroupService_Expecter) Get(ctx interface{}, idOrName interface{}) *GroupService_Get_Call { + return &GroupService_Get_Call{Call: _e.mock.On("Get", ctx, idOrName)} +} + +func (_c *GroupService_Get_Call) Run(run func(ctx context.Context, idOrName string)) *GroupService_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *GroupService_Get_Call) Return(_a0 group.Group, _a1 error) *GroupService_Get_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *GroupService_Get_Call) RunAndReturn(run func(context.Context, string) (group.Group, error)) *GroupService_Get_Call { + _c.Call.Return(run) + return _c +} + // List provides a mock function with given fields: ctx, flt func (_m *GroupService) List(ctx context.Context, flt group.Filter) ([]group.Group, error) { ret := _m.Called(ctx, flt) diff --git a/core/membership/mocks/project_service.go b/core/membership/mocks/project_service.go index 59dd48b65..71561b589 100644 --- a/core/membership/mocks/project_service.go +++ b/core/membership/mocks/project_service.go @@ -23,6 +23,63 @@ func (_m *ProjectService) EXPECT() *ProjectService_Expecter { return &ProjectService_Expecter{mock: &_m.Mock} } +// Get provides a mock function with given fields: ctx, idOrName +func (_m *ProjectService) Get(ctx context.Context, idOrName string) (project.Project, error) { + ret := _m.Called(ctx, idOrName) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 project.Project + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (project.Project, error)); ok { + return rf(ctx, idOrName) + } + if rf, ok := ret.Get(0).(func(context.Context, string) project.Project); ok { + r0 = rf(ctx, idOrName) + } else { + r0 = ret.Get(0).(project.Project) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, idOrName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ProjectService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type ProjectService_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - idOrName string +func (_e *ProjectService_Expecter) Get(ctx interface{}, idOrName interface{}) *ProjectService_Get_Call { + return &ProjectService_Get_Call{Call: _e.mock.On("Get", ctx, idOrName)} +} + +func (_c *ProjectService_Get_Call) Run(run func(ctx context.Context, idOrName string)) *ProjectService_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *ProjectService_Get_Call) Return(_a0 project.Project, _a1 error) *ProjectService_Get_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ProjectService_Get_Call) RunAndReturn(run func(context.Context, string) (project.Project, error)) *ProjectService_Get_Call { + _c.Call.Return(run) + return _c +} + // List provides a mock function with given fields: ctx, flt func (_m *ProjectService) List(ctx context.Context, flt project.Filter) ([]project.Project, error) { ret := _m.Called(ctx, flt) diff --git a/core/membership/mocks/serviceuser_service.go b/core/membership/mocks/serviceuser_service.go new file mode 100644 index 000000000..230b7ee98 --- /dev/null +++ b/core/membership/mocks/serviceuser_service.go @@ -0,0 +1,95 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + serviceuser "github.com/raystack/frontier/core/serviceuser" +) + +// ServiceuserService is an autogenerated mock type for the ServiceuserService type +type ServiceuserService struct { + mock.Mock +} + +type ServiceuserService_Expecter struct { + mock *mock.Mock +} + +func (_m *ServiceuserService) EXPECT() *ServiceuserService_Expecter { + return &ServiceuserService_Expecter{mock: &_m.Mock} +} + +// Get provides a mock function with given fields: ctx, id +func (_m *ServiceuserService) Get(ctx context.Context, id string) (serviceuser.ServiceUser, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 serviceuser.ServiceUser + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (serviceuser.ServiceUser, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) serviceuser.ServiceUser); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(serviceuser.ServiceUser) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ServiceuserService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type ServiceuserService_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *ServiceuserService_Expecter) Get(ctx interface{}, id interface{}) *ServiceuserService_Get_Call { + return &ServiceuserService_Get_Call{Call: _e.mock.On("Get", ctx, id)} +} + +func (_c *ServiceuserService_Get_Call) Run(run func(ctx context.Context, id string)) *ServiceuserService_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *ServiceuserService_Get_Call) Return(_a0 serviceuser.ServiceUser, _a1 error) *ServiceuserService_Get_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ServiceuserService_Get_Call) RunAndReturn(run func(context.Context, string) (serviceuser.ServiceUser, error)) *ServiceuserService_Get_Call { + _c.Call.Return(run) + return _c +} + +// NewServiceuserService creates a new instance of ServiceuserService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewServiceuserService(t interface { + mock.TestingT + Cleanup(func()) +}) *ServiceuserService { + mock := &ServiceuserService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/membership/service.go b/core/membership/service.go index 5aa358709..4c900c2cf 100644 --- a/core/membership/service.go +++ b/core/membership/service.go @@ -15,6 +15,7 @@ import ( "github.com/raystack/frontier/core/project" "github.com/raystack/frontier/core/relation" "github.com/raystack/frontier/core/role" + "github.com/raystack/frontier/core/serviceuser" "github.com/raystack/frontier/core/user" "github.com/raystack/frontier/internal/bootstrap/schema" pkgAuditRecord "github.com/raystack/frontier/pkg/auditrecord" @@ -46,13 +47,19 @@ type UserService interface { } type ProjectService interface { + Get(ctx context.Context, idOrName string) (project.Project, error) List(ctx context.Context, flt project.Filter) ([]project.Project, error) } type GroupService interface { + Get(ctx context.Context, idOrName string) (group.Group, error) List(ctx context.Context, flt group.Filter) ([]group.Group, error) } +type ServiceuserService interface { + Get(ctx context.Context, id string) (serviceuser.ServiceUser, error) +} + type AuditRecordRepository interface { Create(ctx context.Context, auditRecord auditrecord.AuditRecord) (auditrecord.AuditRecord, error) } @@ -66,6 +73,7 @@ type Service struct { userService UserService projectService ProjectService groupService GroupService + serviceuserService ServiceuserService auditRecordRepository AuditRecordRepository } @@ -78,6 +86,7 @@ func NewService( userService UserService, projectService ProjectService, groupService GroupService, + serviceuserService ServiceuserService, auditRecordRepository AuditRecordRepository, ) *Service { return &Service{ @@ -89,6 +98,7 @@ func NewService( userService: userService, projectService: projectService, groupService: groupService, + serviceuserService: serviceuserService, auditRecordRepository: auditRecordRepository, } } @@ -653,3 +663,196 @@ func principalTypeToAuditType(principalType string) (pkgAuditRecord.EntityType, return "", ErrInvalidPrincipalType } } + +// SetProjectMemberRole sets or changes a principal's role in a project (upsert). +// It validates the role is project-scoped and the principal is a member of the parent org. +// No explicit SpiceDB relations are managed — projects use policies only. +func (s *Service) SetProjectMemberRole(ctx context.Context, projectID, principalID, principalType, roleID string) error { + prj, err := s.projectService.Get(ctx, projectID) + if err != nil { + return err + } + + fetchedRole, err := s.validateProjectRole(ctx, roleID, prj.Organization.ID) + if err != nil { + return err + } + resolvedRoleID := fetchedRole.ID + + if err := s.validateOrgMembership(ctx, prj.Organization.ID, principalID, principalType); err != nil { + return err + } + + existing, err := s.policyService.List(ctx, policy.Filter{ + ProjectID: projectID, + PrincipalID: principalID, + PrincipalType: principalType, + }) + if err != nil { + return fmt.Errorf("list existing policies: %w", err) + } + + // skip if the principal already has exactly this role + if len(existing) == 1 && existing[0].RoleID == resolvedRoleID { + return nil + } + + if err := s.replacePolicy(ctx, projectID, schema.ProjectNamespace, principalID, principalType, resolvedRoleID, existing); err != nil { + return err + } + + s.auditProjectMember(ctx, pkgAuditRecord.ProjectMemberRoleChangedEvent, prj, principalID, principalType, map[string]any{"role_id": resolvedRoleID}) + return nil +} + +// RemoveProjectMember removes a principal from a project by deleting all their project-level policies. +func (s *Service) RemoveProjectMember(ctx context.Context, projectID, principalID, principalType string) error { + switch principalType { + case schema.UserPrincipal, schema.ServiceUserPrincipal, schema.GroupPrincipal: + default: + return ErrInvalidPrincipalType + } + + prj, err := s.projectService.Get(ctx, projectID) + if err != nil { + return err + } + + removed, err := s.removeAllPolicies(ctx, projectID, schema.ProjectNamespace, principalID, principalType) + if err != nil { + return err + } + if removed == 0 { + return ErrNotMember + } + + s.auditProjectMember(ctx, pkgAuditRecord.ProjectMemberRemovedEvent, prj, principalID, principalType, nil) + return nil +} + +// removeAllPolicies finds and deletes all policies for a principal on a resource. +// Returns the number of policies deleted. +func (s *Service) removeAllPolicies(ctx context.Context, resourceID, resourceType, principalID, principalType string) (int, error) { + f := policyFilterForResource(resourceID, resourceType, principalID, principalType) + existing, err := s.policyService.List(ctx, f) + if err != nil { + return 0, fmt.Errorf("list policies: %w", err) + } + for _, pol := range existing { + if err := s.policyService.Delete(ctx, pol.ID); err != nil { + return 0, fmt.Errorf("delete policy %s: %w", pol.ID, err) + } + } + return len(existing), nil +} + +// policyFilterForResource builds a policy.Filter with the correct resource-type field set. +func policyFilterForResource(resourceID, resourceType, principalID, principalType string) policy.Filter { + f := policy.Filter{ + PrincipalID: principalID, + PrincipalType: principalType, + } + switch resourceType { + case schema.OrganizationNamespace: + f.OrgID = resourceID + case schema.ProjectNamespace: + f.ProjectID = resourceID + case schema.GroupNamespace: + f.GroupID = resourceID + } + return f +} + +// validateProjectRole checks that the role is valid for project scope: +// - a platform-wide role scoped to projects, or +// - a custom role created for the project's parent organization. +func (s *Service) validateProjectRole(ctx context.Context, roleID, orgID string) (role.Role, error) { + fetchedRole, err := s.roleService.Get(ctx, roleID) + if err != nil { + return role.Role{}, err + } + if !slices.Contains(fetchedRole.Scopes, schema.ProjectNamespace) { + return role.Role{}, ErrInvalidProjectRole + } + + // custom role belonging to the project's parent org + if fetchedRole.OrgID == orgID { + return fetchedRole, nil + } + + // platform-wide role (no org ownership) + if utils.IsNullUUID(fetchedRole.OrgID) { + return fetchedRole, nil + } + + return role.Role{}, ErrInvalidProjectRole +} + +// validateOrgMembership checks that the principal exists and belongs to the given org. +// For users, org membership is verified via org-level policies. +// For service users and groups, org membership is verified via their org ID field. +func (s *Service) validateOrgMembership(ctx context.Context, orgID, principalID, principalType string) error { + switch principalType { + case schema.UserPrincipal: + usr, err := s.userService.GetByID(ctx, principalID) + if err != nil { + return err + } + if usr.State == user.Disabled { + return user.ErrDisabled + } + orgPolicies, err := s.policyService.List(ctx, policy.Filter{ + OrgID: orgID, + PrincipalID: principalID, + PrincipalType: principalType, + }) + if err != nil { + return err + } + if len(orgPolicies) == 0 { + return ErrNotOrgMember + } + case schema.ServiceUserPrincipal: + su, err := s.serviceuserService.Get(ctx, principalID) + if err != nil { + return err + } + if su.OrgID != orgID { + return ErrNotOrgMember + } + case schema.GroupPrincipal: + grp, err := s.groupService.Get(ctx, principalID) + if err != nil { + return err + } + if grp.OrganizationID != orgID { + return ErrNotOrgMember + } + default: + return ErrInvalidPrincipalType + } + return nil +} + +func (s *Service) auditProjectMember(ctx context.Context, event pkgAuditRecord.Event, prj project.Project, principalID, principalType string, meta map[string]any) { + targetType, _ := principalTypeToAuditType(principalType) + if meta == nil { + meta = map[string]any{} + } + meta["principal_type"] = principalType + s.auditRecordRepository.Create(ctx, auditrecord.AuditRecord{ + Event: event, + Resource: auditrecord.Resource{ + ID: prj.ID, + Type: pkgAuditRecord.ProjectType, + Name: prj.Title, + }, + Target: &auditrecord.Target{ + ID: principalID, + Type: targetType, + Metadata: meta, + }, + OrgID: prj.Organization.ID, + OccurredAt: time.Now(), + }) +} diff --git a/core/membership/service_test.go b/core/membership/service_test.go index 2d7db615e..1779cde17 100644 --- a/core/membership/service_test.go +++ b/core/membership/service_test.go @@ -15,6 +15,7 @@ import ( "github.com/raystack/frontier/core/project" "github.com/raystack/frontier/core/relation" "github.com/raystack/frontier/core/role" + "github.com/raystack/frontier/core/serviceuser" "github.com/raystack/frontier/core/user" "github.com/raystack/frontier/internal/bootstrap/schema" "github.com/raystack/salt/log" @@ -256,7 +257,7 @@ func TestService_AddOrganizationMember(t *testing.T) { tt.setup(mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mockAuditRepo) } - svc := membership.NewService(log.NewNoop(), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mocks.NewProjectService(t), mocks.NewGroupService(t), mockAuditRepo) + svc := membership.NewService(log.NewNoop(), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo) principalType := tt.principalType if principalType == "" { @@ -447,7 +448,7 @@ func TestService_SetOrganizationMemberRole(t *testing.T) { tt.setup(mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mockAuditRepo) } - svc := membership.NewService(log.NewNoop(), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mocks.NewProjectService(t), mocks.NewGroupService(t), mockAuditRepo) + svc := membership.NewService(log.NewNoop(), mockPolicySvc, mockRelSvc, mockRoleSvc, mockOrgSvc, mockUserSvc, mocks.NewProjectService(t), mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo) principalType := tt.principalType if principalType == "" { @@ -671,7 +672,7 @@ func TestService_RemoveOrganizationMember(t *testing.T) { tt.setup(d) } - svc := membership.NewService(log.NewNoop(), d.policySvc, d.relSvc, d.roleSvc, d.orgSvc, mocks.NewUserService(t), d.projSvc, d.grpSvc, d.auditRepo) + svc := membership.NewService(log.NewNoop(), d.policySvc, d.relSvc, d.roleSvc, d.orgSvc, mocks.NewUserService(t), d.projSvc, d.grpSvc, mocks.NewServiceuserService(t), d.auditRepo) principalType := tt.principalType if principalType == "" { @@ -689,3 +690,218 @@ func TestService_RemoveOrganizationMember(t *testing.T) { }) } } + +func TestService_SetProjectMemberRole(t *testing.T) { + ctx := context.Background() + projectID := uuid.New().String() + orgID := uuid.New().String() + userID := uuid.New().String() + suID := uuid.New().String() + groupID := uuid.New().String() + roleID := uuid.New().String() + + prj := project.Project{ + ID: projectID, + Organization: organization.Organization{ID: orgID}, + } + + tests := []struct { + name string + setup func(*mocks.PolicyService, *mocks.RoleService, *mocks.ProjectService, *mocks.UserService, *mocks.ServiceuserService, *mocks.GroupService, *mocks.AuditRecordRepository) + principalID string + principalType string + roleID string + wantErr error + }{ + { + name: "should return error if project does not exist", + setup: func(_ *mocks.PolicyService, _ *mocks.RoleService, prjSvc *mocks.ProjectService, _ *mocks.UserService, _ *mocks.ServiceuserService, _ *mocks.GroupService, _ *mocks.AuditRecordRepository) { + prjSvc.EXPECT().Get(ctx, projectID).Return(project.Project{}, project.ErrNotExist) + }, + principalID: userID, principalType: schema.UserPrincipal, roleID: roleID, + wantErr: project.ErrNotExist, + }, + { + name: "should return error if role is not project-scoped", + setup: func(_ *mocks.PolicyService, roleSvc *mocks.RoleService, prjSvc *mocks.ProjectService, _ *mocks.UserService, _ *mocks.ServiceuserService, _ *mocks.GroupService, _ *mocks.AuditRecordRepository) { + prjSvc.EXPECT().Get(ctx, projectID).Return(prj, nil) + roleSvc.EXPECT().Get(ctx, roleID).Return(role.Role{ID: roleID, Scopes: []string{schema.OrganizationNamespace}}, nil) + }, + principalID: userID, principalType: schema.UserPrincipal, roleID: roleID, + wantErr: membership.ErrInvalidProjectRole, + }, + { + name: "should return error if user is not org member", + setup: func(policySvc *mocks.PolicyService, roleSvc *mocks.RoleService, prjSvc *mocks.ProjectService, userSvc *mocks.UserService, _ *mocks.ServiceuserService, _ *mocks.GroupService, _ *mocks.AuditRecordRepository) { + prjSvc.EXPECT().Get(ctx, projectID).Return(prj, nil) + roleSvc.EXPECT().Get(ctx, roleID).Return(role.Role{ID: roleID, Scopes: []string{schema.ProjectNamespace}}, nil) + userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ID: userID, State: user.Enabled}, nil) + policySvc.EXPECT().List(ctx, policy.Filter{OrgID: orgID, PrincipalID: userID, PrincipalType: schema.UserPrincipal}).Return([]policy.Policy{}, nil) + }, + principalID: userID, principalType: schema.UserPrincipal, roleID: roleID, + wantErr: membership.ErrNotOrgMember, + }, + { + name: "should return error if service user is not in org", + setup: func(_ *mocks.PolicyService, roleSvc *mocks.RoleService, prjSvc *mocks.ProjectService, _ *mocks.UserService, suSvc *mocks.ServiceuserService, _ *mocks.GroupService, _ *mocks.AuditRecordRepository) { + prjSvc.EXPECT().Get(ctx, projectID).Return(prj, nil) + roleSvc.EXPECT().Get(ctx, roleID).Return(role.Role{ID: roleID, Scopes: []string{schema.ProjectNamespace}}, nil) + suSvc.EXPECT().Get(ctx, suID).Return(serviceuser.ServiceUser{ID: suID, OrgID: "other-org"}, nil) + }, + principalID: suID, principalType: schema.ServiceUserPrincipal, roleID: roleID, + wantErr: membership.ErrNotOrgMember, + }, + { + name: "should succeed adding new user to project", + setup: func(policySvc *mocks.PolicyService, roleSvc *mocks.RoleService, prjSvc *mocks.ProjectService, userSvc *mocks.UserService, _ *mocks.ServiceuserService, _ *mocks.GroupService, auditRepo *mocks.AuditRecordRepository) { + prjSvc.EXPECT().Get(ctx, projectID).Return(prj, nil) + roleSvc.EXPECT().Get(ctx, roleID).Return(role.Role{ID: roleID, Scopes: []string{schema.ProjectNamespace}}, nil) + userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ID: userID, State: user.Enabled}, nil) + policySvc.EXPECT().List(ctx, policy.Filter{OrgID: orgID, PrincipalID: userID, PrincipalType: schema.UserPrincipal}).Return([]policy.Policy{{ID: "org-p1"}}, nil) + policySvc.EXPECT().List(ctx, policy.Filter{ProjectID: projectID, PrincipalID: userID, PrincipalType: schema.UserPrincipal}).Return([]policy.Policy{}, nil) + policySvc.EXPECT().Create(ctx, policy.Policy{ + RoleID: roleID, ResourceID: projectID, ResourceType: schema.ProjectNamespace, + PrincipalID: userID, PrincipalType: schema.UserPrincipal, + }).Return(policy.Policy{}, nil) + auditRepo.EXPECT().Create(ctx, mock.Anything).Return(auditrecord.AuditRecord{}, nil) + }, + principalID: userID, principalType: schema.UserPrincipal, roleID: roleID, + }, + { + name: "should succeed adding service user to project", + setup: func(policySvc *mocks.PolicyService, roleSvc *mocks.RoleService, prjSvc *mocks.ProjectService, _ *mocks.UserService, suSvc *mocks.ServiceuserService, _ *mocks.GroupService, auditRepo *mocks.AuditRecordRepository) { + prjSvc.EXPECT().Get(ctx, projectID).Return(prj, nil) + roleSvc.EXPECT().Get(ctx, roleID).Return(role.Role{ID: roleID, Scopes: []string{schema.ProjectNamespace}}, nil) + suSvc.EXPECT().Get(ctx, suID).Return(serviceuser.ServiceUser{ID: suID, OrgID: orgID}, nil) + policySvc.EXPECT().List(ctx, policy.Filter{ProjectID: projectID, PrincipalID: suID, PrincipalType: schema.ServiceUserPrincipal}).Return([]policy.Policy{}, nil) + policySvc.EXPECT().Create(ctx, policy.Policy{ + RoleID: roleID, ResourceID: projectID, ResourceType: schema.ProjectNamespace, + PrincipalID: suID, PrincipalType: schema.ServiceUserPrincipal, + }).Return(policy.Policy{}, nil) + auditRepo.EXPECT().Create(ctx, mock.Anything).Return(auditrecord.AuditRecord{}, nil) + }, + principalID: suID, principalType: schema.ServiceUserPrincipal, roleID: roleID, + }, + { + name: "should succeed adding group to project", + setup: func(policySvc *mocks.PolicyService, roleSvc *mocks.RoleService, prjSvc *mocks.ProjectService, _ *mocks.UserService, _ *mocks.ServiceuserService, grpSvc *mocks.GroupService, auditRepo *mocks.AuditRecordRepository) { + prjSvc.EXPECT().Get(ctx, projectID).Return(prj, nil) + roleSvc.EXPECT().Get(ctx, roleID).Return(role.Role{ID: roleID, Scopes: []string{schema.ProjectNamespace}}, nil) + grpSvc.EXPECT().Get(ctx, groupID).Return(group.Group{ID: groupID, OrganizationID: orgID}, nil) + policySvc.EXPECT().List(ctx, policy.Filter{ProjectID: projectID, PrincipalID: groupID, PrincipalType: schema.GroupPrincipal}).Return([]policy.Policy{}, nil) + policySvc.EXPECT().Create(ctx, policy.Policy{ + RoleID: roleID, ResourceID: projectID, ResourceType: schema.ProjectNamespace, + PrincipalID: groupID, PrincipalType: schema.GroupPrincipal, + }).Return(policy.Policy{}, nil) + auditRepo.EXPECT().Create(ctx, mock.Anything).Return(auditrecord.AuditRecord{}, nil) + }, + principalID: groupID, principalType: schema.GroupPrincipal, roleID: roleID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockPolicySvc := mocks.NewPolicyService(t) + mockRoleSvc := mocks.NewRoleService(t) + mockPrjSvc := mocks.NewProjectService(t) + mockUserSvc := mocks.NewUserService(t) + mockSuSvc := mocks.NewServiceuserService(t) + mockGrpSvc := mocks.NewGroupService(t) + mockAuditRepo := mocks.NewAuditRecordRepository(t) + + if tt.setup != nil { + tt.setup(mockPolicySvc, mockRoleSvc, mockPrjSvc, mockUserSvc, mockSuSvc, mockGrpSvc, mockAuditRepo) + } + + svc := membership.NewService(log.NewNoop(), mockPolicySvc, mocks.NewRelationService(t), mockRoleSvc, mocks.NewOrgService(t), mockUserSvc, mockPrjSvc, mockGrpSvc, mockSuSvc, mockAuditRepo) + err := svc.SetProjectMemberRole(ctx, projectID, tt.principalID, tt.principalType, tt.roleID) + + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestService_RemoveProjectMember(t *testing.T) { + ctx := context.Background() + projectID := uuid.New().String() + userID := uuid.New().String() + suID := uuid.New().String() + + prj := project.Project{ + ID: projectID, + Title: "Test Project", + Organization: organization.Organization{ID: uuid.New().String()}, + } + + tests := []struct { + name string + setup func(*mocks.PolicyService, *mocks.ProjectService, *mocks.AuditRecordRepository) + principalID string + principalType string + wantErr error + }{ + { + name: "should return error for invalid principal type", + principalID: userID, + principalType: "app/invalid", + wantErr: membership.ErrInvalidPrincipalType, + }, + { + name: "should return error if not a member", + setup: func(policySvc *mocks.PolicyService, prjSvc *mocks.ProjectService, _ *mocks.AuditRecordRepository) { + prjSvc.EXPECT().Get(ctx, projectID).Return(prj, nil) + policySvc.EXPECT().List(ctx, policy.Filter{ProjectID: projectID, PrincipalID: userID, PrincipalType: schema.UserPrincipal}).Return([]policy.Policy{}, nil) + }, + principalID: userID, + principalType: schema.UserPrincipal, + wantErr: membership.ErrNotMember, + }, + { + name: "should succeed removing a user", + setup: func(policySvc *mocks.PolicyService, prjSvc *mocks.ProjectService, auditRepo *mocks.AuditRecordRepository) { + prjSvc.EXPECT().Get(ctx, projectID).Return(prj, nil) + policySvc.EXPECT().List(ctx, policy.Filter{ProjectID: projectID, PrincipalID: userID, PrincipalType: schema.UserPrincipal}).Return([]policy.Policy{{ID: "p1"}}, nil) + policySvc.EXPECT().Delete(ctx, "p1").Return(nil) + auditRepo.EXPECT().Create(ctx, mock.Anything).Return(auditrecord.AuditRecord{}, nil) + }, + principalID: userID, + principalType: schema.UserPrincipal, + }, + { + name: "should succeed removing a service user", + setup: func(policySvc *mocks.PolicyService, prjSvc *mocks.ProjectService, auditRepo *mocks.AuditRecordRepository) { + prjSvc.EXPECT().Get(ctx, projectID).Return(prj, nil) + policySvc.EXPECT().List(ctx, policy.Filter{ProjectID: projectID, PrincipalID: suID, PrincipalType: schema.ServiceUserPrincipal}).Return([]policy.Policy{{ID: "p1"}}, nil) + policySvc.EXPECT().Delete(ctx, "p1").Return(nil) + auditRepo.EXPECT().Create(ctx, mock.Anything).Return(auditrecord.AuditRecord{}, nil) + }, + principalID: suID, + principalType: schema.ServiceUserPrincipal, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockPolicySvc := mocks.NewPolicyService(t) + mockPrjSvc := mocks.NewProjectService(t) + mockAuditRepo := mocks.NewAuditRecordRepository(t) + + if tt.setup != nil { + tt.setup(mockPolicySvc, mockPrjSvc, mockAuditRepo) + } + + svc := membership.NewService(log.NewNoop(), mockPolicySvc, mocks.NewRelationService(t), mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t), mockPrjSvc, mocks.NewGroupService(t), mocks.NewServiceuserService(t), mockAuditRepo) + err := svc.RemoveProjectMember(ctx, projectID, tt.principalID, tt.principalType) + + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/api/v1beta1connect/errors.go b/internal/api/v1beta1connect/errors.go index 27fb89345..08bada279 100644 --- a/internal/api/v1beta1connect/errors.go +++ b/internal/api/v1beta1connect/errors.go @@ -34,8 +34,8 @@ var ( ErrDomainMismatch = errors.New("user and org's whitelisted domains doesn't match") ErrInvitationNotFound = errors.New("invitation not found") ErrInvitationExpired = errors.New("invitation expired") - ErrAlreadyMember = errors.New("user is already a member of the organization") - ErrNotMember = errors.New("user is not a member of the organization") + ErrAlreadyMember = errors.New("principal is already a member of the resource") + ErrNotMember = errors.New("principal is not a member of the resource") ErrInvalidOrgRole = errors.New("role is not valid for organization scope") ErrInvalidProjectRole = errors.New("role is not valid for project scope") ErrEmptyEmailID = errors.New("email id is empty") diff --git a/internal/api/v1beta1connect/interfaces.go b/internal/api/v1beta1connect/interfaces.go index 7c04dcfec..f3969a259 100644 --- a/internal/api/v1beta1connect/interfaces.go +++ b/internal/api/v1beta1connect/interfaces.go @@ -354,8 +354,6 @@ type ProjectService interface { ListGroups(ctx context.Context, id string) ([]group.Group, error) Enable(ctx context.Context, id string) error Disable(ctx context.Context, id string) error - SetMemberRole(ctx context.Context, projectID, principalID, principalType, newRoleID string) error - RemoveMember(ctx context.Context, projectID, principalID, principalType string) error } type OrgUsersService interface { @@ -409,6 +407,8 @@ type MembershipService interface { AddOrganizationMember(ctx context.Context, orgID, principalID, principalType, roleID string) error SetOrganizationMemberRole(ctx context.Context, orgID, principalID, principalType, roleID string) error RemoveOrganizationMember(ctx context.Context, orgID, principalID, principalType string) error + SetProjectMemberRole(ctx context.Context, projectID, principalID, principalType, roleID string) error + RemoveProjectMember(ctx context.Context, projectID, principalID, principalType string) error } type UserPATService interface { diff --git a/internal/api/v1beta1connect/mocks/membership_service.go b/internal/api/v1beta1connect/mocks/membership_service.go index 790e69c71..3b677ba33 100644 --- a/internal/api/v1beta1connect/mocks/membership_service.go +++ b/internal/api/v1beta1connect/mocks/membership_service.go @@ -120,6 +120,55 @@ func (_c *MembershipService_RemoveOrganizationMember_Call) RunAndReturn(run func return _c } +// RemoveProjectMember provides a mock function with given fields: ctx, projectID, principalID, principalType +func (_m *MembershipService) RemoveProjectMember(ctx context.Context, projectID string, principalID string, principalType string) error { + ret := _m.Called(ctx, projectID, principalID, principalType) + + if len(ret) == 0 { + panic("no return value specified for RemoveProjectMember") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { + r0 = rf(ctx, projectID, principalID, principalType) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MembershipService_RemoveProjectMember_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveProjectMember' +type MembershipService_RemoveProjectMember_Call struct { + *mock.Call +} + +// RemoveProjectMember is a helper method to define mock.On call +// - ctx context.Context +// - projectID string +// - principalID string +// - principalType string +func (_e *MembershipService_Expecter) RemoveProjectMember(ctx interface{}, projectID interface{}, principalID interface{}, principalType interface{}) *MembershipService_RemoveProjectMember_Call { + return &MembershipService_RemoveProjectMember_Call{Call: _e.mock.On("RemoveProjectMember", ctx, projectID, principalID, principalType)} +} + +func (_c *MembershipService_RemoveProjectMember_Call) Run(run func(ctx context.Context, projectID string, principalID string, principalType string)) *MembershipService_RemoveProjectMember_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string)) + }) + return _c +} + +func (_c *MembershipService_RemoveProjectMember_Call) Return(_a0 error) *MembershipService_RemoveProjectMember_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MembershipService_RemoveProjectMember_Call) RunAndReturn(run func(context.Context, string, string, string) error) *MembershipService_RemoveProjectMember_Call { + _c.Call.Return(run) + return _c +} + // SetOrganizationMemberRole provides a mock function with given fields: ctx, orgID, principalID, principalType, roleID func (_m *MembershipService) SetOrganizationMemberRole(ctx context.Context, orgID string, principalID string, principalType string, roleID string) error { ret := _m.Called(ctx, orgID, principalID, principalType, roleID) @@ -170,6 +219,56 @@ func (_c *MembershipService_SetOrganizationMemberRole_Call) RunAndReturn(run fun return _c } +// SetProjectMemberRole provides a mock function with given fields: ctx, projectID, principalID, principalType, roleID +func (_m *MembershipService) SetProjectMemberRole(ctx context.Context, projectID string, principalID string, principalType string, roleID string) error { + ret := _m.Called(ctx, projectID, principalID, principalType, roleID) + + if len(ret) == 0 { + panic("no return value specified for SetProjectMemberRole") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) error); ok { + r0 = rf(ctx, projectID, principalID, principalType, roleID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MembershipService_SetProjectMemberRole_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetProjectMemberRole' +type MembershipService_SetProjectMemberRole_Call struct { + *mock.Call +} + +// SetProjectMemberRole is a helper method to define mock.On call +// - ctx context.Context +// - projectID string +// - principalID string +// - principalType string +// - roleID string +func (_e *MembershipService_Expecter) SetProjectMemberRole(ctx interface{}, projectID interface{}, principalID interface{}, principalType interface{}, roleID interface{}) *MembershipService_SetProjectMemberRole_Call { + return &MembershipService_SetProjectMemberRole_Call{Call: _e.mock.On("SetProjectMemberRole", ctx, projectID, principalID, principalType, roleID)} +} + +func (_c *MembershipService_SetProjectMemberRole_Call) Run(run func(ctx context.Context, projectID string, principalID string, principalType string, roleID string)) *MembershipService_SetProjectMemberRole_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string)) + }) + return _c +} + +func (_c *MembershipService_SetProjectMemberRole_Call) Return(_a0 error) *MembershipService_SetProjectMemberRole_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MembershipService_SetProjectMemberRole_Call) RunAndReturn(run func(context.Context, string, string, string, string) error) *MembershipService_SetProjectMemberRole_Call { + _c.Call.Return(run) + return _c +} + // NewMembershipService creates a new instance of MembershipService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMembershipService(t interface { diff --git a/internal/api/v1beta1connect/mocks/project_service.go b/internal/api/v1beta1connect/mocks/project_service.go index 84504884e..3635dccb6 100644 --- a/internal/api/v1beta1connect/mocks/project_service.go +++ b/internal/api/v1beta1connect/mocks/project_service.go @@ -537,105 +537,6 @@ func (_c *ProjectService_ListUsers_Call) RunAndReturn(run func(context.Context, return _c } -// RemoveMember provides a mock function with given fields: ctx, projectID, principalID, principalType -func (_m *ProjectService) RemoveMember(ctx context.Context, projectID string, principalID string, principalType string) error { - ret := _m.Called(ctx, projectID, principalID, principalType) - - if len(ret) == 0 { - panic("no return value specified for RemoveMember") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { - r0 = rf(ctx, projectID, principalID, principalType) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// ProjectService_RemoveMember_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveMember' -type ProjectService_RemoveMember_Call struct { - *mock.Call -} - -// RemoveMember is a helper method to define mock.On call -// - ctx context.Context -// - projectID string -// - principalID string -// - principalType string -func (_e *ProjectService_Expecter) RemoveMember(ctx interface{}, projectID interface{}, principalID interface{}, principalType interface{}) *ProjectService_RemoveMember_Call { - return &ProjectService_RemoveMember_Call{Call: _e.mock.On("RemoveMember", ctx, projectID, principalID, principalType)} -} - -func (_c *ProjectService_RemoveMember_Call) Run(run func(ctx context.Context, projectID string, principalID string, principalType string)) *ProjectService_RemoveMember_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string)) - }) - return _c -} - -func (_c *ProjectService_RemoveMember_Call) Return(_a0 error) *ProjectService_RemoveMember_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *ProjectService_RemoveMember_Call) RunAndReturn(run func(context.Context, string, string, string) error) *ProjectService_RemoveMember_Call { - _c.Call.Return(run) - return _c -} - -// SetMemberRole provides a mock function with given fields: ctx, projectID, principalID, principalType, newRoleID -func (_m *ProjectService) SetMemberRole(ctx context.Context, projectID string, principalID string, principalType string, newRoleID string) error { - ret := _m.Called(ctx, projectID, principalID, principalType, newRoleID) - - if len(ret) == 0 { - panic("no return value specified for SetMemberRole") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) error); ok { - r0 = rf(ctx, projectID, principalID, principalType, newRoleID) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// ProjectService_SetMemberRole_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetMemberRole' -type ProjectService_SetMemberRole_Call struct { - *mock.Call -} - -// SetMemberRole is a helper method to define mock.On call -// - ctx context.Context -// - projectID string -// - principalID string -// - principalType string -// - newRoleID string -func (_e *ProjectService_Expecter) SetMemberRole(ctx interface{}, projectID interface{}, principalID interface{}, principalType interface{}, newRoleID interface{}) *ProjectService_SetMemberRole_Call { - return &ProjectService_SetMemberRole_Call{Call: _e.mock.On("SetMemberRole", ctx, projectID, principalID, principalType, newRoleID)} -} - -func (_c *ProjectService_SetMemberRole_Call) Run(run func(ctx context.Context, projectID string, principalID string, principalType string, newRoleID string)) *ProjectService_SetMemberRole_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string)) - }) - return _c -} - -func (_c *ProjectService_SetMemberRole_Call) Return(_a0 error) *ProjectService_SetMemberRole_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *ProjectService_SetMemberRole_Call) RunAndReturn(run func(context.Context, string, string, string, string) error) *ProjectService_SetMemberRole_Call { - _c.Call.Return(run) - return _c -} - // Update provides a mock function with given fields: ctx, toUpdate func (_m *ProjectService) Update(ctx context.Context, toUpdate project.Project) (project.Project, error) { ret := _m.Called(ctx, toUpdate) diff --git a/internal/api/v1beta1connect/project.go b/internal/api/v1beta1connect/project.go index 93c45a895..d35cd70e1 100644 --- a/internal/api/v1beta1connect/project.go +++ b/internal/api/v1beta1connect/project.go @@ -6,6 +6,7 @@ import ( "connectrpc.com/connect" "github.com/raystack/frontier/core/audit" "github.com/raystack/frontier/core/group" + "github.com/raystack/frontier/core/membership" "github.com/raystack/frontier/core/organization" "github.com/raystack/frontier/core/project" "github.com/raystack/frontier/core/role" @@ -370,7 +371,7 @@ func (h *ConnectHandler) SetProjectMemberRole(ctx context.Context, request *conn principalType := request.Msg.GetPrincipalType() roleID := request.Msg.GetRoleId() - if err := h.projectService.SetMemberRole(ctx, projectID, principalID, principalType, roleID); err != nil { + if err := h.membershipService.SetProjectMemberRole(ctx, projectID, principalID, principalType, roleID); err != nil { errorLogger.LogServiceError(ctx, request, "SetProjectMemberRole", err, zap.String("project_id", projectID), zap.String("principal_id", principalID), @@ -386,15 +387,17 @@ func (h *ConnectHandler) SetProjectMemberRole(ctx context.Context, request *conn return nil, connect.NewError(connect.CodeNotFound, ErrServiceUserNotFound) case errors.Is(err, group.ErrNotExist): return nil, connect.NewError(connect.CodeNotFound, ErrGroupNotFound) - case errors.Is(err, project.ErrNotOrgMember): + case errors.Is(err, membership.ErrNotOrgMember): return nil, connect.NewError(connect.CodeFailedPrecondition, ErrNotMember) + case errors.Is(err, user.ErrDisabled): + return nil, connect.NewError(connect.CodeFailedPrecondition, ErrBadRequest) case errors.Is(err, role.ErrNotExist): return nil, connect.NewError(connect.CodeNotFound, ErrInvalidRoleID) case errors.Is(err, role.ErrInvalidID): return nil, connect.NewError(connect.CodeInvalidArgument, ErrInvalidRoleID) - case errors.Is(err, project.ErrInvalidProjectRole): + case errors.Is(err, membership.ErrInvalidProjectRole): return nil, connect.NewError(connect.CodeInvalidArgument, ErrInvalidProjectRole) - case errors.Is(err, project.ErrInvalidPrincipalType): + case errors.Is(err, membership.ErrInvalidPrincipalType): return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest) default: return nil, connect.NewError(connect.CodeInternal, ErrInternalServerError) @@ -416,7 +419,7 @@ func (h *ConnectHandler) RemoveProjectMember(ctx context.Context, request *conne principalID := request.Msg.GetPrincipalId() principalType := request.Msg.GetPrincipalType() - if err := h.projectService.RemoveMember(ctx, projectID, principalID, principalType); err != nil { + if err := h.membershipService.RemoveProjectMember(ctx, projectID, principalID, principalType); err != nil { errorLogger.LogServiceError(ctx, request, "RemoveProjectMember", err, zap.String("project_id", projectID), zap.String("principal_id", principalID), @@ -425,9 +428,9 @@ func (h *ConnectHandler) RemoveProjectMember(ctx context.Context, request *conne switch { case errors.Is(err, project.ErrNotExist): return nil, connect.NewError(connect.CodeNotFound, ErrProjectNotFound) - case errors.Is(err, project.ErrNotMember): - return nil, connect.NewError(connect.CodeNotFound, project.ErrNotMember) - case errors.Is(err, project.ErrInvalidPrincipalType): + case errors.Is(err, membership.ErrNotMember): + return nil, connect.NewError(connect.CodeNotFound, ErrNotMember) + case errors.Is(err, membership.ErrInvalidPrincipalType): return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest) default: return nil, connect.NewError(connect.CodeInternal, ErrInternalServerError) diff --git a/pkg/auditrecord/consts.go b/pkg/auditrecord/consts.go index 01166acb7..ca8a09bf2 100644 --- a/pkg/auditrecord/consts.go +++ b/pkg/auditrecord/consts.go @@ -39,6 +39,10 @@ const ( OrganizationMemberRoleChangedEvent Event = "organization.role_changed" OrganizationInvitationAcceptedEvent Event = "organization.accepted" + // Project Member Events + ProjectMemberRoleChangedEvent Event = "project.member_role_changed" + ProjectMemberRemovedEvent Event = "project.member_removed" + // KYC Events KYCVerifiedEvent Event = "kyc.verified" KYCUnverifiedEvent Event = "kyc.unverified"