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 app/controlplane/cmd/wire_gen.go

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

8 changes: 6 additions & 2 deletions app/controlplane/internal/service/orginvitation.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2023 The Chainloop Authors.
// Copyright 2023-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -19,6 +19,8 @@ import (
"context"

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

callerRole := authz.Role(usercontext.CurrentAuthzSubject(ctx))

// Validations are done in the biz layer
i, err := s.useCase.Create(ctx, org.ID, req.ReceiverEmail, opts...)
i, err := s.useCase.Create(ctx, org.ID, req.ReceiverEmail, callerRole, opts...)
if err != nil {
return nil, handleUseCaseErr(err, s.log)
}
Expand Down
10 changes: 8 additions & 2 deletions app/controlplane/pkg/biz/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -569,12 +569,18 @@ func (uc *GroupUseCase) handleNonExistingUser(ctx context.Context, orgID, groupI
return nil, NewErrAlreadyExistsStr("user is already invited to the organization")
}

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

if requesterMembership == nil {
return nil, NewErrNotFound("membership")
}

pass, err := uc.authz.Enforce(ctx, string(requesterMembership.Role), authz.PolicyOrganizationInvitationsCreate)
if err != nil {
return nil, fmt.Errorf("failed to check requester's role: %w", err)
Expand All @@ -591,7 +597,7 @@ func (uc *GroupUseCase) handleNonExistingUser(ctx context.Context, orgID, groupI
}

// Create an invitation for the user to join the organization
if _, err := uc.orgInvitationUC.Create(ctx, orgID.String(), opts.UserEmail,
if _, err := uc.orgInvitationUC.Create(ctx, orgID.String(), opts.UserEmail, requesterMembership.Role,
WithSender(opts.RequesterID),
WithInvitationRole(authz.RoleOrgContributor),
WithInvitationContext(invitationContext)); err != nil {
Expand Down
21 changes: 15 additions & 6 deletions app/controlplane/pkg/biz/membership.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ func (uc *MembershipUseCase) DeleteOther(ctx context.Context, orgID, userID, mem

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

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

// enforceManageOwners checks that the caller has the manage_owners policy.
func (uc *MembershipUseCase) enforceManageOwners(ctx context.Context, callerRole authz.Role) error {
ok, err := uc.authzUC.Enforce(ctx, string(callerRole), authz.PolicyOrganizationManageOwners)
// Denial messages for the owner-scoped guards, kept here so each wording has
// a single home shared by all enforceManageOwners call sites.
const (
errMsgOnlyOwnersManageOwners = "only organization owners can manage owner memberships"
errMsgOnlyOwnersInviteOwners = "only organization owners can invite owners"
)

// enforceManageOwners checks that the caller has the manage_owners policy,
// which is granted only to organization owners. It guards every owner-scoped
// mutation (membership role changes, owner removal, owner invitations).
func enforceManageOwners(ctx context.Context, authzUC *AuthzUseCase, callerRole authz.Role, deniedMsg string) error {
ok, err := authzUC.Enforce(ctx, string(callerRole), authz.PolicyOrganizationManageOwners)
if err != nil {
return fmt.Errorf("failed to enforce manage owners policy: %w", err)
}

if !ok {
return NewErrUnauthorizedStr("only organization owners can manage owner memberships")
return NewErrUnauthorizedStr(deniedMsg)
}

return nil
Expand Down
15 changes: 12 additions & 3 deletions app/controlplane/pkg/biz/orginvitation.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type OrgInvitationUseCase struct {
projectRepo ProjectsRepo
// Use cases
auditor *AuditorUseCase
authzUC *AuthzUseCase
}

type OrgInvitation struct {
Expand Down Expand Up @@ -83,10 +84,10 @@ type OrgInvitationRepo interface {
ChangeStatus(ctx context.Context, ID uuid.UUID, status OrgInvitationStatus) error
}

func NewOrgInvitationUseCase(r OrgInvitationRepo, mRepo MembershipRepo, uRepo UserRepo, auditorUC *AuditorUseCase, groupRepo GroupRepo, projectRepo ProjectsRepo, l log.Logger) (*OrgInvitationUseCase, error) {
func NewOrgInvitationUseCase(r OrgInvitationRepo, mRepo MembershipRepo, uRepo UserRepo, auditorUC *AuditorUseCase, groupRepo GroupRepo, projectRepo ProjectsRepo, authzUC *AuthzUseCase, l log.Logger) (*OrgInvitationUseCase, error) {
return &OrgInvitationUseCase{
logger: servicelogger.ScopedHelper(l, "biz/orgInvitation"),
repo: r, mRepo: mRepo, userRepo: uRepo, auditor: auditorUC, groupRepo: groupRepo, projectRepo: projectRepo,
repo: r, mRepo: mRepo, userRepo: uRepo, auditor: auditorUC, groupRepo: groupRepo, projectRepo: projectRepo, authzUC: authzUC,
}, nil
}

Expand Down Expand Up @@ -118,7 +119,7 @@ func WithSender(senderID uuid.UUID) InvitationCreateOpt {
}
}

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

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

// Only owners can send owner invitations. This closes the Admin→Owner
// invite bypass of PolicyOrganizationManageOwners.
if opts.role == authz.RoleOwner {
if err := enforceManageOwners(ctx, uc.authzUC, callerRole, errMsgOnlyOwnersInviteOwners); err != nil {
return nil, err
}
}

orgUUID, err := uuid.Parse(orgID)
if err != nil {
return nil, NewErrInvalidUUID(err)
Expand Down
Loading
Loading