Skip to content

Commit

Permalink
Consider the environment when generating the invite URLs (#3783)
Browse files Browse the repository at this point in the history
* Consider the environment when generating the invite URLs

Signed-off-by: Radoslav Dimitrov <[email protected]>

* Update the example config to set the email minder base url

Signed-off-by: Radoslav Dimitrov <[email protected]>

* Remove the obsolete email interface

Signed-off-by: Radoslav Dimitrov <[email protected]>

* Parse the URL and use transaction when creating the invite

Signed-off-by: Radoslav Dimitrov <[email protected]>

---------

Signed-off-by: Radoslav Dimitrov <[email protected]>
  • Loading branch information
rdimitrov authored Jul 4, 2024
1 parent ff2be84 commit 3f9c382
Show file tree
Hide file tree
Showing 12 changed files with 1,165 additions and 1,018 deletions.
3 changes: 3 additions & 0 deletions cmd/cli/app/project/role/role_grant.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ func GrantCommand(ctx context.Context, cmd *cobra.Command, _ []string, conn *grp

if ret.Invitation != nil && ret.Invitation.Code != "" {
cmd.Printf("\nThe invitee can accept it by running: \n\nminder auth invite accept %s\n", ret.Invitation.Code)
if ret.Invitation.InviteUrl != "" {
cmd.Printf("\nOr by visiting: %s\n", ret.Invitation.InviteUrl)
}
}
return nil
}
Expand Down
5 changes: 4 additions & 1 deletion config/server-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,7 @@ crypto:
local:
key_dir: "./.ssh"
default:
key_id: token_key_passphrase
key_id: token_key_passphrase

email:
minder_url_base: "http://localhost:6463" # Change to the URL of the frontend server
1 change: 1 addition & 0 deletions docs/docs/ref/proto.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions internal/config/server/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ package server

// EmailConfig is the configuration for the email sending service
type EmailConfig struct {
// MinderURLBase is the base URL to use for minder invite URLs, e.g. https://cloud.stacklok.com
MinderURLBase string `mapstructure:"minder_url_base"`
// AWSSES is the AWS SES configuration
AWSSES AWSSES `mapstructure:"aws_ses"`
}
Expand Down
85 changes: 80 additions & 5 deletions internal/controlplane/handlers_authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import (
"database/sql"
"errors"
"fmt"
"net/url"
"time"

"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/spf13/viper"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
Expand All @@ -31,6 +33,8 @@ import (

"github.com/stacklok/minder/internal/auth/jwt"
"github.com/stacklok/minder/internal/authz"
"github.com/stacklok/minder/internal/config"
serverconfig "github.com/stacklok/minder/internal/config/server"
"github.com/stacklok/minder/internal/db"
"github.com/stacklok/minder/internal/email"
"github.com/stacklok/minder/internal/engine/engcontext"
Expand Down Expand Up @@ -340,6 +344,7 @@ func (s *Server) AssignRole(ctx context.Context, req *minder.AssignRoleRequest)
return nil, util.UserVisibleError(codes.InvalidArgument, "one of subject or email must be specified")
}

//nolint:gocyclo
func (s *Server) inviteUser(
ctx context.Context,
targetProject uuid.UUID,
Expand Down Expand Up @@ -392,6 +397,13 @@ func (s *Server) inviteUser(
return nil, status.Errorf(codes.Internal, "error parsing project metadata: %v", err)
}

// Begin a transaction to ensure that the invitation is created 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)

// Create the invitation
userInvite, err = s.store.CreateInvitation(ctx, db.CreateInvitationParams{
Code: invite.GenerateCode(),
Expand All @@ -404,16 +416,49 @@ func (s *Server) inviteUser(
return nil, status.Errorf(codes.Internal, "error creating invitation: %v", err)
}

// 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)
}
}

// Publish the event for sending the invitation email
msg, err := email.NewMessage(userInvite.Email, userInvite.Code, userInvite.Role, meta.Public.DisplayName, sponsorDisplay)
msg, err := email.NewMessage(
ctx,
userInvite.Email,
inviteURL,
cfg.Email.MinderURLBase,
userInvite.Role,
meta.Public.DisplayName,
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)
}

// 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)
}

// Send the invitation response
return &minder.AssignRoleResponse{
// Leaving the role assignment empty as it's an invitation
Expand All @@ -423,6 +468,7 @@ func (s *Server) inviteUser(
Project: userInvite.Project.String(),
ProjectDisplay: prj.Name,
Code: userInvite.Code,
InviteUrl: inviteURL,
Sponsor: currentUser.IdentitySubject,
SponsorDisplay: sponsorDisplay,
CreatedAt: timestamppb.New(userInvite.CreatedAt),
Expand Down Expand Up @@ -684,6 +730,7 @@ func (s *Server) UpdateRole(ctx context.Context, req *minder.UpdateRoleRequest)
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,
Expand Down Expand Up @@ -751,16 +798,38 @@ func (s *Server) updateInvite(
return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", currentUser.IdentitySubject)
}

// 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)
// 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)
}
}

// 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(userInvite.Email, userInvite.Code, userInvite.Role, meta.Public.DisplayName, identity.Human())
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)
}
Expand All @@ -770,6 +839,11 @@ func (s *Server) updateInvite(
}
}

