diff --git a/README.md b/README.md index 5e63c5583a584..441eca3254066 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ +> ### Note: The purpose of this fork is to build images of the IncidentIO access plugin that are on par with the latest Teleport version. Once the plugin is officialy released we will move to the official version. + +## Building The Plugin: +1. Sync the fork with the desired release version from the upstream. +2. Update the `TELEPORT_VERSION` argument in the file `integrations/access/Dockerfile` with the relevant version. +3. From `integrations/access/incidentio`, run the command `make docker-build`. +4. Tag the image, e.g. `docker tag "146628656107.dkr.ecr.us-west-2.amazonaws.com/gravitational/teleport-plugin-incidentio:" bringg/teleport-plugin-incidentio:` +5. Push the image: `docker push bringg/teleport-plugin-incidentio:` + +------------ + Teleport provides connectivity, authentication, access controls and audit for infrastructure. Here is why you might use Teleport: diff --git a/integrations/access/Dockerfile b/integrations/access/Dockerfile index a7fa663daa2d1..74fe5772b1ecc 100644 --- a/integrations/access/Dockerfile +++ b/integrations/access/Dockerfile @@ -4,12 +4,13 @@ ARG BASE_IMAGE=gcr.io/distroless/static-debian12 # Extract the built tarball to reduce the final image size FROM --platform=${BUILDPLATFORM} alpine:3.20.0 as extractor -ARG TARGETARCH -ARG TARGETOS -ARG ARTIFACT_NAME=plugin # Should be something like 'access-slack' or 'event-handler' -ARG TELEPORT_VERSION=0.0.0 # Should be the version without 'v' prefix +ARG ARTIFACT_NAME=access-incidentio +ARG TELEPORT_VERSION=17.3.2 +ARG TARGETOS=linux +ARG TARGETARCH=arm64 ARG FILENAME=teleport-${ARTIFACT_NAME}-v${TELEPORT_VERSION}-${TARGETOS}-${TARGETARCH}-bin.tar.gz # Optional override + WORKDIR /extraction COPY *${TARGETARCH}*.tar.gz /plugin.tar.gz COPY "${FILENAME}" /plugin.tar.gz diff --git a/integrations/access/incidentio/Makefile b/integrations/access/incidentio/Makefile new file mode 100644 index 0000000000000..d44b9f2869702 --- /dev/null +++ b/integrations/access/incidentio/Makefile @@ -0,0 +1,3 @@ +ACCESS_PLUGIN = incidentio + +include ../common.mk diff --git a/integrations/access/incidentio/README.md b/integrations/access/incidentio/README.md new file mode 100644 index 0000000000000..5012e7be20833 --- /dev/null +++ b/integrations/access/incidentio/README.md @@ -0,0 +1 @@ +# Teleport IncidentIO access request plugin 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..34c0515fe5fa0 --- /dev/null +++ b/integrations/access/incidentio/app.go @@ -0,0 +1,606 @@ +/* + * 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" + "reflect" + "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()) + + // Log all fields of req for debugging + log.WithField("request_details", fmt.Sprintf("%+v", reflect.ValueOf(req).Elem().Interface())).Debug("Request details") + + 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/cmd/teleport-incidentio/teleport-incidentio b/integrations/access/incidentio/cmd/teleport-incidentio/teleport-incidentio new file mode 100755 index 0000000000000..c1f316a1ba267 Binary files /dev/null and b/integrations/access/incidentio/cmd/teleport-incidentio/teleport-incidentio differ 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"` +} diff --git a/package.json b/package.json index a3d74825a1315..4ee9219d8e9d0 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "react-dom": "^18.3.1", "react-highlight": "^0.15.0", "react-is": "^18.3.1", - "react-router": "5.3.4", + "react-router": "7.5.2", "react-router-dom": "5.3.4", "react-select": "^5.10.0", "react-transition-group": "^4.4.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f29e42f645ad..02677d0dcf0ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,8 +79,8 @@ importers: specifier: ^18.3.1 version: 18.3.1 react-router: - specifier: 5.3.4 - version: 5.3.4(react@18.3.1) + specifier: 7.5.2 + version: 7.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router-dom: specifier: 5.3.4 version: 5.3.4(react@18.3.1) @@ -1162,6 +1162,9 @@ packages: '@codemirror/commands@6.8.0': resolution: {integrity: sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ==} + '@codemirror/commands@6.8.1': + resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==} + '@codemirror/lang-sql@6.8.0': resolution: {integrity: sha512-aGLmY4OwGqN3TdSx3h6QeA1NrvaYtF7kkoWR/+W7/JzB0gQtJ+VJxewlnE3+VImhA4WVlhmkJr109PefOOhjLg==} @@ -1183,8 +1186,8 @@ packages: '@codemirror/view@6.36.2': resolution: {integrity: sha512-DZ6ONbs8qdJK0fdN7AB82CgI6tYXf4HWk1wSVa0+9bhVznCuuvhQtX8bFBoy3dv8rZSQqUd8GvhVAcielcidrA==} - '@codemirror/view@6.36.4': - resolution: {integrity: sha512-ZQ0V5ovw/miKEXTvjgzRyjnrk9TwriUB1k4R5p7uNnHR9Hus+D1SXHGdJshijEzPFjU25xea/7nhIeSqYFKdbA==} + '@codemirror/view@6.36.6': + resolution: {integrity: sha512-uxugGLet+Nzp0Jcit8Hn3LypM8ioMLKTsdf8FRoT3HWvZtb9GhaWMe0Cc15rz90Ljab4YFJiAulmIVB74OY0IQ==} '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} @@ -3525,6 +3528,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + core-js-compat@3.40.0: resolution: {integrity: sha512-0XEDpr5y5mijvw8Lbc6E5AkjrHfp7eEoPlu36SWeAbcL8fn1G1ANe8DBlo2XoNN89oVpxWwOjYIPVzR4ZvsKCQ==} @@ -5962,6 +5969,16 @@ packages: peerDependencies: react: '>=15' + react-router@7.5.2: + resolution: {integrity: sha512-9Rw8r199klMnlGZ8VAsV/I8WrIF6IyJ90JQUdboupx1cdkgYqwnrYjH+I/nY/7cA1X5zia4mDJqH36npP7sxGQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react-select-event@5.5.1: resolution: {integrity: sha512-goAx28y0+iYrbqZA2FeRTreHHs/ZtSuKxtA+J5jpKT5RHPCbVZJ4MqACfPnWyFXsEec+3dP5bCrNTxIX8oYe9A==} @@ -6225,6 +6242,9 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -6630,6 +6650,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + turbo-stream@2.4.0: + resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -7945,7 +7968,7 @@ snapshots: dependencies: '@codemirror/language': 6.10.6 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.36.4 + '@codemirror/view': 6.36.6 '@lezer/common': 1.2.3 '@codemirror/commands@6.8.0': @@ -7955,6 +7978,13 @@ snapshots: '@codemirror/view': 6.36.2 '@lezer/common': 1.2.3 + '@codemirror/commands@6.8.1': + dependencies: + '@codemirror/language': 6.10.6 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.36.6 + '@lezer/common': 1.2.3 + '@codemirror/lang-sql@6.8.0': dependencies: '@codemirror/autocomplete': 6.18.4 @@ -7976,13 +8006,13 @@ snapshots: '@codemirror/lint@6.4.2': dependencies: '@codemirror/state': 6.5.2 - '@codemirror/view': 6.36.4 + '@codemirror/view': 6.36.6 crelt: 1.0.6 '@codemirror/search@6.5.3': dependencies: '@codemirror/state': 6.5.2 - '@codemirror/view': 6.36.4 + '@codemirror/view': 6.36.6 crelt: 1.0.6 '@codemirror/state@6.5.2': @@ -7993,7 +8023,7 @@ snapshots: dependencies: '@codemirror/language': 6.10.6 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.36.4 + '@codemirror/view': 6.36.6 '@lezer/highlight': 1.2.1 '@codemirror/view@6.36.2': @@ -8002,7 +8032,7 @@ snapshots: style-mod: 4.1.2 w3c-keyname: 2.2.8 - '@codemirror/view@6.36.4': + '@codemirror/view@6.36.6': dependencies: '@codemirror/state': 6.5.2 style-mod: 4.1.2 @@ -10648,12 +10678,12 @@ snapshots: codemirror@6.0.1: dependencies: '@codemirror/autocomplete': 6.18.6 - '@codemirror/commands': 6.8.0 + '@codemirror/commands': 6.8.1 '@codemirror/language': 6.10.6 '@codemirror/lint': 6.4.2 '@codemirror/search': 6.5.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.36.4 + '@codemirror/view': 6.36.6 collect-v8-coverage@1.0.2: {} @@ -10735,6 +10765,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.0.2: {} + core-js-compat@3.40.0: dependencies: browserslist: 4.24.4 @@ -13910,6 +13942,15 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 + react-router@7.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + cookie: 1.0.2 + react: 18.3.1 + set-cookie-parser: 2.7.1 + turbo-stream: 2.4.0 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + react-select-event@5.5.1: dependencies: '@testing-library/dom': 10.1.0 @@ -14238,6 +14279,8 @@ snapshots: set-blocking@2.0.0: {} + set-cookie-parser@2.7.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -14718,6 +14761,8 @@ snapshots: tslib@2.8.1: {} + turbo-stream@2.4.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1