From bdfbc8a78accfb8d81d9c94d5387eff583def77f Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 22 Jun 2022 09:39:08 -0500 Subject: [PATCH 01/33] add auth_link_requests table --- .../migrations/20220622085329-auth-link-requests.sql | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 migrate/migrations/20220622085329-auth-link-requests.sql diff --git a/migrate/migrations/20220622085329-auth-link-requests.sql b/migrate/migrations/20220622085329-auth-link-requests.sql new file mode 100644 index 0000000000..a3dba1f714 --- /dev/null +++ b/migrate/migrations/20220622085329-auth-link-requests.sql @@ -0,0 +1,11 @@ +-- +migrate Up +CREATE TABLE auth_link_requests ( + id UUID PRIMARY KEY, + provider_id TEXT NOT NULL, + subject_id TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- +migrate Down +DROP TABLE auth_link_requests; From 0a027076a0b8e8401445e2208d5ef3dc1d445025 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 22 Jun 2022 09:39:35 -0500 Subject: [PATCH 02/33] add authlink Store --- auth/authlink/store.go | 129 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 auth/authlink/store.go diff --git a/auth/authlink/store.go b/auth/authlink/store.go new file mode 100644 index 0000000000..903171f85b --- /dev/null +++ b/auth/authlink/store.go @@ -0,0 +1,129 @@ +package authlink + +import ( + "context" + "database/sql" + "errors" + "net/url" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/target/goalert/config" + "github.com/target/goalert/keyring" + "github.com/target/goalert/permission" + "github.com/target/goalert/util" + "github.com/target/goalert/validation" + "github.com/target/goalert/validation/validate" +) + +type Store struct { + db *sql.DB + + k keyring.Keyring + + newLink *sql.Stmt + rmLink *sql.Stmt + addSubject *sql.Stmt +} + +func NewStore(ctx context.Context, db *sql.DB, k keyring.Keyring) (*Store, error) { + p := &util.Prepare{ + DB: db, + Ctx: ctx, + } + + return &Store{ + k: k, + newLink: p.P(`insert into auth_link_requests (id, provider_id, subject_id, expires_at) values ($1, $2, $3, $4)`), + rmLink: p.P(`delete from auth_link_requests where id = $1 and expires_at > now() returning provider_id, subject_id`), + addSubject: p.P(`insert into auth_subjects (provider_id, subject_id, user_id) values ($1, $2, $3)`), + }, p.Err +} + +func (s *Store) LinkAccount(ctx context.Context, token string) error { + err := permission.LimitCheckAny(ctx, permission.User) + if err != nil { + return err + } + + var c jwt.RegisteredClaims + _, err = s.k.VerifyJWT(token, &c) + if err != nil { + return validation.WrapError(err) + } + + if !c.VerifyIssuer("goalert", true) { + return validation.NewGenericError("invalid issuer") + } + if !c.VerifyAudience("auth-link", true) { + return validation.NewGenericError("invalid audience") + } + err = validate.UUID("ID", c.ID) + if err != nil { + return err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + var providerID, subjectID string + err = tx.StmtContext(ctx, s.rmLink).QueryRowContext(ctx, c.ID).Scan(&providerID, &subjectID) + if errors.Is(err, sql.ErrNoRows) { + return validation.NewGenericError("invalid link token") + } + if err != nil { + return err + } + + _, err = tx.StmtContext(ctx, s.addSubject).ExecContext(ctx, providerID, subjectID, permission.UserID(ctx)) + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *Store) AuthLinkURL(ctx context.Context, providerID, subjectID string) (string, error) { + err := permission.LimitCheckAny(ctx, permission.System) + if err != nil { + return "", err + } + err = validate.Many( + validate.SubjectID("ProviderID", providerID), + validate.SubjectID("SubjectID", subjectID), + ) + if err != nil { + return "", err + } + + id := uuid.New() + now := time.Now() + expires := now.Add(5 * time.Minute) + + var c jwt.RegisteredClaims + c.ID = id.String() + c.Audience = jwt.ClaimStrings{"auth-link"} + c.Issuer = "goalert" + c.NotBefore = jwt.NewNumericDate(now.Add(-5 * time.Minute)) + c.ExpiresAt = jwt.NewNumericDate(expires) + c.IssuedAt = jwt.NewNumericDate(now) + + token, err := s.k.SignJWT(c) + if err != nil { + return "", err + } + + _, err = s.newLink.ExecContext(ctx, id, providerID, subjectID, expires) + if err != nil { + return "", err + } + + cfg := config.FromContext(ctx) + p := make(url.Values) + p.Set("link-token", token) + return cfg.CallbackURL("/profile/link", p), nil +} From 88442cf8c1c265f7e025df1f95a79241ac93847a Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 22 Jun 2022 09:40:11 -0500 Subject: [PATCH 03/33] pass auth link store through app --- app/app.go | 9 ++-- app/initengine.go | 2 +- app/initgraphql.go | 6 +-- app/initstores.go | 23 ++++++++ app/shutdown.go | 1 + engine/config.go | 2 + engine/engine.go | 4 ++ graphql2/generated.go | 93 +++++++++++++++++++++++++++++++++ graphql2/graphqlapp/app.go | 7 ++- graphql2/graphqlapp/mutation.go | 6 +++ graphql2/schema.graphql | 2 + keyring/store.go | 5 +- notification/namedreceiver.go | 5 ++ notification/receiver.go | 3 ++ notification/resultreceiver.go | 1 + 15 files changed, 159 insertions(+), 10 deletions(-) diff --git a/app/app.go b/app/app.go index da789c7401..4d7a02c4fa 100644 --- a/app/app.go +++ b/app/app.go @@ -17,6 +17,7 @@ import ( "github.com/target/goalert/alert/alertmetrics" "github.com/target/goalert/app/lifecycle" "github.com/target/goalert/auth" + "github.com/target/goalert/auth/authlink" "github.com/target/goalert/auth/basic" "github.com/target/goalert/auth/nonce" "github.com/target/goalert/calsub" @@ -116,9 +117,10 @@ type App struct { LimitStore *limit.Store HeartbeatStore *heartbeat.Store - OAuthKeyring keyring.Keyring - SessionKeyring keyring.Keyring - APIKeyring keyring.Keyring + OAuthKeyring keyring.Keyring + SessionKeyring keyring.Keyring + APIKeyring keyring.Keyring + AuthLinkKeyring keyring.Keyring NonceStore *nonce.Store LabelStore *label.Store @@ -126,6 +128,7 @@ type App struct { NCStore *notificationchannel.Store TimeZoneStore *timezone.Store NoticeStore *notice.Store + AuthLinkStore *authlink.Store } // NewApp constructs a new App and binds the listening socket. diff --git a/app/initengine.go b/app/initengine.go index 7319cb212e..a8a6fcef1e 100644 --- a/app/initengine.go +++ b/app/initengine.go @@ -10,7 +10,6 @@ import ( ) func (app *App) initEngine(ctx context.Context) error { - var regionIndex int err := app.db.QueryRowContext(ctx, `SELECT id FROM region_ids WHERE name = $1`, app.cfg.RegionName).Scan(®ionIndex) if errors.Is(err, sql.ErrNoRows) { @@ -40,6 +39,7 @@ func (app *App) initEngine(ctx context.Context) error { NCStore: app.NCStore, OnCallStore: app.OnCallStore, ScheduleStore: app.ScheduleStore, + AuthLinkStore: app.AuthLinkStore, ConfigSource: app.ConfigStore, diff --git a/app/initgraphql.go b/app/initgraphql.go index 922413bb68..a84d8e7be6 100644 --- a/app/initgraphql.go +++ b/app/initgraphql.go @@ -7,7 +7,6 @@ import ( ) func (app *App) initGraphQL(ctx context.Context) error { - app.graphql2 = &graphqlapp.App{ DB: app.db, AuthBasicStore: app.AuthBasicStore, @@ -35,11 +34,12 @@ func (app *App) initGraphQL(ctx context.Context) error { NotificationStore: app.NotificationStore, SlackStore: app.slackChan, HeartbeatStore: app.HeartbeatStore, - NoticeStore: *app.NoticeStore, + NoticeStore: app.NoticeStore, Twilio: app.twilioConfig, AuthHandler: app.AuthHandler, FormatDestFunc: app.notificationManager.FormatDestValue, - NotificationManager: *app.notificationManager, + NotificationManager: app.notificationManager, + AuthLinkStore: app.AuthLinkStore, } return nil diff --git a/app/initstores.go b/app/initstores.go index 380945f4b6..b1f0f7ae1e 100644 --- a/app/initstores.go +++ b/app/initstores.go @@ -7,6 +7,7 @@ import ( "github.com/target/goalert/alert" "github.com/target/goalert/alert/alertlog" "github.com/target/goalert/alert/alertmetrics" + "github.com/target/goalert/auth/authlink" "github.com/target/goalert/auth/basic" "github.com/target/goalert/auth/nonce" "github.com/target/goalert/calsub" @@ -78,6 +79,18 @@ func (app *App) initStores(ctx context.Context) error { return errors.Wrap(err, "init oauth state keyring") } + if app.AuthLinkKeyring == nil { + app.AuthLinkKeyring, err = keyring.NewDB(ctx, app.cfg.Logger, app.db, &keyring.Config{ + Name: "auth-link", + RotationDays: 1, + MaxOldKeys: 1, + Keys: app.cfg.EncryptionKeys, + }) + } + if err != nil { + return errors.Wrap(err, "init oauth state keyring") + } + if app.SessionKeyring == nil { app.SessionKeyring, err = keyring.NewDB(ctx, app.cfg.Logger, app.db, &keyring.Config{ Name: "browser-sessions", @@ -101,9 +114,19 @@ func (app *App) initStores(ctx context.Context) error { return errors.Wrap(err, "init API keyring") } + if app.AuthLinkStore == nil { + app.AuthLinkStore, err = authlink.NewStore(ctx, app.db, app.AuthLinkKeyring) + } + if err != nil { + return errors.Wrap(err, "init auth link store") + } + if app.AlertMetricsStore == nil { app.AlertMetricsStore, err = alertmetrics.NewStore(ctx, app.db) } + if err != nil { + return errors.Wrap(err, "init alert metrics store") + } if app.AlertLogStore == nil { app.AlertLogStore, err = alertlog.NewStore(ctx, app.db) diff --git a/app/shutdown.go b/app/shutdown.go index e5d041d2c3..d5f1971d77 100644 --- a/app/shutdown.go +++ b/app/shutdown.go @@ -63,6 +63,7 @@ func (app *App) _Shutdown(ctx context.Context) error { shut(app.SessionKeyring, "session keyring") shut(app.OAuthKeyring, "oauth keyring") shut(app.APIKeyring, "API keyring") + shut(app.AuthLinkKeyring, "auth link keyring") shut(app.NonceStore, "nonce store") shut(app.ConfigStore, "config store") shut(app.requestLock, "context locker") diff --git a/engine/config.go b/engine/config.go index 6035d9b640..49e3bc1560 100644 --- a/engine/config.go +++ b/engine/config.go @@ -3,6 +3,7 @@ package engine import ( "github.com/target/goalert/alert" "github.com/target/goalert/alert/alertlog" + "github.com/target/goalert/auth/authlink" "github.com/target/goalert/config" "github.com/target/goalert/keyring" "github.com/target/goalert/notification" @@ -24,6 +25,7 @@ type Config struct { NCStore *notificationchannel.Store OnCallStore *oncall.Store ScheduleStore *schedule.Store + AuthLinkStore *authlink.Store ConfigSource config.Source diff --git a/engine/engine.go b/engine/engine.go index 27d300f4a3..7af984648e 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -153,6 +153,10 @@ func NewEngine(ctx context.Context, db *sql.DB, c *Config) (*Engine, error) { return p, nil } +func (p *Engine) AuthLinkURL(ctx context.Context, providerID, subjectID string) (string, error) { + return p.cfg.AuthLinkStore.AuthLinkURL(ctx, providerID, subjectID) +} + func (p *Engine) processModule(ctx context.Context, m updater) { defer recoverPanic(ctx, m.Name()) ctx, cancel := context.WithTimeout(ctx, 30*time.Second) diff --git a/graphql2/generated.go b/graphql2/generated.go index 845a4a861e..794c722887 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -275,6 +275,7 @@ type ComplexityRoot struct { DeleteAuthSubject func(childComplexity int, input user.AuthSubject) int EndAllAuthSessionsByCurrentUser func(childComplexity int) int EscalateAlerts func(childComplexity int, input []int) int + LinkAccountToken func(childComplexity int, token string) int SendContactMethodVerification func(childComplexity int, input SendContactMethodVerificationInput) int SetConfig func(childComplexity int, input []ConfigValueInput) int SetFavorite func(childComplexity int, input SetFavoriteInput) int @@ -613,6 +614,7 @@ type IntegrationKeyResolver interface { Href(ctx context.Context, obj *integrationkey.IntegrationKey) (string, error) } type MutationResolver interface { + LinkAccountToken(ctx context.Context, token string) (bool, error) SetTemporarySchedule(ctx context.Context, input SetTemporaryScheduleInput) (bool, error) ClearTemporarySchedules(ctx context.Context, input ClearTemporarySchedulesInput) (bool, error) SetScheduleOnCallNotificationRules(ctx context.Context, input SetScheduleOnCallNotificationRulesInput) (bool, error) @@ -1699,6 +1701,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.EscalateAlerts(childComplexity, args["input"].([]int)), true + case "Mutation.linkAccountToken": + if e.complexity.Mutation.LinkAccountToken == nil { + break + } + + args, err := ec.field_Mutation_linkAccountToken_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.LinkAccountToken(childComplexity, args["token"].(string)), true + case "Mutation.sendContactMethodVerification": if e.complexity.Mutation.SendContactMethodVerification == nil { break @@ -3833,6 +3847,21 @@ func (ec *executionContext) field_Mutation_escalateAlerts_args(ctx context.Conte return args, nil } +func (ec *executionContext) field_Mutation_linkAccountToken_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 string + if tmp, ok := rawArgs["token"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("token")) + arg0, err = ec.unmarshalNID2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["token"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_sendContactMethodVerification_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -9106,6 +9135,61 @@ func (ec *executionContext) fieldContext_LabelConnection_pageInfo(ctx context.Co return fc, nil } +func (ec *executionContext) _Mutation_linkAccountToken(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_linkAccountToken(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().LinkAccountToken(rctx, fc.Args["token"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_linkAccountToken(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_linkAccountToken_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return + } + return fc, nil +} + func (ec *executionContext) _Mutation_setTemporarySchedule(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_setTemporarySchedule(ctx, field) if err != nil { @@ -27174,6 +27258,15 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) switch field.Name { case "__typename": out.Values[i] = graphql.MarshalString("Mutation") + case "linkAccountToken": + + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_linkAccountToken(ctx, field) + }) + + if out.Values[i] == graphql.Null { + invalids++ + } case "setTemporarySchedule": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { diff --git a/graphql2/graphqlapp/app.go b/graphql2/graphqlapp/app.go index d623b3b885..f7731e92cd 100644 --- a/graphql2/graphqlapp/app.go +++ b/graphql2/graphqlapp/app.go @@ -17,6 +17,7 @@ import ( "github.com/target/goalert/alert/alertlog" "github.com/target/goalert/alert/alertmetrics" "github.com/target/goalert/auth" + "github.com/target/goalert/auth/authlink" "github.com/target/goalert/auth/basic" "github.com/target/goalert/calsub" "github.com/target/goalert/config" @@ -74,9 +75,11 @@ type App struct { LimitStore *limit.Store SlackStore *slack.ChannelSender HeartbeatStore *heartbeat.Store - NoticeStore notice.Store + NoticeStore *notice.Store - NotificationManager notification.Manager + AuthLinkStore *authlink.Store + + NotificationManager *notification.Manager AuthHandler *auth.Handler diff --git a/graphql2/graphqlapp/mutation.go b/graphql2/graphqlapp/mutation.go index a86582da31..4aedd87649 100644 --- a/graphql2/graphqlapp/mutation.go +++ b/graphql2/graphqlapp/mutation.go @@ -35,6 +35,11 @@ func (a *Mutation) SetFavorite(ctx context.Context, input graphql2.SetFavoriteIn return true, nil } +func (a *Mutation) LinkAccountToken(ctx context.Context, token string) (bool, error) { + err := a.AuthLinkStore.LinkAccount(ctx, token) + return err == nil, err +} + func (a *Mutation) SetScheduleOnCallNotificationRules(ctx context.Context, input graphql2.SetScheduleOnCallNotificationRulesInput) (bool, error) { schedID, err := parseUUID("ScheduleID", input.ScheduleID) if err != nil { @@ -104,6 +109,7 @@ func (a *Mutation) SetTemporarySchedule(ctx context.Context, input graphql2.SetT return err == nil, err } + func (a *Mutation) ClearTemporarySchedules(ctx context.Context, input graphql2.ClearTemporarySchedulesInput) (bool, error) { schedID, err := parseUUID("ScheduleID", input.ScheduleID) if err != nil { diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index 5354219f1f..00fe398339 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -343,6 +343,8 @@ input SetScheduleShiftInput { } type Mutation { + linkAccountToken(token: ID!): Boolean! + setTemporarySchedule(input: SetTemporaryScheduleInput!): Boolean! clearTemporarySchedules(input: ClearTemporarySchedulesInput!): Boolean! diff --git a/keyring/store.go b/keyring/store.go index 83c1db0b8c..016abee30e 100644 --- a/keyring/store.go +++ b/keyring/store.go @@ -112,6 +112,7 @@ func marshalVerificationKeys(keys map[byte]ecdsa.PublicKey) ([]byte, error) { } return json.Marshal(m) } + func parseVerificationKeys(data []byte) (map[byte]ecdsa.PublicKey, error) { var m map[byte][]byte err := json.Unmarshal(data, &m) @@ -228,6 +229,7 @@ func (db *DB) Shutdown(ctx context.Context) error { <-db.shutdown return nil } + func (db *DB) loop() { t := time.NewTicker(12 * time.Hour) var shutdownCtx context.Context @@ -275,6 +277,7 @@ func (db *DB) newKey() (*ecdsa.PrivateKey, []byte, error) { } return key, data, nil } + func (db *DB) loadKey(encData []byte) (*ecdsa.PrivateKey, error) { data, _, err := db.cfg.Keys.Decrypt(encData) if err != nil { @@ -541,7 +544,7 @@ func (db *DB) VerifyJWT(s string, c jwt.Claims) (bool, error) { return false, err } - return currentKey, nil + return currentKey, c.Valid() } // Verify will validate the signature and metadata, and optionally length, of a message. diff --git a/notification/namedreceiver.go b/notification/namedreceiver.go index 9ed790ec6b..344e0d9a39 100644 --- a/notification/namedreceiver.go +++ b/notification/namedreceiver.go @@ -23,6 +23,11 @@ func (nr *namedReceiver) SetMessageStatus(ctx context.Context, externalID string return nr.r.SetSendResult(ctx, res) } +// AuthLinkURL calls the underlying AuthLinkURL method. +func (nr *namedReceiver) AuthLinkURL(ctx context.Context, providerID, subjectID string) (string, error) { + return nr.r.AuthLinkURL(ctx, providerID, subjectID) +} + // Start implements the Receiver interface by calling the underlying Receiver.Start method. func (nr *namedReceiver) Start(ctx context.Context, d Dest) error { metricRecvTotal.WithLabelValues(d.Type.String(), "START") diff --git a/notification/receiver.go b/notification/receiver.go index 72b1ba46a1..7bd0f373f9 100644 --- a/notification/receiver.go +++ b/notification/receiver.go @@ -16,6 +16,9 @@ type Receiver interface { // ReceiveSubject records a response to a previously sent message from a provider/subject (e.g. Slack user). ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) error + // AuthLinkURL will generate a URL to link a provider and subject to a GoAlert user. + AuthLinkURL(ctx context.Context, providerID, subjectID string) (string, error) + // Start indicates a user has opted-in for notifications to this contact method. Start(context.Context, Dest) error diff --git a/notification/resultreceiver.go b/notification/resultreceiver.go index bc376411bb..ccd4bb9e2b 100644 --- a/notification/resultreceiver.go +++ b/notification/resultreceiver.go @@ -10,6 +10,7 @@ type ResultReceiver interface { Receive(ctx context.Context, callbackID string, result Result) error ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) error + AuthLinkURL(ctx context.Context, providerID, subjectID string) (string, error) Start(context.Context, Dest) error Stop(context.Context, Dest) error From fb533d92e7a2dde3b19e7802b4e88fbd1f9b1bb0 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 22 Jun 2022 09:40:22 -0500 Subject: [PATCH 04/33] generate auth link for unknown slack user --- notification/slack/servemessageaction.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index bb4d1a408a..c64330d628 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -120,13 +120,21 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ err = s.recv.ReceiveSubject(ctx, "slack:"+payload.User.TeamID, payload.User.ID, act.Value, res) if errors.Is(err, notification.ErrUnknownSubject) { - log.Log(ctx, fmt.Errorf("unknown provider/subject ID for Slack 'slack:%s/%s'", payload.User.TeamID, payload.User.ID)) + linkURL, err := s.recv.AuthLinkURL(ctx, "slack:"+payload.User.TeamID, payload.User.ID) + if err != nil { + log.Log(ctx, err) + } err = s.withClient(ctx, func(c *slack.Client) error { + var msg string + if linkURL == "" { + msg = "Your Slack account isn't currently linked to GoAlert, please try again later." + } else { + msg = "Please <" + linkURL + "|CLICK HERE> to link your Slack account with GoAlert, then try again." + } + _, err := c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, slack.MsgOptionResponseURL(payload.ResponseURL, "ephemeral"), - - // TODO: add user-link/OAUTH flow - slack.MsgOptionText("Your Slack account isn't currently linked to GoAlert, the admin will need to set this up for it to work.", false), + slack.MsgOptionText(msg, false), ) return err }) From 599a5110386235313d82304746241e067c9b0ee6 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 22 Jun 2022 09:44:24 -0500 Subject: [PATCH 05/33] use full param name --- auth/authlink/store.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth/authlink/store.go b/auth/authlink/store.go index 903171f85b..5a96bbb042 100644 --- a/auth/authlink/store.go +++ b/auth/authlink/store.go @@ -124,6 +124,6 @@ func (s *Store) AuthLinkURL(ctx context.Context, providerID, subjectID string) ( cfg := config.FromContext(ctx) p := make(url.Values) - p.Set("link-token", token) - return cfg.CallbackURL("/profile/link", p), nil + p.Set("auth-link-token", token) + return cfg.CallbackURL("/profile", p), nil } From 178458a65dcd65a8d6110d55263f3564031c3f7e Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 22 Jun 2022 09:55:17 -0500 Subject: [PATCH 06/33] add link dialog --- auth/authlink/store.go | 2 +- web/src/app/main/App.tsx | 2 ++ web/src/app/main/components/AuthLink.tsx | 36 ++++++++++++++++++++++++ web/src/schema.d.ts | 1 + 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 web/src/app/main/components/AuthLink.tsx diff --git a/auth/authlink/store.go b/auth/authlink/store.go index 5a96bbb042..9f01108186 100644 --- a/auth/authlink/store.go +++ b/auth/authlink/store.go @@ -124,6 +124,6 @@ func (s *Store) AuthLinkURL(ctx context.Context, providerID, subjectID string) ( cfg := config.FromContext(ctx) p := make(url.Values) - p.Set("auth-link-token", token) + p.Set("authLinkToken", token) return cfg.CallbackURL("/profile", p), nil } diff --git a/web/src/app/main/App.tsx b/web/src/app/main/App.tsx index 24b4f8d99a..0687ab77b4 100644 --- a/web/src/app/main/App.tsx +++ b/web/src/app/main/App.tsx @@ -23,6 +23,7 @@ import { Theme } from '@mui/material/styles' import AppRoutes from './AppRoutes' import { useURLKey } from '../actions' import NavBar from './NavBar' +import AuthLink from './components/AuthLink' const useStyles = makeStyles((theme: Theme) => ({ root: { @@ -115,6 +116,7 @@ export default function App(): JSX.Element {
+ clearToken()} + onSubmit={() => linkAccount()} + /> + ) +} diff --git a/web/src/schema.d.ts b/web/src/schema.d.ts index 6aa084218a..9af7eb61e8 100644 --- a/web/src/schema.d.ts +++ b/web/src/schema.d.ts @@ -256,6 +256,7 @@ export interface SetScheduleShiftInput { } export interface Mutation { + linkAccountToken: boolean setTemporarySchedule: boolean clearTemporarySchedules: boolean setScheduleOnCallNotificationRules: boolean From 81675c45a51c6074cfd0af9bb2013a45ceeab916 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 22 Jun 2022 11:20:48 -0500 Subject: [PATCH 07/33] fix permission --- auth/authlink/store.go | 1 + engine/engine.go | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/auth/authlink/store.go b/auth/authlink/store.go index 9f01108186..b005f64612 100644 --- a/auth/authlink/store.go +++ b/auth/authlink/store.go @@ -34,6 +34,7 @@ func NewStore(ctx context.Context, db *sql.DB, k keyring.Keyring) (*Store, error } return &Store{ + db: db, k: k, newLink: p.P(`insert into auth_link_requests (id, provider_id, subject_id, expires_at) values ($1, $2, $3, $4)`), rmLink: p.P(`delete from auth_link_requests where id = $1 and expires_at > now() returning provider_id, subject_id`), diff --git a/engine/engine.go b/engine/engine.go index 7af984648e..98920f737e 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -153,8 +153,11 @@ func NewEngine(ctx context.Context, db *sql.DB, c *Config) (*Engine, error) { return p, nil } -func (p *Engine) AuthLinkURL(ctx context.Context, providerID, subjectID string) (string, error) { - return p.cfg.AuthLinkStore.AuthLinkURL(ctx, providerID, subjectID) +func (p *Engine) AuthLinkURL(ctx context.Context, providerID, subjectID string) (url string, err error) { + permission.SudoContext(ctx, func(ctx context.Context) { + url, err = p.cfg.AuthLinkStore.AuthLinkURL(ctx, providerID, subjectID) + }) + return url, err } func (p *Engine) processModule(ctx context.Context, m updater) { From 19b7f3ce194536905e4f0a2a973af984934b0522 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Thu, 23 Jun 2022 10:26:12 -0500 Subject: [PATCH 08/33] lower window --- auth/authlink/store.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth/authlink/store.go b/auth/authlink/store.go index b005f64612..81ef1fed5e 100644 --- a/auth/authlink/store.go +++ b/auth/authlink/store.go @@ -109,7 +109,7 @@ func (s *Store) AuthLinkURL(ctx context.Context, providerID, subjectID string) ( c.ID = id.String() c.Audience = jwt.ClaimStrings{"auth-link"} c.Issuer = "goalert" - c.NotBefore = jwt.NewNumericDate(now.Add(-5 * time.Minute)) + c.NotBefore = jwt.NewNumericDate(now.Add(-2 * time.Minute)) c.ExpiresAt = jwt.NewNumericDate(expires) c.IssuedAt = jwt.NewNumericDate(now) From 1ea16c7785ad65b8ef946d2b1e829345021a600b Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Thu, 23 Jun 2022 11:01:00 -0500 Subject: [PATCH 09/33] clear token on success --- web/src/app/main/components/AuthLink.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/app/main/components/AuthLink.tsx b/web/src/app/main/components/AuthLink.tsx index 9e1d371113..661c200c99 100644 --- a/web/src/app/main/components/AuthLink.tsx +++ b/web/src/app/main/components/AuthLink.tsx @@ -30,7 +30,11 @@ export default function AuthLink() { subTitle='Click confirm to link this GoAlert account.' errors={linkAccountStatus.error ? [linkAccountStatus.error] : []} onClose={() => clearToken()} - onSubmit={() => linkAccount()} + onSubmit={() => + linkAccount().then(() => { + clearToken() + }) + } /> ) } From f673d38b78f6599faed52985b6cb9e12f0959862 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Thu, 23 Jun 2022 13:04:59 -0500 Subject: [PATCH 10/33] re-order migration --- ...th-link-requests.sql => 20220623130537-auth-link-requests.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename migrate/migrations/{20220622085329-auth-link-requests.sql => 20220623130537-auth-link-requests.sql} (100%) diff --git a/migrate/migrations/20220622085329-auth-link-requests.sql b/migrate/migrations/20220623130537-auth-link-requests.sql similarity index 100% rename from migrate/migrations/20220622085329-auth-link-requests.sql rename to migrate/migrations/20220623130537-auth-link-requests.sql From 896925a7a694d64ba7d14f8ab2cfe2cd64079560 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 2 Aug 2022 10:39:11 -0500 Subject: [PATCH 11/33] re-order migrations --- ...th-link-requests.sql => 20220802104000-auth-link-requests.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename migrate/migrations/{20220623130537-auth-link-requests.sql => 20220802104000-auth-link-requests.sql} (100%) diff --git a/migrate/migrations/20220623130537-auth-link-requests.sql b/migrate/migrations/20220802104000-auth-link-requests.sql similarity index 100% rename from migrate/migrations/20220623130537-auth-link-requests.sql rename to migrate/migrations/20220802104000-auth-link-requests.sql From fabd0dd6779f3d4154544ad8341a845a6a3f6289 Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Wed, 3 Aug 2022 12:01:28 -0500 Subject: [PATCH 12/33] add slack link button Co-authored-by: Forfold --- notification/slack/servemessageaction.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index c64330d628..44ee309a1e 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -129,12 +129,16 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ if linkURL == "" { msg = "Your Slack account isn't currently linked to GoAlert, please try again later." } else { - msg = "Please <" + linkURL + "|CLICK HERE> to link your Slack account with GoAlert, then try again." + msg = "Please link your Slack account with GoAlert then try again." } + linkBtn := slack.NewButtonBlockElement(alertAckActionID, linkURL, slack.NewTextBlockObject("plain_text", "Link Account", false, false)) + linkBtn.URL = linkURL + _, err := c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, slack.MsgOptionResponseURL(payload.ResponseURL, "ephemeral"), slack.MsgOptionText(msg, false), + slack.MsgOptionBlocks(slack.NewActionBlock(alertResponseBlockID, linkBtn)), ) return err }) From aa4ab1096c666f3c493252680dc2d28c9a96bd0c Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Wed, 3 Aug 2022 15:09:19 -0500 Subject: [PATCH 13/33] [WIP] delete ephemeral message Co-authored-by: Forfold --- notification/slack/channel.go | 2 ++ notification/slack/servemessageaction.go | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/notification/slack/channel.go b/notification/slack/channel.go index 9cfe33edfb..c55bbb9376 100644 --- a/notification/slack/channel.go +++ b/notification/slack/channel.go @@ -246,6 +246,7 @@ const ( alertResponseBlockID = "block_alert_response" alertCloseActionID = "action_alert_close" alertAckActionID = "action_alert_ack" + linkActActionID = "action_link_account" ) // alertMsgOption will return the slack.MsgOption for an alert-type message (e.g., notification or status update). @@ -374,3 +375,4 @@ func (s *ChannelSender) lookupTeamIDForToken(ctx context.Context, token string) return teamID, nil } + \ No newline at end of file diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index 44ee309a1e..99ddf22f1a 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -113,6 +113,15 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ res = notification.ResultAcknowledge case alertCloseActionID: res = notification.ResultResolve + case linkActActionID: + s.withClient(ctx, func(c *slack.Client) error { + _, err = c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, slack.MsgOptionText("", false), slack.MsgOptionReplaceOriginal(payload.ResponseURL), slack.MsgOptionDeleteOriginal(payload.ResponseURL)) + if err != nil { + return err + } + return nil + }) + return default: errutil.HTTPError(ctx, w, validation.NewFieldErrorf("action_id", "unknown action ID '%s'", act.ActionID)) return @@ -132,7 +141,7 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ msg = "Please link your Slack account with GoAlert then try again." } - linkBtn := slack.NewButtonBlockElement(alertAckActionID, linkURL, slack.NewTextBlockObject("plain_text", "Link Account", false, false)) + linkBtn := slack.NewButtonBlockElement(linkActActionID, linkURL, slack.NewTextBlockObject("plain_text", "Link Account", false, false)) linkBtn.URL = linkURL _, err := c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, @@ -140,7 +149,11 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ slack.MsgOptionText(msg, false), slack.MsgOptionBlocks(slack.NewActionBlock(alertResponseBlockID, linkBtn)), ) - return err + + if err != nil { + return err + } + return nil }) } if alert.IsAlreadyAcknowledged(err) || alert.IsAlreadyClosed(err) { From b6c0fe9147d4a3ef5c6729eca9aa7666ccd49cad Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Wed, 10 Aug 2022 13:40:24 -0500 Subject: [PATCH 14/33] return username as param in auth link --- notification/slack/servemessageaction.go | 9 +++++++-- web/src/app/main/components/AuthLink.tsx | 14 ++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index 99ddf22f1a..0711c29dc2 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -82,8 +82,9 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ ID string } User struct { - ID string `json:"id"` - TeamID string `json:"team_id"` + ID string `json:"id"` + TeamID string `json:"team_id"` + Username string `json:"username"` } Actions []struct { ActionID string `json:"action_id"` @@ -133,6 +134,10 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ if err != nil { log.Log(ctx, err) } + + // add slack username to url + linkURL = fmt.Sprintf("%s&username=%s", linkURL, payload.User.Username) + err = s.withClient(ctx, func(c *slack.Client) error { var msg string if linkURL == "" { diff --git a/web/src/app/main/components/AuthLink.tsx b/web/src/app/main/components/AuthLink.tsx index 661c200c99..20f228ab19 100644 --- a/web/src/app/main/components/AuthLink.tsx +++ b/web/src/app/main/components/AuthLink.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { ReactNode } from 'react' import { useSessionInfo } from '../../util/RequireConfig' import { useResetURLParams, useURLParam } from '../../actions' import { gql, useMutation } from '@apollo/client' @@ -10,9 +10,11 @@ const mutation = gql` } ` -export default function AuthLink() { +export default function AuthLink(): ReactNode { const [token] = useURLParam('authLinkToken', '') + const [username] = useURLParam('username', '') const clearToken = useResetURLParams('authLinkToken') + const clearUsername = useResetURLParams('username') const { ready } = useSessionInfo() const [linkAccount, linkAccountStatus] = useMutation(mutation, { @@ -27,12 +29,16 @@ export default function AuthLink() { clearToken()} + onClose={() => { + clearToken() + clearUsername() + }} onSubmit={() => linkAccount().then(() => { clearToken() + clearUsername() }) } /> From 497db4dc24f0fbfd33b5cd95318a9c6931599554 Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Wed, 10 Aug 2022 16:26:36 -0500 Subject: [PATCH 15/33] prevent returning 500 error to slack --- notification/slack/servemessageaction.go | 1 + 1 file changed, 1 insertion(+) diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index 0711c29dc2..86a7755241 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -160,6 +160,7 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ } return nil }) + return } if alert.IsAlreadyAcknowledged(err) || alert.IsAlreadyClosed(err) { // ignore errors from duplicate requests From 7b73f4a77ac352358beb6110b7ccb1ac9fa7c832 Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Thu, 11 Aug 2022 11:02:17 -0500 Subject: [PATCH 16/33] navigate to alert when linked Co-authored-by: Forfold --- engine/engine.go | 16 +++++++------- notification/namedreceiver.go | 2 +- notification/receiver.go | 2 +- notification/resultreceiver.go | 2 +- notification/slack/servemessageaction.go | 8 +++---- web/src/app/main/components/AuthLink.tsx | 28 ++++++++++++++---------- 6 files changed, 31 insertions(+), 27 deletions(-) diff --git a/engine/engine.go b/engine/engine.go index 98920f737e..a3635e5ffa 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -280,10 +280,10 @@ func (p *Engine) SetSendResult(ctx context.Context, res *notification.SendResult } // ReceiveSubject will process a notification result. -func (p *Engine) ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result notification.Result) error { +func (p *Engine) ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result notification.Result) (int, error) { cb, err := p.b.FindOne(ctx, callbackID) if err != nil { - return err + return 0, err } if cb.ServiceID != "" { ctx = log.WithField(ctx, "ServiceID", cb.ServiceID) @@ -297,10 +297,10 @@ func (p *Engine) ReceiveSubject(ctx context.Context, providerID, subjectID, call usr, err = p.cfg.UserStore.FindOneBySubject(ctx, providerID, subjectID) }) if err != nil { - return fmt.Errorf("failed to find user: %w", err) + return 0, fmt.Errorf("failed to find user: %w", err) } if usr == nil { - return notification.ErrUnknownSubject + return cb.AlertID, notification.ErrUnknownSubject } ctx = permission.UserSourceContext(ctx, usr.ID, usr.Role, &permission.SourceInfo{ @@ -315,17 +315,17 @@ func (p *Engine) ReceiveSubject(ctx context.Context, providerID, subjectID, call case notification.ResultResolve: newStatus = alert.StatusClosed default: - return errors.New("unknown result type") + return 0, errors.New("unknown result type") } if cb.AlertID != 0 { - return errors.Wrap(p.a.UpdateStatus(ctx, cb.AlertID, newStatus), "update alert") + return 0, errors.Wrap(p.a.UpdateStatus(ctx, cb.AlertID, newStatus), "update alert") } if cb.ServiceID != "" { - return errors.Wrap(p.a.UpdateStatusByService(ctx, cb.ServiceID, newStatus), "update all alerts") + return 0, errors.Wrap(p.a.UpdateStatusByService(ctx, cb.ServiceID, newStatus), "update all alerts") } - return errors.New("unknown callback type") + return 0, errors.New("unknown callback type") } // Receive will process a notification result. diff --git a/notification/namedreceiver.go b/notification/namedreceiver.go index 344e0d9a39..8ba5972783 100644 --- a/notification/namedreceiver.go +++ b/notification/namedreceiver.go @@ -47,7 +47,7 @@ func (nr *namedReceiver) Receive(ctx context.Context, callbackID string, result } // Receive implements the Receiver interface by calling the underlying Receiver.ReceiveSubject method. -func (nr *namedReceiver) ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) error { +func (nr *namedReceiver) ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) (int, error) { metricRecvTotal.WithLabelValues(nr.ns.destType.String(), result.String()) return nr.r.ReceiveSubject(ctx, providerID, subjectID, callbackID, result) } diff --git a/notification/receiver.go b/notification/receiver.go index 7bd0f373f9..eb38dfedb6 100644 --- a/notification/receiver.go +++ b/notification/receiver.go @@ -14,7 +14,7 @@ type Receiver interface { Receive(ctx context.Context, callbackID string, result Result) error // ReceiveSubject records a response to a previously sent message from a provider/subject (e.g. Slack user). - ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) error + ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) (int, error) // AuthLinkURL will generate a URL to link a provider and subject to a GoAlert user. AuthLinkURL(ctx context.Context, providerID, subjectID string) (string, error) diff --git a/notification/resultreceiver.go b/notification/resultreceiver.go index ccd4bb9e2b..6b3af5cd0f 100644 --- a/notification/resultreceiver.go +++ b/notification/resultreceiver.go @@ -9,7 +9,7 @@ type ResultReceiver interface { SetSendResult(ctx context.Context, res *SendResult) error Receive(ctx context.Context, callbackID string, result Result) error - ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) error + ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) (int, error) AuthLinkURL(ctx context.Context, providerID, subjectID string) (string, error) Start(context.Context, Dest) error Stop(context.Context, Dest) error diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index 86a7755241..8f28d3e96d 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -127,16 +127,16 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ errutil.HTTPError(ctx, w, validation.NewFieldErrorf("action_id", "unknown action ID '%s'", act.ActionID)) return } - - err = s.recv.ReceiveSubject(ctx, "slack:"+payload.User.TeamID, payload.User.ID, act.Value, res) + alertID := 0 + alertID, err = s.recv.ReceiveSubject(ctx, "slack:"+payload.User.TeamID, payload.User.ID, act.Value, res) if errors.Is(err, notification.ErrUnknownSubject) { linkURL, err := s.recv.AuthLinkURL(ctx, "slack:"+payload.User.TeamID, payload.User.ID) if err != nil { log.Log(ctx, err) } - // add slack username to url - linkURL = fmt.Sprintf("%s&username=%s", linkURL, payload.User.Username) + // add slack username and alertID to url + linkURL = fmt.Sprintf("%s&username=%s&alertID=%d", linkURL, payload.User.Username, alertID) err = s.withClient(ctx, func(c *slack.Client) error { var msg string diff --git a/web/src/app/main/components/AuthLink.tsx b/web/src/app/main/components/AuthLink.tsx index 20f228ab19..0d830dfa2b 100644 --- a/web/src/app/main/components/AuthLink.tsx +++ b/web/src/app/main/components/AuthLink.tsx @@ -1,8 +1,9 @@ import React, { ReactNode } from 'react' import { useSessionInfo } from '../../util/RequireConfig' -import { useResetURLParams, useURLParam } from '../../actions' +import { useResetURLParams, useURLParams } from '../../actions' import { gql, useMutation } from '@apollo/client' import FormDialog from '../../dialogs/FormDialog' +import { useLocation } from 'wouter' const mutation = gql` mutation ($token: ID!) { @@ -11,17 +12,21 @@ const mutation = gql` ` export default function AuthLink(): ReactNode { - const [token] = useURLParam('authLinkToken', '') - const [username] = useURLParam('username', '') - const clearToken = useResetURLParams('authLinkToken') - const clearUsername = useResetURLParams('username') + const [params] = useURLParams({ + authLinkToken: '', + username: '', + alertID: '', + }) + const [, navigate] = useLocation() + + const resetParams = useResetURLParams('authLinkToken', 'username') const { ready } = useSessionInfo() const [linkAccount, linkAccountStatus] = useMutation(mutation, { - variables: { token }, + variables: { token: params.authLinkToken }, }) - if (!token || !ready) { + if (!params.username || !params.authLinkToken || !ready) { return null } @@ -29,16 +34,15 @@ export default function AuthLink(): ReactNode { { - clearToken() - clearUsername() + resetParams() }} onSubmit={() => linkAccount().then(() => { - clearToken() - clearUsername() + navigate(`/alerts/${params.alertID}`) + resetParams() }) } /> From 0c0ac46302c4cd62f581e5b64a448b9c181c1dc5 Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Thu, 11 Aug 2022 15:08:45 -0500 Subject: [PATCH 17/33] update return type and AuthLinkURL params Co-authored-by: Forfold Co-authored-by: mastercactapus --- auth/authlink/store.go | 4 ++-- engine/engine.go | 23 +++++++++++++---------- notification/namedreceiver.go | 11 +++++++---- notification/receiver.go | 16 +++++++++++----- notification/resultreceiver.go | 5 +++-- notification/slack/servemessageaction.go | 18 ++++++++++++------ web/src/app/main/components/AuthLink.tsx | 19 ++++++++++++++----- 7 files changed, 62 insertions(+), 34 deletions(-) diff --git a/auth/authlink/store.go b/auth/authlink/store.go index 81ef1fed5e..f5f2ae58b4 100644 --- a/auth/authlink/store.go +++ b/auth/authlink/store.go @@ -88,7 +88,7 @@ func (s *Store) LinkAccount(ctx context.Context, token string) error { return tx.Commit() } -func (s *Store) AuthLinkURL(ctx context.Context, providerID, subjectID string) (string, error) { +func (s *Store) AuthLinkURL(ctx context.Context, providerID, subjectID string, params url.Values) (string, error) { err := permission.LimitCheckAny(ctx, permission.System) if err != nil { return "", err @@ -126,5 +126,5 @@ func (s *Store) AuthLinkURL(ctx context.Context, providerID, subjectID string) ( cfg := config.FromContext(ctx) p := make(url.Values) p.Set("authLinkToken", token) - return cfg.CallbackURL("/profile", p), nil + return cfg.CallbackURL("/profile", p, params), nil } diff --git a/engine/engine.go b/engine/engine.go index a3635e5ffa..69d2e3b8b5 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "net/url" "strings" "time" @@ -153,9 +154,9 @@ func NewEngine(ctx context.Context, db *sql.DB, c *Config) (*Engine, error) { return p, nil } -func (p *Engine) AuthLinkURL(ctx context.Context, providerID, subjectID string) (url string, err error) { +func (p *Engine) AuthLinkURL(ctx context.Context, providerID, subjectID string, params url.Values) (url string, err error) { permission.SudoContext(ctx, func(ctx context.Context) { - url, err = p.cfg.AuthLinkStore.AuthLinkURL(ctx, providerID, subjectID) + url, err = p.cfg.AuthLinkStore.AuthLinkURL(ctx, providerID, subjectID, params) }) return url, err } @@ -280,10 +281,10 @@ func (p *Engine) SetSendResult(ctx context.Context, res *notification.SendResult } // ReceiveSubject will process a notification result. -func (p *Engine) ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result notification.Result) (int, error) { +func (p *Engine) ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result notification.Result) error { cb, err := p.b.FindOne(ctx, callbackID) if err != nil { - return 0, err + return err } if cb.ServiceID != "" { ctx = log.WithField(ctx, "ServiceID", cb.ServiceID) @@ -297,10 +298,12 @@ func (p *Engine) ReceiveSubject(ctx context.Context, providerID, subjectID, call usr, err = p.cfg.UserStore.FindOneBySubject(ctx, providerID, subjectID) }) if err != nil { - return 0, fmt.Errorf("failed to find user: %w", err) + return fmt.Errorf("failed to find user: %w", err) } if usr == nil { - return cb.AlertID, notification.ErrUnknownSubject + return ¬ification.UnknownSubjectError{ + AlertID: cb.AlertID, + } } ctx = permission.UserSourceContext(ctx, usr.ID, usr.Role, &permission.SourceInfo{ @@ -315,17 +318,17 @@ func (p *Engine) ReceiveSubject(ctx context.Context, providerID, subjectID, call case notification.ResultResolve: newStatus = alert.StatusClosed default: - return 0, errors.New("unknown result type") + return errors.New("unknown result type") } if cb.AlertID != 0 { - return 0, errors.Wrap(p.a.UpdateStatus(ctx, cb.AlertID, newStatus), "update alert") + return errors.Wrap(p.a.UpdateStatus(ctx, cb.AlertID, newStatus), "update alert") } if cb.ServiceID != "" { - return 0, errors.Wrap(p.a.UpdateStatusByService(ctx, cb.ServiceID, newStatus), "update all alerts") + return errors.Wrap(p.a.UpdateStatusByService(ctx, cb.ServiceID, newStatus), "update all alerts") } - return 0, errors.New("unknown callback type") + return errors.New("unknown callback type") } // Receive will process a notification result. diff --git a/notification/namedreceiver.go b/notification/namedreceiver.go index 8ba5972783..1dfd5ad427 100644 --- a/notification/namedreceiver.go +++ b/notification/namedreceiver.go @@ -1,6 +1,9 @@ package notification -import "context" +import ( + "context" + "net/url" +) type namedReceiver struct { r ResultReceiver @@ -24,8 +27,8 @@ func (nr *namedReceiver) SetMessageStatus(ctx context.Context, externalID string } // AuthLinkURL calls the underlying AuthLinkURL method. -func (nr *namedReceiver) AuthLinkURL(ctx context.Context, providerID, subjectID string) (string, error) { - return nr.r.AuthLinkURL(ctx, providerID, subjectID) +func (nr *namedReceiver) AuthLinkURL(ctx context.Context, providerID, subjectID string, params url.Values) (string, error) { + return nr.r.AuthLinkURL(ctx, providerID, subjectID, params) } // Start implements the Receiver interface by calling the underlying Receiver.Start method. @@ -47,7 +50,7 @@ func (nr *namedReceiver) Receive(ctx context.Context, callbackID string, result } // Receive implements the Receiver interface by calling the underlying Receiver.ReceiveSubject method. -func (nr *namedReceiver) ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) (int, error) { +func (nr *namedReceiver) ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) error { metricRecvTotal.WithLabelValues(nr.ns.destType.String(), result.String()) return nr.r.ReceiveSubject(ctx, providerID, subjectID, callbackID, result) } diff --git a/notification/receiver.go b/notification/receiver.go index eb38dfedb6..4b5a953a33 100644 --- a/notification/receiver.go +++ b/notification/receiver.go @@ -2,7 +2,7 @@ package notification import ( "context" - "errors" + "net/url" ) // A Receiver processes incoming messages and responses. @@ -14,10 +14,10 @@ type Receiver interface { Receive(ctx context.Context, callbackID string, result Result) error // ReceiveSubject records a response to a previously sent message from a provider/subject (e.g. Slack user). - ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) (int, error) + ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) error // AuthLinkURL will generate a URL to link a provider and subject to a GoAlert user. - AuthLinkURL(ctx context.Context, providerID, subjectID string) (string, error) + AuthLinkURL(ctx context.Context, providerID, subjectID string, params url.Values) (string, error) // Start indicates a user has opted-in for notifications to this contact method. Start(context.Context, Dest) error @@ -29,5 +29,11 @@ type Receiver interface { IsKnownDest(ctx context.Context, value string) (bool, error) } -// ErrUnknownSubject is returned from ReceiveSubject when the subject is unknown. -var ErrUnknownSubject = errors.New("unknown subject for that provider") +// UnknownSubjectError is returned from ReceiveSubject when the subject is unknown. +type UnknownSubjectError struct { + AlertID int +} + +func (e UnknownSubjectError) Error() string { + return "unknown subject for that provider" +} \ No newline at end of file diff --git a/notification/resultreceiver.go b/notification/resultreceiver.go index 6b3af5cd0f..aa1e05b2f1 100644 --- a/notification/resultreceiver.go +++ b/notification/resultreceiver.go @@ -2,6 +2,7 @@ package notification import ( "context" + "net/url" ) // A ResultReceiver processes notification responses. @@ -9,8 +10,8 @@ type ResultReceiver interface { SetSendResult(ctx context.Context, res *SendResult) error Receive(ctx context.Context, callbackID string, result Result) error - ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) (int, error) - AuthLinkURL(ctx context.Context, providerID, subjectID string) (string, error) + ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) error + AuthLinkURL(ctx context.Context, providerID, subjectID string, params url.Values) (string, error) Start(context.Context, Dest) error Stop(context.Context, Dest) error diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index 8f28d3e96d..b6d73b3f3d 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strconv" "time" @@ -127,16 +128,21 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ errutil.HTTPError(ctx, w, validation.NewFieldErrorf("action_id", "unknown action ID '%s'", act.ActionID)) return } - alertID := 0 - alertID, err = s.recv.ReceiveSubject(ctx, "slack:"+payload.User.TeamID, payload.User.ID, act.Value, res) - if errors.Is(err, notification.ErrUnknownSubject) { - linkURL, err := s.recv.AuthLinkURL(ctx, "slack:"+payload.User.TeamID, payload.User.ID) + + var e *notification.UnknownSubjectError + err = s.recv.ReceiveSubject(ctx, "slack:"+payload.User.TeamID, payload.User.ID, act.Value, res) + + if errors.As(err, &e) { + v := make(url.Values) + v.Set("details", fmt.Sprintf("slack:%s", payload.User.Username)) + v.Set("alertID", strconv.Itoa(e.AlertID)) + v.Set("action", res.String()) + + linkURL, err := s.recv.AuthLinkURL(ctx, "slack:"+payload.User.TeamID, payload.User.ID, v) if err != nil { log.Log(ctx, err) } - // add slack username and alertID to url - linkURL = fmt.Sprintf("%s&username=%s&alertID=%d", linkURL, payload.User.Username, alertID) err = s.withClient(ctx, func(c *slack.Client) error { var msg string diff --git a/web/src/app/main/components/AuthLink.tsx b/web/src/app/main/components/AuthLink.tsx index 0d830dfa2b..8fa1229a9f 100644 --- a/web/src/app/main/components/AuthLink.tsx +++ b/web/src/app/main/components/AuthLink.tsx @@ -14,19 +14,20 @@ const mutation = gql` export default function AuthLink(): ReactNode { const [params] = useURLParams({ authLinkToken: '', - username: '', + details: '', alertID: '', + action: '', }) const [, navigate] = useLocation() - const resetParams = useResetURLParams('authLinkToken', 'username') + const resetParams = useResetURLParams('authLinkToken', 'details') const { ready } = useSessionInfo() const [linkAccount, linkAccountStatus] = useMutation(mutation, { variables: { token: params.authLinkToken }, }) - if (!params.username || !params.authLinkToken || !ready) { + if (!params.details || !params.authLinkToken || !ready) { return null } @@ -34,14 +35,22 @@ export default function AuthLink(): ReactNode { { resetParams() }} onSubmit={() => linkAccount().then(() => { - navigate(`/alerts/${params.alertID}`) + if (params.alertID) { + navigate(`/alerts/${params.alertID}`) + } + if (params.action) { + // make request to close/ack here + // if fail trigger toast + } + + // always call resetParams() }) } From db432840c7c681b9e8ffddf7e583f9f6d90148ba Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Thu, 11 Aug 2022 16:51:25 -0500 Subject: [PATCH 18/33] add error toast and update alert on link success --- web/src/app/main/components/AuthLink.tsx | 66 +++++++++++++++++++----- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/web/src/app/main/components/AuthLink.tsx b/web/src/app/main/components/AuthLink.tsx index 8fa1229a9f..247d50cf47 100644 --- a/web/src/app/main/components/AuthLink.tsx +++ b/web/src/app/main/components/AuthLink.tsx @@ -1,9 +1,11 @@ -import React, { ReactNode } from 'react' +import React from 'react' import { useSessionInfo } from '../../util/RequireConfig' import { useResetURLParams, useURLParams } from '../../actions' -import { gql, useMutation } from '@apollo/client' +import { gql, useMutation } from 'urql' import FormDialog from '../../dialogs/FormDialog' import { useLocation } from 'wouter' +import Snackbar from '@mui/material/Snackbar' +import { Alert } from '@mui/material' const mutation = gql` mutation ($token: ID!) { @@ -11,7 +13,15 @@ const mutation = gql` } ` -export default function AuthLink(): ReactNode { +const updateStatusMutation = gql` + mutation UpdateAlertsMutation($input: UpdateAlertsInput!) { + updateAlerts(input: $input) { + id + } + } +` + +export default function AuthLink(): JSX.Element { const [params] = useURLParams({ authLinkToken: '', details: '', @@ -23,12 +33,33 @@ export default function AuthLink(): ReactNode { const resetParams = useResetURLParams('authLinkToken', 'details') const { ready } = useSessionInfo() - const [linkAccount, linkAccountStatus] = useMutation(mutation, { - variables: { token: params.authLinkToken }, - }) + const [linkAccountStatus, linkAccount] = useMutation(mutation) + const [_, updateAlertStatus] = useMutation(updateStatusMutation) if (!params.details || !params.authLinkToken || !ready) { - return null + return
{undefined}
+ } + + const authTokenExpired = linkAccountStatus?.error?.message.includes('expired') + + if (linkAccountStatus.error) { + return ( + console.log('hello')} + open={authTokenExpired || Boolean(linkAccountStatus?.error)} + > + + {authTokenExpired + ? 'The auth link token has expired. Please try again later.' + : 'An unexpected error has occurred. Please try again later.'} + + + ) } return ( @@ -41,16 +72,23 @@ export default function AuthLink(): ReactNode { resetParams() }} onSubmit={() => - linkAccount().then(() => { - if (params.alertID) { - navigate(`/alerts/${params.alertID}`) - } + linkAccount({ token: params.authLinkToken }).then((result) => { + if (result.error) return + + if (params.alertID) navigate(`/alerts/${params.alertID}`) + if (params.action) { - // make request to close/ack here - // if fail trigger toast + updateAlertStatus({ + input: { + alertIDs: [params.alertID], + newStatus: + params.action === 'ResultAcknowledge' + ? 'StatusAcknowledged' + : 'StatusClosed', + }, + }) } - // always call resetParams() }) } From c035ad37fadf45183f59ca70bb525ea0bc6bedc8 Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Thu, 11 Aug 2022 16:52:27 -0500 Subject: [PATCH 19/33] format file --- web/src/app/main/components/AuthLink.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/src/app/main/components/AuthLink.tsx b/web/src/app/main/components/AuthLink.tsx index 247d50cf47..7770f4001d 100644 --- a/web/src/app/main/components/AuthLink.tsx +++ b/web/src/app/main/components/AuthLink.tsx @@ -34,7 +34,7 @@ export default function AuthLink(): JSX.Element { const { ready } = useSessionInfo() const [linkAccountStatus, linkAccount] = useMutation(mutation) - const [_, updateAlertStatus] = useMutation(updateStatusMutation) + const [, updateAlertStatus] = useMutation(updateStatusMutation) if (!params.details || !params.authLinkToken || !ready) { return
{undefined}
@@ -74,9 +74,7 @@ export default function AuthLink(): JSX.Element { onSubmit={() => linkAccount({ token: params.authLinkToken }).then((result) => { if (result.error) return - if (params.alertID) navigate(`/alerts/${params.alertID}`) - if (params.action) { updateAlertStatus({ input: { From 3dea61ae4d34d7cfb2393864315da0954bad4d01 Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Tue, 16 Aug 2022 12:10:38 -0500 Subject: [PATCH 20/33] fix post message to display text and button --- notification/slack/servemessageaction.go | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index b6d73b3f3d..80475f64e7 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -117,7 +117,10 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ res = notification.ResultResolve case linkActActionID: s.withClient(ctx, func(c *slack.Client) error { - _, err = c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, slack.MsgOptionText("", false), slack.MsgOptionReplaceOriginal(payload.ResponseURL), slack.MsgOptionDeleteOriginal(payload.ResponseURL)) + // remove ephemeral 'Link Account' button + _, err = c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, + slack.MsgOptionText("", false), slack.MsgOptionReplaceOriginal(payload.ResponseURL), + slack.MsgOptionDeleteOriginal(payload.ResponseURL)) if err != nil { return err } @@ -143,7 +146,6 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ log.Log(ctx, err) } - err = s.withClient(ctx, func(c *slack.Client) error { var msg string if linkURL == "" { @@ -152,13 +154,24 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ msg = "Please link your Slack account with GoAlert then try again." } - linkBtn := slack.NewButtonBlockElement(linkActActionID, linkURL, slack.NewTextBlockObject("plain_text", "Link Account", false, false)) - linkBtn.URL = linkURL + linkBtnBlock := slack.NewButtonBlockElement(linkActActionID, linkURL, + slack.NewTextBlockObject("plain_text", "Link Account", false, false)) + linkBtnBlock.URL = linkURL _, err := c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, slack.MsgOptionResponseURL(payload.ResponseURL, "ephemeral"), slack.MsgOptionText(msg, false), - slack.MsgOptionBlocks(slack.NewActionBlock(alertResponseBlockID, linkBtn)), + slack.MsgOptionAttachments( + slack.Attachment{ + Color: "#862421", + Fallback: msg, + Blocks: slack.Blocks{ + BlockSet: []slack.Block{ + slack.NewActionBlock(alertResponseBlockID, linkBtnBlock), + }, + }, + }, + ), ) if err != nil { From 3d7f04dc6e3d176362348f0b3889219819d9b80c Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Tue, 16 Aug 2022 15:56:23 -0500 Subject: [PATCH 21/33] [WIP] add slack linking smoke test --- devtools/mockslack/chatpostmessage.go | 3 +++ notification/slack/servemessageaction.go | 24 ++++++++--------------- smoketest/harness/slack.go | 7 +++++++ smoketest/slackinteraction_test.go | 25 +++++++++++++++++++++--- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/devtools/mockslack/chatpostmessage.go b/devtools/mockslack/chatpostmessage.go index 971924dca6..7507a794a7 100644 --- a/devtools/mockslack/chatpostmessage.go +++ b/devtools/mockslack/chatpostmessage.go @@ -124,6 +124,7 @@ type Action struct { ActionID string Text string Value string + URL string } // parseAttachments parses the attachments from the payload value. @@ -174,6 +175,7 @@ func parseAttachments(appID, teamID, chanID, value string) (*attachments, error) Text textBlock ActionID string `json:"action_id"` Value string + URL string } err = json.Unmarshal(b.Elements, &acts) if err != nil { @@ -189,6 +191,7 @@ func parseAttachments(appID, teamID, chanID, value string) (*attachments, error) ActionID: a.ActionID, Text: a.Text.Text, Value: a.Value, + URL: a.URL, }) } default: diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index 80475f64e7..33ed1d8912 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -118,8 +118,8 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ case linkActActionID: s.withClient(ctx, func(c *slack.Client) error { // remove ephemeral 'Link Account' button - _, err = c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, - slack.MsgOptionText("", false), slack.MsgOptionReplaceOriginal(payload.ResponseURL), + _, err = c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, + slack.MsgOptionText("", false), slack.MsgOptionReplaceOriginal(payload.ResponseURL), slack.MsgOptionDeleteOriginal(payload.ResponseURL)) if err != nil { return err @@ -154,27 +154,19 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ msg = "Please link your Slack account with GoAlert then try again." } - linkBtnBlock := slack.NewButtonBlockElement(linkActActionID, linkURL, + linkBtnBlock := slack.NewButtonBlockElement(linkActActionID, linkURL, slack.NewTextBlockObject("plain_text", "Link Account", false, false)) linkBtnBlock.URL = linkURL _, err := c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, - slack.MsgOptionResponseURL(payload.ResponseURL, "ephemeral"), - slack.MsgOptionText(msg, false), - slack.MsgOptionAttachments( - slack.Attachment{ - Color: "#862421", - Fallback: msg, - Blocks: slack.Blocks{ - BlockSet: []slack.Block{ - slack.NewActionBlock(alertResponseBlockID, linkBtnBlock), - }, - }, - }, + slack.MsgOptionResponseURL(payload.ResponseURL, "ephemeral"), + slack.MsgOptionBlocks( + slack.NewTextBlockObject("plain_text", msg, false, false), + slack.NewActionBlock(alertResponseBlockID, linkBtnBlock), ), ) - if err != nil { + fmt.Printf("\n\n error: %v", err) return err } return nil diff --git a/smoketest/harness/slack.go b/smoketest/harness/slack.go index 918af4a6ec..6cbf21dabd 100644 --- a/smoketest/harness/slack.go +++ b/smoketest/harness/slack.go @@ -2,6 +2,7 @@ package harness import ( "context" + "fmt" "net/http/httptest" "sort" "strings" @@ -50,6 +51,7 @@ type SlackMessageState interface { type SlackAction interface { Click() + URL() string } type SlackMessage interface { @@ -120,6 +122,11 @@ func (msg *slackMessage) Action(text string) SlackAction { } } +func (a *slackAction) URL() string { + a.h.t.Helper() + return a.Action.URL +} + func (a *slackAction) Click() { a.h.t.Helper() diff --git a/smoketest/slackinteraction_test.go b/smoketest/slackinteraction_test.go index cccf3950cc..e32d66e114 100644 --- a/smoketest/slackinteraction_test.go +++ b/smoketest/slackinteraction_test.go @@ -1,6 +1,8 @@ package smoketest import ( + "fmt" + "net/url" "testing" "github.com/target/goalert/smoketest/harness" @@ -30,7 +32,7 @@ func TestSlackInteraction(t *testing.T) { values ({{uuid "sid"}}, {{uuid "eid"}}, 'service'); ` - h := harness.NewHarness(t, sql, "slack-user-link") + h := harness.NewHarness(t, sql, "auth-link-requests") defer h.Close() h.SetConfigValue("Slack.InteractiveMessages", "true") @@ -44,9 +46,26 @@ func TestSlackInteraction(t *testing.T) { h.IgnoreErrorsWith("unknown provider/subject") msg.Action("Acknowledge").Click() // expect ephemeral - ch.ExpectEphemeralMessage("GoAlert", "admin") - h.LinkSlackUser() + msg = ch.ExpectEphemeralMessage("link", "Slack", "account") + urlStr := msg.Action("link").URL() + + u, err := url.Parse(urlStr) + if err != nil { + t.Fatal("bad link url returned:", err) + } + + tokenStr := u.Query().Get("authLinkToken") + resp := h.GraphQLQuery2(fmt.Sprintf(` + mutation { + linkAccountToken(token: "%s") + } + `, tokenStr)) + + if len(resp.Errors) > 0 { + t.Fatalf("expected no errors but got %v", resp.Errors) + } + msg.Action("Acknowledge").Click() updated := msg.ExpectUpdate() From 4f6b76ccb1830e5031ad6e5208108413ac31d609 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 16 Aug 2022 16:15:42 -0500 Subject: [PATCH 22/33] wrap text in section block --- notification/slack/servemessageaction.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index 33ed1d8912..a403d788fa 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -159,14 +159,16 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ linkBtnBlock.URL = linkURL _, err := c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, - slack.MsgOptionResponseURL(payload.ResponseURL, "ephemeral"), + slack.MsgOptionResponseURL(payload.ResponseURL, "ephemeral"), slack.MsgOptionBlocks( - slack.NewTextBlockObject("plain_text", msg, false, false), + slack.NewSectionBlock( + slack.NewTextBlockObject("plain_text", msg, false, false), + nil, nil, + ), slack.NewActionBlock(alertResponseBlockID, linkBtnBlock), ), ) if err != nil { - fmt.Printf("\n\n error: %v", err) return err } return nil From 09b99409a14386a5e48adead44e1ddfd0e542bf1 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 16 Aug 2022 16:16:22 -0500 Subject: [PATCH 23/33] remove unused import --- smoketest/harness/slack.go | 1 - 1 file changed, 1 deletion(-) diff --git a/smoketest/harness/slack.go b/smoketest/harness/slack.go index 6cbf21dabd..e4144dbb90 100644 --- a/smoketest/harness/slack.go +++ b/smoketest/harness/slack.go @@ -2,7 +2,6 @@ package harness import ( "context" - "fmt" "net/http/httptest" "sort" "strings" From f8c082f4bba58cfcb5093f27fa3b381a80962738 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 16 Aug 2022 17:03:20 -0500 Subject: [PATCH 24/33] fix ephemeral message assertion --- devtools/mockslack/actions.go | 47 ++++++++++++++++++++++++++++-- smoketest/slackinteraction_test.go | 3 +- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/devtools/mockslack/actions.go b/devtools/mockslack/actions.go index 717c3ed31c..9542864019 100644 --- a/devtools/mockslack/actions.go +++ b/devtools/mockslack/actions.go @@ -42,6 +42,18 @@ func (s *Server) ServeActionResponse(w http.ResponseWriter, r *http.Request) { var req struct { Text string Type string `json:"response_type"` + + Blocks []struct { + Type string + Text struct{ Text string } + Elements []struct { + Type string + Text struct{ Text string } + Value string + ActionID string `json:"action_id"` + URL string + } + } } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -60,11 +72,40 @@ func (s *Server) ServeActionResponse(w http.ResponseWriter, r *http.Request) { return } - msg, err := s.API().ChatPostMessage(r.Context(), ChatPostMessageOptions{ + opts := ChatPostMessageOptions{ ChannelID: a.ChannelID, - Text: req.Text, User: r.URL.Query().Get("user"), - }) + } + + if len(req.Blocks) > 0 { + // new API + for _, block := range req.Blocks { + switch block.Type { + case "section": + opts.Text = block.Text.Text + case "actions": + for _, action := range block.Elements { + if action.Type != "button" { + continue + } + + opts.Actions = append(opts.Actions, Action{ + ChannelID: a.ChannelID, + TeamID: a.TeamID, + AppID: a.AppID, + ActionID: action.ActionID, + Text: action.Text.Text, + Value: action.Value, + URL: action.URL, + }) + } + } + } + } else { + opts.Text = req.Text + } + + msg, err := s.API().ChatPostMessage(r.Context(), opts) if respondErr(w, err) { return } diff --git a/smoketest/slackinteraction_test.go b/smoketest/slackinteraction_test.go index e32d66e114..be7ac7f03b 100644 --- a/smoketest/slackinteraction_test.go +++ b/smoketest/slackinteraction_test.go @@ -47,8 +47,7 @@ func TestSlackInteraction(t *testing.T) { h.IgnoreErrorsWith("unknown provider/subject") msg.Action("Acknowledge").Click() // expect ephemeral - msg = ch.ExpectEphemeralMessage("link", "Slack", "account") - urlStr := msg.Action("link").URL() + urlStr := ch.ExpectEphemeralMessage("link", "Slack", "account").Action("Link Account").URL() u, err := url.Parse(urlStr) if err != nil { From a46611aa5ae6682c78b8adcbb0740b95c7a1f6b4 Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Wed, 17 Aug 2022 09:59:09 -0500 Subject: [PATCH 25/33] add ui slack link test --- web/src/cypress/integration/profile.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/web/src/cypress/integration/profile.ts b/web/src/cypress/integration/profile.ts index a6b2ae1b38..51d0228817 100644 --- a/web/src/cypress/integration/profile.ts +++ b/web/src/cypress/integration/profile.ts @@ -418,6 +418,26 @@ function testProfile(): void { cy.get('body').should('contain', 'No notification rules') }) }) + + describe('Slack', () => { + let alert: Alert + beforeEach(() => { + cy.createAlert().then((a: Alert) => { + alert = a + }) + }) + + it('should display slack link pop-up with correct values', () => { + cy.visit( + `/profile?action=ResultAcknowledge&alertID=${alert.alertID}` + + `&authLinkToken=token&details=slack%3A${profile.username}`, + ) + cy.get('body').should( + 'contain', + `Click confirm to link this account to slack:${profile.username}.`, + ) + }) + }) } testScreen('Profile', testProfile) From 5c351d67ee297b9eab22c9ff3fb56653f57fe16c Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Wed, 17 Aug 2022 10:01:22 -0500 Subject: [PATCH 26/33] shorten var name --- notification/slack/servemessageaction.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index a403d788fa..6eb2df9b5e 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -154,9 +154,9 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ msg = "Please link your Slack account with GoAlert then try again." } - linkBtnBlock := slack.NewButtonBlockElement(linkActActionID, linkURL, + btn := slack.NewButtonBlockElement(linkActActionID, linkURL, slack.NewTextBlockObject("plain_text", "Link Account", false, false)) - linkBtnBlock.URL = linkURL + btn.URL = linkURL _, err := c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, slack.MsgOptionResponseURL(payload.ResponseURL, "ephemeral"), @@ -165,7 +165,7 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ slack.NewTextBlockObject("plain_text", msg, false, false), nil, nil, ), - slack.NewActionBlock(alertResponseBlockID, linkBtnBlock), + slack.NewActionBlock(alertResponseBlockID, btn), ), ) if err != nil { From b86e20a700924576cbd682f884e2f39060a7e173 Mon Sep 17 00:00:00 2001 From: tony-tvu Date: Wed, 17 Aug 2022 10:20:41 -0500 Subject: [PATCH 27/33] format and resolve ineffectual assignment to err --- notification/receiver.go | 2 +- notification/slack/channel.go | 3 +-- notification/slack/servemessageaction.go | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/notification/receiver.go b/notification/receiver.go index 4b5a953a33..69a4f43a80 100644 --- a/notification/receiver.go +++ b/notification/receiver.go @@ -36,4 +36,4 @@ type UnknownSubjectError struct { func (e UnknownSubjectError) Error() string { return "unknown subject for that provider" -} \ No newline at end of file +} diff --git a/notification/slack/channel.go b/notification/slack/channel.go index c55bbb9376..55583c6945 100644 --- a/notification/slack/channel.go +++ b/notification/slack/channel.go @@ -246,7 +246,7 @@ const ( alertResponseBlockID = "block_alert_response" alertCloseActionID = "action_alert_close" alertAckActionID = "action_alert_ack" - linkActActionID = "action_link_account" + linkActActionID = "action_link_account" ) // alertMsgOption will return the slack.MsgOption for an alert-type message (e.g., notification or status update). @@ -375,4 +375,3 @@ func (s *ChannelSender) lookupTeamIDForToken(ctx context.Context, token string) return teamID, nil } - \ No newline at end of file diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index 6eb2df9b5e..ffab77b975 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -158,7 +158,7 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ slack.NewTextBlockObject("plain_text", "Link Account", false, false)) btn.URL = linkURL - _, err := c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, + _, err = c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, slack.MsgOptionResponseURL(payload.ResponseURL, "ephemeral"), slack.MsgOptionBlocks( slack.NewSectionBlock( From b1b0e77ed91422f75dcdb23cc11de9edcb3a870e Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Mon, 22 Aug 2022 17:36:27 -0500 Subject: [PATCH 28/33] move metadata to db and add extra info --- auth/authlink/store.go | 78 +++- engine/engine.go | 6 +- graphql2/generated.go | 349 +++++++++++++++++- graphql2/graphqlapp/mutation.go | 2 +- graphql2/graphqlapp/query.go | 28 ++ graphql2/models_gen.go | 6 + graphql2/schema.graphql | 10 +- .../20220802104000-auth-link-requests.sql | 3 +- notification/namedreceiver.go | 7 +- notification/receiver.go | 5 +- notification/resultreceiver.go | 5 +- notification/slack/servemessageaction.go | 33 +- web/src/app/main/components/AuthLink.tsx | 129 +++++-- web/src/schema.d.ts | 9 +- 14 files changed, 582 insertions(+), 88 deletions(-) diff --git a/auth/authlink/store.go b/auth/authlink/store.go index f5f2ae58b4..941f1517ab 100644 --- a/auth/authlink/store.go +++ b/auth/authlink/store.go @@ -3,6 +3,7 @@ package authlink import ( "context" "database/sql" + "encoding/json" "errors" "net/url" "time" @@ -25,6 +26,20 @@ type Store struct { newLink *sql.Stmt rmLink *sql.Stmt addSubject *sql.Stmt + findLink *sql.Stmt +} + +type Metadata struct { + UserDetails string + AlertID int `json:",omitempty"` + AlertAction string `json:",omitempty"` +} + +func (m Metadata) Validate() error { + return validate.Many( + validate.ASCII("UserDetails", m.UserDetails, 1, 255), + validate.OneOf("AlertAction", m.AlertAction, "", "ResultAcknowledge", "ResultResolve"), + ) } func NewStore(ctx context.Context, db *sql.DB, k keyring.Keyring) (*Store, error) { @@ -36,31 +51,71 @@ func NewStore(ctx context.Context, db *sql.DB, k keyring.Keyring) (*Store, error return &Store{ db: db, k: k, - newLink: p.P(`insert into auth_link_requests (id, provider_id, subject_id, expires_at) values ($1, $2, $3, $4)`), + newLink: p.P(`insert into auth_link_requests (id, provider_id, subject_id, expires_at, metadata) values ($1, $2, $3, $4, $5)`), rmLink: p.P(`delete from auth_link_requests where id = $1 and expires_at > now() returning provider_id, subject_id`), addSubject: p.P(`insert into auth_subjects (provider_id, subject_id, user_id) values ($1, $2, $3)`), + findLink: p.P(`select metadata from auth_link_requests where id = $1 and expires_at > now()`), }, p.Err } -func (s *Store) LinkAccount(ctx context.Context, token string) error { +func (s *Store) FindLinkMetadata(ctx context.Context, token string) (*Metadata, error) { err := permission.LimitCheckAny(ctx, permission.User) if err != nil { - return err + return nil, err + } + + tokID, err := s.tokenID(ctx, token) + if err != nil { + // don't return anything, treat it as not found + return nil, nil + } + + var meta Metadata + var data json.RawMessage + err = s.findLink.QueryRowContext(ctx, tokID).Scan(&data) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + + err = json.Unmarshal(data, &meta) + if err != nil { + return nil, err } + return &meta, nil +} + +func (s *Store) tokenID(ctx context.Context, token string) (string, error) { var c jwt.RegisteredClaims - _, err = s.k.VerifyJWT(token, &c) + _, err := s.k.VerifyJWT(token, &c) if err != nil { - return validation.WrapError(err) + return "", validation.WrapError(err) } if !c.VerifyIssuer("goalert", true) { - return validation.NewGenericError("invalid issuer") + return "", validation.NewGenericError("invalid issuer") } if !c.VerifyAudience("auth-link", true) { - return validation.NewGenericError("invalid audience") + return "", validation.NewGenericError("invalid audience") } err = validate.UUID("ID", c.ID) + if err != nil { + return "", err + } + + return c.ID, nil +} + +func (s *Store) LinkAccount(ctx context.Context, token string) error { + err := permission.LimitCheckAny(ctx, permission.User) + if err != nil { + return err + } + + tokID, err := s.tokenID(ctx, token) if err != nil { return err } @@ -72,7 +127,7 @@ func (s *Store) LinkAccount(ctx context.Context, token string) error { defer tx.Rollback() var providerID, subjectID string - err = tx.StmtContext(ctx, s.rmLink).QueryRowContext(ctx, c.ID).Scan(&providerID, &subjectID) + err = tx.StmtContext(ctx, s.rmLink).QueryRowContext(ctx, tokID).Scan(&providerID, &subjectID) if errors.Is(err, sql.ErrNoRows) { return validation.NewGenericError("invalid link token") } @@ -88,7 +143,7 @@ func (s *Store) LinkAccount(ctx context.Context, token string) error { return tx.Commit() } -func (s *Store) AuthLinkURL(ctx context.Context, providerID, subjectID string, params url.Values) (string, error) { +func (s *Store) AuthLinkURL(ctx context.Context, providerID, subjectID string, meta Metadata) (string, error) { err := permission.LimitCheckAny(ctx, permission.System) if err != nil { return "", err @@ -96,6 +151,7 @@ func (s *Store) AuthLinkURL(ctx context.Context, providerID, subjectID string, p err = validate.Many( validate.SubjectID("ProviderID", providerID), validate.SubjectID("SubjectID", subjectID), + meta.Validate(), ) if err != nil { return "", err @@ -118,7 +174,7 @@ func (s *Store) AuthLinkURL(ctx context.Context, providerID, subjectID string, p return "", err } - _, err = s.newLink.ExecContext(ctx, id, providerID, subjectID, expires) + _, err = s.newLink.ExecContext(ctx, id, providerID, subjectID, expires, meta) if err != nil { return "", err } @@ -126,5 +182,5 @@ func (s *Store) AuthLinkURL(ctx context.Context, providerID, subjectID string, p cfg := config.FromContext(ctx) p := make(url.Values) p.Set("authLinkToken", token) - return cfg.CallbackURL("/profile", p, params), nil + return cfg.CallbackURL("/profile", p), nil } diff --git a/engine/engine.go b/engine/engine.go index 69d2e3b8b5..989760ada0 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -4,13 +4,13 @@ import ( "context" "database/sql" "fmt" - "net/url" "strings" "time" "github.com/pkg/errors" "github.com/target/goalert/alert" "github.com/target/goalert/app/lifecycle" + "github.com/target/goalert/auth/authlink" "github.com/target/goalert/engine/cleanupmanager" "github.com/target/goalert/engine/escalationmanager" "github.com/target/goalert/engine/heartbeatmanager" @@ -154,9 +154,9 @@ func NewEngine(ctx context.Context, db *sql.DB, c *Config) (*Engine, error) { return p, nil } -func (p *Engine) AuthLinkURL(ctx context.Context, providerID, subjectID string, params url.Values) (url string, err error) { +func (p *Engine) AuthLinkURL(ctx context.Context, providerID, subjectID string, meta authlink.Metadata) (url string, err error) { permission.SudoContext(ctx, func(ctx context.Context) { - url, err = p.cfg.AuthLinkStore.AuthLinkURL(ctx, providerID, subjectID, params) + url, err = p.cfg.AuthLinkStore.AuthLinkURL(ctx, providerID, subjectID, meta) }) return url, err } diff --git a/graphql2/generated.go b/graphql2/generated.go index 968a463eed..032b67e2c7 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -254,6 +254,12 @@ type ComplexityRoot struct { PageInfo func(childComplexity int) int } + LinkAccountInfo struct { + AlertID func(childComplexity int) int + AlertNewStatus func(childComplexity int) int + UserDetails func(childComplexity int) int + } + Mutation struct { AddAuthSubject func(childComplexity int, input user.AuthSubject) int ClearTemporarySchedules func(childComplexity int, input ClearTemporarySchedulesInput) int @@ -276,7 +282,7 @@ type ComplexityRoot struct { DeleteAuthSubject func(childComplexity int, input user.AuthSubject) int EndAllAuthSessionsByCurrentUser func(childComplexity int) int EscalateAlerts func(childComplexity int, input []int) int - LinkAccountToken func(childComplexity int, token string) int + LinkAccount func(childComplexity int, token string) int SendContactMethodVerification func(childComplexity int, input SendContactMethodVerificationInput) int SetConfig func(childComplexity int, input []ConfigValueInput) int SetFavorite func(childComplexity int, input SetFavoriteInput) int @@ -359,6 +365,7 @@ type ComplexityRoot struct { LabelKeys func(childComplexity int, input *LabelKeySearchOptions) int LabelValues func(childComplexity int, input *LabelValueSearchOptions) int Labels func(childComplexity int, input *LabelSearchOptions) int + LinkAccountInfo func(childComplexity int, token string) int PhoneNumberInfo func(childComplexity int, number string) int Rotation func(childComplexity int, id string) int Rotations func(childComplexity int, input *RotationSearchOptions) int @@ -615,7 +622,7 @@ type IntegrationKeyResolver interface { Href(ctx context.Context, obj *integrationkey.IntegrationKey) (string, error) } type MutationResolver interface { - LinkAccountToken(ctx context.Context, token string) (bool, error) + LinkAccount(ctx context.Context, token string) (bool, error) SetTemporarySchedule(ctx context.Context, input SetTemporaryScheduleInput) (bool, error) ClearTemporarySchedules(ctx context.Context, input ClearTemporarySchedulesInput) (bool, error) SetScheduleOnCallNotificationRules(ctx context.Context, input SetScheduleOnCallNotificationRulesInput) (bool, error) @@ -700,6 +707,7 @@ type QueryResolver interface { SlackChannels(ctx context.Context, input *SlackChannelSearchOptions) (*SlackChannelConnection, error) SlackChannel(ctx context.Context, id string) (*slack.Channel, error) GenerateSlackAppManifest(ctx context.Context) (string, error) + LinkAccountInfo(ctx context.Context, token string) (*LinkAccountInfo, error) } type RotationResolver interface { IsFavorite(ctx context.Context, obj *rotation.Rotation) (bool, error) @@ -1462,6 +1470,27 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.LabelConnection.PageInfo(childComplexity), true + case "LinkAccountInfo.alertID": + if e.complexity.LinkAccountInfo.AlertID == nil { + break + } + + return e.complexity.LinkAccountInfo.AlertID(childComplexity), true + + case "LinkAccountInfo.alertNewStatus": + if e.complexity.LinkAccountInfo.AlertNewStatus == nil { + break + } + + return e.complexity.LinkAccountInfo.AlertNewStatus(childComplexity), true + + case "LinkAccountInfo.userDetails": + if e.complexity.LinkAccountInfo.UserDetails == nil { + break + } + + return e.complexity.LinkAccountInfo.UserDetails(childComplexity), true + case "Mutation.addAuthSubject": if e.complexity.Mutation.AddAuthSubject == nil { break @@ -1709,17 +1738,17 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.EscalateAlerts(childComplexity, args["input"].([]int)), true - case "Mutation.linkAccountToken": - if e.complexity.Mutation.LinkAccountToken == nil { + case "Mutation.linkAccount": + if e.complexity.Mutation.LinkAccount == nil { break } - args, err := ec.field_Mutation_linkAccountToken_args(context.TODO(), rawArgs) + args, err := ec.field_Mutation_linkAccount_args(context.TODO(), rawArgs) if err != nil { return 0, false } - return e.complexity.Mutation.LinkAccountToken(childComplexity, args["token"].(string)), true + return e.complexity.Mutation.LinkAccount(childComplexity, args["token"].(string)), true case "Mutation.sendContactMethodVerification": if e.complexity.Mutation.SendContactMethodVerification == nil { @@ -2328,6 +2357,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.Labels(childComplexity, args["input"].(*LabelSearchOptions)), true + case "Query.linkAccountInfo": + if e.complexity.Query.LinkAccountInfo == nil { + break + } + + args, err := ec.field_Query_linkAccountInfo_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.LinkAccountInfo(childComplexity, args["token"].(string)), true + case "Query.phoneNumberInfo": if e.complexity.Query.PhoneNumberInfo == nil { break @@ -3850,7 +3891,7 @@ func (ec *executionContext) field_Mutation_escalateAlerts_args(ctx context.Conte return args, nil } -func (ec *executionContext) field_Mutation_linkAccountToken_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { +func (ec *executionContext) field_Mutation_linkAccount_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} var arg0 string @@ -4438,6 +4479,21 @@ func (ec *executionContext) field_Query_labels_args(ctx context.Context, rawArgs return args, nil } +func (ec *executionContext) field_Query_linkAccountInfo_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 string + if tmp, ok := rawArgs["token"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("token")) + arg0, err = ec.unmarshalNID2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["token"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query_phoneNumberInfo_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -9169,8 +9225,134 @@ func (ec *executionContext) fieldContext_LabelConnection_pageInfo(ctx context.Co return fc, nil } -func (ec *executionContext) _Mutation_linkAccountToken(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Mutation_linkAccountToken(ctx, field) +func (ec *executionContext) _LinkAccountInfo_userDetails(ctx context.Context, field graphql.CollectedField, obj *LinkAccountInfo) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_LinkAccountInfo_userDetails(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.UserDetails, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_LinkAccountInfo_userDetails(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LinkAccountInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LinkAccountInfo_alertID(ctx context.Context, field graphql.CollectedField, obj *LinkAccountInfo) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_LinkAccountInfo_alertID(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.AlertID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int) + fc.Result = res + return ec.marshalOInt2ᚖint(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_LinkAccountInfo_alertID(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LinkAccountInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LinkAccountInfo_alertNewStatus(ctx context.Context, field graphql.CollectedField, obj *LinkAccountInfo) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_LinkAccountInfo_alertNewStatus(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.AlertNewStatus, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*AlertStatus) + fc.Result = res + return ec.marshalOAlertStatus2ᚖgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐAlertStatus(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_LinkAccountInfo_alertNewStatus(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LinkAccountInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type AlertStatus does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Mutation_linkAccount(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_linkAccount(ctx, field) if err != nil { return graphql.Null } @@ -9183,7 +9365,7 @@ func (ec *executionContext) _Mutation_linkAccountToken(ctx context.Context, fiel }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Mutation().LinkAccountToken(rctx, fc.Args["token"].(string)) + return ec.resolvers.Mutation().LinkAccount(rctx, fc.Args["token"].(string)) }) if err != nil { ec.Error(ctx, err) @@ -9200,7 +9382,7 @@ func (ec *executionContext) _Mutation_linkAccountToken(ctx context.Context, fiel return ec.marshalNBoolean2bool(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Mutation_linkAccountToken(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Mutation_linkAccount(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Mutation", Field: field, @@ -9217,7 +9399,7 @@ func (ec *executionContext) fieldContext_Mutation_linkAccountToken(ctx context.C } }() ctx = graphql.WithFieldContext(ctx, fc) - if fc.Args, err = ec.field_Mutation_linkAccountToken_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + if fc.Args, err = ec.field_Mutation_linkAccount_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { ec.Error(ctx, err) return } @@ -15015,6 +15197,66 @@ func (ec *executionContext) fieldContext_Query_generateSlackAppManifest(ctx cont return fc, nil } +func (ec *executionContext) _Query_linkAccountInfo(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_linkAccountInfo(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().LinkAccountInfo(rctx, fc.Args["token"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*LinkAccountInfo) + fc.Result = res + return ec.marshalOLinkAccountInfo2ᚖgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐLinkAccountInfo(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_linkAccountInfo(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "userDetails": + return ec.fieldContext_LinkAccountInfo_userDetails(ctx, field) + case "alertID": + return ec.fieldContext_LinkAccountInfo_alertID(ctx, field) + case "alertNewStatus": + return ec.fieldContext_LinkAccountInfo_alertNewStatus(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type LinkAccountInfo", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_linkAccountInfo_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return + } + return fc, nil +} + func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query___type(ctx, field) if err != nil { @@ -27276,6 +27518,42 @@ func (ec *executionContext) _LabelConnection(ctx context.Context, sel ast.Select return out } +var linkAccountInfoImplementors = []string{"LinkAccountInfo"} + +func (ec *executionContext) _LinkAccountInfo(ctx context.Context, sel ast.SelectionSet, obj *LinkAccountInfo) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, linkAccountInfoImplementors) + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("LinkAccountInfo") + case "userDetails": + + out.Values[i] = ec._LinkAccountInfo_userDetails(ctx, field, obj) + + if out.Values[i] == graphql.Null { + invalids++ + } + case "alertID": + + out.Values[i] = ec._LinkAccountInfo_alertID(ctx, field, obj) + + case "alertNewStatus": + + out.Values[i] = ec._LinkAccountInfo_alertNewStatus(ctx, field, obj) + + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var mutationImplementors = []string{"Mutation"} func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler { @@ -27295,10 +27573,10 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) switch field.Name { case "__typename": out.Values[i] = graphql.MarshalString("Mutation") - case "linkAccountToken": + case "linkAccount": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { - return ec._Mutation_linkAccountToken(ctx, field) + return ec._Mutation_linkAccount(ctx, field) }) if out.Values[i] == graphql.Null { @@ -28691,6 +28969,26 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr return ec.OperationContext.RootResolverMiddleware(ctx, innerFunc) } + out.Concurrently(i, func() graphql.Marshaler { + return rrm(innerCtx) + }) + case "linkAccountInfo": + field := field + + innerFunc := func(ctx context.Context) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_linkAccountInfo(ctx, field) + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, innerFunc) + } + out.Concurrently(i, func() graphql.Marshaler { return rrm(innerCtx) }) @@ -33807,6 +34105,22 @@ func (ec *executionContext) marshalOAlertStatus2ᚕgithubᚗcomᚋtargetᚋgoale return ret } +func (ec *executionContext) unmarshalOAlertStatus2ᚖgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐAlertStatus(ctx context.Context, v interface{}) (*AlertStatus, error) { + if v == nil { + return nil, nil + } + var res = new(AlertStatus) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOAlertStatus2ᚖgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐAlertStatus(ctx context.Context, sel ast.SelectionSet, v *AlertStatus) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + func (ec *executionContext) unmarshalOBoolean2bool(ctx context.Context, v interface{}) (bool, error) { res, err := graphql.UnmarshalBoolean(v) return res, graphql.ErrorOnPath(ctx, err) @@ -34234,6 +34548,13 @@ func (ec *executionContext) unmarshalOLabelValueSearchOptions2ᚖgithubᚗcomᚋ return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) marshalOLinkAccountInfo2ᚖgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐLinkAccountInfo(ctx context.Context, sel ast.SelectionSet, v *LinkAccountInfo) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._LinkAccountInfo(ctx, sel, v) +} + func (ec *executionContext) marshalONotificationState2ᚖgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐNotificationState(ctx context.Context, sel ast.SelectionSet, v *NotificationState) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/graphql2/graphqlapp/mutation.go b/graphql2/graphqlapp/mutation.go index 4aedd87649..10e0b1fdc1 100644 --- a/graphql2/graphqlapp/mutation.go +++ b/graphql2/graphqlapp/mutation.go @@ -35,7 +35,7 @@ func (a *Mutation) SetFavorite(ctx context.Context, input graphql2.SetFavoriteIn return true, nil } -func (a *Mutation) LinkAccountToken(ctx context.Context, token string) (bool, error) { +func (a *Mutation) LinkAccount(ctx context.Context, token string) (bool, error) { err := a.AuthLinkStore.LinkAccount(ctx, token) return err == nil, err } diff --git a/graphql2/graphqlapp/query.go b/graphql2/graphqlapp/query.go index 1e574ea1e7..792ae42e08 100644 --- a/graphql2/graphqlapp/query.go +++ b/graphql2/graphqlapp/query.go @@ -28,6 +28,34 @@ type ( func (a *App) Query() graphql2.QueryResolver { return (*Query)(a) } +func (a *Query) LinkAccountInfo(ctx context.Context, token string) (*graphql2.LinkAccountInfo, error) { + m, err := a.AuthLinkStore.FindLinkMetadata(ctx, token) + if err != nil { + return nil, err + } + if m == nil { + return nil, nil + } + + info := &graphql2.LinkAccountInfo{ + UserDetails: m.UserDetails, + } + if m.AlertID > 0 { + info.AlertID = &m.AlertID + } + var s graphql2.AlertStatus + switch m.AlertAction { + case notification.ResultAcknowledge.String(): + s = graphql2.AlertStatusStatusAcknowledged + info.AlertNewStatus = &s + case notification.ResultResolve.String(): + s = graphql2.AlertStatusStatusClosed + info.AlertNewStatus = &s + } + + return info, nil +} + func (a *App) formatNC(ctx context.Context, id string) (string, error) { if id == "" { return "", nil diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go index 77da0b2772..833bb14246 100644 --- a/graphql2/models_gen.go +++ b/graphql2/models_gen.go @@ -300,6 +300,12 @@ type LabelValueSearchOptions struct { Omit []string `json:"omit"` } +type LinkAccountInfo struct { + UserDetails string `json:"userDetails"` + AlertID *int `json:"alertID"` + AlertNewStatus *AlertStatus `json:"alertNewStatus"` +} + type NotificationState struct { Details string `json:"details"` Status *NotificationStatus `json:"status"` diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index b841ac09cc..b1bcf853a3 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -109,6 +109,14 @@ type Query { slackChannel(id: ID!): SlackChannel generateSlackAppManifest: String! + + linkAccountInfo(token: ID!): LinkAccountInfo +} + +type LinkAccountInfo { + userDetails: String! + alertID: Int + alertNewStatus: AlertStatus } input AlertMetricsOptions { @@ -341,7 +349,7 @@ input SetScheduleShiftInput { } type Mutation { - linkAccountToken(token: ID!): Boolean! + linkAccount(token: ID!): Boolean! setTemporarySchedule(input: SetTemporaryScheduleInput!): Boolean! clearTemporarySchedules(input: ClearTemporarySchedulesInput!): Boolean! diff --git a/migrate/migrations/20220802104000-auth-link-requests.sql b/migrate/migrations/20220802104000-auth-link-requests.sql index a3dba1f714..c18e9fa51f 100644 --- a/migrate/migrations/20220802104000-auth-link-requests.sql +++ b/migrate/migrations/20220802104000-auth-link-requests.sql @@ -4,7 +4,8 @@ CREATE TABLE auth_link_requests ( provider_id TEXT NOT NULL, subject_id TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + metadata JSONB NOT NULL DEFAULT '{}'::JSONB ); -- +migrate Down diff --git a/notification/namedreceiver.go b/notification/namedreceiver.go index 1dfd5ad427..e63854660e 100644 --- a/notification/namedreceiver.go +++ b/notification/namedreceiver.go @@ -2,7 +2,8 @@ package notification import ( "context" - "net/url" + + "github.com/target/goalert/auth/authlink" ) type namedReceiver struct { @@ -27,8 +28,8 @@ func (nr *namedReceiver) SetMessageStatus(ctx context.Context, externalID string } // AuthLinkURL calls the underlying AuthLinkURL method. -func (nr *namedReceiver) AuthLinkURL(ctx context.Context, providerID, subjectID string, params url.Values) (string, error) { - return nr.r.AuthLinkURL(ctx, providerID, subjectID, params) +func (nr *namedReceiver) AuthLinkURL(ctx context.Context, providerID, subjectID string, meta authlink.Metadata) (string, error) { + return nr.r.AuthLinkURL(ctx, providerID, subjectID, meta) } // Start implements the Receiver interface by calling the underlying Receiver.Start method. diff --git a/notification/receiver.go b/notification/receiver.go index 69a4f43a80..57520cf270 100644 --- a/notification/receiver.go +++ b/notification/receiver.go @@ -2,7 +2,8 @@ package notification import ( "context" - "net/url" + + "github.com/target/goalert/auth/authlink" ) // A Receiver processes incoming messages and responses. @@ -17,7 +18,7 @@ type Receiver interface { ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) error // AuthLinkURL will generate a URL to link a provider and subject to a GoAlert user. - AuthLinkURL(ctx context.Context, providerID, subjectID string, params url.Values) (string, error) + AuthLinkURL(ctx context.Context, providerID, subjectID string, meta authlink.Metadata) (string, error) // Start indicates a user has opted-in for notifications to this contact method. Start(context.Context, Dest) error diff --git a/notification/resultreceiver.go b/notification/resultreceiver.go index aa1e05b2f1..637b8bd828 100644 --- a/notification/resultreceiver.go +++ b/notification/resultreceiver.go @@ -2,7 +2,8 @@ package notification import ( "context" - "net/url" + + "github.com/target/goalert/auth/authlink" ) // A ResultReceiver processes notification responses. @@ -11,7 +12,7 @@ type ResultReceiver interface { Receive(ctx context.Context, callbackID string, result Result) error ReceiveSubject(ctx context.Context, providerID, subjectID, callbackID string, result Result) error - AuthLinkURL(ctx context.Context, providerID, subjectID string, params url.Values) (string, error) + AuthLinkURL(ctx context.Context, providerID, subjectID string, meta authlink.Metadata) (string, error) Start(context.Context, Dest) error Stop(context.Context, Dest) error diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index ffab77b975..11ade3849c 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -9,12 +9,12 @@ import ( "fmt" "io" "net/http" - "net/url" "strconv" "time" "github.com/slack-go/slack" "github.com/target/goalert/alert" + "github.com/target/goalert/auth/authlink" "github.com/target/goalert/config" "github.com/target/goalert/notification" "github.com/target/goalert/util/errutil" @@ -79,13 +79,17 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ var payload struct { Type string ResponseURL string `json:"response_url"` - Channel struct { + Team struct { + ID string + Domain string + } + Channel struct { ID string } User struct { ID string `json:"id"` - TeamID string `json:"team_id"` Username string `json:"username"` + Name string } Actions []struct { ActionID string `json:"action_id"` @@ -133,17 +137,22 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ } var e *notification.UnknownSubjectError - err = s.recv.ReceiveSubject(ctx, "slack:"+payload.User.TeamID, payload.User.ID, act.Value, res) + err = s.recv.ReceiveSubject(ctx, "slack:"+payload.Team.ID, payload.User.ID, act.Value, res) if errors.As(err, &e) { - v := make(url.Values) - v.Set("details", fmt.Sprintf("slack:%s", payload.User.Username)) - v.Set("alertID", strconv.Itoa(e.AlertID)) - v.Set("action", res.String()) - - linkURL, err := s.recv.AuthLinkURL(ctx, "slack:"+payload.User.TeamID, payload.User.ID, v) - if err != nil { - log.Log(ctx, err) + var linkURL string + switch { + case payload.User.Name == "", payload.User.Username == "", payload.Team.ID == "", payload.Team.Domain == "": + // missing data, don't allow linking + default: + linkURL, err = s.recv.AuthLinkURL(ctx, "slack:"+payload.Team.ID, payload.User.ID, authlink.Metadata{ + UserDetails: fmt.Sprintf("Slack user %s (@%s) from %s.slack.com", payload.User.Name, payload.User.Username, payload.Team.Domain), + AlertID: e.AlertID, + AlertAction: res.String(), + }) + if err != nil { + log.Log(ctx, err) + } } err = s.withClient(ctx, func(c *slack.Client) error { diff --git a/web/src/app/main/components/AuthLink.tsx b/web/src/app/main/components/AuthLink.tsx index 7770f4001d..2653b05eea 100644 --- a/web/src/app/main/components/AuthLink.tsx +++ b/web/src/app/main/components/AuthLink.tsx @@ -1,15 +1,25 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import { useSessionInfo } from '../../util/RequireConfig' -import { useResetURLParams, useURLParams } from '../../actions' -import { gql, useMutation } from 'urql' +import { useResetURLParams, useURLParam, useURLParams } from '../../actions' +import { gql, useMutation, useQuery } from 'urql' import FormDialog from '../../dialogs/FormDialog' import { useLocation } from 'wouter' import Snackbar from '@mui/material/Snackbar' -import { Alert } from '@mui/material' +import { Alert, Typography } from '@mui/material' +import { LinkAccountInfo } from '../../../schema' const mutation = gql` mutation ($token: ID!) { - linkAccountToken(token: $token) + linkAccount(token: $token) + } +` +const query = gql` + query ($token: ID!) { + linkAccountInfo(token: $token) { + userDetails + alertID + alertNewStatus + } } ` @@ -21,28 +31,52 @@ const updateStatusMutation = gql` } ` -export default function AuthLink(): JSX.Element { - const [params] = useURLParams({ - authLinkToken: '', - details: '', - alertID: '', - action: '', - }) +export default function AuthLink(): JSX.Element | null { + const [token, setToken] = useURLParam('authLinkToken', '') const [, navigate] = useLocation() - const resetParams = useResetURLParams('authLinkToken', 'details') - const { ready } = useSessionInfo() + const { ready, userName } = useSessionInfo() + const [{ data, fetching, error }] = useQuery({ query, variables: { token } }) const [linkAccountStatus, linkAccount] = useMutation(mutation) const [, updateAlertStatus] = useMutation(updateStatusMutation) + const [snack, setSnack] = useState(true) + + const info: LinkAccountInfo = data?.linkAccountInfo + + useEffect(() => { + if (!ready) return + if (!token) return + if (fetching) return + if (error) return + if (info) return + + setToken('') + }, [!!info, !!error, fetching, ready, token]) - if (!params.details || !params.authLinkToken || !ready) { - return
{undefined}
+ if (!token || !ready || fetching) { + return null } - const authTokenExpired = linkAccountStatus?.error?.message.includes('expired') + if (error) { + return ( + setSnack(false)} + open={snack && !!error} + > + + Unable to fetch account link details. Try again later. + + + ) + } - if (linkAccountStatus.error) { + if (!info) { return ( console.log('hello')} - open={authTokenExpired || Boolean(linkAccountStatus?.error)} + onClose={() => setSnack(false)} + open={snack} > - {authTokenExpired - ? 'The auth link token has expired. Please try again later.' - : 'An unexpected error has occurred. Please try again later.'} + Invalid or expired account link URL. Try again. ) } + let alertAction = '' + if (info.alertID && info.alertNewStatus) { + switch (info.alertNewStatus) { + case 'StatusAcknowledged': + alertAction = `alert #${data.linkAccountInfo.alertID} will be acknowledged.` + break + case 'StatusClosed': + alertAction = `alert #${data.linkAccountInfo.alertID} will be closed.` + break + default: + alertAction = `Alert #${data.linkAccountInfo.alertID} will be updated to ${info.alertNewStatus}.` + break + } + } + return ( { - resetParams() - }} + onClose={() => setToken('')} onSubmit={() => - linkAccount({ token: params.authLinkToken }).then((result) => { + linkAccount({ token }).then((result) => { if (result.error) return - if (params.alertID) navigate(`/alerts/${params.alertID}`) - if (params.action) { + if (info.alertID) navigate(`/alerts/${info.alertID}`) + if (info.alertNewStatus) { updateAlertStatus({ input: { - alertIDs: [params.alertID], - newStatus: - params.action === 'ResultAcknowledge' - ? 'StatusAcknowledged' - : 'StatusClosed', + alertIDs: [info.alertID], + newStatus: info.alertNewStatus, }, }) } - resetParams() + setToken('') }) } - /> + form={ + + + Clicking confirm will link the current GoAlert user{' '} + {userName} with: + + {data.linkAccountInfo.userDetails}. +
+
+ {alertAction && ( + After linking, {alertAction} + )} +
+ } + >
) } diff --git a/web/src/schema.d.ts b/web/src/schema.d.ts index 237da51648..0eb2a1dbee 100644 --- a/web/src/schema.d.ts +++ b/web/src/schema.d.ts @@ -34,6 +34,13 @@ export interface Query { slackChannels: SlackChannelConnection slackChannel?: null | SlackChannel generateSlackAppManifest: string + linkAccountInfo: LinkAccountInfo +} + +export interface LinkAccountInfo { + userDetails: string + alertID?: null | number + alertNewStatus?: null | AlertStatus } export interface AlertMetricsOptions { @@ -256,7 +263,7 @@ export interface SetScheduleShiftInput { } export interface Mutation { - linkAccountToken: boolean + linkAccount: boolean setTemporarySchedule: boolean clearTemporarySchedules: boolean setScheduleOnCallNotificationRules: boolean From 831dd1a5b722d51aa5b9214bce9b1ee7e2b1ed48 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 23 Aug 2022 09:35:18 -0500 Subject: [PATCH 29/33] linting fixes --- web/src/app/main/components/AuthLink.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/app/main/components/AuthLink.tsx b/web/src/app/main/components/AuthLink.tsx index 2653b05eea..c72b3943e3 100644 --- a/web/src/app/main/components/AuthLink.tsx +++ b/web/src/app/main/components/AuthLink.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import { useSessionInfo } from '../../util/RequireConfig' -import { useResetURLParams, useURLParam, useURLParams } from '../../actions' +import { useURLParam } from '../../actions' import { gql, useMutation, useQuery } from 'urql' import FormDialog from '../../dialogs/FormDialog' import { useLocation } from 'wouter' @@ -145,6 +145,6 @@ export default function AuthLink(): JSX.Element | null { )} } - >
+ /> ) } From caf6acf0bfd90bcf7b5ce96f51ce54c7dc0c7c41 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 23 Aug 2022 09:36:35 -0500 Subject: [PATCH 30/33] update schema --- web/src/schema.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/schema.d.ts b/web/src/schema.d.ts index 0eb2a1dbee..09d0724139 100644 --- a/web/src/schema.d.ts +++ b/web/src/schema.d.ts @@ -34,7 +34,7 @@ export interface Query { slackChannels: SlackChannelConnection slackChannel?: null | SlackChannel generateSlackAppManifest: string - linkAccountInfo: LinkAccountInfo + linkAccountInfo?: null | LinkAccountInfo } export interface LinkAccountInfo { From a9e2cb0201c7f90ddb5186c4745626b71e5a3bf0 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Mon, 29 Aug 2022 12:30:37 -0500 Subject: [PATCH 31/33] fix action handling --- devtools/mockslack/actions.go | 6 ++++++ notification/slack/servemessageaction.go | 26 ++++++++++++++---------- test/smoke/harness/slack.go | 3 ++- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/devtools/mockslack/actions.go b/devtools/mockslack/actions.go index 9542864019..1475822069 100644 --- a/devtools/mockslack/actions.go +++ b/devtools/mockslack/actions.go @@ -34,6 +34,10 @@ type actionBody struct { Name string TeamID string `json:"team_id"` } + Team struct { + ID string + Domain string + } ResponseURL string `json:"response_url"` Actions []actionItem } @@ -147,6 +151,8 @@ func (s *Server) PerformActionAs(userID string, a Action) error { p.User.Username = usr.Name p.User.Name = usr.Name p.User.TeamID = a.TeamID + p.Team.ID = a.TeamID + p.Team.Domain = "example.com" p.Channel.ID = a.ChannelID p.AppID = a.AppID diff --git a/notification/slack/servemessageaction.go b/notification/slack/servemessageaction.go index 11ade3849c..284c9eab5a 100644 --- a/notification/slack/servemessageaction.go +++ b/notification/slack/servemessageaction.go @@ -144,6 +144,7 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ switch { case payload.User.Name == "", payload.User.Username == "", payload.Team.ID == "", payload.Team.Domain == "": // missing data, don't allow linking + log.Log(ctx, errors.New("slack payload missing requried data")) default: linkURL, err = s.recv.AuthLinkURL(ctx, "slack:"+payload.Team.ID, payload.User.ID, authlink.Metadata{ UserDetails: fmt.Sprintf("Slack user %s (@%s) from %s.slack.com", payload.User.Name, payload.User.Username, payload.Team.Domain), @@ -160,22 +161,25 @@ func (s *ChannelSender) ServeMessageAction(w http.ResponseWriter, req *http.Requ if linkURL == "" { msg = "Your Slack account isn't currently linked to GoAlert, please try again later." } else { - msg = "Please link your Slack account with GoAlert then try again." + msg = "Please link your Slack account with GoAlert." + } + blocks := []slack.Block{ + slack.NewSectionBlock( + slack.NewTextBlockObject("plain_text", msg, false, false), + nil, nil, + ), } - btn := slack.NewButtonBlockElement(linkActActionID, linkURL, - slack.NewTextBlockObject("plain_text", "Link Account", false, false)) - btn.URL = linkURL + if linkURL != "" { + btn := slack.NewButtonBlockElement(linkActActionID, linkURL, + slack.NewTextBlockObject("plain_text", "Link Account", false, false)) + btn.URL = linkURL + blocks = append(blocks, slack.NewActionBlock(alertResponseBlockID, btn)) + } _, err = c.PostEphemeralContext(ctx, payload.Channel.ID, payload.User.ID, slack.MsgOptionResponseURL(payload.ResponseURL, "ephemeral"), - slack.MsgOptionBlocks( - slack.NewSectionBlock( - slack.NewTextBlockObject("plain_text", msg, false, false), - nil, nil, - ), - slack.NewActionBlock(alertResponseBlockID, btn), - ), + slack.MsgOptionBlocks(blocks...), ) if err != nil { return err diff --git a/test/smoke/harness/slack.go b/test/smoke/harness/slack.go index e4144dbb90..44a8852516 100644 --- a/test/smoke/harness/slack.go +++ b/test/smoke/harness/slack.go @@ -113,7 +113,8 @@ func (msg *slackMessage) Action(text string) SlackAction { a = &action break } - require.NotNil(msg.h.t, a, "could not find action with that text") + require.NotNilf(msg.h.t, a, `expected action "%s"`, text) + msg.h.t.Logf("found action: %s\n%#v", text, *a) return &slackAction{ slackMessage: msg, From 384bb8c9424aada02eb07dc0cce1c8ffba7c478e Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Mon, 29 Aug 2022 12:30:45 -0500 Subject: [PATCH 32/33] fix interaction test --- test/smoke/slackinteraction_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/smoke/slackinteraction_test.go b/test/smoke/slackinteraction_test.go index 7971a49493..d9f691afe1 100644 --- a/test/smoke/slackinteraction_test.go +++ b/test/smoke/slackinteraction_test.go @@ -48,6 +48,7 @@ func TestSlackInteraction(t *testing.T) { msg.Action("Acknowledge").Click() // expect ephemeral urlStr := ch.ExpectEphemeralMessage("link", "Slack", "account").Action("Link Account").URL() + t.Logf("url: %s", urlStr) u, err := url.Parse(urlStr) if err != nil { @@ -57,7 +58,7 @@ func TestSlackInteraction(t *testing.T) { tokenStr := u.Query().Get("authLinkToken") resp := h.GraphQLQuery2(fmt.Sprintf(` mutation { - linkAccountToken(token: "%s") + linkAccount(token: "%s") } `, tokenStr)) From 163015857bc05cf5415beb8ab9fd8b1f33acdd64 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 31 Aug 2022 07:50:42 -0500 Subject: [PATCH 33/33] remove profile slack test, out of scope --- web/src/cypress/integration/profile.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/web/src/cypress/integration/profile.ts b/web/src/cypress/integration/profile.ts index 51d0228817..a6b2ae1b38 100644 --- a/web/src/cypress/integration/profile.ts +++ b/web/src/cypress/integration/profile.ts @@ -418,26 +418,6 @@ function testProfile(): void { cy.get('body').should('contain', 'No notification rules') }) }) - - describe('Slack', () => { - let alert: Alert - beforeEach(() => { - cy.createAlert().then((a: Alert) => { - alert = a - }) - }) - - it('should display slack link pop-up with correct values', () => { - cy.visit( - `/profile?action=ResultAcknowledge&alertID=${alert.alertID}` + - `&authLinkToken=token&details=slack%3A${profile.username}`, - ) - cy.get('body').should( - 'contain', - `Click confirm to link this account to slack:${profile.username}.`, - ) - }) - }) } testScreen('Profile', testProfile)