// 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{
{
Expand All @@ -778,6 +852,7 @@ func (s *Server) updateInvite(
Project: userInvite.Project.String(),
ProjectDisplay: prj.Name,
Code: userInvite.Code,
InviteUrl: inviteURL,
Sponsor: identity.String(),
SponsorDisplay: identity.Human(),
CreatedAt: timestamppb.New(userInvite.CreatedAt),
Expand Down
52 changes: 32 additions & 20 deletions internal/email/awsses.go → internal/email/awsses/awsses.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,43 +12,37 @@
// See the License for the specific language governing permissions and
// limitations under the License.

// Package email provides the email utilities for minder
package email
// Package awsses provides the email utilities for minder
package awsses

import (
"context"
"encoding/json"
"fmt"

"github.com/ThreeDotsLabs/watermill/message"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses"
"github.com/rs/zerolog"

"github.com/stacklok/minder/internal/email"
"github.com/stacklok/minder/internal/events"
)

const (
// CharSet is the character set for the email
CharSet = "UTF-8"
// DefaultAWSRegion is the default AWS region
DefaultAWSRegion = "us-east-1"
// DefaultSender is the default sender email address
DefaultSender = "[email protected]"
)

// AWSSES is the AWS SES client
type AWSSES struct {
// awsSES is the AWS SES client
type awsSES struct {
sender string
svc *ses.SES
}

// NewAWSSES creates a new AWS SES client
func NewAWSSES(sender, region string) (*AWSSES, error) {
// Set the sender and region in case they are not provided.
if sender == "" {
sender = DefaultSender
}
if region == "" {
region = DefaultAWSRegion
}

// New creates a new AWS SES client
func New(sender, region string) (*awsSES, error) {
// Create a new session.
sess, err := session.NewSession(&aws.Config{
Region: aws.String(region)},
Expand All @@ -58,14 +52,32 @@ func NewAWSSES(sender, region string) (*AWSSES, error) {
}

// Create an SES service client.
return &AWSSES{
return &awsSES{
sender: sender,
svc: ses.New(sess),
}, nil
}

// Register implements the Consumer interface.
func (a *awsSES) Register(reg events.Registrar) {
reg.Register(email.TopicQueueInviteEmail, func(msg *message.Message) error {
var e email.MailEventPayload

// Get the message context
msgCtx := msg.Context()

// Unmarshal the message payload
if err := json.Unmarshal(msg.Payload, &e); err != nil {
return fmt.Errorf("error unmarshalling invite email event: %w", err)
}

// Send the email
return a.sendEmail(msgCtx, e.Address, e.Subject, e.BodyHTML, e.BodyText)
})
}

// SendEmail sends an email using AWS SES
func (a *AWSSES) SendEmail(ctx context.Context, to, subject, bodyHTML, bodyText string) error {
func (a *awsSES) sendEmail(ctx context.Context, to, subject, bodyHTML, bodyText string) error {
zerolog.Ctx(ctx).Info().
Str("invitee", to).
Str("subject", subject).
Expand Down
Loading

0 comments on commit 3f9c382

Please sign in to comment.