Skip to content

Commit 575d7d8

Browse files
feat: implement RemoveProjectMember RPC
Add handler, service, and authorization for removing a principal from a project. Supports user, service user, and group principals. - Deletes all project-level policies for the principal - Validates principal_type (returns invalid_argument for unknown types) - Returns distinct errors for project not found vs member not found - Authorization checks update permission on the project - Audit logging for both SetProjectMemberRole and RemoveProjectMember - 6 unit tests covering validation and success paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a07aaa4 commit 575d7d8

11 files changed

Lines changed: 3726 additions & 3300 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
44
VERSION := $(shell git describe --tags ${TAG})
55
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto admin-app compose-up-dev
66
.DEFAULT_GOAL := build
7-
PROTON_COMMIT := "fcb776fb2962a9a0378ea4216177b7c2686efc15"
7+
PROTON_COMMIT := "aed9df8ec21ade1d1a56e6fe041cc29284b8e7ca"
88

99
admin-app:
1010
@echo " > generating admin build"

core/audit/audit.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,11 @@ const (
8282
OrgMemberDeletedEvent EventName = "app.organization.member.deleted"
8383
OrgKycUpdatedEvent EventName = "app.organization.kyc.updated"
8484

85-
ProjectCreatedEvent EventName = "app.project.created"
86-
ProjectUpdatedEvent EventName = "app.project.updated"
87-
ProjectDeletedEvent EventName = "app.project.deleted"
85+
ProjectCreatedEvent EventName = "app.project.created"
86+
ProjectUpdatedEvent EventName = "app.project.updated"
87+
ProjectDeletedEvent EventName = "app.project.deleted"
88+
ProjectMemberRoleSetEvent EventName = "app.project.member.role.set"
89+
ProjectMemberRemovedEvent EventName = "app.project.member.removed"
8890

8991
ResourceCreatedEvent EventName = "app.resource.created"
9092
ResourceUpdatedEvent EventName = "app.resource.updated"

core/project/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ var (
1111
ErrInvalidProjectRole = errors.New("role is not valid for project scope")
1212
ErrNotOrgMember = errors.New("user is not a member of the organization")
1313
ErrInvalidPrincipalType = errors.New("invalid principal type")
14+
ErrNotMember = errors.New("principal is not a member of the project")
1415
)

core/project/service.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,42 @@ func (s Service) SetMemberRole(ctx context.Context, projectID, principalID, prin
401401
return err
402402
}
403403

404+
// RemoveMember removes a principal from a project by deleting all their project-level policies.
405+
// Supported principal types: user, service user, group.
406+
func (s Service) RemoveMember(ctx context.Context, projectID, principalID, principalType string) error {
407+
_, err := s.Get(ctx, projectID)
408+
if err != nil {
409+
return err
410+
}
411+
412+
switch principalType {
413+
case schema.UserPrincipal, schema.ServiceUserPrincipal, schema.GroupPrincipal:
414+
default:
415+
return ErrInvalidPrincipalType
416+
}
417+
418+
existingPolicies, err := s.policyService.List(ctx, policy.Filter{
419+
ProjectID: projectID,
420+
PrincipalID: principalID,
421+
PrincipalType: principalType,
422+
})
423+
if err != nil {
424+
return err
425+
}
426+
427+
if len(existingPolicies) == 0 {
428+
return ErrNotMember
429+
}
430+
431+
for _, p := range existingPolicies {
432+
if err := s.policyService.Delete(ctx, p.ID); err != nil {
433+
return err
434+
}
435+
}
436+
437+
return nil
438+
}
439+
404440
// validatePrincipal checks that the principal exists and belongs to the org.
405441
// For users, org membership is checked via org-level policies.
406442
// For service users and groups, org membership is checked via their org ID field.

core/project/service_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,3 +1243,121 @@ func TestService_SetMemberRole(t *testing.T) {
12431243
})
12441244
}
12451245
}
1246+
1247+
func TestService_RemoveMember(t *testing.T) {
1248+
ctx := context.Background()
1249+
projectID := uuid.New().String()
1250+
userID := uuid.New().String()
1251+
1252+
tests := []struct {
1253+
name string
1254+
projectID string
1255+
principalID string
1256+
principalType string
1257+
setup func(*mocks.Repository, *mocks.PolicyService)
1258+
wantErr error
1259+
}{
1260+
{
1261+
name: "should return error if project does not exist",
1262+
projectID: projectID,
1263+
principalID: userID,
1264+
principalType: schema.UserPrincipal,
1265+
setup: func(repo *mocks.Repository, policySvc *mocks.PolicyService) {
1266+
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{}, project.ErrNotExist)
1267+
},
1268+
wantErr: project.ErrNotExist,
1269+
},
1270+
{
1271+
name: "should return error for invalid principal type",
1272+
projectID: projectID,
1273+
principalID: userID,
1274+
principalType: "app/invalid",
1275+
setup: func(repo *mocks.Repository, policySvc *mocks.PolicyService) {
1276+
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID}, nil)
1277+
},
1278+
wantErr: project.ErrInvalidPrincipalType,
1279+
},
1280+
{
1281+
name: "should return error if principal has no project policies",
1282+
projectID: projectID,
1283+
principalID: userID,
1284+
principalType: schema.UserPrincipal,
1285+
setup: func(repo *mocks.Repository, policySvc *mocks.PolicyService) {
1286+
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID}, nil)
1287+
policySvc.EXPECT().List(ctx, policy.Filter{
1288+
ProjectID: projectID, PrincipalID: userID, PrincipalType: schema.UserPrincipal,
1289+
}).Return([]policy.Policy{}, nil)
1290+
},
1291+
wantErr: project.ErrNotMember,
1292+
},
1293+
{
1294+
name: "should delete all project policies for the principal",
1295+
projectID: projectID,
1296+
principalID: userID,
1297+
principalType: schema.UserPrincipal,
1298+
setup: func(repo *mocks.Repository, policySvc *mocks.PolicyService) {
1299+
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID}, nil)
1300+
policySvc.EXPECT().List(ctx, policy.Filter{
1301+
ProjectID: projectID, PrincipalID: userID, PrincipalType: schema.UserPrincipal,
1302+
}).Return([]policy.Policy{{ID: "p1"}, {ID: "p2"}}, nil)
1303+
policySvc.EXPECT().Delete(ctx, "p1").Return(nil)
1304+
policySvc.EXPECT().Delete(ctx, "p2").Return(nil)
1305+
},
1306+
wantErr: nil,
1307+
},
1308+
{
1309+
name: "should work for service user principal",
1310+
projectID: projectID,
1311+
principalID: userID,
1312+
principalType: schema.ServiceUserPrincipal,
1313+
setup: func(repo *mocks.Repository, policySvc *mocks.PolicyService) {
1314+
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID}, nil)
1315+
policySvc.EXPECT().List(ctx, policy.Filter{
1316+
ProjectID: projectID, PrincipalID: userID, PrincipalType: schema.ServiceUserPrincipal,
1317+
}).Return([]policy.Policy{{ID: "p1"}}, nil)
1318+
policySvc.EXPECT().Delete(ctx, "p1").Return(nil)
1319+
},
1320+
wantErr: nil,
1321+
},
1322+
{
1323+
name: "should work for group principal",
1324+
projectID: projectID,
1325+
principalID: userID,
1326+
principalType: schema.GroupPrincipal,
1327+
setup: func(repo *mocks.Repository, policySvc *mocks.PolicyService) {
1328+
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID}, nil)
1329+
policySvc.EXPECT().List(ctx, policy.Filter{
1330+
ProjectID: projectID, PrincipalID: userID, PrincipalType: schema.GroupPrincipal,
1331+
}).Return([]policy.Policy{{ID: "p1"}}, nil)
1332+
policySvc.EXPECT().Delete(ctx, "p1").Return(nil)
1333+
},
1334+
wantErr: nil,
1335+
},
1336+
}
1337+
1338+
for _, tt := range tests {
1339+
t.Run(tt.name, func(t *testing.T) {
1340+
repo := mocks.NewRepository(t)
1341+
policySvc := mocks.NewPolicyService(t)
1342+
relationSvc := mocks.NewRelationService(t)
1343+
userSvc := mocks.NewUserService(t)
1344+
suserSvc := mocks.NewServiceuserService(t)
1345+
groupSvc := mocks.NewGroupService(t)
1346+
roleSvc := mocks.NewRoleService(t)
1347+
authnSvc := mocks.NewAuthnService(t)
1348+
1349+
if tt.setup != nil {
1350+
tt.setup(repo, policySvc)
1351+
}
1352+
1353+
svc := project.NewService(repo, relationSvc, userSvc, policySvc, authnSvc, suserSvc, groupSvc, roleSvc)
1354+
err := svc.RemoveMember(ctx, tt.projectID, tt.principalID, tt.principalType)
1355+
1356+
if tt.wantErr != nil {
1357+
assert.ErrorIs(t, err, tt.wantErr)
1358+
} else {
1359+
assert.NoError(t, err)
1360+
}
1361+
})
1362+
}
1363+
}

