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

dest: return proper data in graphql errors #3676

Merged
merged 4 commits into from
Feb 12, 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
11 changes: 10 additions & 1 deletion graphql2/graphqlapp/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,16 @@ func isGQLValidation(gqlErr *gqlerror.Error) bool {
return false
}

return code == errcode.ValidationFailed || code == errcode.ParseFailed
switch code {
case errcode.ValidationFailed, errcode.ParseFailed:
// These are gqlgen validation errors.
return true
case ErrCodeInvalidDestType, ErrCodeInvalidDestValue:
// These are destination validation errors.
return true
}

return false
}

func (a *App) Handler() http.Handler {
Expand Down
3 changes: 3 additions & 0 deletions graphql2/graphqlapp/contactmethod.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ func (m *Mutation) CreateUserContactMethod(ctx context.Context, input graphql2.C
cfg := config.FromContext(ctx)

if input.Dest != nil {
if ok, err := (*App)(m).ValidateDestination(ctx, "dest", input.Dest); !ok {
return nil, err
}
t, v := CompatDestToCMTypeVal(*input.Dest)
input.Type = &t
input.Value = &v
Expand Down
181 changes: 181 additions & 0 deletions graphql2/graphqlapp/destinationvalidation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package graphqlapp

import (
"context"
"database/sql"
"errors"
"fmt"

"github.com/99designs/gqlgen/graphql"
"github.com/target/goalert/config"
"github.com/target/goalert/graphql2"
"github.com/target/goalert/permission"
"github.com/target/goalert/validation"
"github.com/target/goalert/validation/validate"
"github.com/vektah/gqlparser/v2/ast"
"github.com/vektah/gqlparser/v2/gqlerror"
)

const (
ErrCodeInvalidDestType = "INVALID_DESTINATION_TYPE"
ErrCodeInvalidDestValue = "INVALID_DESTINATION_FIELD_VALUE"
)

// addDestFieldError will add a destination field error to the current request, and return
// the original error if it is not a destination field validation error.
func addDestFieldError(ctx context.Context, parentField, fieldID string, err error) error {
if permission.IsPermissionError(err) {
// request level, return as is
return err
}
if !validation.IsClientError(err) {
// internal error, return as is
return err
}

p := graphql.GetPath(ctx)
p = append(p,
ast.PathName(parentField),
ast.PathName("values"), // DestinationInput.Values
ast.PathName(fieldID),
)

graphql.AddError(ctx, &gqlerror.Error{
Message: err.Error(),
Path: p,
Extensions: map[string]interface{}{
"code": ErrCodeInvalidDestValue,
},
})

return nil
}

// ValidateDestination will validate a destination input.
//
// In the future this will be a call to the plugin system.
func (a *App) ValidateDestination(ctx context.Context, fieldName string, dest *graphql2.DestinationInput) (ok bool, err error) {
cfg := config.FromContext(ctx)
switch dest.Type {
case destTwilioSMS:
phone := dest.FieldValue(fieldPhoneNumber)
err := validate.Phone(fieldPhoneNumber, phone)
if err != nil {
return false, addDestFieldError(ctx, fieldName, fieldPhoneNumber, err)
}
return true, nil
case destTwilioVoice:
phone := dest.FieldValue(fieldPhoneNumber)
err := validate.Phone(fieldPhoneNumber, phone)
if err != nil {
return false, addDestFieldError(ctx, fieldName, fieldPhoneNumber, err)
}
return true, nil
case destSlackChan:
chanID := dest.FieldValue(fieldSlackChanID)
err := a.SlackStore.ValidateChannel(ctx, chanID)
if err != nil {
return false, addDestFieldError(ctx, fieldName, fieldSlackChanID, err)
}

return true, nil
case destSlackDM:
userID := dest.FieldValue(fieldSlackUserID)
if err := a.SlackStore.ValidateUser(ctx, userID); err != nil {
return false, addDestFieldError(ctx, fieldName, fieldSlackUserID, err)
}
return true, nil
case destSlackUG:
ugID := dest.FieldValue(fieldSlackUGID)
userErr := a.SlackStore.ValidateUserGroup(ctx, ugID)
if err != nil {
return false, addDestFieldError(ctx, fieldName, fieldSlackUGID, userErr)
}

chanID := dest.FieldValue(fieldSlackChanID)
chanErr := a.SlackStore.ValidateChannel(ctx, chanID)
if err != nil {
return false, addDestFieldError(ctx, fieldName, fieldSlackChanID, chanErr)
}

return true, nil
case destSMTP:
email := dest.FieldValue(fieldEmailAddress)
err := validate.Email(fieldEmailAddress, email)
if err != nil {
return false, addDestFieldError(ctx, fieldName, fieldEmailAddress, err)
}
return true, nil
case destWebhook:
url := dest.FieldValue(fieldWebhookURL)
err := validate.AbsoluteURL(fieldWebhookURL, url)
if err != nil {
return false, addDestFieldError(ctx, fieldName, fieldWebhookURL, err)
}
if !cfg.ValidWebhookURL(url) {
return false, addDestFieldError(ctx, fieldName, fieldWebhookURL, validation.NewGenericError("url is not allowed by administator"))
}
return true, nil
case destSchedule: // must be valid UUID and exist
_, err := validate.ParseUUID(fieldScheduleID, dest.FieldValue(fieldScheduleID))
if err != nil {
return false, addDestFieldError(ctx, fieldName, fieldScheduleID, err)
}

_, err = a.ScheduleStore.FindOne(ctx, dest.FieldValue(fieldScheduleID))
if errors.Is(err, sql.ErrNoRows) {
return false, addDestFieldError(ctx, fieldName, fieldScheduleID, validation.NewGenericError("schedule does not exist"))
}
if err != nil {
return false, addDestFieldError(ctx, fieldName, fieldScheduleID, err)
}

return true, nil
case destRotation: // must be valid UUID and exist
rotID := dest.FieldValue(fieldRotationID)
_, err := validate.ParseUUID(fieldRotationID, rotID)
if err != nil {
return false, addDestFieldError(ctx, fieldName, fieldRotationID, err)
}
_, err = a.RotationStore.FindRotation(ctx, rotID)
if errors.Is(err, sql.ErrNoRows) {
return false, addDestFieldError(ctx, fieldName, fieldRotationID, validation.NewGenericError("rotation does not exist"))
}
if err != nil {
return false, addDestFieldError(ctx, fieldName, fieldRotationID, err)
}

return true, nil
case destUser: // must be valid UUID and exist
userID := dest.FieldValue(fieldUserID)
uid, err := validate.ParseUUID(fieldUserID, userID)
if err != nil {
return false, addDestFieldError(ctx, fieldName, fieldUserID, err)
}
check, err := a.UserStore.UserExists(ctx)
if err != nil {
return false, fmt.Errorf("get user existance checker: %w", err)
}
if !check.UserExistsUUID(uid) {
return false, addDestFieldError(ctx, fieldName, fieldUserID, validation.NewGenericError("user does not exist"))
}
return true, nil
}

// unsupported destination type
p := graphql.GetPath(ctx)
p = append(p,
ast.PathName(fieldName),
ast.PathName("type"),
)

graphql.AddError(ctx, &gqlerror.Error{
Message: "unsupported destination type",
Path: p,
Extensions: map[string]interface{}{
"code": ErrCodeInvalidDestType,
},
})

return false, nil
}
31 changes: 31 additions & 0 deletions notification/slack/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ type Channel struct {
ID string
Name string
TeamID string

IsArchived bool
}

// User contains information about a Slack user.
Expand Down Expand Up @@ -133,6 +135,34 @@ func mapError(ctx context.Context, err error) error {
return err
}

func (s *ChannelSender) ValidateChannel(ctx context.Context, id string) error {
err := permission.LimitCheckAny(ctx, permission.User, permission.System)
if err != nil {
return err
}

s.chanMx.Lock()
defer s.chanMx.Unlock()
res, ok := s.chanCache.Get(id)
if !ok {
res, err = s.loadChannel(ctx, id)
if err != nil {
if rootMsg(err) == "channel_not_found" {
return validation.NewGenericError("Channel does not exist, is private (need to invite goalert bot).")
}

return err
}
s.chanCache.Add(id, res)
}

if res.IsArchived {
return validation.NewGenericError("Channel is archived.")
}

return nil
}

// Channel will lookup a single Slack channel for the bot.
func (s *ChannelSender) Channel(ctx context.Context, channelID string) (*Channel, error) {
err := permission.LimitCheckAny(ctx, permission.User, permission.System)
Expand Down Expand Up @@ -232,6 +262,7 @@ func (s *ChannelSender) loadChannel(ctx context.Context, channelID string) (*Cha

ch.ID = resp.ID
ch.Name = "#" + resp.Name
ch.IsArchived = resp.IsArchived

return nil
})
Expand Down
18 changes: 18 additions & 0 deletions notification/slack/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,26 @@ import (

"github.com/slack-go/slack"
"github.com/target/goalert/permission"
"github.com/target/goalert/validation"
)

func (s *ChannelSender) ValidateUser(ctx context.Context, id string) error {
err := permission.LimitCheckAny(ctx, permission.User, permission.System)
if err != nil {
return err
}

_, err = s.User(ctx, id)
if rootMsg(err) == "user_not_found" {
return validation.NewGenericError("user not found")
}
if err != nil {
return fmt.Errorf("validate user: %w", err)
}

return nil
}

// User will lookup a single Slack user.
func (s *ChannelSender) User(ctx context.Context, id string) (*User, error) {
err := permission.LimitCheckAny(ctx, permission.User, permission.System)
Expand Down
26 changes: 26 additions & 0 deletions notification/slack/usergroup.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,34 @@ type UserGroup struct {
Handle string
}

func (s *ChannelSender) ValidateUserGroup(ctx context.Context, id string) error {
ug, err := s._UserGroup(ctx, id)
if err != nil {
return err
}

if ug == nil {
return validation.NewGenericError("user group not found")
}

return nil
}

// User will lookup a single Slack user group.
func (s *ChannelSender) UserGroup(ctx context.Context, id string) (*UserGroup, error) {
ug, err := s._UserGroup(ctx, id)
if err != nil {
return nil, err
}
if ug == nil {
return nil, validation.NewGenericError("invalid user group id")
}

return ug, nil
}

// User will lookup a single Slack user group.
func (s *ChannelSender) _UserGroup(ctx context.Context, id string) (*UserGroup, error) {
err := permission.LimitCheckAny(ctx, permission.User, permission.System)
if err != nil {
return nil, err
Expand Down
Loading