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

Initial implementation of the invite email sending service #3735

Merged
merged 5 commits into from
Jul 1, 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
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/ThreeDotsLabs/watermill v1.3.5
github.com/ThreeDotsLabs/watermill-sql/v3 v3.0.1
github.com/alexdrl/zerowater v0.0.3
github.com/aws/aws-sdk-go v1.53.21
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v0.17.1
Expand Down Expand Up @@ -124,6 +125,7 @@ require (
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jon-whit/go-grpc-prometheus v1.4.0 // indirect
github.com/karlseguin/ccache/v3 v3.0.5 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
Expand Down Expand Up @@ -207,7 +209,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect
github.com/docker/cli v26.1.4+incompatible // indirect
github.com/docker/cli v26.1.4+incompatible
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker v26.1.4+incompatible // indirect
github.com/docker/docker-credential-helpers v0.8.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,8 @@ github.com/jeremija/gosubmit v0.2.7 h1:At0OhGCFGPXyjPYAsCchoBUhE099pcBXmsb4iZqRO
github.com/jeremija/gosubmit v0.2.7/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs=
github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI=
github.com/jon-whit/go-grpc-prometheus v1.4.0 h1:/wmpGDJcLXuEjXryWhVYEGt9YBRhtLwFEN7T+Flr8sw=
Expand Down
1 change: 1 addition & 0 deletions internal/config/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type Config struct {
Marketplace MarketplaceConfig `mapstructure:"marketplace"`
DefaultProfiles DefaultProfilesConfig `mapstructure:"default_profiles"`
Crypto CryptoConfig `mapstructure:"crypto"`
Email EmailConfig `mapstructure:"email"`
}

// DefaultConfigForTest returns a configuration with all the struct defaults set,
Expand Down
30 changes: 30 additions & 0 deletions internal/config/server/email.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// Copyright 2024 Stacklok, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package server

// EmailConfig is the configuration for the email sending service
type EmailConfig struct {
// AWSSES is the AWS SES configuration
AWSSES AWSSES `mapstructure:"aws_ses"`
}

// AWSSES is the AWS SES configuration
type AWSSES struct {
// Sender is the email address of the sender
Sender string `mapstructure:"sender"`
// Region is the AWS region to use for AWS SES
Region string `mapstructure:"region"`
}
77 changes: 49 additions & 28 deletions internal/controlplane/handlers_authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"time"

"github.com/google/uuid"
"github.com/rs/zerolog"
Expand All @@ -30,6 +32,7 @@ import (
"github.com/stacklok/minder/internal/auth"
"github.com/stacklok/minder/internal/authz"
"github.com/stacklok/minder/internal/db"
"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"
Expand Down Expand Up @@ -291,14 +294,14 @@ func (s *Server) ListRoleAssignments(
func (s *Server) AssignRole(ctx context.Context, req *minder.AssignRoleRequest) (*minder.AssignRoleResponse, error) {
role := req.GetRoleAssignment().GetRole()
sub := req.GetRoleAssignment().GetSubject()
email := req.GetRoleAssignment().GetEmail()
inviteeEmail := req.GetRoleAssignment().GetEmail()

// Determine the target project.
entityCtx := engcontext.EntityFromContext(ctx)
targetProject := entityCtx.Project.ID

// Ensure user is not updating their own role
err := isUserSelfUpdating(ctx, sub, email)
err := isUserSelfUpdating(ctx, sub, inviteeEmail)
if err != nil {
return nil, err
}
Expand All @@ -320,12 +323,12 @@ func (s *Server) AssignRole(ctx context.Context, req *minder.AssignRoleRequest)
}

// Decide if it's an invitation or a role assignment
if sub == "" && email != "" {
if sub == "" && inviteeEmail != "" {
if flags.Bool(ctx, s.featureFlags, flags.UserManagement) {
return s.inviteUser(ctx, targetProject, authzRole, email)
return s.inviteUser(ctx, targetProject, authzRole, inviteeEmail)
}
return nil, util.UserVisibleError(codes.Unimplemented, "user management is not enabled")
} else if sub != "" && email == "" {
} else if sub != "" && inviteeEmail == "" {
// Enable one or the other.
// This is temporary until we deprecate it completely in favor of email-based role assignments
if !flags.Bool(ctx, s.featureFlags, flags.UserManagement) {
Expand All @@ -340,7 +343,7 @@ func (s *Server) inviteUser(
ctx context.Context,
targetProject uuid.UUID,
role authz.Role,
email string,
inviteeEmail string,
) (*minder.AssignRoleResponse, error) {
var userInvite db.UserInvite
// Get the sponsor's user information (current user)
Expand All @@ -351,7 +354,7 @@ func (s *Server) inviteUser(

// Check if the user is already invited
existingInvites, err := s.store.GetInvitationsByEmailAndProject(ctx, db.GetInvitationsByEmailAndProjectParams{
Email: email,
Email: inviteeEmail,
Project: targetProject,
})
if err != nil {
Expand Down Expand Up @@ -385,7 +388,7 @@ func (s *Server) inviteUser(
// Create the invitation
userInvite, err = s.store.CreateInvitation(ctx, db.CreateInvitationParams{
Code: invite.GenerateCode(),
Email: email,
Email: inviteeEmail,
Role: role.String(),
Project: targetProject,
Sponsor: currentUser.ID,
Expand All @@ -394,7 +397,15 @@ func (s *Server) inviteUser(
return nil, status.Errorf(codes.Internal, "error creating invitation: %v", err)
}

// TODO: Publish the event for sending the invitation email
// Publish the event for sending the invitation email
rdimitrov marked this conversation as resolved.
Show resolved Hide resolved
msg, err := email.NewMessage(userInvite.Email, userInvite.Code, userInvite.Role, prj.Name, sponsorDisplay)
if err != nil {
return nil, fmt.Errorf("error generating UUID: %w", err)
}
err = s.evt.Publish(email.TopicQueueInviteEmail, msg)
if err != nil {
return nil, status.Errorf(codes.Internal, "error publishing event: %v", err)
}

// Send the invitation response
return &minder.AssignRoleResponse{
Expand Down Expand Up @@ -471,7 +482,7 @@ func (s *Server) assignRole(
func (s *Server) RemoveRole(ctx context.Context, req *minder.RemoveRoleRequest) (*minder.RemoveRoleResponse, error) {
role := req.GetRoleAssignment().GetRole()
sub := req.GetRoleAssignment().GetSubject()
email := req.GetRoleAssignment().GetEmail()
inviteeEmail := req.GetRoleAssignment().GetEmail()
// Determine the target project.
entityCtx := engcontext.EntityFromContext(ctx)
targetProject := entityCtx.Project.ID
Expand All @@ -483,12 +494,12 @@ func (s *Server) RemoveRole(ctx context.Context, req *minder.RemoveRoleRequest)
}

// Validate the subject and email - decide if it's about removing an invitation or a role assignment
if sub == "" && email != "" {
if sub == "" && inviteeEmail != "" {
if flags.Bool(ctx, s.featureFlags, flags.UserManagement) {
return s.removeInvite(ctx, targetProject, authzRole, email)
return s.removeInvite(ctx, targetProject, authzRole, inviteeEmail)
}
return nil, util.UserVisibleError(codes.Unimplemented, "user management is not enabled")
} else if sub != "" && email == "" {
} else if sub != "" && inviteeEmail == "" {
// If there's a subject, we assume it's a role assignment
return s.removeRole(ctx, targetProject, authzRole, sub)
}
Expand All @@ -499,11 +510,11 @@ func (s *Server) removeInvite(
ctx context.Context,
targetPrj uuid.UUID,
role authz.Role,
email string,
inviteeEmail string,
) (*minder.RemoveRoleResponse, error) {
// Get all invitations for this email and project
invitesToRemove, err := s.store.GetInvitationsByEmailAndProject(ctx, db.GetInvitationsByEmailAndProjectParams{
Email: email,
Email: inviteeEmail,
Project: targetPrj,
})
if err != nil {
Expand Down Expand Up @@ -635,14 +646,14 @@ func (s *Server) UpdateRole(ctx context.Context, req *minder.UpdateRoleRequest)
}
role := req.GetRoles()[0]
sub := req.GetSubject()
email := req.GetEmail()
inviteeEmail := req.GetEmail()

// Determine the target project.
entityCtx := engcontext.EntityFromContext(ctx)
targetProject := entityCtx.Project.ID

// Ensure user is not updating their own role
err := isUserSelfUpdating(ctx, sub, email)
err := isUserSelfUpdating(ctx, sub, inviteeEmail)
if err != nil {
return nil, err
}
Expand All @@ -654,12 +665,12 @@ 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 == "" && email != "" {
if sub == "" && inviteeEmail != "" {
if flags.Bool(ctx, s.featureFlags, flags.UserManagement) {
return s.updateInvite(ctx, targetProject, authzRole, email)
return s.updateInvite(ctx, targetProject, authzRole, inviteeEmail)
}
return nil, util.UserVisibleError(codes.Unimplemented, "user management is not enabled")
} else if sub != "" && email == "" {
} else if sub != "" && inviteeEmail == "" {
// If there's a subject, we assume it's a role assignment update
return s.updateRole(ctx, targetProject, authzRole, sub)
}
Expand All @@ -670,7 +681,7 @@ func (s *Server) updateInvite(
ctx context.Context,
targetProject uuid.UUID,
authzRole authz.Role,
email string,
inviteeEmail string,
) (*minder.UpdateRoleResponse, error) {
var userInvite db.UserInvite
// Get the sponsor's user information (current user)
Expand All @@ -681,7 +692,7 @@ func (s *Server) updateInvite(

// Get all invitations for this email and project
existingInvites, err := s.store.GetInvitationsByEmailAndProject(ctx, db.GetInvitationsByEmailAndProjectParams{
Email: email,
Email: inviteeEmail,
Project: targetProject,
})
if err != nil {
Expand Down Expand Up @@ -727,13 +738,23 @@ func (s *Server) updateInvite(
}

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

// TODO: Publish the event for sending the invitation email
// 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 invite was created at least a day ago.
// 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(userInvite.Email, userInvite.Code, userInvite.Role, prj.Name, identity.Human())
if err != nil {
return nil, fmt.Errorf("error generating UUID: %w", err)
}
err = s.evt.Publish(email.TopicQueueInviteEmail, msg)
if err != nil {
return nil, status.Errorf(codes.Internal, "error publishing event: %v", err)
}
}

return &minder.UpdateRoleResponse{
Invitations: []*minder.Invitation{
Expand Down Expand Up @@ -810,18 +831,18 @@ func (s *Server) updateRole(
}

// isUserSelfUpdating is used to prevent if the user is trying to update their own role
func isUserSelfUpdating(ctx context.Context, subject, email string) error {
func isUserSelfUpdating(ctx context.Context, subject, inviteeEmail string) error {
if subject != "" {
if auth.GetUserSubjectFromContext(ctx) == subject {
return util.UserVisibleError(codes.InvalidArgument, "cannot update your own role")
}
}
if email != "" {
if inviteeEmail != "" {
tokenEmail, err := auth.GetUserEmailFromContext(ctx)
if err != nil {
return util.UserVisibleError(codes.Internal, "error getting user email from token: %v", err)
}
if tokenEmail == email {
if tokenEmail == inviteeEmail {
return util.UserVisibleError(codes.InvalidArgument, "cannot update your own role")
}
}
Expand Down
Loading