Skip to content

Commit 3cbb22d

Browse files
authored
fix(controlplane): restrict owner-role invitations to org owners (CP-1) (#3263)
Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent 63d4002 commit 3cbb22d

10 files changed

Lines changed: 367 additions & 165 deletions

File tree

‎app/controlplane/cmd/wire_gen.go‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/controlplane/internal/service/orginvitation.go‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2023 The Chainloop Authors.
2+
// Copyright 2023-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -19,6 +19,8 @@ import (
1919
"context"
2020

2121
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
22+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext"
23+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
2224
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
2325
"github.com/google/uuid"
2426
"google.golang.org/protobuf/types/known/timestamppb"
@@ -58,8 +60,10 @@ func (s *OrgInvitationService) Create(ctx context.Context, req *pb.OrgInvitation
5860
opts = append(opts, biz.WithSender(userID))
5961
}
6062

63+
callerRole := authz.Role(usercontext.CurrentAuthzSubject(ctx))
64+
6165
// Validations are done in the biz layer
62-
i, err := s.useCase.Create(ctx, org.ID, req.ReceiverEmail, opts...)
66+
i, err := s.useCase.Create(ctx, org.ID, req.ReceiverEmail, callerRole, opts...)
6367
if err != nil {
6468
return nil, handleUseCaseErr(err, s.log)
6569
}

‎app/controlplane/pkg/biz/group.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -569,12 +569,18 @@ func (uc *GroupUseCase) handleNonExistingUser(ctx context.Context, orgID, groupI
569569
return nil, NewErrAlreadyExistsStr("user is already invited to the organization")
570570
}
571571

572-
// Check if the requester is an admin or owner of the organization
572+
// Check if the requester is an admin or owner of the organization.
573+
// The repo returns (nil, nil) when no membership exists, so guard against
574+
// a requester without a membership in the org.
573575
requesterMembership, err := uc.membershipRepo.FindByOrgAndUser(ctx, orgID, opts.RequesterID)
574576
if err != nil {
575577
return nil, fmt.Errorf("failed to check requester's role: %w", err)
576578
}
577579

580+
if requesterMembership == nil {
581+
return nil, NewErrNotFound("membership")
582+
}
583+
578584
pass, err := uc.authz.Enforce(ctx, string(requesterMembership.Role), authz.PolicyOrganizationInvitationsCreate)
579585
if err != nil {
580586
return nil, fmt.Errorf("failed to check requester's role: %w", err)
@@ -591,7 +597,7 @@ func (uc *GroupUseCase) handleNonExistingUser(ctx context.Context, orgID, groupI
591597
}
592598

593599
// Create an invitation for the user to join the organization
594-
if _, err := uc.orgInvitationUC.Create(ctx, orgID.String(), opts.UserEmail,
600+
if _, err := uc.orgInvitationUC.Create(ctx, orgID.String(), opts.UserEmail, requesterMembership.Role,
595601
WithSender(opts.RequesterID),
596602
WithInvitationRole(authz.RoleOrgContributor),
597603
WithInvitationContext(invitationContext)); err != nil {

‎app/controlplane/pkg/biz/membership.go‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ func (uc *MembershipUseCase) DeleteOther(ctx context.Context, orgID, userID, mem
130130

131131
// Only owners can remove other owners
132132
if m.Role == authz.RoleOwner {
133-
if err := uc.enforceManageOwners(ctx, callerRole); err != nil {
133+
if err := enforceManageOwners(ctx, uc.authzUC, callerRole, errMsgOnlyOwnersManageOwners); err != nil {
134134
return err
135135
}
136136
}
@@ -194,7 +194,7 @@ func (uc *MembershipUseCase) UpdateRole(ctx context.Context, orgID, userID, memb
194194

195195
// Only owners can modify owner memberships or promote users to owner
196196
if m.Role == authz.RoleOwner || role == authz.RoleOwner {
197-
if err := uc.enforceManageOwners(ctx, callerRole); err != nil {
197+
if err := enforceManageOwners(ctx, uc.authzUC, callerRole, errMsgOnlyOwnersManageOwners); err != nil {
198198
return nil, err
199199
}
200200
}
@@ -226,15 +226,24 @@ func (uc *MembershipUseCase) UpdateRole(ctx context.Context, orgID, userID, memb
226226
return updatedMembership, nil
227227
}
228228

229-
// enforceManageOwners checks that the caller has the manage_owners policy.
230-
func (uc *MembershipUseCase) enforceManageOwners(ctx context.Context, callerRole authz.Role) error {
231-
ok, err := uc.authzUC.Enforce(ctx, string(callerRole), authz.PolicyOrganizationManageOwners)
229+
// Denial messages for the owner-scoped guards, kept here so each wording has
230+
// a single home shared by all enforceManageOwners call sites.
231+
const (
232+
errMsgOnlyOwnersManageOwners = "only organization owners can manage owner memberships"
233+
errMsgOnlyOwnersInviteOwners = "only organization owners can invite owners"
234+
)
235+
236+
// enforceManageOwners checks that the caller has the manage_owners policy,
237+
// which is granted only to organization owners. It guards every owner-scoped
238+
// mutation (membership role changes, owner removal, owner invitations).
239+
func enforceManageOwners(ctx context.Context, authzUC *AuthzUseCase, callerRole authz.Role, deniedMsg string) error {
240+
ok, err := authzUC.Enforce(ctx, string(callerRole), authz.PolicyOrganizationManageOwners)
232241
if err != nil {
233242
return fmt.Errorf("failed to enforce manage owners policy: %w", err)
234243
}
235244

236245
if !ok {
237-
return NewErrUnauthorizedStr("only organization owners can manage owner memberships")
246+
return NewErrUnauthorizedStr(deniedMsg)
238247
}
239248

240249
return nil

‎app/controlplane/pkg/biz/orginvitation.go‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type OrgInvitationUseCase struct {
4545
projectRepo ProjectsRepo
4646
// Use cases
4747
auditor *AuditorUseCase
48+
authzUC *AuthzUseCase
4849
}
4950

5051
type OrgInvitation struct {
@@ -83,10 +84,10 @@ type OrgInvitationRepo interface {
8384
ChangeStatus(ctx context.Context, ID uuid.UUID, status OrgInvitationStatus) error
8485
}
8586

86-
func NewOrgInvitationUseCase(r OrgInvitationRepo, mRepo MembershipRepo, uRepo UserRepo, auditorUC *AuditorUseCase, groupRepo GroupRepo, projectRepo ProjectsRepo, l log.Logger) (*OrgInvitationUseCase, error) {
87+
func NewOrgInvitationUseCase(r OrgInvitationRepo, mRepo MembershipRepo, uRepo UserRepo, auditorUC *AuditorUseCase, groupRepo GroupRepo, projectRepo ProjectsRepo, authzUC *AuthzUseCase, l log.Logger) (*OrgInvitationUseCase, error) {
8788
return &OrgInvitationUseCase{
8889
logger: servicelogger.ScopedHelper(l, "biz/orgInvitation"),
89-
repo: r, mRepo: mRepo, userRepo: uRepo, auditor: auditorUC, groupRepo: groupRepo, projectRepo: projectRepo,
90+
repo: r, mRepo: mRepo, userRepo: uRepo, auditor: auditorUC, groupRepo: groupRepo, projectRepo: projectRepo, authzUC: authzUC,
9091
}, nil
9192
}
9293

@@ -118,7 +119,7 @@ func WithSender(senderID uuid.UUID) InvitationCreateOpt {
118119
}
119120
}
120121

121-
func (uc *OrgInvitationUseCase) Create(ctx context.Context, orgID, receiverEmail string, createOpts ...InvitationCreateOpt) (*OrgInvitation, error) {
122+
func (uc *OrgInvitationUseCase) Create(ctx context.Context, orgID, receiverEmail string, callerRole authz.Role, createOpts ...InvitationCreateOpt) (*OrgInvitation, error) {
122123
ctx, span := otelx.Start(ctx, orgInvitationTracer, "OrgInvitationUseCase.Create")
123124
defer span.End()
124125

@@ -143,6 +144,14 @@ func (uc *OrgInvitationUseCase) Create(ctx context.Context, orgID, receiverEmail
143144
return nil, NewErrValidationStr("role is required")
144145
}
145146

147+
// Only owners can send owner invitations. This closes the Admin→Owner
148+
// invite bypass of PolicyOrganizationManageOwners.
149+
if opts.role == authz.RoleOwner {
150+
if err := enforceManageOwners(ctx, uc.authzUC, callerRole, errMsgOnlyOwnersInviteOwners); err != nil {
151+
return nil, err
152+
}
153+
}
154+
146155
orgUUID, err := uuid.Parse(orgID)
147156
if err != nil {
148157
return nil, NewErrInvalidUUID(err)

0 commit comments

Comments
 (0)