Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 := "fcb776fb2962a9a0378ea4216177b7c2686efc15"
PROTON_COMMIT := "aed9df8ec21ade1d1a56e6fe041cc29284b8e7ca"

admin-app:
@echo " > generating admin build"
Expand Down
8 changes: 5 additions & 3 deletions core/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,11 @@ const (
OrgMemberDeletedEvent EventName = "app.organization.member.deleted"
OrgKycUpdatedEvent EventName = "app.organization.kyc.updated"

ProjectCreatedEvent EventName = "app.project.created"
ProjectUpdatedEvent EventName = "app.project.updated"
ProjectDeletedEvent EventName = "app.project.deleted"
ProjectCreatedEvent EventName = "app.project.created"
ProjectUpdatedEvent EventName = "app.project.updated"
ProjectDeletedEvent EventName = "app.project.deleted"
ProjectMemberRoleSetEvent EventName = "app.project.member.role.set"
ProjectMemberRemovedEvent EventName = "app.project.member.removed"

ResourceCreatedEvent EventName = "app.resource.created"
ResourceUpdatedEvent EventName = "app.resource.updated"
Expand Down
1 change: 1 addition & 0 deletions core/project/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ var (
ErrInvalidProjectRole = errors.New("role is not valid for project scope")
ErrNotOrgMember = errors.New("user is not a member of the organization")
ErrInvalidPrincipalType = errors.New("invalid principal type")
ErrNotMember = errors.New("principal is not a member of the project")
)
36 changes: 36 additions & 0 deletions core/project/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,42 @@ func (s Service) SetMemberRole(ctx context.Context, projectID, principalID, prin
return err
}

// RemoveMember removes a principal from a project by deleting all their project-level policies.
// Supported principal types: user, service user, group.
func (s Service) RemoveMember(ctx context.Context, projectID, principalID, principalType string) error {
_, err := s.Get(ctx, projectID)
if err != nil {
return err
}

switch principalType {
case schema.UserPrincipal, schema.ServiceUserPrincipal, schema.GroupPrincipal:
default:
return ErrInvalidPrincipalType
}

existingPolicies, err := s.policyService.List(ctx, policy.Filter{
ProjectID: projectID,
PrincipalID: principalID,
PrincipalType: principalType,
})
if err != nil {
return err
}

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

for _, p := range existingPolicies {
if err := s.policyService.Delete(ctx, p.ID); err != nil {
return err
}
}

return nil
}

// validatePrincipal checks that the principal exists and belongs to the org.
// For users, org membership is checked via org-level policies.
// For service users and groups, org membership is checked via their org ID field.
Expand Down
118 changes: 118 additions & 0 deletions core/project/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1243,3 +1243,121 @@ func TestService_SetMemberRole(t *testing.T) {
})
}
}

func TestService_RemoveMember(t *testing.T) {
ctx := context.Background()
projectID := uuid.New().String()
userID := uuid.New().String()

tests := []struct {
name string
projectID string
principalID string
principalType string
setup func(*mocks.Repository, *mocks.PolicyService)
wantErr error
}{
{
name: "should return error if project does not exist",
projectID: projectID,
principalID: userID,
principalType: schema.UserPrincipal,
setup: func(repo *mocks.Repository, policySvc *mocks.PolicyService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{}, project.ErrNotExist)
},
wantErr: project.ErrNotExist,
},
{
name: "should return error for invalid principal type",
projectID: projectID,
principalID: userID,
principalType: "app/invalid",
setup: func(repo *mocks.Repository, policySvc *mocks.PolicyService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID}, nil)
},
wantErr: project.ErrInvalidPrincipalType,
},
{
name: "should return error if principal has no project policies",
projectID: projectID,
principalID: userID,
principalType: schema.UserPrincipal,
setup: func(repo *mocks.Repository, policySvc *mocks.PolicyService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
ProjectID: projectID, PrincipalID: userID, PrincipalType: schema.UserPrincipal,
}).Return([]policy.Policy{}, nil)
},
wantErr: project.ErrNotMember,
},
{
name: "should delete all project policies for the principal",
projectID: projectID,
principalID: userID,
principalType: schema.UserPrincipal,
setup: func(repo *mocks.Repository, policySvc *mocks.PolicyService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
ProjectID: projectID, PrincipalID: userID, PrincipalType: schema.UserPrincipal,
}).Return([]policy.Policy{{ID: "p1"}, {ID: "p2"}}, nil)
policySvc.EXPECT().Delete(ctx, "p1").Return(nil)
policySvc.EXPECT().Delete(ctx, "p2").Return(nil)
},
wantErr: nil,
},
{
name: "should work for service user principal",
projectID: projectID,
principalID: userID,
principalType: schema.ServiceUserPrincipal,
setup: func(repo *mocks.Repository, policySvc *mocks.PolicyService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
ProjectID: projectID, PrincipalID: userID, PrincipalType: schema.ServiceUserPrincipal,
}).Return([]policy.Policy{{ID: "p1"}}, nil)
policySvc.EXPECT().Delete(ctx, "p1").Return(nil)
},
wantErr: nil,
},
{
name: "should work for group principal",
projectID: projectID,
principalID: userID,
principalType: schema.GroupPrincipal,
setup: func(repo *mocks.Repository, policySvc *mocks.PolicyService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
ProjectID: projectID, PrincipalID: userID, PrincipalType: schema.GroupPrincipal,
}).Return([]policy.Policy{{ID: "p1"}}, nil)
policySvc.EXPECT().Delete(ctx, "p1").Return(nil)
},
wantErr: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := mocks.NewRepository(t)
policySvc := mocks.NewPolicyService(t)
relationSvc := mocks.NewRelationService(t)
userSvc := mocks.NewUserService(t)
suserSvc := mocks.NewServiceuserService(t)
groupSvc := mocks.NewGroupService(t)
roleSvc := mocks.NewRoleService(t)
authnSvc := mocks.NewAuthnService(t)