internal/api/v1beta1connect/interfaces.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ type ProjectService interface {
357357
Enable(ctx context.Context, id string) error
358358
Disable(ctx context.Context, id string) error
359359
SetMemberRole(ctx context.Context, projectID, principalID, principalType, newRoleID string) error
360+
RemoveMember(ctx context.Context, projectID, principalID, principalType string) error
360361
}
361362

362363
type OrgUsersService interface {

internal/api/v1beta1connect/mocks/project_service.go

Lines changed: 49 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/api/v1beta1connect/project.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,9 +401,46 @@ func (h *ConnectHandler) SetProjectMemberRole(ctx context.Context, request *conn
401401
}
402402
}
403403

404+
audit.GetAuditor(ctx, "").LogWithAttrs(audit.ProjectMemberRoleSetEvent, audit.ProjectTarget(projectID), map[string]string{
405+
"principal_id": principalID,
406+
"principal_type": principalType,
407+
"role_id": roleID,
408+
})
404409
return connect.NewResponse(&frontierv1beta1.SetProjectMemberRoleResponse{}), nil
405410
}
406411

412+
func (h *ConnectHandler) RemoveProjectMember(ctx context.Context, request *connect.Request[frontierv1beta1.RemoveProjectMemberRequest]) (*connect.Response[frontierv1beta1.RemoveProjectMemberResponse], error) {
413+
errorLogger := NewErrorLogger()
414+
415+
projectID := request.Msg.GetProjectId()
416+
principalID := request.Msg.GetPrincipalId()
417+
principalType := request.Msg.GetPrincipalType()
418+
419+
if err := h.projectService.RemoveMember(ctx, projectID, principalID, principalType); err != nil {
420+
errorLogger.LogServiceError(ctx, request, "RemoveProjectMember", err,
421+
zap.String("project_id", projectID),
422+
zap.String("principal_id", principalID),
423+
zap.String("principal_type", principalType))
424+
425+
switch {
426+
case errors.Is(err, project.ErrNotExist):
427+
return nil, connect.NewError(connect.CodeNotFound, ErrProjectNotFound)
428+
case errors.Is(err, project.ErrNotMember):
429+
return nil, connect.NewError(connect.CodeNotFound, project.ErrNotMember)
430+
case errors.Is(err, project.ErrInvalidPrincipalType):
431+
return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest)
432+
default:
433+
return nil, connect.NewError(connect.CodeInternal, ErrInternalServerError)
434+
}
435+
}
436+
437+
audit.GetAuditor(ctx, "").LogWithAttrs(audit.ProjectMemberRemovedEvent, audit.ProjectTarget(projectID), map[string]string{
438+
"principal_id": principalID,
439+
"principal_type": principalType,
440+
})
441+
return connect.NewResponse(&frontierv1beta1.RemoveProjectMemberResponse{}), nil
442+
}
443+
407444
func transformProjectToPB(prj project.Project) (*frontierv1beta1.Project, error) {
408445
metaData, err := prj.Metadata.ToStructPB()
409446
if err != nil {

pkg/server/connect_interceptors/authorization.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,10 @@ var authorizationValidationMap = map[string]func(ctx context.Context, handler *v
516516
pbreq := req.(*connect.Request[frontierv1beta1.SetProjectMemberRoleRequest])
517517
return handler.IsAuthorized(ctx, relation.Object{Namespace: schema.ProjectNamespace, ID: pbreq.Msg.GetProjectId()}, schema.UpdatePermission, req)
518518
},
519+
"/raystack.frontier.v1beta1.FrontierService/RemoveProjectMember": func(ctx context.Context, handler *v1beta1connect.ConnectHandler, req connect.AnyRequest) error {
520+
pbreq := req.(*connect.Request[frontierv1beta1.RemoveProjectMemberRequest])
521+
return handler.IsAuthorized(ctx, relation.Object{Namespace: schema.ProjectNamespace, ID: pbreq.Msg.GetProjectId()}, schema.UpdatePermission, req)
522+
},
519523

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

0 commit comments

Comments
 (0)