From 6582f1b2c630256be7791d8a8ea6f8a5e7c9cb51 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 31 Jul 2025 10:40:44 +0200 Subject: [PATCH 1/5] feat(invitations): Expand invitation context to external metadata Signed-off-by: Javier Rodriguez --- app/controlplane/pkg/biz/orginvitation.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/controlplane/pkg/biz/orginvitation.go b/app/controlplane/pkg/biz/orginvitation.go index 8d430e884..521aec0fb 100644 --- a/app/controlplane/pkg/biz/orginvitation.go +++ b/app/controlplane/pkg/biz/orginvitation.go @@ -17,6 +17,7 @@ package biz import ( "context" + "encoding/json" "fmt" "strings" "time" @@ -65,6 +66,8 @@ type OrgInvitationContext struct { ProjectIDToJoin uuid.UUID `json:"project_id_to_join,omitempty"` // ProjectRole is the role to assign to the user in the project ProjectRole authz.Role `json:"project_role,omitempty"` + // ExternalMetadata can be used to store additional information + ExternalMetadata json.RawMessage `json:"external_metadata,omitempty"` } type OrgInvitationRepo interface { From c299c714d29cbfe49a647671f6c1079b110ad641 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 31 Jul 2025 10:45:21 +0200 Subject: [PATCH 2/5] add tests Signed-off-by: Javier Rodriguez --- .../pkg/biz/orginvitation_integration_test.go | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/controlplane/pkg/biz/orginvitation_integration_test.go b/app/controlplane/pkg/biz/orginvitation_integration_test.go index 5a2774f10..9fc517f26 100644 --- a/app/controlplane/pkg/biz/orginvitation_integration_test.go +++ b/app/controlplane/pkg/biz/orginvitation_integration_test.go @@ -647,6 +647,30 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { }) } +func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithExternalMetadata() { + ctx := context.Background() + receiverEmail := "externalmeta@cyberdyne.io" + externalMeta := []byte(`{"foo":"bar","baz":123}`) + invitationContext := &biz.OrgInvitationContext{ + ExternalMetadata: externalMeta, + } + + invite, err := s.OrgInvitation.Create( + ctx, + s.org1.ID, + s.user.ID, + receiverEmail, + biz.WithInvitationRole(authz.RoleViewer), + biz.WithInvitationContext(invitationContext), + ) + s.Require().NoError(err) + s.Require().NotNil(invite) + + s.Require().NotNil(invite.Context) + s.Require().NotNil(invite.Context.ExternalMetadata) + s.JSONEq(string(externalMeta), string(invite.Context.ExternalMetadata), "ExternalMetadata should be persisted and retrievable") +} + // Utility struct to hold the test suite type OrgInvitationIntegrationTestSuite struct { testhelpers.UseCasesEachTestSuite From 7156326ab15cdfdff4c4ff0d755786276d580cf8 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 31 Jul 2025 12:01:24 +0200 Subject: [PATCH 3/5] make context fields optional Signed-off-by: Javier Rodriguez --- app/controlplane/pkg/biz/group.go | 2 +- app/controlplane/pkg/biz/orginvitation.go | 22 ++--- .../pkg/biz/orginvitation_integration_test.go | 98 +++++++++++++------ app/controlplane/pkg/biz/project.go | 2 +- .../pkg/biz/testhelpers/database.go | 15 +-- .../pkg/biz/testhelpers/wire_gen.go | 15 +-- 6 files changed, 95 insertions(+), 59 deletions(-) diff --git a/app/controlplane/pkg/biz/group.go b/app/controlplane/pkg/biz/group.go index 1272b7d7e..61e9de0ad 100644 --- a/app/controlplane/pkg/biz/group.go +++ b/app/controlplane/pkg/biz/group.go @@ -557,7 +557,7 @@ func (uc *GroupUseCase) handleNonExistingUser(ctx context.Context, orgID, groupI // Create an organization invitation with group context invitationContext := &OrgInvitationContext{ - GroupIDToJoin: groupID, + GroupIDToJoin: &groupID, GroupMaintainer: opts.Maintainer, } diff --git a/app/controlplane/pkg/biz/orginvitation.go b/app/controlplane/pkg/biz/orginvitation.go index 521aec0fb..eb2f8d7bc 100644 --- a/app/controlplane/pkg/biz/orginvitation.go +++ b/app/controlplane/pkg/biz/orginvitation.go @@ -59,11 +59,11 @@ type OrgInvitation struct { // OrgInvitationContext is used to pass additional context when accepting an invitation type OrgInvitationContext struct { // GroupIDToJoin is the ID of the group to join when accepting the invitation - GroupIDToJoin uuid.UUID `json:"group_id_to_join,omitempty"` + GroupIDToJoin *uuid.UUID `json:"group_id_to_join,omitempty"` // GroupMaintainer indicates if the user should be added as a maintainer of the group GroupMaintainer bool `json:"group_maintainer,omitempty"` // ProjectIDToJoin is the ID of the project to join when accepting the invitation - ProjectIDToJoin uuid.UUID `json:"project_id_to_join,omitempty"` + ProjectIDToJoin *uuid.UUID `json:"project_id_to_join,omitempty"` // ProjectRole is the role to assign to the user in the project ProjectRole authz.Role `json:"project_role,omitempty"` // ExternalMetadata can be used to store additional information @@ -341,7 +341,7 @@ func (uc *OrgInvitationUseCase) FindByID(ctx context.Context, invitationID strin // processGroupMembership adds a user to a group if the invitation context contains a group to join func (uc *OrgInvitationUseCase) processGroupMembership(ctx context.Context, invitation *OrgInvitation, orgUUID uuid.UUID, userUUID uuid.UUID) error { // Skip if there's no group to join in the invitation context - if invitation.Context == nil || invitation.Context.GroupIDToJoin == uuid.Nil { + if invitation.Context == nil || invitation.Context.GroupIDToJoin == nil || *invitation.Context.GroupIDToJoin == uuid.Nil { return nil } @@ -349,12 +349,12 @@ func (uc *OrgInvitationUseCase) processGroupMembership(ctx context.Context, invi uc.logger.Infow("msg", "Adding user to group", "invitation_id", invitation.ID.String(), "org_id", invitation.Org.ID, "user_id", userUUID, "group_id", groupID) // Check if the group exists - gr, err := uc.groupRepo.FindByOrgAndID(ctx, orgUUID, groupID) + gr, err := uc.groupRepo.FindByOrgAndID(ctx, orgUUID, *groupID) if err != nil { return fmt.Errorf("error finding group %s: %w", groupID.String(), err) } - if _, err := uc.groupRepo.AddMemberToGroup(ctx, orgUUID, groupID, userUUID, invitation.Context.GroupMaintainer); err != nil { + if _, err := uc.groupRepo.AddMemberToGroup(ctx, orgUUID, *groupID, userUUID, invitation.Context.GroupMaintainer); err != nil { if IsErrAlreadyExists(err) { // User is already a member of the group, nothing to do uc.logger.Infow("msg", "User already in group", "invitation_id", invitation.ID.String(), "org_id", invitation.Org.ID, "user_id", userUUID.String(), "group_id", groupID.String()) @@ -367,7 +367,7 @@ func (uc *OrgInvitationUseCase) processGroupMembership(ctx context.Context, invi // Dispatch event to the audit log for group membership addition uc.auditor.Dispatch(ctx, &events.GroupMemberAdded{ GroupBase: &events.GroupBase{ - GroupID: &groupID, + GroupID: groupID, GroupName: gr.Name, }, UserID: &userUUID, @@ -383,7 +383,7 @@ func (uc *OrgInvitationUseCase) processGroupMembership(ctx context.Context, invi // processProjectMembership adds a user to a project if the invitation context contains a project to join func (uc *OrgInvitationUseCase) processProjectMembership(ctx context.Context, invitation *OrgInvitation, orgUUID uuid.UUID, userUUID uuid.UUID) error { // Skip if there's no group to join in the invitation context - if invitation.Context == nil || invitation.Context.ProjectIDToJoin == uuid.Nil { + if invitation.Context == nil || invitation.Context.ProjectIDToJoin == nil || *invitation.Context.ProjectIDToJoin == uuid.Nil { return nil } @@ -391,7 +391,7 @@ func (uc *OrgInvitationUseCase) processProjectMembership(ctx context.Context, in uc.logger.Infow("msg", "Adding user to project", "invitation_id", invitation.ID.String(), "org_id", invitation.Org.ID, "user_id", userUUID, "project_id", projectID) // Check if the project exists - project, err := uc.projectRepo.FindProjectByOrgIDAndID(ctx, orgUUID, projectID) + project, err := uc.projectRepo.FindProjectByOrgIDAndID(ctx, orgUUID, *projectID) if err != nil { return fmt.Errorf("error finding project %s: %w", projectID.String(), err) } @@ -409,7 +409,7 @@ func (uc *OrgInvitationUseCase) processProjectMembership(ctx context.Context, in } // Check if the user is already a member of the project - existingMembership, err := uc.projectRepo.FindProjectMembershipByProjectAndID(ctx, orgUUID, projectID, userUUID, authz.MembershipTypeUser) + existingMembership, err := uc.projectRepo.FindProjectMembershipByProjectAndID(ctx, orgUUID, *projectID, userUUID, authz.MembershipTypeUser) if err != nil && !IsNotFound(err) { return fmt.Errorf("error checking project membership for user %s: %w", userUUID, err) } @@ -421,14 +421,14 @@ func (uc *OrgInvitationUseCase) processProjectMembership(ctx context.Context, in } // Add the user to the project - if _, err := uc.projectRepo.AddMemberToProject(ctx, orgUUID, projectID, userUUID, authz.MembershipTypeUser, role); err != nil { + if _, err := uc.projectRepo.AddMemberToProject(ctx, orgUUID, *projectID, userUUID, authz.MembershipTypeUser, role); err != nil { return fmt.Errorf("error adding user %s to project %s: %w", userUUID, projectID.String(), err) } // Dispatch event to the audit log for project membership addition uc.auditor.Dispatch(ctx, &events.ProjectMembershipAdded{ ProjectBase: &events.ProjectBase{ - ProjectID: &projectID, + ProjectID: projectID, ProjectName: project.Name, }, UserID: &userUUID, diff --git a/app/controlplane/pkg/biz/orginvitation_integration_test.go b/app/controlplane/pkg/biz/orginvitation_integration_test.go index 9fc517f26..1d33644b7 100644 --- a/app/controlplane/pkg/biz/orginvitation_integration_test.go +++ b/app/controlplane/pkg/biz/orginvitation_integration_test.go @@ -314,7 +314,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { s.T().Run("invitation with group context adds user to group when accepted", func(t *testing.T) { // Create invitation context with group information invitationContext := &biz.OrgInvitationContext{ - GroupIDToJoin: group.ID, + GroupIDToJoin: &group.ID, GroupMaintainer: true, } @@ -332,7 +332,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { // Verify context was saved properly assert.NotNil(t, invite.Context) - assert.Equal(t, group.ID, invite.Context.GroupIDToJoin) + assert.Equal(t, group.ID, *invite.Context.GroupIDToJoin) assert.Equal(t, true, invite.Context.GroupMaintainer) // Accept the invitation @@ -380,7 +380,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { // Create invitation context with group information, but not as maintainer invitationContext := &biz.OrgInvitationContext{ - GroupIDToJoin: group.ID, + GroupIDToJoin: &group.ID, GroupMaintainer: false, } @@ -447,7 +447,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { s.T().Run("invitation with project context adds user to project when accepted", func(t *testing.T) { // Create invitation context with project information invitationContext := &biz.OrgInvitationContext{ - ProjectIDToJoin: project.ID, + ProjectIDToJoin: &project.ID, ProjectRole: authz.RoleProjectAdmin, } @@ -465,7 +465,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { // Verify context was saved properly assert.NotNil(t, invite.Context) - assert.Equal(t, project.ID, invite.Context.ProjectIDToJoin) + assert.Equal(t, project.ID, *invite.Context.ProjectIDToJoin) assert.Equal(t, authz.RoleProjectAdmin, invite.Context.ProjectRole) // Accept the invitation @@ -511,7 +511,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { // Create invitation context with project information, but with viewer role invitationContext := &biz.OrgInvitationContext{ - ProjectIDToJoin: project.ID, + ProjectIDToJoin: &project.ID, ProjectRole: authz.RoleProjectViewer, } @@ -570,9 +570,9 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { // Create invitation context with both group and project information invitationContext := &biz.OrgInvitationContext{ - GroupIDToJoin: group.ID, + GroupIDToJoin: &group.ID, GroupMaintainer: true, - ProjectIDToJoin: project.ID, + ProjectIDToJoin: &project.ID, ProjectRole: authz.RoleProjectViewer, } @@ -590,9 +590,9 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { // Verify context was saved properly assert.NotNil(t, invite.Context) - assert.Equal(t, group.ID, invite.Context.GroupIDToJoin) + 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, project.ID, *invite.Context.ProjectIDToJoin) assert.Equal(t, authz.RoleProjectViewer, invite.Context.ProjectRole) // Accept the invitation @@ -645,30 +645,64 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { 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") }) -} -func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithExternalMetadata() { - ctx := context.Background() - receiverEmail := "externalmeta@cyberdyne.io" - externalMeta := []byte(`{"foo":"bar","baz":123}`) - invitationContext := &biz.OrgInvitationContext{ - ExternalMetadata: externalMeta, - } + s.T().Run("invitation with nil UUID on project is rejected", func(t *testing.T) { + // 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) + + // Create invitation context with nil project ID + invitationContext := &biz.OrgInvitationContext{ + ProjectIDToJoin: &uuid.Nil, + } - invite, err := s.OrgInvitation.Create( - ctx, - s.org1.ID, - s.user.ID, - receiverEmail, - biz.WithInvitationRole(authz.RoleViewer), - biz.WithInvitationContext(invitationContext), - ) - s.Require().NoError(err) - s.Require().NotNil(invite) - - s.Require().NotNil(invite.Context) - s.Require().NotNil(invite.Context.ExternalMetadata) - s.JSONEq(string(externalMeta), string(invite.Context.ExternalMetadata), "ExternalMetadata should be persisted and retrievable") + // Create invitation with combined context + invite, err := s.Repos.OrgInvitationRepo.Create( + ctx, + uuid.MustParse(s.org1.ID), + uuid.MustParse(s.user.ID), + newReceiverEmail, + authz.RoleViewer, + invitationContext, + ) + require.NoError(t, err) + require.NotNil(t, 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") + }) + + s.T().Run("invitation with nil UUID on group is rejected", func(t *testing.T) { + // 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) + + // Create invitation context with nil group ID + invitationContext := &biz.OrgInvitationContext{ + GroupIDToJoin: &uuid.Nil, + } + + // Create invitation with combined context + invite, err := s.Repos.OrgInvitationRepo.Create( + ctx, + uuid.MustParse(s.org1.ID), + uuid.MustParse(s.user.ID), + newReceiverEmail, + authz.RoleViewer, + invitationContext, + ) + require.NoError(t, err) + require.NotNil(t, 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") + }) } // Utility struct to hold the test suite diff --git a/app/controlplane/pkg/biz/project.go b/app/controlplane/pkg/biz/project.go index a20864754..ee0545740 100644 --- a/app/controlplane/pkg/biz/project.go +++ b/app/controlplane/pkg/biz/project.go @@ -392,7 +392,7 @@ func (uc *ProjectUseCase) handleNonExistingUser(ctx context.Context, orgID, proj // Create an organization invitation with project context invitationContext := &OrgInvitationContext{ - ProjectIDToJoin: projectID, + ProjectIDToJoin: &projectID, ProjectRole: opts.Role, } diff --git a/app/controlplane/pkg/biz/testhelpers/database.go b/app/controlplane/pkg/biz/testhelpers/database.go index e62cd9c5a..e3358a87e 100644 --- a/app/controlplane/pkg/biz/testhelpers/database.go +++ b/app/controlplane/pkg/biz/testhelpers/database.go @@ -82,13 +82,14 @@ type TestingUseCases struct { } type TestingRepos struct { - Membership biz.MembershipRepo - Referrer biz.ReferrerRepo - Workflow biz.WorkflowRepo - WorkflowRunRepo biz.WorkflowRunRepo - AttestationState biz.AttestationStateRepo - OrganizationRepo biz.OrganizationRepo - GroupRepo biz.GroupRepo + Membership biz.MembershipRepo + Referrer biz.ReferrerRepo + Workflow biz.WorkflowRepo + WorkflowRunRepo biz.WorkflowRunRepo + AttestationState biz.AttestationStateRepo + OrganizationRepo biz.OrganizationRepo + GroupRepo biz.GroupRepo + OrgInvitationRepo biz.OrgInvitationRepo } type newTestingOpts struct { diff --git a/app/controlplane/pkg/biz/testhelpers/wire_gen.go b/app/controlplane/pkg/biz/testhelpers/wire_gen.go index 01080520a..792161285 100644 --- a/app/controlplane/pkg/biz/testhelpers/wire_gen.go +++ b/app/controlplane/pkg/biz/testhelpers/wire_gen.go @@ -155,13 +155,14 @@ func WireTestData(testDatabase *TestDatabase, t *testing.T, logger log.Logger, r groupUseCase := biz.NewGroupUseCase(logger, groupRepo, membershipRepo, userRepo, orgInvitationUseCase, auditorUseCase, orgInvitationRepo, enforcer, membershipUseCase) projectUseCase := biz.NewProjectsUseCase(logger, projectsRepo, membershipRepo, auditorUseCase, groupUseCase, membershipUseCase, orgInvitationUseCase, orgInvitationRepo, enforcer) testingRepos := &TestingRepos{ - Membership: membershipRepo, - Referrer: referrerRepo, - Workflow: workflowRepo, - WorkflowRunRepo: workflowRunRepo, - AttestationState: attestationStateRepo, - OrganizationRepo: organizationRepo, - GroupRepo: groupRepo, + Membership: membershipRepo, + Referrer: referrerRepo, + Workflow: workflowRepo, + WorkflowRunRepo: workflowRunRepo, + AttestationState: attestationStateRepo, + OrganizationRepo: organizationRepo, + GroupRepo: groupRepo, + OrgInvitationRepo: orgInvitationRepo, } testingUseCases := &TestingUseCases{ DB: testDatabase, From 1a81c6e664690896daa01c17400eb4d4b59176d3 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 31 Jul 2025 13:11:52 +0200 Subject: [PATCH 4/5] fix tests Signed-off-by: Javier Rodriguez --- app/controlplane/pkg/biz/project_integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controlplane/pkg/biz/project_integration_test.go b/app/controlplane/pkg/biz/project_integration_test.go index a48858ff3..735dcd460 100644 --- a/app/controlplane/pkg/biz/project_integration_test.go +++ b/app/controlplane/pkg/biz/project_integration_test.go @@ -330,7 +330,7 @@ func (s *projectMembersIntegrationTestSuite) TestAddMemberToProject() { // Verify the invitation has project context s.NotNil(foundInvitation.Context, "Invitation should have context") - s.Equal(projectID, foundInvitation.Context.ProjectIDToJoin) + s.Equal(projectID, *foundInvitation.Context.ProjectIDToJoin) s.Equal(authz.RoleProjectViewer, foundInvitation.Context.ProjectRole) }) From c3556617439b7778b554f02898efaf41c332a88b Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 31 Jul 2025 13:25:14 +0200 Subject: [PATCH 5/5] fix tests Signed-off-by: Javier Rodriguez --- app/controlplane/pkg/biz/project_integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controlplane/pkg/biz/project_integration_test.go b/app/controlplane/pkg/biz/project_integration_test.go index 735dcd460..3503023fa 100644 --- a/app/controlplane/pkg/biz/project_integration_test.go +++ b/app/controlplane/pkg/biz/project_integration_test.go @@ -1807,7 +1807,7 @@ func (s *projectMembersIntegrationTestSuite) TestAddNonExistingMemberToProject() // Verify the invitation has project context s.NotNil(foundInvitation.Context, "Invitation should have context") - s.Equal(projectID, foundInvitation.Context.ProjectIDToJoin) + s.Equal(projectID, *foundInvitation.Context.ProjectIDToJoin) s.Equal(authz.RoleProjectViewer, foundInvitation.Context.ProjectRole) s.Equal(authz.RoleOrgMember, foundInvitation.Role) })