if tt.setup != nil {
tt.setup(repo, policySvc)
}

svc := project.NewService(repo, relationSvc, userSvc, policySvc, authnSvc, suserSvc, groupSvc, roleSvc)
err := svc.RemoveMember(ctx, tt.projectID, tt.principalID, tt.principalType)

if tt.wantErr != nil {
assert.ErrorIs(t, err, tt.wantErr)
} else {
assert.NoError(t, err)
}
})
}
}
1 change: 1 addition & 0 deletions internal/api/v1beta1connect/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ type ProjectService interface {
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 {
Expand Down
49 changes: 49 additions & 0 deletions internal/api/v1beta1connect/mocks/project_service.go

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

37 changes: 37 additions & 0 deletions internal/api/v1beta1connect/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,9 +401,46 @@ func (h *ConnectHandler) SetProjectMemberRole(ctx context.Context, request *conn
}
}

audit.GetAuditor(ctx, "").LogWithAttrs(audit.ProjectMemberRoleSetEvent, audit.ProjectTarget(projectID), map[string]string{
"principal_id": principalID,
"principal_type": principalType,
"role_id": roleID,
})
return connect.NewResponse(&frontierv1beta1.SetProjectMemberRoleResponse{}), nil
}

func (h *ConnectHandler) RemoveProjectMember(ctx context.Context, request *connect.Request[frontierv1beta1.RemoveProjectMemberRequest]) (*connect.Response[frontierv1beta1.RemoveProjectMemberResponse], error) {
errorLogger := NewErrorLogger()

projectID := request.Msg.GetProjectId()
principalID := request.Msg.GetPrincipalId()
principalType := request.Msg.GetPrincipalType()

if err := h.projectService.RemoveMember(ctx, projectID, principalID, principalType); err != nil {
errorLogger.LogServiceError(ctx, request, "RemoveProjectMember", err,
zap.String("project_id", projectID),
zap.String("principal_id", principalID),
zap.String("principal_type", principalType))

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):
return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest)
default:
return nil, connect.NewError(connect.CodeInternal, ErrInternalServerError)
}
}

audit.GetAuditor(ctx, "").LogWithAttrs(audit.ProjectMemberRemovedEvent, audit.ProjectTarget(projectID), map[string]string{
"principal_id": principalID,
"principal_type": principalType,
})
return connect.NewResponse(&frontierv1beta1.RemoveProjectMemberResponse{}), nil
}

func transformProjectToPB(prj project.Project) (*frontierv1beta1.Project, error) {
metaData, err := prj.Metadata.ToStructPB()
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions pkg/server/connect_interceptors/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,10 @@ var authorizationValidationMap = map[string]func(ctx context.Context, handler *v
pbreq := req.(*connect.Request[frontierv1beta1.SetProjectMemberRoleRequest])
return handler.IsAuthorized(ctx, relation.Object{Namespace: schema.ProjectNamespace, ID: pbreq.Msg.GetProjectId()}, schema.UpdatePermission, req)
},
"/raystack.frontier.v1beta1.FrontierService/RemoveProjectMember": func(ctx context.Context, handler *v1beta1connect.ConnectHandler, req connect.AnyRequest) error {
pbreq := req.(*connect.Request[frontierv1beta1.RemoveProjectMemberRequest])
return handler.IsAuthorized(ctx, relation.Object{Namespace: schema.ProjectNamespace, ID: pbreq.Msg.GetProjectId()}, schema.UpdatePermission, req)
},

// roles
"/raystack.frontier.v1beta1.FrontierService/ListRoles": func(ctx context.Context, handler *v1beta1connect.ConnectHandler, req connect.AnyRequest) error {
Expand Down
Loading
Loading