Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tests for UpdateRole #3902

Merged
merged 1 commit into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 31 additions & 204 deletions internal/controlplane/handlers_authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"errors"
"fmt"
"net/url"
"time"

"github.com/google/uuid"
"github.com/rs/zerolog"
Expand All @@ -39,7 +38,7 @@ import (
"github.com/stacklok/minder/internal/email"
"github.com/stacklok/minder/internal/engine/engcontext"
"github.com/stacklok/minder/internal/flags"
"github.com/stacklok/minder/internal/invite"
"github.com/stacklok/minder/internal/invites"
"github.com/stacklok/minder/internal/projects"
"github.com/stacklok/minder/internal/util"
minder "github.com/stacklok/minder/pkg/api/protobuf/go/minder/v1"
Expand Down Expand Up @@ -277,19 +276,19 @@ func (s *Server) ListRoleAssignments(

if flags.Bool(ctx, s.featureFlags, flags.UserManagement) {
// Add invitations, which are only stored in the Minder DB
invites, err := s.store.ListInvitationsForProject(ctx, targetProject)
projectInvites, err := s.store.ListInvitationsForProject(ctx, targetProject)
if err != nil {
// return the information we can and log the error
zerolog.Ctx(ctx).Error().Err(err).Msg("error getting invitations")
}
for _, i := range invites {
for _, i := range projectInvites {
invitations = append(invitations, &minder.Invitation{
Role: i.Role,
Email: i.Email,
Project: targetProject.String(),
CreatedAt: timestamppb.New(i.CreatedAt),
ExpiresAt: invite.GetExpireIn7Days(i.UpdatedAt),
Expired: invite.IsExpired(i.UpdatedAt),
ExpiresAt: invites.GetExpireIn7Days(i.UpdatedAt),
Expired: invites.IsExpired(i.UpdatedAt),
Sponsor: i.IdentitySubject,
SponsorDisplay: mapIdToDisplay[i.IdentitySubject],
// Code is explicitly not returned here
Expand Down Expand Up @@ -415,7 +414,7 @@ func (s *Server) inviteUser(

// Create the invitation
userInvite, err = s.store.CreateInvitation(ctx, db.CreateInvitationParams{
Code: invite.GenerateCode(),
Code: invites.GenerateCode(),
Email: inviteeEmail,
Role: role.String(),
Project: targetProject,
Expand Down Expand Up @@ -481,8 +480,8 @@ func (s *Server) inviteUser(
Sponsor: currentUser.IdentitySubject,
SponsorDisplay: sponsorDisplay,
CreatedAt: timestamppb.New(userInvite.CreatedAt),
ExpiresAt: invite.GetExpireIn7Days(userInvite.UpdatedAt),
Expired: invite.IsExpired(userInvite.UpdatedAt),
ExpiresAt: invites.GetExpireIn7Days(userInvite.UpdatedAt),
Expired: invites.IsExpired(userInvite.UpdatedAt),
},
}, nil
}
Expand Down Expand Up @@ -635,8 +634,8 @@ func (s *Server) removeInvite(
Project: ret.Project.String(),
Code: ret.Code,
CreatedAt: timestamppb.New(ret.CreatedAt),
ExpiresAt: invite.GetExpireIn7Days(ret.UpdatedAt),
Expired: invite.IsExpired(ret.UpdatedAt),
ExpiresAt: invites.GetExpireIn7Days(ret.UpdatedAt),
Expired: invites.IsExpired(ret.UpdatedAt),
Sponsor: sponsorUser.IdentitySubject,
SponsorDisplay: sponsorDisplay,
ProjectDisplay: prj.Name,
Expand Down Expand Up @@ -729,208 +728,36 @@ func (s *Server) UpdateRole(ctx context.Context, req *minder.UpdateRoleRequest)
// Validate the subject and email - decide if it's about updating an invitation or a role assignment
if sub == "" && inviteeEmail != "" {
if flags.Bool(ctx, s.featureFlags, flags.UserManagement) {
return s.updateInvite(ctx, targetProject, authzRole, inviteeEmail)
updatedInvitation, err := db.WithTransaction(s.store, func(qtx db.ExtendQuerier) (*minder.Invitation, error) {
return s.invites.UpdateInvite(ctx, qtx, s.idClient, s.evt, s.cfg.Email, targetProject, authzRole, inviteeEmail)
})
if err != nil {
return nil, err
}

return &minder.UpdateRoleResponse{
Invitations: []*minder.Invitation{
updatedInvitation,
},
}, nil
}
return nil, util.UserVisibleError(codes.Unimplemented, "user management is not enabled")
} else if sub != "" && inviteeEmail == "" {
// If there's a subject, we assume it's a role assignment update
return s.updateRole(ctx, targetProject, authzRole, sub)
}
return nil, util.UserVisibleError(codes.InvalidArgument, "one of subject or email must be specified")
}

// nolint:gocyclo
func (s *Server) updateInvite(
ctx context.Context,
targetProject uuid.UUID,
authzRole authz.Role,
inviteeEmail string,
) (*minder.UpdateRoleResponse, error) {
var userInvite db.UserInvite
// Get the sponsor's user information (current user)
currentUser, err := s.store.GetUserBySubject(ctx, jwt.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: inviteeEmail,
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")
}

// Begin a transaction to ensure that the invitation is updated atomically
tx, err := s.store.BeginTransaction()
if err != nil {
return nil, status.Errorf(codes.Internal, "error starting transaction: %v", err)
}
defer s.store.Rollback(tx)

// At this point, there should be exactly 1 invitation.
// Depending on the role from the request, we can either update the role and its expiration
// or just bump the expiration date.
// In both cases, we can use the same query.
userInvite, err = s.store.UpdateInvitationRole(ctx, db.UpdateInvitationRoleParams{
Code: existingInvites[0].Code,
Role: authzRole.String(),
})
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)
}

// Parse the project metadata, so we can get the display name set by project owner
meta, err := projects.ParseMetadata(&prj)
if err != nil {
return nil, status.Errorf(codes.Internal, "error parsing project metadata: %v", 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)
}

// Read the server config, so we can get the Minder base URL
cfg, err := config.ReadConfigFromViper[serverconfig.Config](viper.GetViper())
if err != nil {
return nil, fmt.Errorf("unable to read config: %w", err)
}

// Create the invite URL
inviteURL := ""
if cfg.Email.MinderURLBase != "" {
baseUrl, err := url.Parse(cfg.Email.MinderURLBase)
if err != nil {
return nil, fmt.Errorf("error parsing base URL: %w", err)
}
inviteURL, err = url.JoinPath(baseUrl.String(), "join", userInvite.Code)
if err != nil {
return nil, fmt.Errorf("error joining URL path: %w", err)
}
}

emailSkipped := false
// Publish the event for sending the invitation email
// This will happen only if the role is updated (existingInvites[0].Role != authzRole.String())
// or the role stayed the same, but the last invite update was more than a day ago
if existingInvites[0].Role != authzRole.String() || userInvite.UpdatedAt.Sub(existingInvites[0].UpdatedAt) > 24*time.Hour {
msg, err := email.NewMessage(
ctx,
userInvite.Email,
inviteURL,
cfg.Email.MinderURLBase,
userInvite.Role,
meta.Public.DisplayName,
identity.Human(),
)
if err != nil {
return nil, fmt.Errorf("error generating UUID: %w", err)
}
err = s.evt.Publish(email.TopicQueueInviteEmail, msg)
updatedAssignment, err := db.WithTransaction(s.store, func(qtx db.ExtendQuerier) (*minder.RoleAssignment, error) {
return s.roles.UpdateRoleAssignment(ctx, qtx, s.authzClient, s.idClient, targetProject, sub, authzRole)
})
if err != nil {
return nil, status.Errorf(codes.Internal, "error publishing event: %v", err)
return nil, err
}
} else {
zerolog.Ctx(ctx).Info().Msg("skipping sending email, role stayed the same and last update was less than a day ago")
emailSkipped = true
}

// Commit the transaction to persist the changes
if err = s.store.Commit(tx); err != nil {
return nil, status.Errorf(codes.Internal, "error committing transaction: %v", err)
}

return &minder.UpdateRoleResponse{
Invitations: []*minder.Invitation{
{
Role: userInvite.Role,
Email: userInvite.Email,
Project: userInvite.Project.String(),
ProjectDisplay: prj.Name,
Code: userInvite.Code,
InviteUrl: inviteURL,
Sponsor: identity.String(),
SponsorDisplay: identity.Human(),
CreatedAt: timestamppb.New(userInvite.CreatedAt),
ExpiresAt: invite.GetExpireIn7Days(userInvite.UpdatedAt),
Expired: invite.IsExpired(userInvite.UpdatedAt),
EmailSkipped: emailSkipped,
return &minder.UpdateRoleResponse{
RoleAssignments: []*minder.RoleAssignment{
updatedAssignment,
},
},
}, 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.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)
}

// Remove the existing role assignment for the user
as, err := s.authzClient.AssignmentsToProject(ctx, targetProject)
if err != nil {
return nil, status.Errorf(codes.Internal, "error getting role assignments: %v", err)
}, nil
}

for _, a := range as {
if a.Subject == identity.String() {
roleToDelete, err := authz.ParseRole(a.Role)
if err != nil {
return nil, util.UserVisibleError(codes.Internal, err.Error())
}
if err := s.authzClient.Delete(ctx, identity.String(), roleToDelete, targetProject); err != nil {
return nil, status.Errorf(codes.Internal, "error deleting previous role assignment: %v", err)
}
}
}

// Update the role assignment for the user
if err := s.authzClient.Write(ctx, identity.String(), authzRole, targetProject); err != nil {
return nil, status.Errorf(codes.Internal, "error writing role assignment: %v", err)
}

respProj := targetProject.String()
return &minder.UpdateRoleResponse{
RoleAssignments: []*minder.RoleAssignment{
{
Role: authzRole.String(),
Subject: identity.Human(),
Project: &respProj,
},
},
}, nil
return nil, util.UserVisibleError(codes.InvalidArgument, "one of subject or email must be specified")
}

// isUserSelfUpdating is used to prevent if the user is trying to update their own role
Expand Down
Loading