diff --git a/cmd/cli/app/auth/invite/invite_accept.go b/cmd/cli/app/auth/invite/invite_accept.go index 0659fa515c..2c64f05f5a 100644 --- a/cmd/cli/app/auth/invite/invite_accept.go +++ b/cmd/cli/app/auth/invite/invite_accept.go @@ -51,7 +51,7 @@ func inviteAcceptCommand(ctx context.Context, cmd *cobra.Command, args []string, if err != nil { return cli.MessageAndError("Error resolving invitation", err) } - cmd.Printf("Invitation %s for %s to become %s of project %s was accepted!\n", code, res.Email, res.Role, res.Project) + cmd.Printf("Invitation %s for %s to become %s of project %s was accepted!\n", code, res.Email, res.Role, res.ProjectDisplay) return nil } diff --git a/cmd/cli/app/auth/invite/invite_decline.go b/cmd/cli/app/auth/invite/invite_decline.go index 4c710b4271..a8ec6ed4ef 100644 --- a/cmd/cli/app/auth/invite/invite_decline.go +++ b/cmd/cli/app/auth/invite/invite_decline.go @@ -51,7 +51,7 @@ func inviteDeclineCommand(ctx context.Context, cmd *cobra.Command, args []string if err != nil { return cli.MessageAndError("Error resolving invitation", err) } - cmd.Printf("Invitation %s for %s to become %s of project %s was declined!\n", code, res.Email, res.Role, res.Project) + cmd.Printf("Invitation %s for %s to become %s of project %s was declined!\n", code, res.Email, res.Role, res.ProjectDisplay) return nil } diff --git a/cmd/cli/app/project/role/role_grant.go b/cmd/cli/app/project/role/role_grant.go index e419783abc..9b37003340 100644 --- a/cmd/cli/app/project/role/role_grant.go +++ b/cmd/cli/app/project/role/role_grant.go @@ -60,8 +60,8 @@ func GrantCommand(ctx context.Context, cmd *cobra.Command, _ []string, conn *grp Role: r, Email: email, } - failMsg = "Error creating/updating an invite" - successMsg = "Invite created/updated successfully." + failMsg = "Error creating an invite" + successMsg = "Invite created successfully." } ret, err := client.AssignRole(ctx, &minderv1.AssignRoleRequest{ diff --git a/cmd/cli/app/project/role/role_grant_list.go b/cmd/cli/app/project/role/role_grant_list.go index fa08664b69..46e18a4ac9 100644 --- a/cmd/cli/app/project/role/role_grant_list.go +++ b/cmd/cli/app/project/role/role_grant_list.go @@ -18,7 +18,9 @@ package role import ( "context" "fmt" + "strconv" "strings" + "time" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -78,17 +80,30 @@ func GrantListCommand(ctx context.Context, cmd *cobra.Command, _ []string, conn } cmd.Println(out) case app.Table: - t := initializeTableForGrantList() + t := initializeTableForGrantListRoleAssignments() for _, r := range resp.RoleAssignments { - t.AddRow(r.Subject, r.Role) + t.AddRow(r.Subject, r.Role, *r.Project) } t.Render() + if len(resp.Invitations) > 0 { + t := initializeTableForGrantListInvitations() + for _, r := range resp.Invitations { + t.AddRow(r.Email, r.Role, r.SponsorDisplay, r.ExpiresAt.AsTime().Format(time.RFC3339), strconv.FormatBool(r.Expired), r.Code) + } + t.Render() + } else { + cmd.Println("No pending invitations found.") + } } return nil } -func initializeTableForGrantList() table.Table { - return table.New(table.Simple, layouts.Default, []string{"Subject", "Role"}) +func initializeTableForGrantListRoleAssignments() table.Table { + return table.New(table.Simple, layouts.Default, []string{"Subject", "Role", "Project"}) +} + +func initializeTableForGrantListInvitations() table.Table { + return table.New(table.Simple, layouts.Default, []string{"Invitee", "Role", "Sponsor", "Expires At", "Expired", "Code"}) } func init() { diff --git a/cmd/cli/app/project/role/role_update.go b/cmd/cli/app/project/role/role_update.go index d53c93dc4d..aa6362e9c9 100644 --- a/cmd/cli/app/project/role/role_update.go +++ b/cmd/cli/app/project/role/role_update.go @@ -17,6 +17,7 @@ package role import ( "context" + "time" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -38,40 +39,64 @@ to a user (subject) on a particular project.`, func UpdateCommand(ctx context.Context, cmd *cobra.Command, _ []string, conn *grpc.ClientConn) error { client := minderv1.NewPermissionsServiceClient(conn) - sub := viper.GetString("sub") r := viper.GetString("role") project := viper.GetString("project") + sub := viper.GetString("sub") + email := viper.GetString("email") // No longer print usage on returned error, since we've parsed our inputs // See https://github.com/spf13/cobra/issues/340#issuecomment-374617413 cmd.SilenceUsage = true - ret, err := client.UpdateRole(ctx, &minderv1.UpdateRoleRequest{ + req := &minderv1.UpdateRoleRequest{ Context: &minderv1.Context{ Project: &project, }, Roles: []string{r}, Subject: sub, - }) + } + failMsg := "Error updating role" + successMsg := "Updated role successfully." + if email != "" { + req.Email = email + failMsg = "Error updating an invite" + successMsg = "Invite updated successfully." + } + + ret, err := client.UpdateRole(ctx, req) if err != nil { - return cli.MessageAndError("Error updating role", err) + return cli.MessageAndError(failMsg, err) } - cmd.Println("Update role successfully.") - cmd.Printf( - "Subject \"%s\" is now assigned to role \"%s\" on project \"%s\"\n", - ret.RoleAssignments[0].Subject, - ret.RoleAssignments[0].Role, - *ret.RoleAssignments[0].Project, - ) + cmd.Println(successMsg) + if email != "" { + t := initializeTableForGrantListInvitations() + for _, r := range ret.Invitations { + expired := "No" + if r.Expired { + expired = "Yes" + } + t.AddRow(r.Email, r.Role, r.Sponsor, r.ExpiresAt.AsTime().Format(time.RFC3339), expired, r.Code) + } + t.Render() + return nil + } + // Otherwise, print the role assignments if it was about updating a role + t := initializeTableForGrantListRoleAssignments() + for _, r := range ret.RoleAssignments { + t.AddRow(r.Subject, r.Role, *r.Project) + } + t.Render() return nil } func init() { RoleCmd.AddCommand(updateCmd) - updateCmd.Flags().StringP("sub", "s", "", "subject to update role access for") updateCmd.Flags().StringP("role", "r", "", "the role to update it to") - updateCmd.MarkFlagsRequiredTogether("sub", "role") + updateCmd.Flags().StringP("sub", "s", "", "subject to update role access for") + updateCmd.Flags().StringP("email", "e", "", "email to send invitation to") + updateCmd.MarkFlagsOneRequired("sub", "email") + updateCmd.MarkFlagsMutuallyExclusive("sub", "email") } diff --git a/database/mock/store.go b/database/mock/store.go index 0939a45f09..18a27cefe8 100644 --- a/database/mock/store.go +++ b/database/mock/store.go @@ -809,10 +809,10 @@ func (mr *MockStoreMockRecorder) GetInstallationIDByProviderID(arg0, arg1 any) * } // GetInvitationByCode mocks base method. -func (m *MockStore) GetInvitationByCode(arg0 context.Context, arg1 string) (db.UserInvite, error) { +func (m *MockStore) GetInvitationByCode(arg0 context.Context, arg1 string) (db.GetInvitationByCodeRow, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetInvitationByCode", arg0, arg1) - ret0, _ := ret[0].(db.UserInvite) + ret0, _ := ret[0].(db.GetInvitationByCodeRow) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -823,34 +823,34 @@ func (mr *MockStoreMockRecorder) GetInvitationByCode(arg0, arg1 any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInvitationByCode", reflect.TypeOf((*MockStore)(nil).GetInvitationByCode), arg0, arg1) } -// GetInvitationByEmailAndProjectAndRole mocks base method. -func (m *MockStore) GetInvitationByEmailAndProjectAndRole(arg0 context.Context, arg1 db.GetInvitationByEmailAndProjectAndRoleParams) (db.UserInvite, error) { +// GetInvitationsByEmail mocks base method. +func (m *MockStore) GetInvitationsByEmail(arg0 context.Context, arg1 string) ([]db.GetInvitationsByEmailRow, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetInvitationByEmailAndProjectAndRole", arg0, arg1) - ret0, _ := ret[0].(db.UserInvite) + ret := m.ctrl.Call(m, "GetInvitationsByEmail", arg0, arg1) + ret0, _ := ret[0].([]db.GetInvitationsByEmailRow) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetInvitationByEmailAndProjectAndRole indicates an expected call of GetInvitationByEmailAndProjectAndRole. -func (mr *MockStoreMockRecorder) GetInvitationByEmailAndProjectAndRole(arg0, arg1 any) *gomock.Call { +// GetInvitationsByEmail indicates an expected call of GetInvitationsByEmail. +func (mr *MockStoreMockRecorder) GetInvitationsByEmail(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInvitationByEmailAndProjectAndRole", reflect.TypeOf((*MockStore)(nil).GetInvitationByEmailAndProjectAndRole), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInvitationsByEmail", reflect.TypeOf((*MockStore)(nil).GetInvitationsByEmail), arg0, arg1) } -// GetInvitationsByEmail mocks base method. -func (m *MockStore) GetInvitationsByEmail(arg0 context.Context, arg1 string) ([]db.UserInvite, error) { +// GetInvitationsByEmailAndProject mocks base method. +func (m *MockStore) GetInvitationsByEmailAndProject(arg0 context.Context, arg1 db.GetInvitationsByEmailAndProjectParams) ([]db.GetInvitationsByEmailAndProjectRow, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetInvitationsByEmail", arg0, arg1) - ret0, _ := ret[0].([]db.UserInvite) + ret := m.ctrl.Call(m, "GetInvitationsByEmailAndProject", arg0, arg1) + ret0, _ := ret[0].([]db.GetInvitationsByEmailAndProjectRow) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetInvitationsByEmail indicates an expected call of GetInvitationsByEmail. -func (mr *MockStoreMockRecorder) GetInvitationsByEmail(arg0, arg1 any) *gomock.Call { +// GetInvitationsByEmailAndProject indicates an expected call of GetInvitationsByEmailAndProject. +func (mr *MockStoreMockRecorder) GetInvitationsByEmailAndProject(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInvitationsByEmail", reflect.TypeOf((*MockStore)(nil).GetInvitationsByEmail), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInvitationsByEmailAndProject", reflect.TypeOf((*MockStore)(nil).GetInvitationsByEmailAndProject), arg0, arg1) } // GetLatestEvalStateForRuleEntity mocks base method. diff --git a/database/query/invitations.sql b/database/query/invitations.sql index 6a6da4a919..d959ced88f 100644 --- a/database/query/invitations.sql +++ b/database/query/invitations.sql @@ -4,7 +4,7 @@ -- the invitee. -- name: ListInvitationsForProject :many -SELECT user_invites.email, role, users.identity_subject, user_invites.created_at, user_invites.updated_at +SELECT user_invites.email, role, users.identity_subject, user_invites.created_at, user_invites.updated_at, user_invites.code FROM user_invites JOIN users ON user_invites.sponsor = users.id WHERE project = $1; @@ -14,15 +14,22 @@ WHERE project = $1; -- to allow them to accept invitations even if email delivery was not working. -- Note that this requires that the destination email address matches the email -- address of the logged in user in the external identity service / auth token. +-- This clarification is related solely for user's ListInvitations calls and does +-- not affect to resolving invitations intended for other mail addresses. -- name: GetInvitationsByEmail :many -SELECT * FROM user_invites WHERE email = $1; +SELECT user_invites.*, users.identity_subject +FROM user_invites + JOIN users ON user_invites.sponsor = users.id +WHERE email = $1; --- GetInvitationByEmailAndProjectAndRole retrieves an invitation by email, project, --- and role. +-- GetInvitationsByEmailAndProject retrieves all invitations by email and project. --- name: GetInvitationByEmailAndProjectAndRole :one -SELECT * FROM user_invites WHERE email = $1 AND project = $2 AND role = $3; +-- name: GetInvitationsByEmailAndProject :many +SELECT user_invites.*, users.identity_subject +FROM user_invites + JOIN users ON user_invites.sponsor = users.id +WHERE email = $1 AND project = $2; -- GetInvitationByCode retrieves an invitation by its code. This is intended to -- be called by a user who has received an invitation email and is following the @@ -30,7 +37,10 @@ SELECT * FROM user_invites WHERE email = $1 AND project = $2 AND role = $3; -- invitation. -- name: GetInvitationByCode :one -SELECT * FROM user_invites WHERE code = $1; +SELECT user_invites.*, users.identity_subject +FROM user_invites + JOIN users ON user_invites.sponsor = users.id +WHERE code = $1; -- CreateInvitation creates a new invitation. The code is a secret that is sent -- to the invitee, and the email is the address to which the invitation will be diff --git a/internal/controlplane/handlers_authz.go b/internal/controlplane/handlers_authz.go index 0ec41714d7..8bbc075b49 100644 --- a/internal/controlplane/handlers_authz.go +++ b/internal/controlplane/handlers_authz.go @@ -18,10 +18,8 @@ import ( "context" "database/sql" "errors" - "time" "github.com/google/uuid" - gauth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" "github.com/rs/zerolog" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -241,17 +239,16 @@ func (s *Server) ListRoleAssignments( return nil, status.Errorf(codes.Internal, "error getting role assignments: %v", err) } - if flags.Bool(ctx, s.featureFlags, flags.IDPResolver) { - for i := range as { - identity, err := s.idClient.Resolve(ctx, as[i].Subject) - if err != nil { - // if we can't resolve the subject, report the raw ID value - zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") - continue - } - as[i].Subject = identity.Human() + for i := range as { + identity, err := s.idClient.Resolve(ctx, as[i].Subject) + if err != nil { + // if we can't resolve the subject, report the raw ID value + zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") + continue } + as[i].Subject = identity.String() } + if flags.Bool(ctx, s.featureFlags, flags.UserManagement) { mapIdToDisplay := make(map[string]string, len(as)) for i := range as { @@ -278,10 +275,11 @@ func (s *Server) ListRoleAssignments( Email: i.Email, Project: targetProject.String(), CreatedAt: timestamppb.New(i.CreatedAt), - ExpiresAt: timestamppb.New(i.UpdatedAt.Add(7 * 24 * time.Hour)), - Expired: time.Now().After(i.UpdatedAt.Add(7 * 24 * time.Hour)), + ExpiresAt: invite.GetExpireIn7Days(i.UpdatedAt), + Expired: invite.IsExpired(i.UpdatedAt), Sponsor: i.IdentitySubject, SponsorDisplay: mapIdToDisplay[i.IdentitySubject], + Code: i.Code, }) } } @@ -304,7 +302,7 @@ func (s *Server) AssignRole(ctx context.Context, req *minder.AssignRoleRequest) targetProject := entityCtx.Project.ID // Ensure user is not updating their own role - err := s.isUserSelfUpdating(ctx, sub, email) + err := isUserSelfUpdating(ctx, sub, email) if err != nil { return nil, err } @@ -325,7 +323,7 @@ func (s *Server) AssignRole(ctx context.Context, req *minder.AssignRoleRequest) return nil, status.Errorf(codes.Internal, "error getting project: %v", err) } - // Validate the subject and email - decide if it's an invitation or a role assignment + // Decide if it's an invitation or a role assignment if sub == "" && email != "" { if flags.Bool(ctx, s.featureFlags, flags.UserManagement) { return s.inviteUser(ctx, targetProject, authzRole, email) @@ -344,72 +342,56 @@ func (s *Server) inviteUser( email string, ) (*minder.AssignRoleResponse, error) { var userInvite db.UserInvite - // Current user is always authorized to get themselves - tokenString, err := gauth.AuthFromMD(ctx, "bearer") + // Get the sponsor's user information (current user) + currentUser, err := s.store.GetUserBySubject(ctx, auth.GetUserSubjectFromContext(ctx)) if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "no auth token: %v", err) + return nil, status.Errorf(codes.Internal, "failed to get user: %s", err) } - openIdToken, err := s.jwt.ParseAndValidate(tokenString) + // Check if the user is already invited + existingInvites, err := s.store.GetInvitationsByEmailAndProject(ctx, db.GetInvitationsByEmailAndProjectParams{ + Email: email, + Project: targetProject, + }) if err != nil { - return nil, status.Errorf(codes.Unauthenticated, "failed to parse bearer token: %v", err) + return nil, status.Errorf(codes.Internal, "error getting invitations: %v", err) } - // Get the sponsor's user information (current user) - currentUser, err := s.store.GetUserBySubject(ctx, openIdToken.Subject()) - if err != nil { - return nil, status.Errorf(codes.Internal, "failed to get user: %s", err) + // Check if there are any existing invitations for this email + if len(existingInvites) != 0 { + return nil, util.UserVisibleError( + codes.AlreadyExists, + "invitation for this email and project already exists, use update instead", + ) } - // Check if the user is already invited - existingInvite, err := s.store.GetInvitationByEmailAndProjectAndRole(ctx, db.GetInvitationByEmailAndProjectAndRoleParams{ + // If there are no invitations for this email, great, we should create one + userInvite, err = s.store.CreateInvitation(ctx, db.CreateInvitationParams{ + Code: invite.GenerateCode(), Email: email, - Project: targetProject, Role: role.String(), + Project: targetProject, + Sponsor: currentUser.ID, }) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - // If there are no invitations for this email, great, we should create one - userInvite, err = s.store.CreateInvitation(ctx, db.CreateInvitationParams{ - Code: invite.GenerateCode(), - Email: email, - Role: role.String(), - Project: targetProject, - Sponsor: currentUser.ID, - }) - if err != nil { - return nil, status.Errorf(codes.Internal, "error creating invitation: %v", err) - } - } else { - // Some other error happened, return it - return nil, status.Errorf(codes.Internal, "error getting invitations: %v", err) - } - } else { - // If we didn't get an error, this means there's an existing invite. - // We should update its expiration and send the response. - userInvite, err = s.store.UpdateInvitation(ctx, existingInvite.Code) - if err != nil { - return nil, status.Errorf(codes.Internal, "error updating invitation: %v", err) - } + return nil, status.Errorf(codes.Internal, "error creating invitation: %v", err) } - // If we are here, this means we either created a new invite or updated an existing one // Resolve the sponsor's identity and display name - identity := &auth.Identity{ - Provider: nil, - UserID: currentUser.IdentitySubject, - HumanName: currentUser.IdentitySubject, - } - if flags.Bool(ctx, s.featureFlags, flags.IDPResolver) { - identity, err = s.idClient.Resolve(ctx, currentUser.IdentitySubject) - if err != nil { - zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") - return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", currentUser.IdentitySubject) - } + identity, err := s.idClient.Resolve(ctx, currentUser.IdentitySubject) + if err != nil { + zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") + return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", currentUser.IdentitySubject) } // TODO: Publish the event for sending the invitation email + // Resolve the project's display name + prj, err := s.store.GetProjectByID(ctx, userInvite.Project) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get project: %s", err) + } + // Send the invitation response return &minder.AssignRoleResponse{ // Leaving the role assignment empty as it's an invitation @@ -417,12 +399,13 @@ func (s *Server) inviteUser( Role: userInvite.Role, Email: userInvite.Email, Project: userInvite.Project.String(), + ProjectDisplay: prj.Name, Code: userInvite.Code, Sponsor: identity.UserID, SponsorDisplay: identity.Human(), CreatedAt: timestamppb.New(userInvite.CreatedAt), - ExpiresAt: timestamppb.New(userInvite.UpdatedAt.Add(7 * 24 * time.Hour)), - Expired: time.Now().After(userInvite.UpdatedAt.Add(7 * 24 * time.Hour)), + ExpiresAt: invite.GetExpireIn7Days(userInvite.UpdatedAt), + Expired: invite.IsExpired(userInvite.UpdatedAt), }, }, nil } @@ -434,28 +417,18 @@ func (s *Server) assignRole( subject string, ) (*minder.AssignRoleResponse, error) { var err error - // We may be given a human-readable identifier which can vary over time. Resolve - // it to an IDP-specific stable identifier so that we can support subject renames. - identity := &auth.Identity{ - Provider: nil, - UserID: subject, - HumanName: subject, - } - if flags.Bool(ctx, s.featureFlags, flags.IDPResolver) { - identity, err = s.idClient.Resolve(ctx, subject) - if err != nil { - zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") - return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", subject) - } + // Resolve the subject to an identity + identity, err := s.idClient.Resolve(ctx, subject) + if err != nil { + zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") + return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", subject) } // Verify if user exists. // TODO: this assumes that we store all users in the database, and that we don't // need to namespace identify providers. We should revisit these assumptions. // - // Note: We could use `identity.String()` here, relying on Keycloak being registered - // as the default with Provider.String() == "". - if _, err := s.store.GetUserBySubject(ctx, identity.UserID); err != nil { + if _, err := s.store.GetUserBySubject(ctx, identity.String()); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, util.UserVisibleError(codes.NotFound, "User not found") } @@ -500,7 +473,7 @@ func (s *Server) RemoveRole(ctx context.Context, req *minder.RemoveRoleRequest) targetProject := entityCtx.Project.ID // Ensure user is not updating their own role - err := s.isUserSelfUpdating(ctx, sub, email) + err := isUserSelfUpdating(ctx, sub, email) if err != nil { return nil, err } @@ -530,32 +503,70 @@ func (s *Server) removeInvite( role authz.Role, email string, ) (*minder.RemoveRoleResponse, error) { - prj := targetPrj.String() - // Get all invitations for this email, project and role - inviteToRemove, err := s.store.GetInvitationByEmailAndProjectAndRole(ctx, db.GetInvitationByEmailAndProjectAndRoleParams{ + // Get all invitations for this email and project + invitesToRemove, err := s.store.GetInvitationsByEmailAndProject(ctx, db.GetInvitationsByEmailAndProjectParams{ Email: email, Project: targetPrj, - Role: role.String(), }) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, util.UserVisibleError(codes.NotFound, "no invitation found for this email, project and role") - } return nil, status.Errorf(codes.Internal, "error getting invitation: %v", err) } + // If there are no invitations for this email, return an error + if len(invitesToRemove) == 0 { + return nil, util.UserVisibleError(codes.NotFound, "no invitations found for this email and project") + } + + // Find the invitation to remove. There should be only one invitation for the given role and email in the project. + var inviteToRemove *db.GetInvitationsByEmailAndProjectRow + for _, i := range invitesToRemove { + if i.Role == role.String() { + inviteToRemove = &i + break + } + } + // If there's no invitation to remove, return an error + if inviteToRemove == nil { + return nil, util.UserVisibleError(codes.NotFound, "no invitation found for this role and email in the project") + } // Delete the invitation - _, err = s.store.DeleteInvitation(ctx, inviteToRemove.Code) + ret, err := s.store.DeleteInvitation(ctx, inviteToRemove.Code) if err != nil { return nil, status.Errorf(codes.Internal, "error deleting invitation: %v", err) } + // Resolve the project's display name + prj, err := s.store.GetProjectByID(ctx, ret.Project) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get project: %s", err) + } + + // Get the sponsor's user information (current user) + sponsorUser, err := s.store.GetUserByID(ctx, ret.Sponsor) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get user: %s", err) + } + + // Resolve the sponsor's identity and display name + identity, err := s.idClient.Resolve(ctx, sponsorUser.IdentitySubject) + if err != nil { + zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") + return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", sponsorUser.IdentitySubject) + } + // Return the response return &minder.RemoveRoleResponse{ - RoleAssignment: &minder.RoleAssignment{ - Role: role.String(), - Email: email, - Project: &prj, + Invitation: &minder.Invitation{ + Role: ret.Role, + Email: ret.Email, + Project: ret.Project.String(), + Code: ret.Code, + CreatedAt: timestamppb.New(ret.CreatedAt), + ExpiresAt: invite.GetExpireIn7Days(ret.UpdatedAt), + Expired: invite.IsExpired(ret.UpdatedAt), + Sponsor: sponsorUser.IdentitySubject, + SponsorDisplay: identity.Human(), + ProjectDisplay: prj.Name, }, }, nil } @@ -567,29 +578,22 @@ func (s *Server) removeRole( subject string, ) (*minder.RemoveRoleResponse, error) { var err error - // We may be given a human-readable identifier which can vary over time. Resolve - // it to an IDP-specific stable identifier so that we can support subject renames. - identity := &auth.Identity{ - Provider: nil, - UserID: subject, - HumanName: subject, - } - if flags.Bool(ctx, s.featureFlags, flags.IDPResolver) { - identity, err = s.idClient.Resolve(ctx, subject) - if err != nil { - zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") - return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", subject) - } + // Resolve the subject to an identity + identity, err := s.idClient.Resolve(ctx, subject) + if err != nil { + zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") + return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", subject) } // Verify if user exists - if _, err := s.store.GetUserBySubject(ctx, identity.UserID); err != nil { + if _, err := s.store.GetUserBySubject(ctx, identity.String()); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, util.UserVisibleError(codes.NotFound, "User not found") } return nil, status.Errorf(codes.Internal, "error getting user: %v", err) } + // Delete the role assignment if err := s.authzClient.Delete(ctx, identity.String(), role, targetProject); err != nil { return nil, status.Errorf(codes.Internal, "error writing role assignment: %v", err) } @@ -611,17 +615,14 @@ func (s *Server) UpdateRole(ctx context.Context, req *minder.UpdateRoleRequest) } role := req.GetRoles()[0] sub := req.GetSubject() + email := req.GetEmail() // Determine the target project. entityCtx := engine.EntityFromContext(ctx) targetProject := entityCtx.Project.ID - if sub == "" { - return nil, util.UserVisibleError(codes.InvalidArgument, "role and subject must be specified") - } - // Ensure user is not updating their own role - err := s.isUserSelfUpdating(ctx, sub, "") + err := isUserSelfUpdating(ctx, sub, email) if err != nil { return nil, err } @@ -632,23 +633,120 @@ func (s *Server) UpdateRole(ctx context.Context, req *minder.UpdateRoleRequest) return nil, util.UserVisibleError(codes.InvalidArgument, err.Error()) } - // We may be given a human-readable identifier which can vary over time. Resolve - // it to an IDP-specific stable identifier so that we can support subject renames. - identity := &auth.Identity{ - Provider: nil, - UserID: sub, - HumanName: sub, + // Validate the subject and email - decide if it's about updating an invitation or a role assignment + if sub == "" && email != "" { + if flags.Bool(ctx, s.featureFlags, flags.UserManagement) { + return s.updateInvite(ctx, targetProject, authzRole, email) + } + return nil, util.UserVisibleError(codes.Unimplemented, "user management is not enabled") + } else if sub != "" && email == "" { + // If there's a subject, we assume it's a role assignment update + return s.updateRole(ctx, targetProject, authzRole, sub) } - if flags.Bool(ctx, s.featureFlags, flags.IDPResolver) { - identity, err = s.idClient.Resolve(ctx, sub) + return nil, util.UserVisibleError(codes.InvalidArgument, "one of subject or email must be specified") +} + +func (s *Server) updateInvite( + ctx context.Context, + targetProject uuid.UUID, + authzRole authz.Role, + email string, +) (*minder.UpdateRoleResponse, error) { + var userInvite db.UserInvite + // Get the sponsor's user information (current user) + currentUser, err := s.store.GetUserBySubject(ctx, auth.GetUserSubjectFromContext(ctx)) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get user: %s", err) + } + + // Get all invitations for this email and project + existingInvites, err := s.store.GetInvitationsByEmailAndProject(ctx, db.GetInvitationsByEmailAndProjectParams{ + Email: email, + Project: targetProject, + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "error getting invitations: %v", err) + } + + // Exit early if there are no or multiple existing invitations for this email and project + if len(existingInvites) == 0 { + return nil, util.UserVisibleError(codes.NotFound, "no invitations found for this email and project") + } else if len(existingInvites) > 1 { + return nil, status.Errorf(codes.Internal, "multiple invitations found for this email and project") + } + + // At this point, there should be exactly 1 invitation. We should either update its expiration or + // discard it and create a new one + if existingInvites[0].Role != authzRole.String() { + // If there's an existing invite with a different role, we should delete it and create a new one + // Delete the existing invitation + _, err = s.store.DeleteInvitation(ctx, existingInvites[0].Code) if err != nil { - zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") - return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", sub) + return nil, status.Errorf(codes.Internal, "error deleting previous invitation: %v", err) } + // Create a new invitation + userInvite, err = s.store.CreateInvitation(ctx, db.CreateInvitationParams{ + Code: invite.GenerateCode(), + Email: email, + Role: authzRole.String(), + Project: targetProject, + Sponsor: currentUser.ID, + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "error creating invitation: %v", err) + } + } else { + // If the role is the same, we should update the expiration + userInvite, err = s.store.UpdateInvitation(ctx, existingInvites[0].Code) + if err != nil { + return nil, status.Errorf(codes.Internal, "error updating invitation: %v", err) + } + } + // Resolve the project's display name + prj, err := s.store.GetProjectByID(ctx, userInvite.Project) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get project: %s", err) + } + // Resolve the sponsor's identity and display name + identity, err := s.idClient.Resolve(ctx, currentUser.IdentitySubject) + if err != nil { + zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") + return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", currentUser.IdentitySubject) + } + return &minder.UpdateRoleResponse{ + // Leaving the role assignment empty as it's an invitation + Invitations: []*minder.Invitation{ + { + Role: userInvite.Role, + Email: userInvite.Email, + Project: userInvite.Project.String(), + ProjectDisplay: prj.Name, + Code: userInvite.Code, + Sponsor: identity.String(), + SponsorDisplay: identity.Human(), + CreatedAt: timestamppb.New(userInvite.CreatedAt), + ExpiresAt: invite.GetExpireIn7Days(userInvite.UpdatedAt), + Expired: invite.IsExpired(userInvite.UpdatedAt), + }, + }, + }, nil +} + +func (s *Server) updateRole( + ctx context.Context, + targetProject uuid.UUID, + authzRole authz.Role, + sub string, +) (*minder.UpdateRoleResponse, error) { + // Resolve the subject to an identity + identity, err := s.idClient.Resolve(ctx, sub) + if err != nil { + zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") + return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", sub) } // Verify if user exists - if _, err := s.store.GetUserBySubject(ctx, identity.UserID); err != nil { + if _, err := s.store.GetUserBySubject(ctx, identity.String()); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, util.UserVisibleError(codes.NotFound, "User not found") } @@ -682,7 +780,7 @@ func (s *Server) UpdateRole(ctx context.Context, req *minder.UpdateRoleRequest) return &minder.UpdateRoleResponse{ RoleAssignments: []*minder.RoleAssignment{ { - Role: role, + Role: authzRole.String(), Subject: identity.Human(), Project: &respProj, }, @@ -691,23 +789,18 @@ func (s *Server) UpdateRole(ctx context.Context, req *minder.UpdateRoleRequest) } // isUserSelfUpdating is used to prevent if the user is trying to update their own role -func (s *Server) isUserSelfUpdating(ctx context.Context, subject, email string) error { - // Ensure user is not updating their own role - tokenString, err := gauth.AuthFromMD(ctx, "bearer") - if err != nil { - return status.Errorf(codes.InvalidArgument, "no auth token: %v", err) - } - token, err := s.jwt.ParseAndValidate(tokenString) - if err != nil { - return status.Errorf(codes.Unauthenticated, "failed to parse bearer token: %v", err) - } +func isUserSelfUpdating(ctx context.Context, subject, email string) error { if subject != "" { - if token.Subject() == subject { + if auth.GetUserSubjectFromContext(ctx) == subject { return util.UserVisibleError(codes.InvalidArgument, "cannot update your own role") } } if email != "" { - if token.Email() == email { + tokenEmail, err := auth.GetUserEmailFromContext(ctx) + if err != nil { + return util.UserVisibleError(codes.Internal, "error getting user email from token: %v", err) + } + if tokenEmail == email { return util.UserVisibleError(codes.InvalidArgument, "cannot update your own role") } } diff --git a/internal/controlplane/handlers_invites.go b/internal/controlplane/handlers_invites.go index 3444ffdb83..38d65a58ec 100644 --- a/internal/controlplane/handlers_invites.go +++ b/internal/controlplane/handlers_invites.go @@ -18,13 +18,12 @@ import ( "context" "database/sql" "errors" - "time" "github.com/rs/zerolog" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/timestamppb" + "github.com/stacklok/minder/internal/invite" "github.com/stacklok/minder/internal/util" pb "github.com/stacklok/minder/pkg/api/protobuf/go/minder/v1" ) @@ -45,28 +44,22 @@ func (s *Server) GetInviteDetails(ctx context.Context, req *pb.GetInviteDetailsR return nil, status.Errorf(codes.Internal, "failed to get invitation: %s", err) } - // Get the sponsor's user information - sponsorUser, err := s.store.GetUserByID(ctx, retInvite.Sponsor) - if err != nil { - return nil, status.Errorf(codes.Internal, "failed to get user: %s", err) - } - targetProject, err := s.store.GetProjectByID(ctx, retInvite.Project) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get project: %s", err) } // Resolve the sponsor's identity and display name - identity, err := s.idClient.Resolve(ctx, sponsorUser.IdentitySubject) + identity, err := s.idClient.Resolve(ctx, retInvite.IdentitySubject) if err != nil { zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") - return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", sponsorUser.IdentitySubject) + return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", retInvite.IdentitySubject) } return &pb.GetInviteDetailsResponse{ ProjectDisplay: targetProject.Name, SponsorDisplay: identity.Human(), - ExpiresAt: timestamppb.New(retInvite.UpdatedAt.Add(7 * 24 * time.Hour)), - Expired: time.Now().After(retInvite.UpdatedAt.Add(7 * 24 * time.Hour)), + ExpiresAt: invite.GetExpireIn7Days(retInvite.UpdatedAt), + Expired: invite.IsExpired(retInvite.UpdatedAt), }, nil } diff --git a/internal/controlplane/handlers_user.go b/internal/controlplane/handlers_user.go index be9485ad64..5efba557b1 100644 --- a/internal/controlplane/handlers_user.go +++ b/internal/controlplane/handlers_user.go @@ -21,8 +21,8 @@ import ( "net/http" "path" "strconv" - "time" + "github.com/google/uuid" gauth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" "github.com/rs/zerolog" "google.golang.org/grpc/codes" @@ -33,6 +33,7 @@ import ( "github.com/stacklok/minder/internal/authz" "github.com/stacklok/minder/internal/db" "github.com/stacklok/minder/internal/flags" + "github.com/stacklok/minder/internal/invite" "github.com/stacklok/minder/internal/logger" "github.com/stacklok/minder/internal/projects" "github.com/stacklok/minder/internal/util" @@ -272,53 +273,41 @@ func (s *Server) ListInvitations(ctx context.Context, _ *pb.ListInvitationsReque invitations := make([]*pb.Invitation, 0) // Extracts the user email from the token - tokenString, err := gauth.AuthFromMD(ctx, "bearer") - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "no auth token: %v", err) - } - token, err := s.jwt.ParseAndValidate(tokenString) + tokenEmail, err := auth.GetUserEmailFromContext(ctx) if err != nil { - return nil, status.Errorf(codes.Unauthenticated, "failed to parse bearer token: %v", err) + return nil, status.Errorf(codes.Internal, "failed to get user email: %s", err) } // Get the list of invitations for the user - invites, err := s.store.GetInvitationsByEmail(ctx, token.Email()) + invites, err := s.store.GetInvitationsByEmail(ctx, tokenEmail) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return &pb.ListInvitationsResponse{}, nil - } return nil, status.Errorf(codes.Internal, "failed to get invitations: %s", err) } // Build the response list of invitations - for _, invite := range invites { - // Get the sponsor's user information (current user) - sponsorUser, err := s.store.GetUserByID(ctx, invite.Sponsor) - if err != nil { - return nil, status.Errorf(codes.Internal, "failed to get user: %s", err) - } + for _, i := range invites { // Resolve the sponsor's identity and display name - identity := &auth.Identity{ - Provider: nil, - UserID: sponsorUser.IdentitySubject, - HumanName: sponsorUser.IdentitySubject, + identity, err := s.idClient.Resolve(ctx, i.IdentitySubject) + if err != nil { + zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") + return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", i.IdentitySubject) } - if flags.Bool(ctx, s.featureFlags, flags.IDPResolver) { - identity, err = s.idClient.Resolve(ctx, sponsorUser.IdentitySubject) - if err != nil { - zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity") - return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", sponsorUser.IdentitySubject) - } + + // Resolve the project's display name + targetProject, err := s.store.GetProjectByID(ctx, i.Project) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get project: %s", err) } invitations = append(invitations, &pb.Invitation{ - Code: invite.Code, - Role: invite.Role, - Email: invite.Email, - Project: invite.Project.String(), - CreatedAt: timestamppb.New(invite.CreatedAt), - ExpiresAt: timestamppb.New(invite.UpdatedAt.Add(7 * 24 * time.Hour)), - Expired: time.Now().After(invite.UpdatedAt.Add(7 * 24 * time.Hour)), - Sponsor: identity.UserID, + Code: i.Code, + Role: i.Role, + Email: i.Email, + Project: i.Project.String(), + ProjectDisplay: targetProject.Name, + CreatedAt: timestamppb.New(i.CreatedAt), + ExpiresAt: invite.GetExpireIn7Days(i.UpdatedAt), + Expired: invite.IsExpired(i.UpdatedAt), + Sponsor: identity.String(), SponsorDisplay: identity.Human(), }) } @@ -335,59 +324,84 @@ func (s *Server) ResolveInvitation(ctx context.Context, req *pb.ResolveInvitatio return nil, status.Error(codes.Unimplemented, "feature not enabled") } - // Extracts the user email from the token - tokenString, err := gauth.AuthFromMD(ctx, "bearer") - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "no auth token: %v", err) - } - token, err := s.jwt.ParseAndValidate(tokenString) - if err != nil { - return nil, status.Errorf(codes.Unauthenticated, "failed to parse bearer token: %v", err) - } - // Check if the invitation code is valid - ret, err := s.store.GetInvitationByCode(ctx, req.Code) + userInvite, err := s.store.GetInvitationByCode(ctx, req.Code) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, util.UserVisibleError(codes.NotFound, "invitation not found") + return nil, util.UserVisibleError(codes.NotFound, "invitation not found or already used") } return nil, status.Errorf(codes.Internal, "failed to get invitation: %s", err) } - // Check if the invitation matches the user email - if token.Email() != ret.Email { - return nil, util.UserVisibleError(codes.PermissionDenied, "invitation does not match user") - } - // Check if the invitation is expired - if time.Now().After(ret.UpdatedAt.Add(7 * 24 * time.Hour)) { + if invite.IsExpired(userInvite.UpdatedAt) { return nil, util.UserVisibleError(codes.PermissionDenied, "invitation expired") } - isAccepted := false // Accept invitation if req.Accept { - // Parse the role - authzRole, err := authz.ParseRole(ret.Role) - if err != nil { - return nil, status.Errorf(codes.Internal, "failed to parse invitation role: %s", err) - } - // Add the user to the project - if err := s.authzClient.Write(ctx, token.Subject(), authzRole, ret.Project); err != nil { - return nil, status.Errorf(codes.Internal, "error writing role assignment: %v", err) + if err := s.acceptInvitation(ctx, userInvite); err != nil { + return nil, err } - isAccepted = true } // Delete the invitation since its resolved - ret, err = s.store.DeleteInvitation(ctx, req.Code) + deletedInvite, err := s.store.DeleteInvitation(ctx, req.Code) if err != nil { return nil, status.Errorf(codes.Internal, "failed to delete invitation: %s", err) } + + // Resolve the project's display name + targetProject, err := s.store.GetProjectByID(ctx, deletedInvite.Project) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get project: %s", err) + } return &pb.ResolveInvitationResponse{ - Role: ret.Role, - Project: ret.Project.String(), - Email: ret.Email, - IsAccepted: isAccepted, + Role: deletedInvite.Role, + Project: deletedInvite.Project.String(), + ProjectDisplay: targetProject.Name, + Email: deletedInvite.Email, + IsAccepted: req.Accept, }, nil } + +func (s *Server) acceptInvitation(ctx context.Context, userInvite db.GetInvitationByCodeRow) error { + // Validate in case there's an existing role assignment for the user + as, err := s.authzClient.AssignmentsToProject(ctx, userInvite.Project) + if err != nil { + return status.Errorf(codes.Internal, "error getting role assignments: %v", err) + } + // Loop through all role assignments for the project and check if this user already has a role + for _, existing := range as { + if existing.Subject == auth.GetUserSubjectFromContext(ctx) { + // User already has the same role in the project + if existing.Role == userInvite.Role { + return util.UserVisibleError(codes.AlreadyExists, "user already has the same role in the project") + } + // Revoke the existing role assignments for the user in the project + existingRole, err := authz.ParseRole(existing.Role) + if err != nil { + return status.Errorf(codes.Internal, "failed to parse invitation role: %s", err) + } + // Delete the role assignment + if err := s.authzClient.Delete( + ctx, + auth.GetUserSubjectFromContext(ctx), + existingRole, + uuid.MustParse(*existing.Project), + ); err != nil { + return status.Errorf(codes.Internal, "error writing role assignment: %v", err) + } + } + } + // Parse the role + authzRole, err := authz.ParseRole(userInvite.Role) + if err != nil { + return status.Errorf(codes.Internal, "failed to parse invitation role: %s", err) + } + // Add the user to the project + if err := s.authzClient.Write(ctx, auth.GetUserSubjectFromContext(ctx), authzRole, userInvite.Project); err != nil { + return status.Errorf(codes.Internal, "error writing role assignment: %v", err) + } + return nil +} diff --git a/internal/db/invitations.sql.go b/internal/db/invitations.sql.go index c1caea61f3..b478679636 100644 --- a/internal/db/invitations.sql.go +++ b/internal/db/invitations.sql.go @@ -76,16 +76,30 @@ func (q *Queries) DeleteInvitation(ctx context.Context, code string) (UserInvite const getInvitationByCode = `-- name: GetInvitationByCode :one -SELECT code, email, role, project, sponsor, created_at, updated_at FROM user_invites WHERE code = $1 +SELECT user_invites.code, user_invites.email, user_invites.role, user_invites.project, user_invites.sponsor, user_invites.created_at, user_invites.updated_at, users.identity_subject +FROM user_invites + JOIN users ON user_invites.sponsor = users.id +WHERE code = $1 ` +type GetInvitationByCodeRow struct { + Code string `json:"code"` + Email string `json:"email"` + Role string `json:"role"` + Project uuid.UUID `json:"project"` + Sponsor int32 `json:"sponsor"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + IdentitySubject string `json:"identity_subject"` +} + // GetInvitationByCode retrieves an invitation by its code. This is intended to // be called by a user who has received an invitation email and is following the // link to accept the invitation or when querying for additional info about the // invitation. -func (q *Queries) GetInvitationByCode(ctx context.Context, code string) (UserInvite, error) { +func (q *Queries) GetInvitationByCode(ctx context.Context, code string) (GetInvitationByCodeRow, error) { row := q.db.QueryRowContext(ctx, getInvitationByCode, code) - var i UserInvite + var i GetInvitationByCodeRow err := row.Scan( &i.Code, &i.Email, @@ -94,57 +108,103 @@ func (q *Queries) GetInvitationByCode(ctx context.Context, code string) (UserInv &i.Sponsor, &i.CreatedAt, &i.UpdatedAt, + &i.IdentitySubject, ) return i, err } -const getInvitationByEmailAndProjectAndRole = `-- name: GetInvitationByEmailAndProjectAndRole :one +const getInvitationsByEmail = `-- name: GetInvitationsByEmail :many -SELECT code, email, role, project, sponsor, created_at, updated_at FROM user_invites WHERE email = $1 AND project = $2 AND role = $3 +SELECT user_invites.code, user_invites.email, user_invites.role, user_invites.project, user_invites.sponsor, user_invites.created_at, user_invites.updated_at, users.identity_subject +FROM user_invites + JOIN users ON user_invites.sponsor = users.id +WHERE email = $1 ` -type GetInvitationByEmailAndProjectAndRoleParams struct { - Email string `json:"email"` - Project uuid.UUID `json:"project"` - Role string `json:"role"` -} - -// GetInvitationByEmailAndProjectAndRole retrieves an invitation by email, project, -// and role. -func (q *Queries) GetInvitationByEmailAndProjectAndRole(ctx context.Context, arg GetInvitationByEmailAndProjectAndRoleParams) (UserInvite, error) { - row := q.db.QueryRowContext(ctx, getInvitationByEmailAndProjectAndRole, arg.Email, arg.Project, arg.Role) - var i UserInvite - err := row.Scan( - &i.Code, - &i.Email, - &i.Role, - &i.Project, - &i.Sponsor, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err +type GetInvitationsByEmailRow struct { + Code string `json:"code"` + Email string `json:"email"` + Role string `json:"role"` + Project uuid.UUID `json:"project"` + Sponsor int32 `json:"sponsor"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + IdentitySubject string `json:"identity_subject"` } -const getInvitationsByEmail = `-- name: GetInvitationsByEmail :many - -SELECT code, email, role, project, sponsor, created_at, updated_at FROM user_invites WHERE email = $1 -` - // GetInvitationsByEmail retrieves all invitations for a given email address. // This is intended to be called by a logged in user with their own email address, // to allow them to accept invitations even if email delivery was not working. // Note that this requires that the destination email address matches the email // address of the logged in user in the external identity service / auth token. -func (q *Queries) GetInvitationsByEmail(ctx context.Context, email string) ([]UserInvite, error) { +// This clarification is related solely for user's ListInvitations calls and does +// not affect to resolving invitations intended for other mail addresses. +func (q *Queries) GetInvitationsByEmail(ctx context.Context, email string) ([]GetInvitationsByEmailRow, error) { rows, err := q.db.QueryContext(ctx, getInvitationsByEmail, email) if err != nil { return nil, err } defer rows.Close() - items := []UserInvite{} + items := []GetInvitationsByEmailRow{} + for rows.Next() { + var i GetInvitationsByEmailRow + if err := rows.Scan( + &i.Code, + &i.Email, + &i.Role, + &i.Project, + &i.Sponsor, + &i.CreatedAt, + &i.UpdatedAt, + &i.IdentitySubject, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getInvitationsByEmailAndProject = `-- name: GetInvitationsByEmailAndProject :many + +SELECT user_invites.code, user_invites.email, user_invites.role, user_invites.project, user_invites.sponsor, user_invites.created_at, user_invites.updated_at, users.identity_subject +FROM user_invites + JOIN users ON user_invites.sponsor = users.id +WHERE email = $1 AND project = $2 +` + +type GetInvitationsByEmailAndProjectParams struct { + Email string `json:"email"` + Project uuid.UUID `json:"project"` +} + +type GetInvitationsByEmailAndProjectRow struct { + Code string `json:"code"` + Email string `json:"email"` + Role string `json:"role"` + Project uuid.UUID `json:"project"` + Sponsor int32 `json:"sponsor"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + IdentitySubject string `json:"identity_subject"` +} + +// GetInvitationsByEmailAndProject retrieves all invitations by email and project. +func (q *Queries) GetInvitationsByEmailAndProject(ctx context.Context, arg GetInvitationsByEmailAndProjectParams) ([]GetInvitationsByEmailAndProjectRow, error) { + rows, err := q.db.QueryContext(ctx, getInvitationsByEmailAndProject, arg.Email, arg.Project) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetInvitationsByEmailAndProjectRow{} for rows.Next() { - var i UserInvite + var i GetInvitationsByEmailAndProjectRow if err := rows.Scan( &i.Code, &i.Email, @@ -153,6 +213,7 @@ func (q *Queries) GetInvitationsByEmail(ctx context.Context, email string) ([]Us &i.Sponsor, &i.CreatedAt, &i.UpdatedAt, + &i.IdentitySubject, ); err != nil { return nil, err } @@ -169,7 +230,7 @@ func (q *Queries) GetInvitationsByEmail(ctx context.Context, email string) ([]Us const listInvitationsForProject = `-- name: ListInvitationsForProject :many -SELECT user_invites.email, role, users.identity_subject, user_invites.created_at, user_invites.updated_at +SELECT user_invites.email, role, users.identity_subject, user_invites.created_at, user_invites.updated_at, user_invites.code FROM user_invites JOIN users ON user_invites.sponsor = users.id WHERE project = $1 @@ -181,6 +242,7 @@ type ListInvitationsForProjectRow struct { IdentitySubject string `json:"identity_subject"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Code string `json:"code"` } // ListInvitationsForProject collects the information visible to project @@ -202,6 +264,7 @@ func (q *Queries) ListInvitationsForProject(ctx context.Context, project uuid.UU &i.IdentitySubject, &i.CreatedAt, &i.UpdatedAt, + &i.Code, ); err != nil { return nil, err } diff --git a/internal/db/querier.go b/internal/db/querier.go index 2b68609be1..35a01f842a 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -83,16 +83,17 @@ type Querier interface { // be called by a user who has received an invitation email and is following the // link to accept the invitation or when querying for additional info about the // invitation. - GetInvitationByCode(ctx context.Context, code string) (UserInvite, error) - // GetInvitationByEmailAndProjectAndRole retrieves an invitation by email, project, - // and role. - GetInvitationByEmailAndProjectAndRole(ctx context.Context, arg GetInvitationByEmailAndProjectAndRoleParams) (UserInvite, error) + GetInvitationByCode(ctx context.Context, code string) (GetInvitationByCodeRow, error) // GetInvitationsByEmail retrieves all invitations for a given email address. // This is intended to be called by a logged in user with their own email address, // to allow them to accept invitations even if email delivery was not working. // Note that this requires that the destination email address matches the email // address of the logged in user in the external identity service / auth token. - GetInvitationsByEmail(ctx context.Context, email string) ([]UserInvite, error) + // This clarification is related solely for user's ListInvitations calls and does + // not affect to resolving invitations intended for other mail addresses. + GetInvitationsByEmail(ctx context.Context, email string) ([]GetInvitationsByEmailRow, error) + // GetInvitationsByEmailAndProject retrieves all invitations by email and project. + GetInvitationsByEmailAndProject(ctx context.Context, arg GetInvitationsByEmailAndProjectParams) ([]GetInvitationsByEmailAndProjectRow, error) // Copyright 2024 Stacklok, Inc // // Licensed under the Apache License, Version 2.0 (the "License");