From 30419af6e5004e7d8e0042d92454959228434f32 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Sun, 5 Jul 2026 19:20:20 +0200 Subject: [PATCH 1/3] fix(controlplane): restrict owner-role invitations to org owners (CP-1) OrgInvitationUseCase.Create accepted any caller-supplied role verbatim, so an Admin (who passes the Casbin middleware via IsAdmin() short-circuit) could invite a new external email as MEMBERSHIP_ROLE_ORG_OWNER. AcceptPendingInvitations then auto-creates an Owner membership, bypassing PolicyOrganizationManageOwners (the Owner-only manage-owners gate) and bootstrapping a fresh Owner via the invitation path. Add an enforceManageOwners check in the biz layer: when the requested role is RoleOwner, enforce PolicyOrganizationManageOwners against the caller's role. This mirrors the existing guard in MembershipUseCase.UpdateRole/DeleteOther. The service layer passes the caller's authz subject down via a new callerRole parameter; the two existing biz callers (group/project invitation flows) pass the requester's membership role. Non-owner callers now receive ErrUnauthorized("only organization owners can invite owners"). Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino --- app/controlplane/cmd/wire_gen.go | 2 +- .../internal/service/orginvitation.go | 11 +- app/controlplane/pkg/biz/group.go | 2 +- app/controlplane/pkg/biz/orginvitation.go | 31 +++- .../pkg/biz/orginvitation_integration_test.go | 149 +++++++++++++++--- app/controlplane/pkg/biz/project.go | 2 +- .../pkg/biz/project_integration_test.go | 2 +- .../pkg/biz/testhelpers/wire_gen.go | 2 +- 8 files changed, 171 insertions(+), 30 deletions(-) diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 8c3626bd6..13ff75105 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -176,7 +176,7 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr return nil, nil, err } orgInvitationRepo := data.NewOrgInvitation(dataData, logger) - orgInvitationUseCase, err := biz.NewOrgInvitationUseCase(orgInvitationRepo, membershipRepo, userRepo, auditorUseCase, groupRepo, projectsRepo, logger) + orgInvitationUseCase, err := biz.NewOrgInvitationUseCase(orgInvitationRepo, membershipRepo, userRepo, auditorUseCase, groupRepo, projectsRepo, authzUseCase, logger) if err != nil { cleanup3() cleanup2() diff --git a/app/controlplane/internal/service/orginvitation.go b/app/controlplane/internal/service/orginvitation.go index bbec4a48e..63be524cc 100644 --- a/app/controlplane/internal/service/orginvitation.go +++ b/app/controlplane/internal/service/orginvitation.go @@ -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. @@ -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" @@ -58,8 +60,13 @@ func (s *OrgInvitationService) Create(ctx context.Context, req *pb.OrgInvitation opts = append(opts, biz.WithSender(userID)) } + // Pass the caller's authz subject (org role for users, "api-token:" + // for API tokens) so the biz layer can enforce that only owners may invite + // owners (audit finding CP-1). + 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) } diff --git a/app/controlplane/pkg/biz/group.go b/app/controlplane/pkg/biz/group.go index d6dab9734..ff8919e2c 100644 --- a/app/controlplane/pkg/biz/group.go +++ b/app/controlplane/pkg/biz/group.go @@ -591,7 +591,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 { diff --git a/app/controlplane/pkg/biz/orginvitation.go b/app/controlplane/pkg/biz/orginvitation.go index 0e301ff74..f0ec2b43d 100644 --- a/app/controlplane/pkg/biz/orginvitation.go +++ b/app/controlplane/pkg/biz/orginvitation.go @@ -45,6 +45,7 @@ type OrgInvitationUseCase struct { projectRepo ProjectsRepo // Use cases auditor *AuditorUseCase + authzUC *AuthzUseCase } type OrgInvitation struct { @@ -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 } @@ -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() @@ -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 (audit finding CP-1). + if opts.role == authz.RoleOwner { + if err := uc.enforceManageOwners(ctx, callerRole); err != nil { + return nil, err + } + } + orgUUID, err := uuid.Parse(orgID) if err != nil { return nil, NewErrInvalidUUID(err) @@ -461,6 +470,22 @@ func (uc *OrgInvitationUseCase) processProjectMembership(ctx context.Context, in return nil } +// enforceManageOwners checks that the caller has the manage_owners policy, +// which is granted only to organization owners. It is the same check used by +// MembershipUseCase.UpdateRole / DeleteOther to guard owner-scoped mutations. +func (uc *OrgInvitationUseCase) enforceManageOwners(ctx context.Context, callerRole authz.Role) error { + ok, err := uc.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 invite owners") + } + + return nil +} + type OrgInvitationStatus string var ( diff --git a/app/controlplane/pkg/biz/orginvitation_integration_test.go b/app/controlplane/pkg/biz/orginvitation_integration_test.go index d372ea9fc..4972c8c83 100644 --- a/app/controlplane/pkg/biz/orginvitation_integration_test.go +++ b/app/controlplane/pkg/biz/orginvitation_integration_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-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. @@ -34,12 +34,12 @@ const receiverEmail = "sarah@cyberdyne.io" func (s *OrgInvitationIntegrationTestSuite) TestList() { ctx := context.Background() - inviteOrg1A, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) + inviteOrg1A, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) // same org but another user - inviteOrg1B, err := s.OrgInvitation.Create(ctx, s.org1.ID, "another-email@cyberdyne.io", biz.WithSender(uuid.MustParse(s.user2.ID))) + inviteOrg1B, err := s.OrgInvitation.Create(ctx, s.org1.ID, "another-email@cyberdyne.io", authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user2.ID))) s.NoError(err) - inviteOrg2A, err := s.OrgInvitation.Create(ctx, s.org2.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) + inviteOrg2A, err := s.OrgInvitation.Create(ctx, s.org2.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) testCases := []struct { @@ -76,21 +76,21 @@ func (s *OrgInvitationIntegrationTestSuite) TestList() { func (s *OrgInvitationIntegrationTestSuite) TestCreate() { ctx := context.Background() s.T().Run("invalid org ID", func(_ *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, "deadbeef", receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, "deadbeef", receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.True(biz.IsErrInvalidUUID(err)) s.Nil(invite) }) s.T().Run("missing receiver email", func(_ *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "", biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "", authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.True(biz.IsErrValidation(err)) s.Nil(invite) }) s.T().Run("receiver email same than sender", func(_ *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.Email, biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.Email, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "sender and receiver emails cannot be the same") s.True(biz.IsErrValidation(err)) @@ -98,7 +98,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { }) s.T().Run("receiver is already a member", func(_ *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user2.Email, biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user2.Email, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "user already exists in the org") s.True(biz.IsErrValidation(err)) @@ -106,7 +106,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { }) s.T().Run("org not found", func(_ *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.Nil)) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.Nil)) s.Error(err) s.True(biz.IsNotFound(err)) s.Nil(invite) @@ -114,7 +114,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { s.T().Run("can create invites for org1 and 2", func(_ *testing.T) { for _, org := range []*biz.Organization{s.org1, s.org2} { - invite, err := s.OrgInvitation.Create(ctx, org.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, org.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) s.Equal(org, invite.Org) s.Equal(s.user, invite.Sender) @@ -125,7 +125,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { }) s.T().Run("but can't create if there is one pending", func(_ *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "already exists") s.True(biz.IsErrValidation(err)) @@ -133,33 +133,137 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { }) s.T().Run("but it can if it's another email", func(_ *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "anotheremail@cyberdyne.io", biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "anotheremail@cyberdyne.io", authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.Equal("anotheremail@cyberdyne.io", invite.ReceiverEmail) s.Equal(s.org1, invite.Org) s.NoError(err) }) s.T().Run("the default role is viewer", func(_ *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "viewer@cyberdyne.io", biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "viewer@cyberdyne.io", authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) s.Equal(authz.RoleViewer, invite.Role) }) s.T().Run("but can have other roles", func(_ *testing.T) { for _, r := range []authz.Role{authz.RoleOwner, authz.RoleAdmin, authz.RoleViewer} { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, fmt.Sprintf("%s@cyberdyne.io", r), biz.WithInvitationRole(r), biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, fmt.Sprintf("%s@cyberdyne.io", r), authz.RoleOwner, biz.WithInvitationRole(r), biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) s.Equal(r, invite.Role) } }) s.Run("and the email address is downcased", func() { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "WasCamelCase@cyberdyne.io", biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "WasCamelCase@cyberdyne.io", authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) s.Equal("wascamelcase@cyberdyne.io", invite.ReceiverEmail) }) } +// TestCreateOwnerGuard covers audit finding CP-1: only organization owners may +// send owner-role invitations. Admins (and lower roles) must not be able to +// bootstrap a fresh Owner via the invitation path, which would bypass +// PolicyOrganizationManageOwners (the Owner-only manage-owners gate). +func (s *OrgInvitationIntegrationTestSuite) TestCreateOwnerGuard() { + ctx := context.Background() + + owner, err := s.User.UpsertByEmail(ctx, "guard-owner@test.com", nil) + s.NoError(err) + admin, err := s.User.UpsertByEmail(ctx, "guard-admin@test.com", nil) + s.NoError(err) + + org, err := s.Organization.CreateWithRandomName(ctx) + s.NoError(err) + + _, err = s.Membership.Create(ctx, org.ID, owner.ID, biz.WithMembershipRole(authz.RoleOwner)) + s.NoError(err) + _, err = s.Membership.Create(ctx, org.ID, admin.ID, biz.WithMembershipRole(authz.RoleAdmin)) + s.NoError(err) + + s.Run("owner can invite owner", func() { + invite, err := s.OrgInvitation.Create(ctx, org.ID, "owner-invite-1@cyberdyne.io", authz.RoleOwner, biz.WithInvitationRole(authz.RoleOwner)) + s.NoError(err) + s.Equal(authz.RoleOwner, invite.Role) + }) + + s.Run("admin cannot invite owner", func() { + _, err := s.OrgInvitation.Create(ctx, org.ID, "admin-invite-owner@cyberdyne.io", authz.RoleAdmin, biz.WithInvitationRole(authz.RoleOwner)) + s.Error(err) + s.True(biz.IsErrUnauthorized(err)) + s.Contains(err.Error(), "only organization owners can invite owners") + }) + + s.Run("viewer cannot invite owner", func() { + _, err := s.OrgInvitation.Create(ctx, org.ID, "viewer-invite-owner@cyberdyne.io", authz.RoleViewer, biz.WithInvitationRole(authz.RoleOwner)) + s.Error(err) + s.True(biz.IsErrUnauthorized(err)) + }) + + s.Run("empty callerRole cannot invite owner", func() { + _, err := s.OrgInvitation.Create(ctx, org.ID, "empty-role-invite-owner@cyberdyne.io", "", biz.WithInvitationRole(authz.RoleOwner)) + s.Error(err) + s.True(biz.IsErrUnauthorized(err)) + }) + + s.Run("admin can invite admin", func() { + invite, err := s.OrgInvitation.Create(ctx, org.ID, "admin-invite-admin@cyberdyne.io", authz.RoleAdmin, biz.WithInvitationRole(authz.RoleAdmin)) + s.NoError(err) + s.Equal(authz.RoleAdmin, invite.Role) + }) + + s.Run("admin can invite viewer", func() { + invite, err := s.OrgInvitation.Create(ctx, org.ID, "admin-invite-viewer@cyberdyne.io", authz.RoleAdmin, biz.WithInvitationRole(authz.RoleViewer)) + s.NoError(err) + s.Equal(authz.RoleViewer, invite.Role) + }) + + s.Run("viewer can invite viewer (guard only applies to owner role)", func() { + invite, err := s.OrgInvitation.Create(ctx, org.ID, "viewer-invite-viewer@cyberdyne.io", authz.RoleViewer, biz.WithInvitationRole(authz.RoleViewer)) + s.NoError(err) + s.Equal(authz.RoleViewer, invite.Role) + }) + + // RBAC-enabled org roles (Member / Contributor) are denied owner invitations + // at the Casbin middleware before reaching the biz layer, but the biz guard + // must also fail-closed if they ever did reach it. This verifies the + // defense-in-depth invariant without depending on the middleware. + s.Run("org member cannot invite owner", func() { + _, err := s.OrgInvitation.Create(ctx, org.ID, "member-invite-owner@cyberdyne.io", authz.RoleOrgMember, biz.WithInvitationRole(authz.RoleOwner)) + s.Error(err) + s.True(biz.IsErrUnauthorized(err)) + }) + + s.Run("org contributor cannot invite owner", func() { + _, err := s.OrgInvitation.Create(ctx, org.ID, "contributor-invite-owner@cyberdyne.io", authz.RoleOrgContributor, biz.WithInvitationRole(authz.RoleOwner)) + s.Error(err) + s.True(biz.IsErrUnauthorized(err)) + }) + + // API-token caller path. AuthzUseCase.Enforce resolves the token's stored + // DB policies when the subject is "api-token:". A default API token + // does NOT carry PolicyOrganizationManageOwners, so it must be rejected. + // A token explicitly granted that policy must be admitted. + s.Run("default API token cannot invite owner", func() { + token, err := s.APIToken.Create(ctx, "guard-default-token", nil, nil, &org.ID) + s.NoError(err) + subject := fmt.Sprintf("api-token:%s", token.ID) + _, err = s.OrgInvitation.Create(ctx, org.ID, "default-token-invite-owner@cyberdyne.io", authz.Role(subject), biz.WithInvitationRole(authz.RoleOwner)) + s.Error(err) + s.True(biz.IsErrUnauthorized(err)) + }) + + s.Run("API token with manage-owners policy can invite owner", func() { + token, err := s.APIToken.Create(ctx, "guard-owner-token", nil, nil, &org.ID, + biz.APITokenWithPolicies([]*authz.Policy{authz.PolicyOrganizationManageOwners}), + ) + s.NoError(err) + subject := fmt.Sprintf("api-token:%s", token.ID) + invite, err := s.OrgInvitation.Create(ctx, org.ID, "owner-token-invite-owner@cyberdyne.io", authz.Role(subject), biz.WithInvitationRole(authz.RoleOwner)) + s.NoError(err) + s.Equal(authz.RoleOwner, invite.Role) + }) +} + func (s *OrgInvitationIntegrationTestSuite) TestAcceptPendingInvitations() { ctx := context.Background() receiver, err := s.User.UpsertByEmail(ctx, receiverEmail, nil) @@ -180,7 +284,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestAcceptPendingInvitations() { }) s.T().Run("user is invited to org 1 as viewer", func(_ *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) require.NoError(s.T(), err) err = s.OrgInvitation.AcceptPendingInvitations(ctx, receiverEmail) s.NoError(err) @@ -204,7 +308,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestAcceptPendingInvitations() { receiverEmail := fmt.Sprintf("user%d@cyberdyne.io", i) receiver, err := s.User.UpsertByEmail(ctx, receiverEmail, nil) s.NoError(err) - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithInvitationRole(r), biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, authz.RoleOwner, biz.WithInvitationRole(r), biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) s.Equal(r, invite.Role) // accept the invite and make sure the new membership has the role @@ -234,7 +338,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestRevoke() { }) s.T().Run("invitation in another org", func(_ *testing.T) { - _, err := s.OrgInvitation.Create(ctx, s.org2.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) + _, err := s.OrgInvitation.Create(ctx, s.org2.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) err = s.OrgInvitation.Revoke(ctx, s.org1.ID, uuid.NewString()) s.Error(err) @@ -242,7 +346,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestRevoke() { }) s.T().Run("invitation not in pending state", func(_ *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) require.NoError(s.T(), err) err = s.OrgInvitation.AcceptInvitation(ctx, invite.ID.String()) require.NoError(s.T(), err) @@ -255,7 +359,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestRevoke() { }) s.T().Run("happy path", func(_ *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) require.NoError(s.T(), err) err = s.OrgInvitation.Revoke(ctx, s.org1.ID, invite.ID.String()) s.NoError(err) @@ -300,6 +404,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { ctx, s.org1.ID, receiverForGroupEmail, + authz.RoleOwner, biz.WithInvitationRole(authz.RoleViewer), biz.WithInvitationContext(invitationContext), biz.WithSender(uuid.MustParse(s.user.ID)), @@ -366,6 +471,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { ctx, s.org1.ID, anotherReceiverEmail, + authz.RoleOwner, biz.WithInvitationRole(authz.RoleViewer), biz.WithInvitationContext(invitationContext), biz.WithSender(uuid.MustParse(s.user.ID)), @@ -433,6 +539,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { ctx, s.org1.ID, receiverForProjectEmail, + authz.RoleOwner, biz.WithInvitationRole(authz.RoleViewer), biz.WithSender(uuid.MustParse(s.user.ID)), biz.WithInvitationContext(invitationContext), @@ -497,6 +604,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { ctx, s.org1.ID, anotherReceiverEmail, + authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID)), biz.WithInvitationRole(authz.RoleViewer), biz.WithInvitationContext(invitationContext), @@ -558,6 +666,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { ctx, s.org1.ID, combinedReceiverEmail, + authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID)), biz.WithInvitationRole(authz.RoleViewer), biz.WithInvitationContext(invitationContext), diff --git a/app/controlplane/pkg/biz/project.go b/app/controlplane/pkg/biz/project.go index aa7a9593e..2d2be1034 100644 --- a/app/controlplane/pkg/biz/project.go +++ b/app/controlplane/pkg/biz/project.go @@ -428,7 +428,7 @@ func (uc *ProjectUseCase) handleNonExistingUser(ctx context.Context, orgID, proj } // Create an invitation for the user to join the organization with project context - 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.RoleOrgMember), WithInvitationContext(invitationContext)); err != nil { diff --git a/app/controlplane/pkg/biz/project_integration_test.go b/app/controlplane/pkg/biz/project_integration_test.go index 72fdc8456..4d9ed50a0 100644 --- a/app/controlplane/pkg/biz/project_integration_test.go +++ b/app/controlplane/pkg/biz/project_integration_test.go @@ -1865,7 +1865,7 @@ func (s *projectMembersIntegrationTestSuite) TestAddNonExistingMemberToProject() alreadyInvitedEmail := "already-invited@example.com" // First create an invitation - _, err := s.OrgInvitation.Create(ctx, s.org.ID, alreadyInvitedEmail, biz.WithSender(uuid.MustParse(s.user.ID))) + _, err := s.OrgInvitation.Create(ctx, s.org.ID, alreadyInvitedEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) // Now try to add the same email to a project diff --git a/app/controlplane/pkg/biz/testhelpers/wire_gen.go b/app/controlplane/pkg/biz/testhelpers/wire_gen.go index 1d7a2446a..a4cad431c 100644 --- a/app/controlplane/pkg/biz/testhelpers/wire_gen.go +++ b/app/controlplane/pkg/biz/testhelpers/wire_gen.go @@ -142,7 +142,7 @@ func WireTestData(contextContext context.Context, testDatabase *TestDatabase, t robotAccountRepo := data.NewRobotAccountRepo(dataData, logger) robotAccountUseCase := biz.NewRootAccountUseCase(robotAccountRepo, workflowRepo, auth, logger) orgInvitationRepo := data.NewOrgInvitation(dataData, logger) - orgInvitationUseCase, err := biz.NewOrgInvitationUseCase(orgInvitationRepo, membershipRepo, userRepo, auditorUseCase, groupRepo, projectsRepo, logger) + orgInvitationUseCase, err := biz.NewOrgInvitationUseCase(orgInvitationRepo, membershipRepo, userRepo, auditorUseCase, groupRepo, projectsRepo, authzUseCase, logger) if err != nil { cleanup() return nil, nil, err From 92bb44ee967e09a5a7a87ac5f3ccdd65f2cf5058 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Sun, 5 Jul 2026 22:22:10 +0200 Subject: [PATCH 2/3] refactor(controlplane): consolidate manage-owners guard and invite tests - share a single enforceManageOwners helper between MembershipUseCase and OrgInvitationUseCase, with the denial messages defined once - guard against a nil requester membership in GroupUseCase.handleNonExistingUser - rework TestCreateOwnerGuard as a table-driven test, drop unused setup and pin the intentional instance-admin denial - replace s.T().Run subtests with suite-style s.Run in the invitation tests Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 321ef432-c960-4e92-bc01-9e70c8dba97d --- app/controlplane/pkg/biz/group.go | 8 +- app/controlplane/pkg/biz/membership.go | 21 +- app/controlplane/pkg/biz/orginvitation.go | 18 +- .../pkg/biz/orginvitation_integration_test.go | 409 +++++++++--------- 4 files changed, 218 insertions(+), 238 deletions(-) diff --git a/app/controlplane/pkg/biz/group.go b/app/controlplane/pkg/biz/group.go index ff8919e2c..485523def 100644 --- a/app/controlplane/pkg/biz/group.go +++ b/app/controlplane/pkg/biz/group.go @@ -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) diff --git a/app/controlplane/pkg/biz/membership.go b/app/controlplane/pkg/biz/membership.go index 2ee510c1b..9e347d255 100644 --- a/app/controlplane/pkg/biz/membership.go +++ b/app/controlplane/pkg/biz/membership.go @@ -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 } } @@ -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 } } @@ -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 diff --git a/app/controlplane/pkg/biz/orginvitation.go b/app/controlplane/pkg/biz/orginvitation.go index f0ec2b43d..51ace0f88 100644 --- a/app/controlplane/pkg/biz/orginvitation.go +++ b/app/controlplane/pkg/biz/orginvitation.go @@ -147,7 +147,7 @@ func (uc *OrgInvitationUseCase) Create(ctx context.Context, orgID, receiverEmail // Only owners can send owner invitations. This closes the Admin→Owner // invite bypass of PolicyOrganizationManageOwners (audit finding CP-1). if opts.role == authz.RoleOwner { - if err := uc.enforceManageOwners(ctx, callerRole); err != nil { + if err := enforceManageOwners(ctx, uc.authzUC, callerRole, errMsgOnlyOwnersInviteOwners); err != nil { return nil, err } } @@ -470,22 +470,6 @@ func (uc *OrgInvitationUseCase) processProjectMembership(ctx context.Context, in return nil } -// enforceManageOwners checks that the caller has the manage_owners policy, -// which is granted only to organization owners. It is the same check used by -// MembershipUseCase.UpdateRole / DeleteOther to guard owner-scoped mutations. -func (uc *OrgInvitationUseCase) enforceManageOwners(ctx context.Context, callerRole authz.Role) error { - ok, err := uc.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 invite owners") - } - - return nil -} - type OrgInvitationStatus string var ( diff --git a/app/controlplane/pkg/biz/orginvitation_integration_test.go b/app/controlplane/pkg/biz/orginvitation_integration_test.go index 4972c8c83..cbf83a022 100644 --- a/app/controlplane/pkg/biz/orginvitation_integration_test.go +++ b/app/controlplane/pkg/biz/orginvitation_integration_test.go @@ -26,7 +26,6 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -65,7 +64,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestList() { } for _, tc := range testCases { - s.T().Run(tc.name, func(t *testing.T) { + s.Run(tc.name, func() { invites, err := s.OrgInvitation.ListByOrg(ctx, tc.orgID) s.NoError(err) s.Equal(tc.expected, invites) @@ -75,21 +74,21 @@ func (s *OrgInvitationIntegrationTestSuite) TestList() { func (s *OrgInvitationIntegrationTestSuite) TestCreate() { ctx := context.Background() - s.T().Run("invalid org ID", func(_ *testing.T) { + s.Run("invalid org ID", func() { invite, err := s.OrgInvitation.Create(ctx, "deadbeef", receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.True(biz.IsErrInvalidUUID(err)) s.Nil(invite) }) - s.T().Run("missing receiver email", func(_ *testing.T) { + s.Run("missing receiver email", func() { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "", authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.True(biz.IsErrValidation(err)) s.Nil(invite) }) - s.T().Run("receiver email same than sender", func(_ *testing.T) { + s.Run("receiver email same than sender", func() { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.Email, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "sender and receiver emails cannot be the same") @@ -97,7 +96,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { s.Nil(invite) }) - s.T().Run("receiver is already a member", func(_ *testing.T) { + s.Run("receiver is already a member", func() { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user2.Email, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "user already exists in the org") @@ -105,14 +104,14 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { s.Nil(invite) }) - s.T().Run("org not found", func(_ *testing.T) { + s.Run("org not found", func() { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.Nil)) s.Error(err) s.True(biz.IsNotFound(err)) s.Nil(invite) }) - s.T().Run("can create invites for org1 and 2", func(_ *testing.T) { + s.Run("can create invites for org1 and 2", func() { for _, org := range []*biz.Organization{s.org1, s.org2} { invite, err := s.OrgInvitation.Create(ctx, org.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) @@ -124,7 +123,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { } }) - s.T().Run("but can't create if there is one pending", func(_ *testing.T) { + s.Run("but can't create if there is one pending", func() { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "already exists") @@ -132,20 +131,20 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { s.Nil(invite) }) - s.T().Run("but it can if it's another email", func(_ *testing.T) { + s.Run("but it can if it's another email", func() { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "anotheremail@cyberdyne.io", authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.Equal("anotheremail@cyberdyne.io", invite.ReceiverEmail) s.Equal(s.org1, invite.Org) s.NoError(err) }) - s.T().Run("the default role is viewer", func(_ *testing.T) { + s.Run("the default role is viewer", func() { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "viewer@cyberdyne.io", authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) s.Equal(authz.RoleViewer, invite.Role) }) - s.T().Run("but can have other roles", func(_ *testing.T) { + s.Run("but can have other roles", func() { for _, r := range []authz.Role{authz.RoleOwner, authz.RoleAdmin, authz.RoleViewer} { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, fmt.Sprintf("%s@cyberdyne.io", r), authz.RoleOwner, biz.WithInvitationRole(r), biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) @@ -167,114 +166,97 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { func (s *OrgInvitationIntegrationTestSuite) TestCreateOwnerGuard() { ctx := context.Background() - owner, err := s.User.UpsertByEmail(ctx, "guard-owner@test.com", nil) - s.NoError(err) - admin, err := s.User.UpsertByEmail(ctx, "guard-admin@test.com", nil) - s.NoError(err) - org, err := s.Organization.CreateWithRandomName(ctx) - s.NoError(err) - - _, err = s.Membership.Create(ctx, org.ID, owner.ID, biz.WithMembershipRole(authz.RoleOwner)) - s.NoError(err) - _, err = s.Membership.Create(ctx, org.ID, admin.ID, biz.WithMembershipRole(authz.RoleAdmin)) - s.NoError(err) - - s.Run("owner can invite owner", func() { - invite, err := s.OrgInvitation.Create(ctx, org.ID, "owner-invite-1@cyberdyne.io", authz.RoleOwner, biz.WithInvitationRole(authz.RoleOwner)) - s.NoError(err) - s.Equal(authz.RoleOwner, invite.Role) - }) - - s.Run("admin cannot invite owner", func() { - _, err := s.OrgInvitation.Create(ctx, org.ID, "admin-invite-owner@cyberdyne.io", authz.RoleAdmin, biz.WithInvitationRole(authz.RoleOwner)) - s.Error(err) - s.True(biz.IsErrUnauthorized(err)) - s.Contains(err.Error(), "only organization owners can invite owners") - }) - - s.Run("viewer cannot invite owner", func() { - _, err := s.OrgInvitation.Create(ctx, org.ID, "viewer-invite-owner@cyberdyne.io", authz.RoleViewer, biz.WithInvitationRole(authz.RoleOwner)) - s.Error(err) - s.True(biz.IsErrUnauthorized(err)) - }) + s.Require().NoError(err) - s.Run("empty callerRole cannot invite owner", func() { - _, err := s.OrgInvitation.Create(ctx, org.ID, "empty-role-invite-owner@cyberdyne.io", "", biz.WithInvitationRole(authz.RoleOwner)) - s.Error(err) - s.True(biz.IsErrUnauthorized(err)) - }) - - s.Run("admin can invite admin", func() { - invite, err := s.OrgInvitation.Create(ctx, org.ID, "admin-invite-admin@cyberdyne.io", authz.RoleAdmin, biz.WithInvitationRole(authz.RoleAdmin)) - s.NoError(err) - s.Equal(authz.RoleAdmin, invite.Role) - }) - - s.Run("admin can invite viewer", func() { - invite, err := s.OrgInvitation.Create(ctx, org.ID, "admin-invite-viewer@cyberdyne.io", authz.RoleAdmin, biz.WithInvitationRole(authz.RoleViewer)) - s.NoError(err) - s.Equal(authz.RoleViewer, invite.Role) - }) - - s.Run("viewer can invite viewer (guard only applies to owner role)", func() { - invite, err := s.OrgInvitation.Create(ctx, org.ID, "viewer-invite-viewer@cyberdyne.io", authz.RoleViewer, biz.WithInvitationRole(authz.RoleViewer)) - s.NoError(err) - s.Equal(authz.RoleViewer, invite.Role) - }) + testCases := []struct { + name string + callerRole authz.Role + inviteRole authz.Role + wantDeny bool + }{ + {name: "owner can invite owner", callerRole: authz.RoleOwner, inviteRole: authz.RoleOwner}, + {name: "admin cannot invite owner", callerRole: authz.RoleAdmin, inviteRole: authz.RoleOwner, wantDeny: true}, + {name: "viewer cannot invite owner", callerRole: authz.RoleViewer, inviteRole: authz.RoleOwner, wantDeny: true}, + // Instance admins hold PolicyOrganizationInvitationsCreate but + // intentionally not PolicyOrganizationManageOwners: they must not be + // able to seed owners into organizations they administer. + {name: "instance admin cannot invite owner", callerRole: authz.RoleInstanceAdmin, inviteRole: authz.RoleOwner, wantDeny: true}, + {name: "empty callerRole cannot invite owner", callerRole: "", inviteRole: authz.RoleOwner, wantDeny: true}, + // RBAC-enabled org roles (Member / Contributor) are denied owner + // invitations at the Casbin middleware before reaching the biz layer, + // but the biz guard must also fail-closed if they ever did reach it. + {name: "org member cannot invite owner", callerRole: authz.RoleOrgMember, inviteRole: authz.RoleOwner, wantDeny: true}, + {name: "org contributor cannot invite owner", callerRole: authz.RoleOrgContributor, inviteRole: authz.RoleOwner, wantDeny: true}, + {name: "admin can invite admin", callerRole: authz.RoleAdmin, inviteRole: authz.RoleAdmin}, + {name: "admin can invite viewer", callerRole: authz.RoleAdmin, inviteRole: authz.RoleViewer}, + {name: "viewer can invite viewer (guard only applies to owner role)", callerRole: authz.RoleViewer, inviteRole: authz.RoleViewer}, + } - // RBAC-enabled org roles (Member / Contributor) are denied owner invitations - // at the Casbin middleware before reaching the biz layer, but the biz guard - // must also fail-closed if they ever did reach it. This verifies the - // defense-in-depth invariant without depending on the middleware. - s.Run("org member cannot invite owner", func() { - _, err := s.OrgInvitation.Create(ctx, org.ID, "member-invite-owner@cyberdyne.io", authz.RoleOrgMember, biz.WithInvitationRole(authz.RoleOwner)) - s.Error(err) - s.True(biz.IsErrUnauthorized(err)) - }) + for i, tc := range testCases { + s.Run(tc.name, func() { + receiver := fmt.Sprintf("owner-guard-%d@cyberdyne.io", i) + invite, err := s.OrgInvitation.Create(ctx, org.ID, receiver, tc.callerRole, biz.WithInvitationRole(tc.inviteRole)) + if tc.wantDeny { + s.Error(err) + s.True(biz.IsErrUnauthorized(err)) + s.Contains(err.Error(), "only organization owners can invite owners") + return + } - s.Run("org contributor cannot invite owner", func() { - _, err := s.OrgInvitation.Create(ctx, org.ID, "contributor-invite-owner@cyberdyne.io", authz.RoleOrgContributor, biz.WithInvitationRole(authz.RoleOwner)) - s.Error(err) - s.True(biz.IsErrUnauthorized(err)) - }) + s.NoError(err) + s.Equal(tc.inviteRole, invite.Role) + }) + } // API-token caller path. AuthzUseCase.Enforce resolves the token's stored // DB policies when the subject is "api-token:". A default API token - // does NOT carry PolicyOrganizationManageOwners, so it must be rejected. - // A token explicitly granted that policy must be admitted. - s.Run("default API token cannot invite owner", func() { - token, err := s.APIToken.Create(ctx, "guard-default-token", nil, nil, &org.ID) - s.NoError(err) - subject := fmt.Sprintf("api-token:%s", token.ID) - _, err = s.OrgInvitation.Create(ctx, org.ID, "default-token-invite-owner@cyberdyne.io", authz.Role(subject), biz.WithInvitationRole(authz.RoleOwner)) - s.Error(err) - s.True(biz.IsErrUnauthorized(err)) - }) + // does NOT carry PolicyOrganizationManageOwners, so it must be rejected; + // a token explicitly granted that policy must be admitted. + tokenCases := []struct { + name string + policies []*authz.Policy + wantDeny bool + }{ + {name: "default API token cannot invite owner", wantDeny: true}, + {name: "API token with manage-owners policy can invite owner", policies: []*authz.Policy{authz.PolicyOrganizationManageOwners}}, + } - s.Run("API token with manage-owners policy can invite owner", func() { - token, err := s.APIToken.Create(ctx, "guard-owner-token", nil, nil, &org.ID, - biz.APITokenWithPolicies([]*authz.Policy{authz.PolicyOrganizationManageOwners}), - ) - s.NoError(err) - subject := fmt.Sprintf("api-token:%s", token.ID) - invite, err := s.OrgInvitation.Create(ctx, org.ID, "owner-token-invite-owner@cyberdyne.io", authz.Role(subject), biz.WithInvitationRole(authz.RoleOwner)) - s.NoError(err) - s.Equal(authz.RoleOwner, invite.Role) - }) + for i, tc := range tokenCases { + s.Run(tc.name, func() { + var opts []biz.APITokenCreateOpt + if tc.policies != nil { + opts = append(opts, biz.APITokenWithPolicies(tc.policies)) + } + token, err := s.APIToken.Create(ctx, fmt.Sprintf("guard-token-%d", i), nil, nil, &org.ID, opts...) + s.Require().NoError(err) + + subject := authz.Role(fmt.Sprintf("api-token:%s", token.ID)) + receiver := fmt.Sprintf("token-guard-%d@cyberdyne.io", i) + invite, err := s.OrgInvitation.Create(ctx, org.ID, receiver, subject, biz.WithInvitationRole(authz.RoleOwner)) + if tc.wantDeny { + s.Error(err) + s.True(biz.IsErrUnauthorized(err)) + return + } + + s.NoError(err) + s.Equal(authz.RoleOwner, invite.Role) + }) + } } func (s *OrgInvitationIntegrationTestSuite) TestAcceptPendingInvitations() { ctx := context.Background() receiver, err := s.User.UpsertByEmail(ctx, receiverEmail, nil) - require.NoError(s.T(), err) + s.Require().NoError(err) - s.T().Run("user doesn't exist", func(_ *testing.T) { + s.Run("user doesn't exist", func() { err := s.OrgInvitation.AcceptPendingInvitations(ctx, "non-existant@cyberdyne.io") s.ErrorContains(err, "not found") }) - s.T().Run("no invites for user", func(_ *testing.T) { + s.Run("no invites for user", func() { err = s.OrgInvitation.AcceptPendingInvitations(ctx, receiverEmail) s.NoError(err) @@ -283,18 +265,18 @@ func (s *OrgInvitationIntegrationTestSuite) TestAcceptPendingInvitations() { s.Len(memberships, 0) }) - s.T().Run("user is invited to org 1 as viewer", func(_ *testing.T) { + s.Run("user is invited to org 1 as viewer", func() { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) - require.NoError(s.T(), err) + s.Require().NoError(err) err = s.OrgInvitation.AcceptPendingInvitations(ctx, receiverEmail) s.NoError(err) memberships, err := s.Membership.ByUser(ctx, receiver.ID) s.NoError(err) s.Len(memberships, 1) - assert.Equal(s.T(), s.org1.ID, memberships[0].OrganizationID.String()) + s.Equal(s.org1.ID, memberships[0].OrganizationID.String()) // It should be a viewer - assert.Equal(s.T(), authz.RoleViewer, memberships[0].Role) + s.Equal(authz.RoleViewer, memberships[0].Role) // the invite is now accepted invite, err = s.OrgInvitation.FindByID(ctx, invite.ID.String()) @@ -302,7 +284,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestAcceptPendingInvitations() { s.Equal(biz.OrgInvitationStatusAccepted, invite.Status) }) - s.T().Run("or take any other role", func(_ *testing.T) { + s.Run("or take any other role", func() { for i, r := range []authz.Role{authz.RoleOwner, authz.RoleAdmin, authz.RoleViewer} { // Create user and invite it with different roles receiverEmail := fmt.Sprintf("user%d@cyberdyne.io", i) @@ -318,26 +300,26 @@ func (s *OrgInvitationIntegrationTestSuite) TestAcceptPendingInvitations() { memberships, err := s.Membership.ByUser(ctx, receiver.ID) s.NoError(err) s.Len(memberships, 1) - assert.Equal(s.T(), r, memberships[0].Role) + s.Equal(r, memberships[0].Role) } }) } func (s *OrgInvitationIntegrationTestSuite) TestRevoke() { ctx := context.Background() - s.T().Run("invalid ID", func(_ *testing.T) { + s.Run("invalid ID", func() { err := s.OrgInvitation.Revoke(ctx, s.org1.ID, "deadbeef") s.Error(err) s.True(biz.IsErrInvalidUUID(err)) }) - s.T().Run("invitation not found", func(_ *testing.T) { + s.Run("invitation not found", func() { err := s.OrgInvitation.Revoke(ctx, s.org1.ID, uuid.NewString()) s.Error(err) s.True(biz.IsNotFound(err)) }) - s.T().Run("invitation in another org", func(_ *testing.T) { + s.Run("invitation in another org", func() { _, err := s.OrgInvitation.Create(ctx, s.org2.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) err = s.OrgInvitation.Revoke(ctx, s.org1.ID, uuid.NewString()) @@ -345,11 +327,11 @@ func (s *OrgInvitationIntegrationTestSuite) TestRevoke() { s.True(biz.IsNotFound(err)) }) - s.T().Run("invitation not in pending state", func(_ *testing.T) { + s.Run("invitation not in pending state", func() { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) - require.NoError(s.T(), err) + s.Require().NoError(err) err = s.OrgInvitation.AcceptInvitation(ctx, invite.ID.String()) - require.NoError(s.T(), err) + s.Require().NoError(err) // It's in accepted state now so it can not be revoked err = s.OrgInvitation.Revoke(ctx, s.org1.ID, invite.ID.String()) @@ -358,9 +340,9 @@ func (s *OrgInvitationIntegrationTestSuite) TestRevoke() { s.True(biz.IsErrValidation(err)) }) - s.T().Run("happy path", func(_ *testing.T) { + s.Run("happy path", func() { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, authz.RoleOwner, biz.WithSender(uuid.MustParse(s.user.ID))) - require.NoError(s.T(), err) + s.Require().NoError(err) err = s.OrgInvitation.Revoke(ctx, s.org1.ID, invite.ID.String()) s.NoError(err) err = s.OrgInvitation.Revoke(ctx, s.org1.ID, invite.ID.String()) @@ -384,15 +366,15 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { orgUUID := uuid.MustParse(s.org1.ID) group, err := s.Group.Create(ctx, orgUUID, groupName, groupDescription, &userUUID) - require.NoError(s.T(), err) - require.NotNil(s.T(), group) + s.Require().NoError(err) + s.Require().NotNil(group) // Create a new receiver that isn't a member of any org yet receiverForGroupEmail := "group-receiver@cyberdyne.io" receiver, err := s.User.UpsertByEmail(ctx, receiverForGroupEmail, nil) - require.NoError(s.T(), err) + s.Require().NoError(err) - s.T().Run("invitation with group context adds user to group when accepted", func(t *testing.T) { + s.Run("invitation with group context adds user to group when accepted", func() { // Create invitation context with group information invitationContext := &biz.OrgInvitationContext{ GroupIDToJoin: &group.ID, @@ -409,24 +391,24 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { biz.WithInvitationContext(invitationContext), biz.WithSender(uuid.MustParse(s.user.ID)), ) - require.NoError(t, err) - require.NotNil(t, invite) + s.Require().NoError(err) + s.Require().NotNil(invite) // Verify context was saved properly - assert.NotNil(t, invite.Context) - assert.Equal(t, group.ID, *invite.Context.GroupIDToJoin) - assert.Equal(t, true, invite.Context.GroupMaintainer) + s.NotNil(invite.Context) + s.Equal(group.ID, *invite.Context.GroupIDToJoin) + s.True(invite.Context.GroupMaintainer) // Accept the invitation err = s.OrgInvitation.AcceptPendingInvitations(ctx, receiverForGroupEmail) - require.NoError(t, err) + s.Require().NoError(err) // Verify user is now a member of the organization memberships, err := s.Membership.ByUser(ctx, receiver.ID) - require.NoError(t, err) - assert.Len(t, memberships, 1) - assert.Equal(t, s.org1.ID, memberships[0].OrganizationID.String()) - assert.Equal(t, authz.RoleViewer, memberships[0].Role) + s.Require().NoError(err) + s.Len(memberships, 1) + s.Equal(s.org1.ID, memberships[0].OrganizationID.String()) + s.Equal(authz.RoleViewer, memberships[0].Role) // Verify user is now a member of the group groupMembers, count, err := s.Group.ListMembers(ctx, orgUUID, &biz.ListMembersOpts{ @@ -434,10 +416,10 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { ID: &group.ID, }, }, nil) - require.NoError(t, err) + s.Require().NoError(err) // Should be 2 members: the original creator and the new member - assert.Equal(t, 2, count) + s.Equal(2, count) // Find the new member in the list var foundMember bool @@ -450,15 +432,15 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { } } - assert.True(t, foundMember, "The user should be a member of the group") - assert.True(t, isMaintainer, "The user should be a maintainer of the group") + s.True(foundMember, "The user should be a member of the group") + s.True(isMaintainer, "The user should be a maintainer of the group") }) - s.T().Run("invitation with non-maintainer group context works correctly", func(t *testing.T) { + s.Run("invitation with non-maintainer group context works correctly", func() { // Create another test receiver anotherReceiverEmail := "regular-group-member@cyberdyne.io" anotherReceiver, err := s.User.UpsertByEmail(ctx, anotherReceiverEmail, nil) - require.NoError(t, err) + s.Require().NoError(err) // Create invitation context with group information, but not as maintainer invitationContext := &biz.OrgInvitationContext{ @@ -476,12 +458,12 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { biz.WithInvitationContext(invitationContext), biz.WithSender(uuid.MustParse(s.user.ID)), ) - require.NoError(t, err) - require.NotNil(t, invite) + s.Require().NoError(err) + s.Require().NotNil(invite) // Accept the invitation err = s.OrgInvitation.AcceptPendingInvitations(ctx, anotherReceiverEmail) - require.NoError(t, err) + s.Require().NoError(err) // Verify user is now a member of the group groupMembers, count, err := s.Group.ListMembers(ctx, orgUUID, &biz.ListMembersOpts{ @@ -489,10 +471,10 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { ID: &group.ID, }, }, nil) - require.NoError(t, err) + s.Require().NoError(err) // Should be 3 members now - assert.Equal(t, 3, count) + s.Equal(3, count) // Find the new member in the list var foundMember bool @@ -505,8 +487,8 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { } } - assert.True(t, foundMember, "The user should be a member of the group") - assert.False(t, isMaintainer, "The user should not be a maintainer of the group") + s.True(foundMember, "The user should be a member of the group") + s.False(isMaintainer, "The user should not be a maintainer of the group") }) } @@ -519,15 +501,17 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { orgUUID := uuid.MustParse(s.org1.ID) project, err := s.Project.Create(ctx, s.org1.ID, projectName) - require.NoError(s.T(), err) - require.NotNil(s.T(), project) + s.Require().NoError(err) + s.Require().NotNil(project) // Create a new receiver that isn't a member of any org yet receiverForProjectEmail := "project-receiver@cyberdyne.io" + // Receiver shared by the combined-context and nil-UUID subtests below + combinedReceiverEmail := "combined-receiver@cyberdyne.io" receiver, err := s.User.UpsertByEmail(ctx, receiverForProjectEmail, nil) - require.NoError(s.T(), err) + s.Require().NoError(err) - s.T().Run("invitation with project context adds user to project when accepted", func(t *testing.T) { + s.Run("invitation with project context adds user to project when accepted", func() { // Create invitation context with project information invitationContext := &biz.OrgInvitationContext{ ProjectIDToJoin: &project.ID, @@ -544,33 +528,33 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { biz.WithSender(uuid.MustParse(s.user.ID)), biz.WithInvitationContext(invitationContext), ) - require.NoError(t, err) - require.NotNil(t, invite) + s.Require().NoError(err) + s.Require().NotNil(invite) // Verify context was saved properly - assert.NotNil(t, invite.Context) - assert.Equal(t, project.ID, *invite.Context.ProjectIDToJoin) - assert.Equal(t, authz.RoleProjectAdmin, invite.Context.ProjectRole) + s.NotNil(invite.Context) + s.Equal(project.ID, *invite.Context.ProjectIDToJoin) + s.Equal(authz.RoleProjectAdmin, invite.Context.ProjectRole) // Accept the invitation err = s.OrgInvitation.AcceptPendingInvitations(ctx, receiverForProjectEmail) - require.NoError(t, err) + s.Require().NoError(err) // Verify user is now a member of the organization memberships, err := s.Membership.ByUser(ctx, receiver.ID) - require.NoError(t, err) - assert.Len(t, memberships, 1) - assert.Equal(t, s.org1.ID, memberships[0].OrganizationID.String()) - assert.Equal(t, authz.RoleViewer, memberships[0].Role) + s.Require().NoError(err) + s.Len(memberships, 1) + s.Equal(s.org1.ID, memberships[0].OrganizationID.String()) + s.Equal(authz.RoleViewer, memberships[0].Role) // Verify user is now a member of the project projectMembers, count, err := s.Project.ListMembers(ctx, orgUUID, &biz.IdentityReference{ ID: &project.ID, }, nil) - require.NoError(t, err) + s.Require().NoError(err) // The count should include the original project members plus the new member - assert.Greater(t, count, 0, "The project should have at least one member") + s.Greater(count, 0, "The project should have at least one member") // Find the new member in the list var foundMember bool @@ -583,15 +567,15 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { } } - assert.True(t, foundMember, "The user should be a member of the project") - assert.Equal(t, authz.RoleProjectAdmin, memberRole, "The user should have the project admin role") + s.True(foundMember, "The user should be a member of the project") + s.Equal(authz.RoleProjectAdmin, memberRole, "The user should have the project admin role") }) - s.T().Run("invitation with different project role works correctly", func(t *testing.T) { + s.Run("invitation with different project role works correctly", func() { // Create another test receiver anotherReceiverEmail := "project-viewer@cyberdyne.io" anotherReceiver, err := s.User.UpsertByEmail(ctx, anotherReceiverEmail, nil) - require.NoError(t, err) + s.Require().NoError(err) // Create invitation context with project information, but with viewer role invitationContext := &biz.OrgInvitationContext{ @@ -609,21 +593,21 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { biz.WithInvitationRole(authz.RoleViewer), biz.WithInvitationContext(invitationContext), ) - require.NoError(t, err) - require.NotNil(t, invite) + s.Require().NoError(err) + s.Require().NotNil(invite) // Accept the invitation err = s.OrgInvitation.AcceptPendingInvitations(ctx, anotherReceiverEmail) - require.NoError(t, err) + s.Require().NoError(err) // Verify user is now a member of the project projectMembers, count, err := s.Project.ListMembers(ctx, orgUUID, &biz.IdentityReference{ ID: &project.ID, }, nil) - require.NoError(t, err) + s.Require().NoError(err) // The count should have increased - assert.Greater(t, count, 1, "The project should have multiple members") + s.Greater(count, 1, "The project should have multiple members") // Find the new member in the list var foundMember bool @@ -636,22 +620,21 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { } } - assert.True(t, foundMember, "The user should be a member of the project") - assert.Equal(t, authz.RoleProjectViewer, memberRole, "The user should have the project viewer role") + s.True(foundMember, "The user should be a member of the project") + s.Equal(authz.RoleProjectViewer, memberRole, "The user should have the project viewer role") }) - s.T().Run("invitation with both group and project context works correctly", func(t *testing.T) { + s.Run("invitation with both group and project context works correctly", func() { // Create a test group groupName := "combined-test-group" groupDescription := "A group for testing combined invitation context" group, err := s.Group.Create(ctx, orgUUID, groupName, groupDescription, &userUUID) - require.NoError(t, err) - require.NotNil(t, group) + s.Require().NoError(err) + s.Require().NotNil(group) // Create another test receiver - combinedReceiverEmail := "combined-receiver@cyberdyne.io" combinedReceiver, err := s.User.UpsertByEmail(ctx, combinedReceiverEmail, nil) - require.NoError(t, err) + s.Require().NoError(err) // Create invitation context with both group and project information invitationContext := &biz.OrgInvitationContext{ @@ -671,25 +654,25 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { biz.WithInvitationRole(authz.RoleViewer), biz.WithInvitationContext(invitationContext), ) - require.NoError(t, err) - require.NotNil(t, invite) + s.Require().NoError(err) + s.Require().NotNil(invite) // Verify context was saved properly - assert.NotNil(t, invite.Context) - assert.Equal(t, group.ID, *invite.Context.GroupIDToJoin) - assert.True(t, invite.Context.GroupMaintainer) - assert.Equal(t, project.ID, *invite.Context.ProjectIDToJoin) - assert.Equal(t, authz.RoleProjectViewer, invite.Context.ProjectRole) + s.NotNil(invite.Context) + s.Equal(group.ID, *invite.Context.GroupIDToJoin) + s.True(invite.Context.GroupMaintainer) + s.Equal(project.ID, *invite.Context.ProjectIDToJoin) + s.Equal(authz.RoleProjectViewer, invite.Context.ProjectRole) // Accept the invitation err = s.OrgInvitation.AcceptPendingInvitations(ctx, combinedReceiverEmail) - require.NoError(t, err) + s.Require().NoError(err) // Verify user is now a member of the organization memberships, err := s.Membership.ByUser(ctx, combinedReceiver.ID) - require.NoError(t, err) - assert.Len(t, memberships, 1) - assert.Equal(t, s.org1.ID, memberships[0].OrganizationID.String()) + s.Require().NoError(err) + s.Len(memberships, 1) + s.Equal(s.org1.ID, memberships[0].OrganizationID.String()) // Verify user is now a member of the group groupMembers, groupCount, err := s.Group.ListMembers(ctx, orgUUID, &biz.ListMembersOpts{ @@ -697,8 +680,8 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { ID: &group.ID, }, }, nil) - require.NoError(t, err) - assert.Greater(t, groupCount, 0, "The group should have at least one member") + s.Require().NoError(err) + s.Greater(groupCount, 0, "The group should have at least one member") var foundGroupMember bool var isMaintainer bool @@ -709,15 +692,15 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { break } } - assert.True(t, foundGroupMember, "The user should be a member of the group") - assert.True(t, isMaintainer, "The user should be a maintainer of the group") + s.True(foundGroupMember, "The user should be a member of the group") + s.True(isMaintainer, "The user should be a maintainer of the group") // Verify user is now a member of the project projectMembers, projectCount, err := s.Project.ListMembers(ctx, orgUUID, &biz.IdentityReference{ ID: &project.ID, }, nil) - require.NoError(t, err) - assert.Greater(t, projectCount, 0, "The project should have at least one member") + s.Require().NoError(err) + s.Greater(projectCount, 0, "The project should have at least one member") var foundProjectMember bool var projectRole authz.Role @@ -728,16 +711,15 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { break } } - assert.True(t, foundProjectMember, "The user should be a member of the project") - assert.Equal(t, authz.RoleProjectViewer, projectRole, "The user should have the project contributor role") + s.True(foundProjectMember, "The user should be a member of the project") + s.Equal(authz.RoleProjectViewer, projectRole, "The user should have the project contributor role") }) - s.T().Run("invitation with nil UUID on project is rejected", func(t *testing.T) { + s.Run("invitation with nil UUID on project is rejected", func() { // Create a new receiver that isn't a member of any org yet - newReceiverEmail := "combined-receiver@cyberdyne.io" - newReceiver, err := s.User.UpsertByEmail(ctx, newReceiverEmail, nil) - require.NoError(t, err) - require.NotNil(t, newReceiver) + newReceiver, err := s.User.UpsertByEmail(ctx, combinedReceiverEmail, nil) + s.Require().NoError(err) + s.Require().NotNil(newReceiver) // Create invitation context with nil project ID invitationContext := &biz.OrgInvitationContext{ @@ -749,24 +731,23 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { ctx, uuid.MustParse(s.org1.ID), biz.ToPtr(uuid.MustParse(s.user.ID)), - newReceiverEmail, + combinedReceiverEmail, authz.RoleViewer, invitationContext, ) - require.NoError(t, err) - require.NotNil(t, invite) + s.Require().NoError(err) + s.Require().NotNil(invite) // Accept the invitation and check that there is no error - err = s.OrgInvitation.AcceptPendingInvitations(ctx, newReceiverEmail) - require.NoError(t, err, "Accepting invitation with nil project ID should not fail just skip the project context") + err = s.OrgInvitation.AcceptPendingInvitations(ctx, combinedReceiverEmail) + s.Require().NoError(err, "Accepting invitation with nil project ID should not fail just skip the project context") }) - s.T().Run("invitation with nil UUID on group is rejected", func(t *testing.T) { + s.Run("invitation with nil UUID on group is rejected", func() { // Create a new receiver that isn't a member of any org yet - newReceiverEmail := "combined-receiver@cyberdyne.io" - newReceiver, err := s.User.UpsertByEmail(ctx, newReceiverEmail, nil) - require.NoError(t, err) - require.NotNil(t, newReceiver) + newReceiver, err := s.User.UpsertByEmail(ctx, combinedReceiverEmail, nil) + s.Require().NoError(err) + s.Require().NotNil(newReceiver) // Create invitation context with nil group ID invitationContext := &biz.OrgInvitationContext{ @@ -778,16 +759,16 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { ctx, uuid.MustParse(s.org1.ID), biz.ToPtr(uuid.MustParse(s.user.ID)), - newReceiverEmail, + combinedReceiverEmail, authz.RoleViewer, invitationContext, ) - require.NoError(t, err) - require.NotNil(t, invite) + s.Require().NoError(err) + s.Require().NotNil(invite) // Accept the invitation and check that there is no error - err = s.OrgInvitation.AcceptPendingInvitations(ctx, newReceiverEmail) - require.NoError(t, err, "Accepting invitation with nil group ID should not fail just skip the project context") + err = s.OrgInvitation.AcceptPendingInvitations(ctx, combinedReceiverEmail) + s.Require().NoError(err, "Accepting invitation with nil group ID should not fail just skip the project context") }) } From c56d555f49d41f554ddfa1518c1d47c6380044a6 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Mon, 6 Jul 2026 11:18:37 +0200 Subject: [PATCH 3/3] fix: add tests Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino --- .../internal/service/orginvitation.go | 3 - app/controlplane/pkg/biz/orginvitation.go | 2 +- .../pkg/biz/orginvitation_integration_test.go | 7 +- app/controlplane/pkg/biz/project.go | 8 +- .../pkg/policies/policyprovider_test.go | 79 +++++++++++++++++++ 5 files changed, 90 insertions(+), 9 deletions(-) diff --git a/app/controlplane/internal/service/orginvitation.go b/app/controlplane/internal/service/orginvitation.go index 63be524cc..c8230b22e 100644 --- a/app/controlplane/internal/service/orginvitation.go +++ b/app/controlplane/internal/service/orginvitation.go @@ -60,9 +60,6 @@ func (s *OrgInvitationService) Create(ctx context.Context, req *pb.OrgInvitation opts = append(opts, biz.WithSender(userID)) } - // Pass the caller's authz subject (org role for users, "api-token:" - // for API tokens) so the biz layer can enforce that only owners may invite - // owners (audit finding CP-1). callerRole := authz.Role(usercontext.CurrentAuthzSubject(ctx)) // Validations are done in the biz layer diff --git a/app/controlplane/pkg/biz/orginvitation.go b/app/controlplane/pkg/biz/orginvitation.go index 51ace0f88..9c16e3302 100644 --- a/app/controlplane/pkg/biz/orginvitation.go +++ b/app/controlplane/pkg/biz/orginvitation.go @@ -145,7 +145,7 @@ func (uc *OrgInvitationUseCase) Create(ctx context.Context, orgID, receiverEmail } // Only owners can send owner invitations. This closes the Admin→Owner - // invite bypass of PolicyOrganizationManageOwners (audit finding CP-1). + // invite bypass of PolicyOrganizationManageOwners. if opts.role == authz.RoleOwner { if err := enforceManageOwners(ctx, uc.authzUC, callerRole, errMsgOnlyOwnersInviteOwners); err != nil { return nil, err diff --git a/app/controlplane/pkg/biz/orginvitation_integration_test.go b/app/controlplane/pkg/biz/orginvitation_integration_test.go index cbf83a022..7ba75868f 100644 --- a/app/controlplane/pkg/biz/orginvitation_integration_test.go +++ b/app/controlplane/pkg/biz/orginvitation_integration_test.go @@ -159,9 +159,9 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { }) } -// TestCreateOwnerGuard covers audit finding CP-1: only organization owners may -// send owner-role invitations. Admins (and lower roles) must not be able to -// bootstrap a fresh Owner via the invitation path, which would bypass +// TestCreateOwnerGuard verifies only organization owners may send owner-role +// invitations. Admins (and lower roles) must not be able to bootstrap a fresh +// Owner via the invitation path, which would bypass // PolicyOrganizationManageOwners (the Owner-only manage-owners gate). func (s *OrgInvitationIntegrationTestSuite) TestCreateOwnerGuard() { ctx := context.Background() @@ -506,7 +506,6 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { // Create a new receiver that isn't a member of any org yet receiverForProjectEmail := "project-receiver@cyberdyne.io" - // Receiver shared by the combined-context and nil-UUID subtests below combinedReceiverEmail := "combined-receiver@cyberdyne.io" receiver, err := s.User.UpsertByEmail(ctx, receiverForProjectEmail, nil) s.Require().NoError(err) diff --git a/app/controlplane/pkg/biz/project.go b/app/controlplane/pkg/biz/project.go index 2d2be1034..fad8f8ad8 100644 --- a/app/controlplane/pkg/biz/project.go +++ b/app/controlplane/pkg/biz/project.go @@ -406,12 +406,18 @@ func (uc *ProjectUseCase) handleNonExistingUser(ctx context.Context, orgID, proj 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.membershipUC.FindByOrgAndUser(ctx, orgID.String(), opts.RequesterID.String()) 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) diff --git a/app/controlplane/pkg/policies/policyprovider_test.go b/app/controlplane/pkg/policies/policyprovider_test.go index 54cc84de5..cef67ee1b 100644 --- a/app/controlplane/pkg/policies/policyprovider_test.go +++ b/app/controlplane/pkg/policies/policyprovider_test.go @@ -16,6 +16,9 @@ package policies import ( + "encoding/json" + "net/http" + "net/http/httptest" "net/url" "testing" @@ -92,3 +95,79 @@ func TestUnmarshalFromRaw(t *testing.T) { }) } } + +// TestResolve_CP9_CrossOrgPolicyFetch_Documentation documents the control-plane +// side of CP-9: the control plane forwards the attacker-controlled orgName as +// the organization_name query param, while the caller's actual org goes only in +// the Chainloop-Organization header. The control plane does not cross-check +// these values. However, the exploit is NOT reachable because the backend +// policy provider (platform/backend/internal/service/policy.go:73-78) rejects +// the request with PermissionDenied when organization_name != the caller's +// authenticated org. This test demonstrates the control-plane gap (the two +// values differ on the wire) for defense-in-depth documentation only. +func TestResolve_CP9_CrossOrgPolicyFetch_Documentation(t *testing.T) { + var capturedRequest struct { + queryOrgName string + orgHeader string + authHeader string + } + + // Fake policy provider that returns a policy and records what the + // control plane sent it. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedRequest.queryOrgName = r.URL.Query().Get(orgNameParam) + capturedRequest.orgHeader = r.Header.Get(organizationHeader) + capturedRequest.authHeader = r.Header.Get("Authorization") + + resp := ProviderResponse{ + Digest: "sha256:abc123", + Raw: &RawMessage{ + Format: "FORMAT_JSON", + Body: []byte(`{"apiVersion":"workflowcontract.chainloop.dev/v1","kind":"Policy","metadata":{"name":"secret-policy"},"spec":{"policies":[{"kind":"CONTAINER_IMAGE","embedded":"package main"}]}}`), + }, + OrganizationName: "victim-org", + } + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer srv.Close() + + srvURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + provider := &PolicyProvider{name: "test", url: srvURL.String()} + + // Simulate a caller in "attacker-org" requesting a policy from "victim-org". + // - policyOrgName = "victim-org" (attacker-controlled via req.GetOrgName()) + // - authOpts.OrgName = "attacker-org" (caller's actual org from requireCurrentOrg) + policy, ref, err := provider.Resolve( + "secret-policy", // policyName + "victim-org", // policyOrgName — attacker-controlled + ProviderAuthOpts{ // authOpts — derived from the caller's actual org + Token: "attacker-jwt", + OrgName: "attacker-org", + }, + ) + require.NoError(t, err) + + // The control plane sent the attacker's org name as the namespace to read + // from (organization_name query param) and the caller's actual org only as + // the Chainloop-Organization header. These differ — the control plane did + // not validate that policyOrgName matches the caller's org. + assert.Equal(t, "victim-org", capturedRequest.queryOrgName, + "the organization_name query param is the attacker-controlled value") + assert.Equal(t, "attacker-org", capturedRequest.orgHeader, + "the Chainloop-Organization header is the caller's actual org") + assert.NotEqual(t, capturedRequest.queryOrgName, capturedRequest.orgHeader, + "CP-9 control-plane gap: the namespace query param and the caller's org header differ") + + // NOTE: In production, the backend (platform/backend) blocks this request at + // policy.go:73-78 with PermissionDenied because organization_name != + // membership.OrgName. The policy content returned here is only received + // because this test uses a mock provider that doesn't implement that check. + require.NotNil(t, policy) + assert.Equal(t, "secret-policy", policy.Metadata.Name) + require.NotNil(t, ref) + assert.Contains(t, ref.URL, "victim-org") +}