diff --git a/api/types/plugin.go b/api/types/plugin.go index a939bdb7ecf94..0f16a2ec69633 100644 --- a/api/types/plugin.go +++ b/api/types/plugin.go @@ -72,6 +72,8 @@ const ( PluginTypeEntraID = "entra-id" // PluginTypeSCIM indicates a generic SCIM integration PluginTypeSCIM = "scim" + // PluginTypeIncidentio is the incident.io access request plugin + PluginTypeIncidentio = "incidentio" ) // PluginSubkind represents the type of the plugin, e.g., access request, MDM etc. diff --git a/integrations/access/incidentio/alert_client_test.go b/integrations/access/incidentio/alert_client_test.go new file mode 100644 index 0000000000000..d91f4724bb71e --- /dev/null +++ b/integrations/access/incidentio/alert_client_test.go @@ -0,0 +1,119 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package incidentio + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" + + "github.com/gravitational/teleport/api/types" +) + +func TestCreateAlert(t *testing.T) { + recievedReq := "" + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + log.Fatal(err) + } + recievedReq = string(bodyBytes) + })) + defer func() { testServer.Close() }() + + c, err := NewAlertClient(ClientConfig{ + AlertSourceEndpoint: testServer.URL, + ClusterName: "someClusterName", + }) + assert.NoError(t, err) + + _, err = c.CreateAlert(context.Background(), "someRequestID", RequestData{ + User: "someUser", + Roles: []string{"role1", "role2"}, + RequestReason: "someReason", + SystemAnnotations: types.Labels{ + types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel: {"responder@example.com", "bb4d9938-c3c2-455d-aaab-727aa701c0d8"}, + }, + }) + assert.NoError(t, err) + + expected := AlertBody{ + Title: "Access request from someUser", + DeduplicationKey: "teleport-access-request/someRequestID", + Description: "Access request from someUser", + Status: "firing", + Metadata: map[string]string{ + "request_id": "someRequestID", + }, + } + var got AlertBody + err = json.Unmarshal([]byte(recievedReq), &got) + assert.NoError(t, err) + + assert.Equal(t, expected, got) +} + +func TestResolveAlert(t *testing.T) { + recievedReq := "" + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + log.Fatal(err) + } + recievedReq = string(bodyBytes) + })) + defer func() { testServer.Close() }() + + c, err := NewAlertClient(ClientConfig{ + AlertSourceEndpoint: testServer.URL, + ClusterName: "someClusterName", + }) + assert.NoError(t, err) + + err = c.ResolveAlert(context.Background(), "someAlertID", Resolution{ + Tag: ResolvedApproved, + Reason: "someReason", + }) + + assert.NoError(t, err) + + assert.Equal(t, `{"message":"Access request resolved: approved","description":"Access request has been approved","deduplication_key":"teleport-access-request/someAlertID","status":"resolved","metadata":{"request_id":"someAlertID"}}`, recievedReq) +} + +func TestCreateAlertError(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + res.WriteHeader(http.StatusForbidden) + })) + defer func() { testServer.Close() }() + + c, err := NewAlertClient(ClientConfig{ + AlertSourceEndpoint: testServer.URL, + }) + assert.NoError(t, err) + + _, err = c.CreateAlert(context.Background(), "someRequestID", RequestData{}) + assert.True(t, trace.IsAccessDenied(err)) +} diff --git a/integrations/access/incidentio/api_client_test.go b/integrations/access/incidentio/api_client_test.go new file mode 100644 index 0000000000000..e6f331360f8bc --- /dev/null +++ b/integrations/access/incidentio/api_client_test.go @@ -0,0 +1,70 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package incidentio + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetSchedule(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/v2/schedules/someRequestID" { + res.WriteHeader(http.StatusOK) + } else { + res.WriteHeader(http.StatusBadRequest) + } + })) + defer func() { testServer.Close() }() + + c, err := NewAPIClient(ClientConfig{ + APIEndpoint: testServer.URL, + APIKey: "someAPIKey", + ClusterName: "someClusterName", + }) + assert.NoError(t, err) + + _, err = c.GetOnCall(context.Background(), "someRequestID") + assert.NoError(t, err) +} + +func TestHealthCheck(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/v2/schedules" { + res.WriteHeader(http.StatusOK) + } else { + res.WriteHeader(http.StatusBadRequest) + } + })) + defer func() { testServer.Close() }() + + c, err := NewAPIClient(ClientConfig{ + APIEndpoint: testServer.URL, + APIKey: "someAPIKey", + ClusterName: "someClusterName", + }) + assert.NoError(t, err) + + err = c.CheckHealth(context.Background()) + assert.NoError(t, err) +} diff --git a/integrations/access/incidentio/app.go b/integrations/access/incidentio/app.go new file mode 100644 index 0000000000000..9e5727ceefbb1 --- /dev/null +++ b/integrations/access/incidentio/app.go @@ -0,0 +1,602 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package incidentio + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + + tp "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/client/proto" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/access/accessmonitoring" + "github.com/gravitational/teleport/integrations/access/common" + "github.com/gravitational/teleport/integrations/access/common/teleport" + "github.com/gravitational/teleport/integrations/lib" + "github.com/gravitational/teleport/integrations/lib/backoff" + "github.com/gravitational/teleport/integrations/lib/logger" + "github.com/gravitational/teleport/integrations/lib/watcherjob" + "github.com/gravitational/teleport/lib/utils" +) + +const ( + // pluginName is used to tag incident.io GenericPluginData and as a Delegator in Audit log. + pluginName = "incidentio" + // minServerVersion is the minimal teleport version the plugin supports. + minServerVersion = "16.2.1" + // initTimeout is used to bound execution time of health check and teleport version check. + initTimeout = time.Second * 30 + // handlerTimeout is used to bound the execution time of watcher event handler. + handlerTimeout = time.Second * 30 + // modifyPluginDataBackoffBase is an initial (minimum) backoff value. + modifyPluginDataBackoffBase = time.Millisecond + // modifyPluginDataBackoffMax is a backoff threshold + modifyPluginDataBackoffMax = time.Second +) + +// errMissingAnnotation is used for cases where request annotations are not set +var errMissingAnnotation = errors.New("access request is missing annotations") + +// App is a wrapper around the base app to allow for extra functionality. +type App struct { + *lib.Process + + PluginName string + teleport teleport.Client + alertClient *AlertClient + incClient *APIClient + mainJob lib.ServiceJob + conf Config + + accessMonitoringRules *accessmonitoring.RuleHandler +} + +// NewIncidentApp initializes a new teleport-incidentio app and returns it. +func NewIncidentApp(ctx context.Context, conf *Config) (*App, error) { + incidentApp := &App{ + PluginName: pluginName, + conf: *conf, + } + teleClient, err := conf.GetTeleportClient(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + incidentApp.accessMonitoringRules = accessmonitoring.NewRuleHandler(accessmonitoring.RuleHandlerConfig{ + Client: teleClient, + PluginType: string(conf.BaseConfig.PluginType), + FetchRecipientCallback: createScheduleRecipient, + }) + incidentApp.mainJob = lib.NewServiceJob(incidentApp.run) + return incidentApp, nil +} + +// Run initializes and runs a watcher and a callback server +func (a *App) Run(ctx context.Context) error { + // Initialize the process. + a.Process = lib.NewProcess(ctx) + a.SpawnCriticalJob(a.mainJob) + <-a.Process.Done() + return a.Err() +} + +// Err returns the error app finished with. +func (a *App) Err() error { + return trace.Wrap(a.mainJob.Err()) +} + +// WaitReady waits for http and watcher service to start up. +func (a *App) WaitReady(ctx context.Context) (bool, error) { + return a.mainJob.WaitReady(ctx) +} + +func (a *App) run(ctx context.Context) error { + var err error + + log := logger.Get(ctx) + log.Infof("Starting Teleport Access incident.io Plugin") + + if err = a.init(ctx); err != nil { + return trace.Wrap(err) + } + + watcherJob, err := watcherjob.NewJob( + a.teleport, + watcherjob.Config{ + Watch: types.Watch{Kinds: []types.WatchKind{ + {Kind: types.KindAccessRequest}, + {Kind: types.KindAccessMonitoringRule}, + }}, + EventFuncTimeout: handlerTimeout, + }, + a.onWatcherEvent, + ) + log.Info("Starting watcher job") + + if err != nil { + return trace.Wrap(err) + } + a.SpawnCriticalJob(watcherJob) + ok, err := watcherJob.WaitReady(ctx) + if err != nil { + return trace.Wrap(err) + } + + if err := a.accessMonitoringRules.InitAccessMonitoringRulesCache(ctx); err != nil { + return trace.Wrap(err) + } + + a.mainJob.SetReady(ok) + if ok { + log.Info("Plugin is ready") + } else { + log.Error("Plugin is not ready") + } + + <-watcherJob.Done() + + return trace.Wrap(watcherJob.Err()) +} + +func (a *App) init(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, initTimeout) + defer cancel() + + var err error + a.teleport, err = a.conf.GetTeleportClient(ctx) + if err != nil { + return trace.Wrap(err, "getting teleport client") + } + + if _, err = a.checkTeleportVersion(ctx); err != nil { + return trace.Wrap(err) + } + + a.alertClient, err = NewAlertClient(a.conf.ClientConfig) + if err != nil { + return trace.Wrap(err) + } + + a.incClient, err = NewAPIClient(a.conf.ClientConfig) + if err != nil { + return trace.Wrap(err) + } + + log := logger.Get(ctx) + log.Debug("Starting API health check...") + if err = a.incClient.CheckHealth(ctx); err != nil { + return trace.Wrap(err, "API health check failed") + } + log.Debug("API health check finished ok") + return nil +} + +func (a *App) checkTeleportVersion(ctx context.Context) (proto.PingResponse, error) { + log := logger.Get(ctx) + log.Debug("Checking Teleport server version") + + pong, err := a.teleport.Ping(ctx) + if err != nil { + if trace.IsNotImplemented(err) { + return pong, trace.Wrap(err, "server version must be at least %s", minServerVersion) + } + log.Error("Unable to get Teleport server version") + return pong, trace.Wrap(err) + } + err = utils.CheckMinVersion(pong.ServerVersion, minServerVersion) + return pong, trace.Wrap(err) +} + +// onWatcherEvent is called for every cluster Event. It will call the handlers +// for access request and access monitoring rule events. +func (a *App) onWatcherEvent(ctx context.Context, event types.Event) error { + switch event.Resource.GetKind() { + case types.KindAccessMonitoringRule: + return trace.Wrap(a.accessMonitoringRules.HandleAccessMonitoringRule(ctx, event)) + case types.KindAccessRequest: + return trace.Wrap(a.handleAcessRequest(ctx, event)) + } + return trace.BadParameter("unexpected kind %s", event.Resource.GetKind()) +} + +func (a *App) handleAcessRequest(ctx context.Context, event types.Event) error { + if kind := event.Resource.GetKind(); kind != types.KindAccessRequest { + return trace.Errorf("unexpected kind %s", kind) + } + op := event.Type + reqID := event.Resource.GetName() + ctx, _ = logger.WithField(ctx, "request_id", reqID) + + switch op { + case types.OpPut: + ctx, _ = logger.WithField(ctx, "request_op", "put") + req, ok := event.Resource.(types.AccessRequest) + if !ok { + return trace.Errorf("unexpected resource type %T", event.Resource) + } + ctx, log := logger.WithField(ctx, "request_state", req.GetState().String()) + + var err error + switch { + case req.GetState().IsPending(): + err = a.onPendingRequest(ctx, req) + case req.GetState().IsResolved(): + err = a.onResolvedRequest(ctx, req) + default: + log.WithField("event", event).Warn("Unknown request state") + return nil + } + + if err != nil { + log.WithError(err).Error("Failed to process request") + return trace.Wrap(err) + } + + return nil + case types.OpDelete: + ctx, log := logger.WithField(ctx, "request_op", "delete") + + if err := a.onDeletedRequest(ctx, reqID); err != nil { + log.WithError(err).Error("Failed to process deleted request") + return trace.Wrap(err) + } + return nil + default: + return trace.BadParameter("unexpected event operation %s", op) + } +} + +func (a *App) onPendingRequest(ctx context.Context, req types.AccessRequest) error { + // First, try to create a notification alert. + isNew, notifyErr := a.tryNotifyService(ctx, req) + + // To minimize the count of auto-approval tries, let's only attempt it only when we have just created an alert. + // But if there's an error, we can't really know if the alert is new or not so lets just try. + if !isNew && notifyErr == nil { + return nil + } + // Don't show the error if the annotation is just missing. + if errors.Is(trace.Unwrap(notifyErr), errMissingAnnotation) { + notifyErr = nil + } + + // Then, try to approve the request if user is currently on-call. + approveErr := trace.Wrap(a.tryApproveRequest(ctx, req)) + return trace.NewAggregate(notifyErr, approveErr) +} + +func (a *App) onResolvedRequest(ctx context.Context, req types.AccessRequest) error { + var notifyErr error + + resolution := Resolution{Reason: req.GetResolveReason()} + switch req.GetState() { + case types.RequestState_APPROVED: + resolution.Tag = ResolvedApproved + case types.RequestState_DENIED: + resolution.Tag = ResolvedDenied + case types.RequestState_PROMOTED: + resolution.Tag = ResolvedPromoted + } + err := trace.Wrap(a.resolveAlert(ctx, req.GetName(), resolution)) + return trace.NewAggregate(notifyErr, err) +} + +func (a *App) onDeletedRequest(ctx context.Context, reqID string) error { + return a.resolveAlert(ctx, reqID, Resolution{Tag: ResolvedExpired}) +} + +// getNotifySchedulesAndTeams get schedules and teams to notify from both +// annotations: /notify-services and /teams, returns an error if both are empty. +func (a *App) getNotifySchedulesAndTeams(ctx context.Context, req types.AccessRequest) (schedules []string, teams []string, err error) { + log := logger.Get(ctx) + + scheduleAnnotationKey := types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel + schedules, err = common.GetServiceNamesFromAnnotations(req, scheduleAnnotationKey) + if err != nil { + log.Debugf("No schedules to notify in %s", scheduleAnnotationKey) + } + + if len(schedules) == 0 && len(teams) == 0 { + return nil, nil, trace.NotFound("no schedules or teams to notify") + } + + return schedules, teams, nil +} + +func (a *App) getOnCallServiceNames(req types.AccessRequest) ([]string, error) { + annotationKey := types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel + return common.GetServiceNamesFromAnnotations(req, annotationKey) +} + +func (a *App) tryNotifyService(ctx context.Context, req types.AccessRequest) (bool, error) { + log := logger.Get(ctx) + + recipientSchedules, _, err := a.getMessageRecipients(ctx, req) + if err != nil { + log.Debugf("Skipping the notification: %s", err) + return false, trace.Wrap(errMissingAnnotation) + } + + reqID := req.GetName() + annotations := types.Labels{} + for k, v := range req.GetSystemAnnotations() { + annotations[k] = v + } + + if len(recipientSchedules) != 0 { + schedules := make([]string, 0, len(recipientSchedules)) + for _, s := range recipientSchedules { + schedules = append(schedules, s.Name) + } + annotations[types.TeleportNamespace+types.ReqAnnotationNotifySchedulesLabel] = schedules + } + + reqData := RequestData{ + User: req.GetUser(), + Roles: req.GetRoles(), + Created: req.GetCreationTime(), + RequestReason: req.GetRequestReason(), + SystemAnnotations: annotations, + } + + // Create plugin data if it didn't exist before. + isNew, err := a.modifyPluginData(ctx, reqID, func(existing *PluginData) (PluginData, bool) { + if existing != nil { + return PluginData{}, false + } + return PluginData{RequestData: reqData}, true + }) + if err != nil { + return isNew, trace.Wrap(err, "updating plugin data") + } + + if isNew { + if err = a.createAlert(ctx, reqID, reqData); err != nil { + return isNew, trace.Wrap(err, "creating incidentio alert") + } + } + return isNew, nil +} + +func (a *App) getMessageRecipients(ctx context.Context, req types.AccessRequest) ([]common.Recipient, []common.Recipient, error) { + recipientSetSchedules := common.NewRecipientSet() + recipientSchedules := a.accessMonitoringRules.RecipientsFromAccessMonitoringRules(ctx, req) + recipientSchedules.ForEach(func(r common.Recipient) { + recipientSetSchedules.Add(r) + }) + // Access Monitoring Rules recipients does not have a way to handle separate recipient types currently. + // Recipients from Access Monitoring Rules will be schedules only currently. + if recipientSetSchedules.Len() != 0 { + return recipientSetSchedules.ToSlice(), nil, nil + } + rawSchedules, rawTeams, err := a.getNotifySchedulesAndTeams(ctx, req) + if err != nil { + return nil, nil, trace.Wrap(err) + } + for _, rawSchedule := range rawSchedules { + recipientSetSchedules.Add(common.Recipient{ + Name: rawSchedule, + ID: rawSchedule, + Kind: common.RecipientKindSchedule, + }) + } + + recipientSetTeams := common.NewRecipientSet() + for _, rawTeam := range rawTeams { + recipientSetTeams.Add(common.Recipient{ + Name: rawTeam, + ID: rawTeam, + Kind: common.RecipientKindTeam, + }) + } + return recipientSetSchedules.ToSlice(), nil, nil +} + +// createAlert posts an alert with request information. +func (a *App) createAlert(ctx context.Context, reqID string, reqData RequestData) error { + data, err := a.alertClient.CreateAlert(ctx, reqID, reqData) + if err != nil { + return trace.Wrap(err) + } + ctx, log := logger.WithField(ctx, "incident_deduplication_key", data.DeduplicationKey) + log.Info("Successfully created incident.io alert") + + _, err = a.modifyPluginData(ctx, reqID, func(existing *PluginData) (PluginData, bool) { + var pluginData PluginData + if existing != nil { + pluginData = *existing + } else { + // It must be impossible but lets handle it just in case. + pluginData = PluginData{RequestData: reqData} + } + pluginData.IncidentAlertData = data + return pluginData, true + }) + return trace.Wrap(err) +} + +// tryApproveRequest attempts to submit an approval if the requesting user is on-call in one of the services provided in request annotation. +func (a *App) tryApproveRequest(ctx context.Context, req types.AccessRequest) error { + log := logger.Get(ctx) + + serviceNames, err := a.getOnCallServiceNames(req) + if err != nil { + logger.Get(ctx).Debugf("Skipping the approval: %s", err) + return nil + } + + onCallUsers := []string{} + for _, scheduleName := range serviceNames { + respondersResult, err := a.incClient.GetOnCall(ctx, scheduleName) + if err != nil { + return trace.Wrap(err) + } + for _, shift := range respondersResult.Schedule.CurrentShifts { + if shift.User != nil { + onCallUsers = append(onCallUsers, shift.User.Email) + } + } + + } + + userIsOnCall := false + for _, user := range onCallUsers { + if req.GetUser() == user { + userIsOnCall = true + } + } + if userIsOnCall { + if _, err := a.teleport.SubmitAccessReview(ctx, types.AccessReviewSubmission{ + RequestID: req.GetName(), + Review: types.AccessReview{ + Author: a.conf.TeleportUserName, + ProposedState: types.RequestState_APPROVED, + Reason: fmt.Sprintf("Access requested by user %s who is on call on service(s) %s", + tp.SystemAccessApproverUserName, + strings.Join(serviceNames, ","), + ), + Created: time.Now(), + }, + }); err != nil { + if strings.HasSuffix(err.Error(), "has already reviewed this request") { + log.Debug("Already reviewed the request") + return nil + } + return trace.Wrap(err, "submitting access request") + } + + } + log.Info("Successfully submitted a request approval") + return nil +} + +// resolveAlert resolves the notification alert created by plugin if the alert exists. +func (a *App) resolveAlert(ctx context.Context, reqID string, resolution Resolution) error { + var alertID string + + // Save request resolution info in plugin data. + ok, err := a.modifyPluginData(ctx, reqID, func(existing *PluginData) (PluginData, bool) { + // If plugin data is empty or missing alertID, we cannot do anything. + if existing == nil { + return PluginData{}, false + } + if alertID = existing.DeduplicationKey; alertID == "" { + return PluginData{}, false + } + + // If resolution field is not empty then we already resolved the alert before. In this case we just quit. + if existing.RequestData.Resolution.Tag != Unresolved { + return PluginData{}, false + } + + // Mark alert as resolved. + pluginData := *existing + pluginData.Resolution = resolution + return pluginData, true + }) + if err != nil { + return trace.Wrap(err) + } + if !ok { + logger.Get(ctx).Debugf("Failed to resolve the alert: plugin data is missing") + return nil + } + + ctx, log := logger.WithField(ctx, "incidentio_deduplication_key", alertID) + if err := a.alertClient.ResolveAlert(ctx, alertID, resolution); err != nil { + return trace.Wrap(err) + } + log.Info("Successfully resolved the alert") + + return nil +} + +// modifyPluginData performs a compare-and-swap update of access request's plugin data. +// Callback function parameter is nil if plugin data hasn't been created yet. +// Otherwise, callback function parameter is a pointer to current plugin data contents. +// Callback function return value is an updated plugin data contents plus the boolean flag +// indicating whether it should be written or not. +// Note that callback function fn might be called more than once due to retry mechanism baked in +// so make sure that the function is "pure" i.e. it doesn't interact with the outside world: +// it doesn't perform any sort of I/O operations so even things like Go channels must be avoided. +// Indeed, this limitation is not that ultimate at least if you know what you're doing. +func (a *App) modifyPluginData(ctx context.Context, reqID string, fn func(data *PluginData) (PluginData, bool)) (bool, error) { + backoff := backoff.NewDecorr(modifyPluginDataBackoffBase, modifyPluginDataBackoffMax, clockwork.NewRealClock()) + for { + oldData, err := a.getPluginData(ctx, reqID) + if err != nil && !trace.IsNotFound(err) { + return false, trace.Wrap(err) + } + newData, ok := fn(oldData) + if !ok { + return false, nil + } + var expectData PluginData + if oldData != nil { + expectData = *oldData + } + err = trace.Wrap(a.updatePluginData(ctx, reqID, newData, expectData)) + if err == nil { + return true, nil + } + if !trace.IsCompareFailed(err) { + return false, trace.Wrap(err) + } + if err := backoff.Do(ctx); err != nil { + return false, trace.Wrap(err) + } + } +} + +// getPluginData loads a plugin data for a given access request. It returns nil if it's not found. +func (a *App) getPluginData(ctx context.Context, reqID string) (*PluginData, error) { + dataMaps, err := a.teleport.GetPluginData(ctx, types.PluginDataFilter{ + Kind: types.KindAccessRequest, + Resource: reqID, + Plugin: pluginName, + }) + if err != nil { + return nil, trace.Wrap(err) + } + if len(dataMaps) == 0 { + return nil, trace.NotFound("plugin data not found") + } + entry := dataMaps[0].Entries()[pluginName] + if entry == nil { + return nil, trace.NotFound("plugin data entry not found") + } + data := DecodePluginData(entry.Data) + return &data, nil +} + +// updatePluginData updates an existing plugin data or sets a new one if it didn't exist. +func (a *App) updatePluginData(ctx context.Context, reqID string, data PluginData, expectData PluginData) error { + return a.teleport.UpdatePluginData(ctx, types.PluginDataUpdateParams{ + Kind: types.KindAccessRequest, + Resource: reqID, + Plugin: pluginName, + Set: EncodePluginData(data), + Expect: EncodePluginData(expectData), + }) +} diff --git a/integrations/access/incidentio/bot.go b/integrations/access/incidentio/bot.go new file mode 100644 index 0000000000000..80c658899dae4 --- /dev/null +++ b/integrations/access/incidentio/bot.go @@ -0,0 +1,141 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package incidentio + +import ( + "context" + "net/url" + "time" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/accesslist" + "github.com/gravitational/teleport/integrations/access/accessrequest" + "github.com/gravitational/teleport/integrations/access/common" + pd "github.com/gravitational/teleport/integrations/lib/plugindata" +) + +// Bot is a incident.io client that works with AccessRequest. +// It's responsible for formatting and incident.io alerts when an +// action occurs with an access request: a new request popped up, or a +// request is processed/updated. +type Bot struct { + apiClient *APIClient + alertClient *AlertClient + clusterName string + webProxyURL *url.URL +} + +// SupportedApps are the apps supported by this bot. +func (b *Bot) SupportedApps() []common.App { + return []common.App{ + accessrequest.NewApp(b), + } +} + +// CheckHealth checks if the bot can connect to its messaging service +func (b *Bot) CheckHealth(ctx context.Context) error { + return trace.Wrap(b.apiClient.CheckHealth(ctx)) +} + +// SendReviewReminders will send a review reminder that an access list needs to be reviewed. +func (b Bot) SendReviewReminders(ctx context.Context, recipients []common.Recipient, accessLists []*accesslist.AccessList) error { + return trace.NotImplemented("access list review reminder is not yet implemented") +} + +// BroadcastAccessRequestMessage creates an alert for the provided recipients (schedules) +func (b *Bot) BroadcastAccessRequestMessage(ctx context.Context, recipientSchedules []common.Recipient, reqID string, reqData pd.AccessRequestData) (data accessrequest.SentMessages, err error) { + notificationSchedules := make([]string, 0, len(recipientSchedules)) + for _, notifySchedule := range recipientSchedules { + notificationSchedules = append(notificationSchedules, notifySchedule.Name) + } + autoApprovalSchedules := []string{} + if annotationAutoApprovalSchedules, ok := reqData.SystemAnnotations[types.TeleportNamespace+types.ReqAnnotationApproveSchedulesLabel]; ok { + autoApprovalSchedules = annotationAutoApprovalSchedules + } + if len(autoApprovalSchedules) == 0 { + autoApprovalSchedules = append(autoApprovalSchedules, b.apiClient.DefaultSchedules...) + } + incidentReqData := RequestData{ + User: reqData.User, + Roles: reqData.Roles, + Created: time.Now(), + RequestReason: reqData.RequestReason, + ReviewsCount: reqData.ReviewsCount, + Resolution: Resolution{ + Tag: ResolutionTag(reqData.ResolutionTag), + Reason: reqData.ResolutionReason, + }, + SystemAnnotations: types.Labels{ + types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel: autoApprovalSchedules, + types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel: notificationSchedules, + }, + } + incidentAlertData, err := b.alertClient.CreateAlert(ctx, reqID, incidentReqData) + if err != nil { + return nil, trace.Wrap(err) + } + data = accessrequest.SentMessages{{ + MessageID: incidentAlertData.DeduplicationKey, + }} + + return data, nil + +} + +// PostReviewReply posts an alert note. +func (b *Bot) PostReviewReply(ctx context.Context, _ string, alertID string, review types.AccessReview) error { + return trace.NotImplemented("post review reply not implemented for plugin") +} + +// NotifyUser will send users a direct message with the access request status +func (b Bot) NotifyUser(ctx context.Context, reqID string, reqData pd.AccessRequestData) error { + return trace.NotImplemented("notify user not implemented for plugin") +} + +// UpdateMessages add notes to the alert containing updates to status. +// This will also resolve alerts based on the resolution tag. +func (b *Bot) UpdateMessages(ctx context.Context, reqID string, data pd.AccessRequestData, alertData accessrequest.SentMessages, reviews []types.AccessReview) error { + var errs []error + for _, alert := range alertData { + resolution := Resolution{ + Tag: ResolutionTag(data.ResolutionTag), + Reason: data.ResolutionReason, + } + err := b.alertClient.ResolveAlert(ctx, alert.MessageID, resolution) + if err != nil { + errs = append(errs, err) + } + } + return trace.NewAggregate(errs...) +} + +// FetchRecipient returns the recipient for the given raw recipient. +func (b *Bot) FetchRecipient(ctx context.Context, name string) (*common.Recipient, error) { + return createScheduleRecipient(ctx, name) +} + +func createScheduleRecipient(ctx context.Context, name string) (*common.Recipient, error) { + return &common.Recipient{ + Name: name, + ID: name, + Kind: common.RecipientKindSchedule, + }, nil +} diff --git a/integrations/access/incidentio/client.go b/integrations/access/incidentio/client.go new file mode 100644 index 0000000000000..15d90b4f4f187 --- /dev/null +++ b/integrations/access/incidentio/client.go @@ -0,0 +1,233 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package incidentio + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/aws/aws-sdk-go/aws/defaults" + "github.com/go-resty/resty/v2" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/access/common" + "github.com/gravitational/teleport/integrations/lib/logger" + "github.com/gravitational/trace" +) + +const ( + // alertKeyPrefix is the prefix for Alert's alias field used when creating an Alert. + alertKeyPrefix = "teleport-access-request" +) + +// AlertClient is a wrapper around resty.Client. +type AlertClient struct { + ClientConfig + + client *resty.Client +} + +type APIClient struct { + ClientConfig + + client *resty.Client +} + +// ClientConfig is the config for the incident.io client. +type ClientConfig struct { + // AccessToken is the token for the incident.io Alert Source + AccessToken string `toml:"access_token"` + // APIKey is the API key for the incident.io API + APIKey string `toml:"api_key"` + // AlertSourceEndpoint is the endpoint for the incident.io Alert Source + AlertSourceEndpoint string `toml:"alert_source_endpoint"` + // APIEndpoint is the endpoint for the incident.io API + // api url of the form https://api.incident.io/v2/ with optional trailing '/' + APIEndpoint string `toml:"api_endpoint"` + // DefaultSchedules are the default on-call schedules to check for auto approval + DefaultSchedules []string `toml:"default_schedules"` + + // WebProxyURL is the Teleport address used when building the bodies of the alerts + // allowing links to the access requests to be built + WebProxyURL *url.URL `toml:"web_proxy_url"` + // ClusterName is the name of the Teleport cluster + ClusterName string `toml:"cluster_name"` + + // StatusSink receives any status updates from the plugin for + // further processing. Status updates will be ignored if not set. + StatusSink common.StatusSink +} + +func (cfg *ClientConfig) CheckAndSetDefaults() error { + if cfg.AccessToken == "" { + return trace.BadParameter("missing required value AccessToken") + } + if cfg.AlertSourceEndpoint == "" { + return trace.BadParameter("missing required value AlertSourceEndpoint") + } + if cfg.APIKey == "" { + return trace.BadParameter("missing required value APIKey") + } + if cfg.APIEndpoint == "" { + return trace.BadParameter("missing required value APIEndpoint") + } + return nil +} + +// NewAlertClient creates a new incident.io client for sending alerts. +func NewAlertClient(conf ClientConfig) (*AlertClient, error) { + client := resty.NewWithClient(defaults.Config().HTTPClient) + client.SetHeader("Authorization", "Bearer "+conf.AccessToken) + return &AlertClient{ + client: client, + ClientConfig: conf, + }, nil +} + +// NewAPIClient creates a new incident.io client for interacting with the incident.io API. +func NewAPIClient(conf ClientConfig) (*APIClient, error) { + client := resty.NewWithClient(defaults.Config().HTTPClient) + client.SetHeader("Authorization", "Bearer "+conf.APIKey) + client.SetBaseURL(conf.APIEndpoint) + return &APIClient{ + client: client, + ClientConfig: conf, + }, nil +} + +func errWrapper(statusCode int, body string) error { + switch statusCode { + case http.StatusForbidden: + return trace.AccessDenied("incident.io API access denied: status code %v: %q", statusCode, body) + case http.StatusRequestTimeout: + return trace.ConnectionProblem(trace.Errorf("status code %v: %q", statusCode, body), + "connecting to incident.io API") + } + return trace.Errorf("connecting to incident.io API status code %v: %q", statusCode, body) +} + +// CreateAlert creates an incidentio alert. +func (inc AlertClient) CreateAlert(ctx context.Context, reqID string, reqData RequestData) (IncidentAlertData, error) { + body := AlertBody{ + Title: fmt.Sprintf("Access request from %s", reqData.User), + DeduplicationKey: fmt.Sprintf("%s/%s", alertKeyPrefix, reqID), + Description: fmt.Sprintf("Access request from %s", reqData.User), + Status: "firing", + Metadata: map[string]string{ + "request_id": reqID, + }, + } + + var result AlertBody + resp, err := inc.client.NewRequest(). + SetContext(ctx). + SetBody(body). + SetResult(&result). + Post(inc.AlertSourceEndpoint) + if err != nil { + return IncidentAlertData{}, trace.Wrap(err) + } + defer resp.RawResponse.Body.Close() + if resp.IsError() { + return IncidentAlertData{}, errWrapper(resp.StatusCode(), string(resp.Body())) + } + + return IncidentAlertData{ + DeduplicationKey: result.DeduplicationKey, + }, nil +} + +// ResolveAlert resolves an alert and posts a note with resolution details. +func (inc AlertClient) ResolveAlert(ctx context.Context, alertID string, resolution Resolution) error { + alertBody := &AlertBody{ + Status: "resolved", + Title: fmt.Sprintf("Access request resolved: %s", resolution.Tag), + Description: fmt.Sprintf("Access request has been %s", resolution.Tag), + DeduplicationKey: fmt.Sprintf("%s/%s", alertKeyPrefix, alertID), + Metadata: map[string]string{ + "request_id": alertID, + }, + } + + resp, err := inc.client.NewRequest(). + SetContext(ctx). + SetBody(alertBody). + Post(inc.AlertSourceEndpoint) + if err != nil { + return trace.Wrap(err) + } + defer resp.RawResponse.Body.Close() + if resp.IsError() { + return errWrapper(resp.StatusCode(), string(resp.Body())) + } + return nil +} + +// GetOnCall returns the list of responders on-call for a schedule. +func (inc APIClient) GetOnCall(ctx context.Context, scheduleID string) (GetScheduleResponse, error) { + var result GetScheduleResponse + resp, err := inc.client.NewRequest(). + SetContext(ctx). + SetPathParams(map[string]string{"scheduleID": scheduleID}). + SetResult(&result). + Get("/v2/schedules/{scheduleID}") + if err != nil { + return GetScheduleResponse{}, trace.Wrap(err) + } + defer resp.RawResponse.Body.Close() + if resp.IsError() { + return GetScheduleResponse{}, errWrapper(resp.StatusCode(), string(resp.Body())) + } + return result, nil +} + +// CheckHealth pings incident.io. +func (inc APIClient) CheckHealth(ctx context.Context) error { + // The heartbeat pings will respond even if the heartbeat does not exist. + resp, err := inc.client.NewRequest(). + SetContext(ctx). + Get("v2/schedules") + + if err != nil { + return trace.Wrap(err) + } + defer resp.RawResponse.Body.Close() + + if inc.StatusSink != nil { + var code types.PluginStatusCode + switch { + case resp.StatusCode() == http.StatusUnauthorized: + code = types.PluginStatusCode_UNAUTHORIZED + case resp.StatusCode() >= 200 && resp.StatusCode() < 400: + code = types.PluginStatusCode_RUNNING + default: + code = types.PluginStatusCode_OTHER_ERROR + } + if err := inc.StatusSink.Emit(ctx, &types.PluginStatusV1{Code: code}); err != nil { + logger.Get(resp.Request.Context()).WithError(err). + WithField("code", resp.StatusCode()).Errorf("Error while emitting servicenow plugin status: %v", err) + } + } + + if resp.IsError() { + return errWrapper(resp.StatusCode(), string(resp.Body())) + } + return nil +} diff --git a/integrations/access/incidentio/cmd/teleport-incidentio/install b/integrations/access/incidentio/cmd/teleport-incidentio/install new file mode 100755 index 0000000000000..7fef902373aa6 --- /dev/null +++ b/integrations/access/incidentio/cmd/teleport-incidentio/install @@ -0,0 +1,19 @@ +#!/bin/sh + +# +# the directory where Teleport binaries will be located +# +BINDIR=/usr/local/bin + +# +# the directory where Teleport Plugins store their data files +# and certificates +# +DATADIR=/var/lib/teleport/plugins/incidentio + +[ ! $(id -u) != "0" ] || { echo "ERROR: You must be root"; exit 1; } +cd $(dirname $0) +mkdir -p $BINDIR $DATADIR || exit 1 +cp -f teleport-incidentio $BINDIR/ || exit 1 + +echo "Teleport incident.io binaries have been copied to $BINDIR" diff --git a/integrations/access/incidentio/cmd/teleport-incidentio/main.go b/integrations/access/incidentio/cmd/teleport-incidentio/main.go new file mode 100644 index 0000000000000..c92a3ae9cae5d --- /dev/null +++ b/integrations/access/incidentio/cmd/teleport-incidentio/main.go @@ -0,0 +1,98 @@ +// Copyright 2023 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "os" + "time" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/teleport/integrations/access/incidentio" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/integrations/lib" + "github.com/gravitational/teleport/integrations/lib/logger" +) + +var ( + appName = "teleport-incidentio" + gracefulShutdownTimeout = 15 * time.Second + configPath = "/etc/teleport-incidentio.toml" +) + +func main() { + logger.Init() + app := kingpin.New(appName, "Teleport incident.io plugin") + + app.Command("version", "Prints teleport-incidentio version and exits") + + startCmd := app.Command("start", "Starts Teleport incident.io plugin") + startConfigPath := startCmd.Flag("config", "TOML config file path"). + Short('c'). + Default(configPath). + String() + debug := startCmd.Flag("debug", "Enable verbose logging to stderr"). + Short('d'). + Bool() + + selectedCmd, err := app.Parse(os.Args[1:]) + if err != nil { + lib.Bail(err) + } + + switch selectedCmd { + + case "version": + lib.PrintVersion(app.Name, teleport.Version, teleport.Gitref) + case "start": + if err := run(*startConfigPath, *debug); err != nil { + lib.Bail(err) + } else { + logger.Standard().Info("Successfully shut down") + } + } + +} + +func run(configPath string, debug bool) error { + conf, err := incidentio.LoadConfig(configPath) + if err != nil { + return trace.Wrap(err) + } + + logConfig := conf.Log + if debug { + logConfig.Severity = "debug" + } + if err = logger.Setup(logConfig); err != nil { + return err + } + if debug { + logger.Standard().Debugf("DEBUG logging enabled") + } + + app, err := incidentio.NewIncidentApp(context.Background(), conf) + if err != nil { + return trace.Wrap(err) + } + + go lib.ServeSignals(app, gracefulShutdownTimeout) + + return trace.Wrap( + app.Run(context.Background()), + ) +} diff --git a/integrations/access/incidentio/config.go b/integrations/access/incidentio/config.go new file mode 100644 index 0000000000000..8767b0c3ea461 --- /dev/null +++ b/integrations/access/incidentio/config.go @@ -0,0 +1,120 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package incidentio + +import ( + "context" + "net/url" + + "github.com/gravitational/trace" + "github.com/pelletier/go-toml" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/access/common" + "github.com/gravitational/teleport/integrations/access/common/teleport" +) + +// Config stores the full configuration for the teleport-incidentio plugin to run. +type Config struct { + common.BaseConfig + // Incident contains the incident.io specific configuration. + Incident common.GenericAPIConfig + // ClientConfig contains the config for the incident.io client. + ClientConfig ClientConfig `toml:"client_config"` + + // Teleport is a handle to the client to use when communicating with + // the Teleport auth server. The ServiceNow app will create a gRPC-based + // client on startup if this is not set. + Client teleport.Client + // TeleportUserName is the name of the Teleport user that will act + // as the access request approver. + TeleportUserName string +} + +// LoadConfig reads the config file, initializes a new Config struct object, and returns it. +// Optionally returns an error if the file is not readable, or if file format is invalid. +func LoadConfig(filepath string) (*Config, error) { + t, err := toml.LoadFile(filepath) + if err != nil { + return nil, trace.Wrap(err) + } + conf := &Config{} + if err := t.Unmarshal(conf); err != nil { + return nil, trace.Wrap(err) + } + if err := conf.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + return conf, nil +} + +// CheckAndSetDefaults checks the config struct for any logical errors, and sets default values +// if some values are missing. +// If critical values are missing and we can't set defaults for them, this will return an error. +func (c *Config) CheckAndSetDefaults() error { + if err := c.Teleport.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err) + } + if err := c.ClientConfig.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err) + } + + if c.Log.Output == "" { + c.Log.Output = "stderr" + } + if c.Log.Severity == "" { + c.Log.Severity = "info" + } + + c.PluginType = types.PluginTypeIncidentio + return nil +} + +// GetTeleportClient returns the configured Teleport client. +func (c *Config) GetTeleportClient(ctx context.Context) (teleport.Client, error) { + if c.Client != nil { + return c.Client, nil + } + return c.BaseConfig.GetTeleportClient(ctx) +} + +// NewBot initializes the new incident.io message generator (Incidentbot) +func (c *Config) NewBot(clusterName, webProxyAddr string) (common.MessagingBot, error) { + webProxyURL, err := url.Parse(webProxyAddr) + if err != nil { + return nil, trace.Wrap(err) + } + c.ClientConfig.WebProxyURL = webProxyURL + c.ClientConfig.ClusterName = clusterName + apiClient, err := NewAPIClient(c.ClientConfig) + if err != nil { + return nil, trace.Wrap(err) + } + alertClient, err := NewAlertClient(c.ClientConfig) + if err != nil { + return nil, trace.Wrap(err) + } + + return &Bot{ + apiClient: apiClient, + alertClient: alertClient, + clusterName: clusterName, + webProxyURL: webProxyURL, + }, nil +} diff --git a/integrations/access/incidentio/config_test.go b/integrations/access/incidentio/config_test.go new file mode 100644 index 0000000000000..7528047fa2cf8 --- /dev/null +++ b/integrations/access/incidentio/config_test.go @@ -0,0 +1,83 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package incidentio + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/integrations/access/common" +) + +func newMinimalValidConfig() Config { + return Config{ + Incident: common.GenericAPIConfig{ + Token: "someToken", + }, + ClientConfig: ClientConfig{ + APIKey: "someAPIKey", + APIEndpoint: "someEnpoint", + WebProxyURL: &url.URL{}, + }, + } +} + +func TestInvalidConfigFailsCheck(t *testing.T) { + t.Run("Token", func(t *testing.T) { + cfg := newMinimalValidConfig() + cfg.Incident.Token = "" + require.Error(t, cfg.CheckAndSetDefaults()) + }) + t.Run("API Key", func(t *testing.T) { + cfg := newMinimalValidConfig() + cfg.ClientConfig.APIKey = "" + require.Error(t, cfg.CheckAndSetDefaults()) + }) + t.Run("API endpoint", func(t *testing.T) { + cfg := newMinimalValidConfig() + cfg.ClientConfig.APIEndpoint = "" + require.Error(t, cfg.CheckAndSetDefaults()) + }) + t.Run("Web proxy url", func(t *testing.T) { + cfg := newMinimalValidConfig() + cfg.ClientConfig.WebProxyURL = nil + require.Error(t, cfg.CheckAndSetDefaults()) + }) + +} + +func TestConfigDefaults(t *testing.T) { + cfg := Config{ + Incident: common.GenericAPIConfig{ + Token: "someToken", + }, + ClientConfig: ClientConfig{ + APIKey: "someAPIKey", + APIEndpoint: "someEnpoint", + AccessToken: "someAccessToken", + AlertSourceEndpoint: "someAlertSourceEndpoint", + WebProxyURL: &url.URL{}, + }, + } + require.NoError(t, cfg.CheckAndSetDefaults()) + require.Equal(t, "stderr", cfg.Log.Output) + require.Equal(t, "info", cfg.Log.Severity) +} diff --git a/integrations/access/incidentio/plugindata.go b/integrations/access/incidentio/plugindata.go new file mode 100644 index 0000000000000..1edecb0444cb3 --- /dev/null +++ b/integrations/access/incidentio/plugindata.go @@ -0,0 +1,120 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package incidentio + +import ( + "fmt" + "strings" + "time" + + "github.com/gravitational/teleport/api/types" +) + +// PluginData is a data associated with access request that we store in Teleport using UpdatePluginData API. +type PluginData struct { + RequestData + IncidentAlertData +} + +// Resolution stores the resolution (approved, denied or expired) and its reason. +type Resolution struct { + Tag ResolutionTag + Reason string +} + +// ResolutionTag represents if and in which state an access request was resolved. +type ResolutionTag string + +// Unresolved is added to alerts that are yet to be resolved. +const Unresolved = ResolutionTag("") + +// ResolvedApproved is added to alerts that are approved. +const ResolvedApproved = ResolutionTag("approved") + +// ResolvedDenied is added to alerts that are denied. +const ResolvedDenied = ResolutionTag("denied") + +// ResolvedExpired is added to alerts that are expired. +const ResolvedExpired = ResolutionTag("expired") + +// ResolvedPromoted is added to alerts that are promoted to an access list. +const ResolvedPromoted = ResolutionTag("promoted") + +// RequestData stores a slice of some request fields in a convenient format. +type RequestData struct { + User string + Roles []string + Created time.Time + RequestReason string + ReviewsCount int + Resolution Resolution + SystemAnnotations types.Labels +} + +// IncidentAlertData stores the deduplication key +type IncidentAlertData struct { + DeduplicationKey string +} + +// DecodePluginData deserializes a string map to PluginData struct. +func DecodePluginData(dataMap map[string]string) (data PluginData) { + data.User = dataMap["user"] + if str := dataMap["roles"]; str != "" { + data.Roles = strings.Split(str, ",") + } + if str := dataMap["created"]; str != "" { + var created int64 + fmt.Sscanf(dataMap["created"], "%d", &created) + data.Created = time.Unix(created, 0) + } + data.RequestReason = dataMap["request_reason"] + if str := dataMap["reviews_count"]; str != "" { + fmt.Sscanf(str, "%d", &data.ReviewsCount) + } + data.Resolution.Tag = ResolutionTag(dataMap["resolution"]) + data.Resolution.Reason = dataMap["resolve_reason"] + data.DeduplicationKey = dataMap["deduplication_key"] + return +} + +// EncodePluginData serializes a PluginData struct into a string map. +func EncodePluginData(data PluginData) map[string]string { + result := make(map[string]string) + result["deduplication_key"] = data.DeduplicationKey + result["user"] = data.User + result["roles"] = strings.Join(data.Roles, ",") + + var createdStr string + if !data.Created.IsZero() { + createdStr = fmt.Sprintf("%d", data.Created.Unix()) + } + result["created"] = createdStr + + result["request_reason"] = data.RequestReason + + var reviewsCountStr string + if data.ReviewsCount != 0 { + reviewsCountStr = fmt.Sprintf("%d", data.ReviewsCount) + } + result["reviews_count"] = reviewsCountStr + + result["resolution"] = string(data.Resolution.Tag) + result["resolve_reason"] = data.Resolution.Reason + return result +} diff --git a/integrations/access/incidentio/testlib/fake_incident.go b/integrations/access/incidentio/testlib/fake_incident.go new file mode 100644 index 0000000000000..20f366efe33ea --- /dev/null +++ b/integrations/access/incidentio/testlib/fake_incident.go @@ -0,0 +1,225 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package testlib + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "runtime/debug" + "sync" + "time" + + "github.com/gravitational/teleport/integrations/access/incidentio" + "github.com/gravitational/trace" + "github.com/julienschmidt/httprouter" + log "github.com/sirupsen/logrus" + + "github.com/gravitational/teleport/integrations/lib/stringset" +) + +type FakeIncident struct { + srv *httptest.Server + + objects sync.Map + // Alerts + alertIDCounter uint64 + newAlerts chan incidentio.AlertBody + alertUpdates chan incidentio.AlertBody +} + +type QueryValues url.Values + +func (q QueryValues) GetAsSet(name string) stringset.StringSet { + values := q[name] + result := stringset.NewWithCap(len(values)) + for _, v := range values { + if v != "" { + result[v] = struct{}{} + } + } + if len(result) == 0 { + return nil + } + return result +} + +func NewFakeIncident(concurrency int) *FakeIncident { + router := httprouter.New() + + mock := &FakeIncident{ + newAlerts: make(chan incidentio.AlertBody, concurrency), + alertUpdates: make(chan incidentio.AlertBody, concurrency), + srv: httptest.NewServer(router), + } + + router.GET("/v2/schedules/:scheduleID", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { + rw.Header().Add("Content-Type", "application/json") + scheduleID := ps.ByName("scheduleID") + + // Check if exists + _, ok := mock.GetSchedule(scheduleID) + if !ok { + rw.WriteHeader(http.StatusNotFound) + return + } + + emails := mock.GetOnCallEmailsForSchedule(scheduleID) + + response := incidentio.ScheduleResult{ + Annotations: map[string]string{}, + Config: incidentio.ScheduleConfig{ + Rotations: nil, + }, + CreatedAt: time.Time{}, + CurrentShifts: []incidentio.CurrentShift{ + { + EndAt: time.Time{}, + EntryID: "someEntryID", + Fingerprint: "someFingerprint", + LayerID: "someLayerID", + RotationID: "someRotationID", + StartAt: time.Time{}, + User: &incidentio.User{ + Email: emails[0], + }, + }, + }, + HolidaysPublicConfig: incidentio.HolidaysPublicConfig{ + CountryCodes: nil, + }, + ID: "someSchedule", + Name: "Schedule", + Timezone: "UTC", + UpdatedAt: time.Time{}, + } + + rw.WriteHeader(http.StatusOK) + err := json.NewEncoder(rw).Encode(response) + panicIf(err) + }) + router.GET("/v2/schedules", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { + rw.WriteHeader(http.StatusOK) + }) + router.POST("/v2/alert_events/http/someRequestID", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusCreated) + + var alert incidentio.AlertBody + err := json.NewDecoder(r.Body).Decode(&alert) + panicIf(err) + + if alert.Status == "firing" { + mock.newAlerts <- alert + } else if alert.Status == "resolved" { + mock.alertUpdates <- alert + } else { + panic("unsupported alert status") + } + mock.StoreAlert(alert) + + err = json.NewEncoder(rw).Encode(alert) + panicIf(err) + }) + return mock +} + +func (s *FakeIncident) URL() string { + return s.srv.URL +} + +func (s *FakeIncident) Close() { + s.srv.Close() + close(s.newAlerts) + close(s.alertUpdates) +} + +func (s *FakeIncident) GetAlert(id string) (incidentio.AlertBody, bool) { + if obj, ok := s.objects.Load(id); ok { + alert, ok := obj.(incidentio.AlertBody) + return alert, ok + } + return incidentio.AlertBody{}, false +} + +func (s *FakeIncident) StoreAlert(alert incidentio.AlertBody) incidentio.AlertBody { + s.objects.Store(alert.DeduplicationKey, alert) + return alert +} + +func (s *FakeIncident) CheckNewAlert(ctx context.Context) (incidentio.AlertBody, error) { + select { + case alert := <-s.newAlerts: + return alert, nil + case <-ctx.Done(): + return incidentio.AlertBody{}, trace.Wrap(ctx.Err()) + } +} + +func (s *FakeIncident) CheckAlertUpdate(ctx context.Context) (incidentio.AlertBody, error) { + select { + case alert := <-s.alertUpdates: + return alert, nil + case <-ctx.Done(): + return incidentio.AlertBody{}, trace.Wrap(ctx.Err()) + } +} + +func (s *FakeIncident) StoreSchedule(scheduleID string, schedule incidentio.ScheduleResult) incidentio.ScheduleResult { + key := fmt.Sprintf("schedule-%s", scheduleID) + s.objects.Store(key, schedule) + return schedule +} + +func (s *FakeIncident) GetSchedule(scheduleID string) (*incidentio.ScheduleResult, bool) { + key := fmt.Sprintf("schedule-%s", scheduleID) + value, ok := s.objects.Load(key) + if !ok { + return nil, false + } + schedule, ok := value.(incidentio.ScheduleResult) + if !ok { + panic("cannot cast to schedule") + } + return &schedule, true +} + +func panicIf(err error) { + if err != nil { + log.Panicf("%v at %v", err, string(debug.Stack())) + } +} + +func (s *FakeIncident) GetOnCallEmailsForSchedule(scheduleID string) []string { + var emails []string + schedule, ok := s.GetSchedule(scheduleID) + if !ok { + return nil + } + for _, shift := range schedule.CurrentShifts { + if shift.User != nil { + emails = append(emails, shift.User.Email) + } + } + + return emails +} diff --git a/integrations/access/incidentio/testlib/helpers.go b/integrations/access/incidentio/testlib/helpers.go new file mode 100644 index 0000000000000..596ffbc9b7344 --- /dev/null +++ b/integrations/access/incidentio/testlib/helpers.go @@ -0,0 +1,39 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package testlib + +import ( + "context" + + "github.com/gravitational/teleport/integrations/access/incidentio" + "github.com/stretchr/testify/require" +) + +func (s *IncidentBaseSuite) checkPluginData(ctx context.Context, reqID string, cond func(incidentio.PluginData) bool) incidentio.PluginData { + t := s.T() + t.Helper() + + for { + rawData, err := s.Ruler().PollAccessRequestPluginData(ctx, "incidentio", reqID) + require.NoError(t, err) + if data := incidentio.DecodePluginData(rawData); cond(data) { + return data + } + } +} diff --git a/integrations/access/incidentio/testlib/oss_integration_test.go b/integrations/access/incidentio/testlib/oss_integration_test.go new file mode 100644 index 0000000000000..83508cbf79e78 --- /dev/null +++ b/integrations/access/incidentio/testlib/oss_integration_test.go @@ -0,0 +1,38 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package testlib + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/gravitational/teleport/integrations/lib/testing/integration" +) + +func TestIncidentPluginOSS(t *testing.T) { + incidentioSuite := &IncidentSuiteOSS{ + IncidentBaseSuite: IncidentBaseSuite{ + AccessRequestSuite: &integration.AccessRequestSuite{ + AuthHelper: &integration.MinimalAuthHelper{}, + }, + }, + } + suite.Run(t, incidentioSuite) +} diff --git a/integrations/access/incidentio/testlib/suite.go b/integrations/access/incidentio/testlib/suite.go new file mode 100644 index 0000000000000..cfb90076702b7 --- /dev/null +++ b/integrations/access/incidentio/testlib/suite.go @@ -0,0 +1,357 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package testlib + +import ( + "context" + "runtime" + "time" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/access/incidentio" + "github.com/gravitational/teleport/integrations/lib/logger" + "github.com/gravitational/teleport/integrations/lib/testing/integration" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + NotifyScheduleAnnotation = types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel + ApprovalScheduleName = "Teleport Approval" + ApprovalScheduleAnnotation = types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel +) + +// IncidentBaseSuite is the incident.io access plugin test suite. +// It implements the testify.TestingSuite interface. +type IncidentBaseSuite struct { + *integration.AccessRequestSuite + appConfig incidentio.Config + raceNumber int + fakeIncident *FakeIncident + + incSchedule incidentio.ScheduleResult +} + +// SetupTest starts a fake incident.io and generates the plugin configuration. +// It also configures the role notifications for incident.io notifications and +// automatic approval. +// It is run for each test. +func (s *IncidentBaseSuite) SetupTest() { + t := s.T() + ctx := context.Background() + + err := logger.Setup(logger.Config{Severity: "debug"}) + require.NoError(t, err) + s.raceNumber = 2 * runtime.GOMAXPROCS(0) + + s.fakeIncident = NewFakeIncident(s.raceNumber) + t.Cleanup(s.fakeIncident.Close) + + // This service should be notified for every access request. + s.incSchedule = s.fakeIncident.StoreSchedule("aScheduleID", incidentio.ScheduleResult{ + Annotations: nil, + Config: incidentio.ScheduleConfig{ + Rotations: nil, + }, + CreatedAt: time.Time{}, + CurrentShifts: nil, + HolidaysPublicConfig: incidentio.HolidaysPublicConfig{ + CountryCodes: nil, + }, + ID: "aScheduleID", + Name: "Teleport Notifications One", + Timezone: "", + UpdatedAt: time.Time{}, + }) + + s.AnnotateRequesterRoleAccessRequests( + ctx, + NotifyScheduleAnnotation, + []string{"aScheduleID"}, + ) + + var conf incidentio.Config + conf.Teleport = s.TeleportConfig() + conf.ClientConfig.APIEndpoint = s.fakeIncident.URL() + conf.ClientConfig.AlertSourceEndpoint = s.fakeIncident.URL() + "/v2/alert_events/http/someRequestID" + conf.PluginType = types.PluginTypeIncidentio + + s.appConfig = conf +} + +// startApp starts the incident.io plugin, waits for it to become ready and returns. +func (s *IncidentBaseSuite) startApp() { + s.T().Helper() + t := s.T() + + app, err := incidentio.NewIncidentApp(context.Background(), &s.appConfig) + require.NoError(t, err) + integration.RunAndWaitReady(t, app) +} + +// IncidentSuiteOSS contains all tests that support running against a Teleport +// OSS Server. +type IncidentSuiteOSS struct { + IncidentBaseSuite +} + +// IncidentSuiteEnterprise contains all tests that require a Teleport Enterprise +// to run. +type IncidentSuiteEnterprise struct { + IncidentBaseSuite +} + +// SetupTest overrides IncidentBaseSuite.SetupTest to check the Teleport features +// before each test. +func (s *IncidentSuiteEnterprise) SetupTest() { + t := s.T() + s.RequireAdvancedWorkflow(t) + s.IncidentBaseSuite.SetupTest() +} + +// TestAlertCreationForSchedules validates that an alert is created to the service +// specified in the role's annotation using /notify-services annotation +func (s *IncidentSuiteOSS) TestAlertCreationForSchedules() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + s.startApp() + + // Test execution: create an access request + req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, nil) + + // Validate the alert has been created in incident.io and its ID is stored in + // the plugin_data. + pluginData := s.checkPluginData(ctx, req.GetName(), func(data incidentio.PluginData) bool { + return data.DeduplicationKey != "" + }) + + alert, err := s.fakeIncident.CheckNewAlert(ctx) + + require.NoError(t, err, "no new alerts stored") + + assert.Equal(t, alert.DeduplicationKey, pluginData.DeduplicationKey) +} + +// TestApproval tests that when a request is approved, its corresponding alert +// is updated to reflect the new request state and a note is added to the alert. +func (s *IncidentSuiteOSS) TestApproval() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + s.startApp() + + // Test setup: we create an access request and wait for its alert. + req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, nil) + + _, err := s.fakeIncident.CheckNewAlert(ctx) + require.NoError(t, err, "no new alerts stored") + + // Test execution: we approve the request + err = s.Ruler().ApproveAccessRequest(ctx, req.GetName(), "okay") + require.NoError(t, err) + + // Validating the plugin resolved the alert. + alertUpdate, err := s.fakeIncident.CheckAlertUpdate(ctx) + require.NoError(t, err) + assert.Equal(t, "resolved", alertUpdate.Status) +} + +// TestDenial tests that when a request is denied, its corresponding alert +// is updated to reflect the new request state and a note is added to the alert. +func (s *IncidentSuiteOSS) TestDenial() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + s.startApp() + + // Test setup: we create an access request and wait for its alert. + req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, nil) + + _, err := s.fakeIncident.CheckNewAlert(ctx) + require.NoError(t, err, "no new alerts stored") + + // Test execution: we deny the request + err = s.Ruler().DenyAccessRequest(ctx, req.GetName(), "not okay") + require.NoError(t, err) + + // Validating the plugin resolved the alert. + alertUpdate, err := s.fakeIncident.CheckAlertUpdate(ctx) + require.NoError(t, err) + assert.Equal(t, "resolved", alertUpdate.Status) +} + +// TestApprovalByReview tests that the alert is annotated and resolved after the +// access request approval threshold is reached. +func (s *IncidentSuiteEnterprise) TestApprovalByReview() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + s.startApp() + + // Test setup: we create an access request and wait for its alert. + req := s.CreateAccessRequest(ctx, integration.Requester1UserName, nil) + + _, err := s.fakeIncident.CheckNewAlert(ctx) + require.NoError(t, err, "no new alerts stored") + + // Test execution: we submit two reviews + err = s.Reviewer1().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{ + Author: integration.Reviewer1UserName, + ProposedState: types.RequestState_APPROVED, + Created: time.Now(), + Reason: "okay", + }) + require.NoError(t, err) + + err = s.Reviewer2().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{ + Author: integration.Reviewer2UserName, + ProposedState: types.RequestState_APPROVED, + Created: time.Now(), + Reason: "finally okay", + }) + require.NoError(t, err) + + // Validate the alert got resolved. + data := s.checkPluginData(ctx, req.GetName(), func(data incidentio.PluginData) bool { + return data.ReviewsCount == 2 && data.Resolution.Tag != incidentio.Unresolved + }) + assert.Equal(t, incidentio.Resolution{Tag: incidentio.ResolvedApproved, Reason: "finally okay"}, data.Resolution) + + alertUpdate, err := s.fakeIncident.CheckAlertUpdate(ctx) + require.NoError(t, err) + assert.Equal(t, "resolved", alertUpdate.Status) +} + +// TestDenialByReview tests that the alert is annotated and resolved after the +// access request denial threshold is reached. +func (s *IncidentSuiteEnterprise) TestDenialByReview() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + s.startApp() + + // Test setup: we create an access request and wait for its alert. + req := s.CreateAccessRequest(ctx, integration.Requester1UserName, nil) + + _, err := s.fakeIncident.CheckNewAlert(ctx) + require.NoError(t, err, "no new alerts stored") + + // Test execution: we submit two reviews + err = s.Reviewer1().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{ + Author: integration.Reviewer1UserName, + ProposedState: types.RequestState_DENIED, + Created: time.Now(), + Reason: "not okay", + }) + require.NoError(t, err) + + err = s.Reviewer2().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{ + Author: integration.Reviewer2UserName, + ProposedState: types.RequestState_DENIED, + Created: time.Now(), + Reason: "finally not okay", + }) + require.NoError(t, err) + + // Validate the alert got resolved. + data := s.checkPluginData(ctx, req.GetName(), func(data incidentio.PluginData) bool { + return data.ReviewsCount == 2 && data.Resolution.Tag != incidentio.Unresolved + }) + assert.Equal(t, incidentio.Resolution{Tag: incidentio.ResolvedDenied, Reason: "finally not okay"}, data.Resolution) + + alertUpdate, err := s.fakeIncident.CheckAlertUpdate(ctx) + require.NoError(t, err) + assert.Equal(t, "resolved", alertUpdate.Status) +} + +// TestAutoApprovalWhenNotOnCall tests that access requests are not automatically +// approved when the user is not on-call. +func (s *IncidentSuiteEnterprise) TestAutoApprovalWhenNotOnCall() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + // Test setup: create an on-call schedule with a non-Teleport user in it. + s.fakeIncident.StoreSchedule(ApprovalScheduleName, s.incSchedule) + s.AnnotateRequesterRoleAccessRequests( + ctx, + ApprovalScheduleAnnotation, + []string{ApprovalScheduleName}, + ) + + s.startApp() + + // Test Execution: we create an access request and wait for its alert. + req := s.CreateAccessRequest(ctx, integration.Requester1UserName, nil) + + _ = s.checkPluginData(ctx, req.GetName(), func(data incidentio.PluginData) bool { + return data.DeduplicationKey != "" + }) + + _, err := s.fakeIncident.CheckNewAlert(ctx) + require.NoError(t, err, "no new alerts stored") + + // Fetch updated access request + req, err = s.Ruler().GetAccessRequest(ctx, req.GetName()) + require.NoError(t, err) + + require.Empty(t, req.GetReviews(), "no review should be submitted automatically") +} + +// TestAutoApprovalWhenOnCall tests that access requests are automatically +// approved when the user is not on-call. +func (s *IncidentSuiteEnterprise) TestAutoApprovalWhenOnCall() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + // Test setup: create an on-call schedule with a non-Teleport user in it. + s.fakeIncident.StoreSchedule(ApprovalScheduleName, s.incSchedule) + s.AnnotateRequesterRoleAccessRequests( + ctx, + ApprovalScheduleAnnotation, + []string{ApprovalScheduleName}, + ) + + s.startApp() + + // Test Execution: we create an access request and wait for its alert. + req := s.CreateAccessRequest(ctx, integration.Requester1UserName, nil) + + _ = s.checkPluginData(ctx, req.GetName(), func(data incidentio.PluginData) bool { + return data.DeduplicationKey != "" + }) + + _, err := s.fakeIncident.CheckNewAlert(ctx) + require.NoError(t, err, "no new alerts stored") + + // Fetch updated access request + req, err = s.Ruler().GetAccessRequest(ctx, req.GetName()) + require.NoError(t, err) + + reviews := req.GetReviews() + require.Len(t, reviews, 1, "a review should be submitted automatically") + require.Equal(t, types.RequestState_APPROVED, reviews[0].ProposedState) +} diff --git a/integrations/access/incidentio/types.go b/integrations/access/incidentio/types.go new file mode 100644 index 0000000000000..c4c84fcb2974c --- /dev/null +++ b/integrations/access/incidentio/types.go @@ -0,0 +1,104 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package incidentio + +import "time" + +// AlertBody represents an incident.io Alert +type AlertBody struct { + // Title is the title of the alert. + Title string `json:"message,omitempty"` + // Description field of the alert. + Description string `json:"description,omitempty"` + // DeduplicationKey is the key to use for deduplication. + DeduplicationKey string `json:"deduplication_key,omitempty"` + // Status is the status of the alert. + Status string `json:"status,omitempty"` + // Metadata is a map of key-value pairs to use as custom properties of the alert. + Metadata map[string]string `json:"metadata,omitempty"` +} + +type GetScheduleResponse struct { + Schedule ScheduleResult `json:"schedule"` +} + +type ScheduleResult struct { + Annotations map[string]string `json:"annotations"` + Config ScheduleConfig `json:"config"` + CreatedAt time.Time `json:"created_at"` + CurrentShifts []CurrentShift `json:"current_shifts"` + HolidaysPublicConfig HolidaysPublicConfig `json:"holidays_public_config"` + ID string `json:"id"` + Name string `json:"name"` + Timezone string `json:"timezone"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ScheduleConfig struct { + Rotations []Rotation `json:"rotations"` +} + +type Rotation struct { + EffectiveFrom time.Time `json:"effective_from"` + HandoverStartAt time.Time `json:"handover_start_at"` + Handovers []Handover `json:"handovers"` + ID string `json:"id"` + Layers []Layer `json:"layers"` + Name string `json:"name"` + Users []User `json:"users"` + WorkingInterval []WorkingInterval `json:"working_interval"` +} + +type Handover struct { + Interval int `json:"interval"` + IntervalType string `json:"interval_type"` +} + +type Layer struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type User struct { + Email string `json:"email"` + ID string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + SlackUserID string `json:"slack_user_id"` +} + +type WorkingInterval struct { + EndTime string `json:"end_time"` + StartTime string `json:"start_time"` + Weekday string `json:"weekday"` +} + +type CurrentShift struct { + EndAt time.Time `json:"end_at"` + EntryID string `json:"entry_id"` + Fingerprint string `json:"fingerprint"` + LayerID string `json:"layer_id"` + RotationID string `json:"rotation_id"` + StartAt time.Time `json:"start_at"` + User *User `json:"user"` +} + +type HolidaysPublicConfig struct { + CountryCodes []string `json:"country_codes"` +}