diff --git a/integrations/access/accesslist/app_test.go b/integrations/access/accesslist/app_test.go index 6d4eab0ada7d..07a0f4d420a0 100644 --- a/integrations/access/accesslist/app_test.go +++ b/integrations/access/accesslist/app_test.go @@ -108,6 +108,10 @@ func (m *mockPluginConfig) GetPluginType() types.PluginType { return types.PluginTypeSlack } +func (m *mockPluginConfig) GetTeleportUser() string { + return "" +} + func TestAccessListReminders_Single(t *testing.T) { t.Parallel() diff --git a/integrations/access/accessrequest/app.go b/integrations/access/accessrequest/app.go index 1bf8d4417824..893d3db9caba 100644 --- a/integrations/access/accessrequest/app.go +++ b/integrations/access/accessrequest/app.go @@ -22,6 +22,7 @@ import ( "context" "fmt" "slices" + "strings" "time" "github.com/gravitational/trace" @@ -54,6 +55,9 @@ type App struct { job lib.ServiceJob accessMonitoringRules *accessmonitoring.RuleHandler + // teleportUser is the name of the Teleport user that will act as the + // access request approver. + teleportUser string } // NewApp will create a new access request application. @@ -88,6 +92,7 @@ func (a *App) Init(baseApp *common.BaseApp) error { PluginName: a.pluginName, FetchRecipientCallback: a.bot.FetchRecipient, }) + a.teleportUser = baseApp.Conf.GetTeleportUser() return nil } @@ -262,6 +267,11 @@ func (a *App) onPendingRequest(ctx context.Context, req types.AccessRequest) err } else { log.Warning("No channel to post") } + + // Try to approve the request if user is currently on-call. + if err := a.tryApproveRequest(ctx, reqID, req); err != nil { + log.Warningf("Failed to auto approve request: %v", err) + } case trace.IsAlreadyExists(err): // The messages were already sent, nothing to do, we can update the reviews default: @@ -531,3 +541,44 @@ func (a *App) getResourceNames(ctx context.Context, req types.AccessRequest) ([] } return resourceNames, nil } + +// tryApproveRequest attempts to automatically approve the access request if the +// user is on call for the configured service/team. +func (a *App) tryApproveRequest(ctx context.Context, reqID string, req types.AccessRequest) error { + log := logger.Get(ctx). + WithField("req_id", reqID). + WithField("user", req.GetUser()) + + oncallUsers, err := a.bot.FetchOncallUsers(ctx, req) + if trace.IsNotImplemented(err) { + log.Debugf("Skipping auto-approval because %q bot does not support automatic approvals.", a.pluginName) + return nil + } + if err != nil { + return trace.Wrap(err) + } + + if !slices.Contains(oncallUsers, req.GetUser()) { + log.Debug("Skipping approval because user is not on-call.") + return nil + } + + if _, err := a.apiClient.SubmitAccessReview(ctx, types.AccessReviewSubmission{ + RequestID: reqID, + Review: types.AccessReview{ + Author: a.teleportUser, + ProposedState: types.RequestState_APPROVED, + Reason: fmt.Sprintf("Access request has been automatically approved by %q plugin because user %q is on-call.", a.pluginName, req.GetUser()), + Created: time.Now(), + }, + }); err != nil { + if strings.HasSuffix(err.Error(), "has already reviewed this request") { + log.Debug("Request has already been reviewed.") + return nil + } + return trace.Wrap(err) + } + + log.Info("Successfully submitted a request approval.") + return nil +} diff --git a/integrations/access/accessrequest/app_test.go b/integrations/access/accessrequest/app_test.go index 70e855ce3a89..dffe5a34dc51 100644 --- a/integrations/access/accessrequest/app_test.go +++ b/integrations/access/accessrequest/app_test.go @@ -20,7 +20,9 @@ package accessrequest import ( "context" + "fmt" "testing" + "time" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -39,6 +41,22 @@ func (m *mockTeleportClient) GetRole(ctx context.Context, name string) (types.Ro return args.Get(0).(types.Role), args.Error(1) } +func (m *mockTeleportClient) SubmitAccessReview(ctx context.Context, review types.AccessReviewSubmission) (types.AccessRequest, error) { + review.Review.Created = time.Time{} + args := m.Called(ctx, review) + return (types.AccessRequest)(nil), args.Error(1) +} + +type mockMessagingBot struct { + mock.Mock + MessagingBot +} + +func (m *mockMessagingBot) FetchOncallUsers(ctx context.Context, req types.AccessRequest) ([]string, error) { + args := m.Called(ctx, req) + return args.Get(0).([]string), args.Error(1) +} + func TestGetLoginsByRole(t *testing.T) { teleportClient := &mockTeleportClient{} teleportClient.On("GetRole", mock.Anything, "admin").Return(&types.RoleV6{ @@ -82,3 +100,72 @@ func TestGetLoginsByRole(t *testing.T) { require.Equal(t, expected, loginsByRole) teleportClient.AssertNumberOfCalls(t, "GetRole", 3) } + +func TestTryApproveRequest(t *testing.T) { + teleportClient := &mockTeleportClient{} + bot := &mockMessagingBot{} + app := App{ + apiClient: teleportClient, + bot: bot, + teleportUser: "test-access-plugin", + pluginName: "test", + } + user := "user@example.com" + requestID := "request-0" + + // Example with user on-call + bot.On("FetchOncallUsers", mock.Anything, &types.AccessRequestV3{ + Spec: types.AccessRequestSpecV3{ + User: user, + SystemAnnotations: map[string][]string{ + "example-auto-approvals": {"team-includes-requester"}, + }, + }, + }).Return([]string{user}, (error)(nil)) + + // Example with user not on-call + bot.On("FetchOncallUsers", mock.Anything, &types.AccessRequestV3{ + Spec: types.AccessRequestSpecV3{ + User: user, + SystemAnnotations: map[string][]string{ + "example-auto-approvals": {"team-not-includes-requester"}, + }, + }, + }).Return([]string{"admin@example.com"}, (error)(nil)) + + // Successful review + teleportClient.On("SubmitAccessReview", mock.Anything, types.AccessReviewSubmission{ + RequestID: requestID, + Review: types.AccessReview{ + Author: app.teleportUser, + ProposedState: types.RequestState_APPROVED, + Reason: fmt.Sprintf("Access request has been automatically approved by %q plugin because user %q is on-call.", app.pluginName, user), + }, + }).Return((types.AccessRequest)(nil), (error)(nil)) + + ctx := context.Background() + + // Test user is on-call + require.NoError(t, app.tryApproveRequest(ctx, requestID, &types.AccessRequestV3{ + Spec: types.AccessRequestSpecV3{ + User: user, + SystemAnnotations: map[string][]string{ + "example-auto-approvals": {"team-includes-requester"}, + }, + }, + })) + bot.AssertNumberOfCalls(t, "FetchOncallUsers", 1) + teleportClient.AssertNumberOfCalls(t, "SubmitAccessReview", 1) + + // Test user is not on-call + require.NoError(t, app.tryApproveRequest(ctx, requestID, &types.AccessRequestV3{ + Spec: types.AccessRequestSpecV3{ + User: user, + SystemAnnotations: map[string][]string{ + "example-auto-approvals": {"team-not-includes-requester"}, + }, + }, + })) + bot.AssertNumberOfCalls(t, "FetchOncallUsers", 2) + teleportClient.AssertNumberOfCalls(t, "SubmitAccessReview", 1) +} diff --git a/integrations/access/accessrequest/bot.go b/integrations/access/accessrequest/bot.go index ea92f962bf56..e764c4c80564 100644 --- a/integrations/access/accessrequest/bot.go +++ b/integrations/access/accessrequest/bot.go @@ -39,4 +39,6 @@ type MessagingBot interface { UpdateMessages(ctx context.Context, reqID string, data pd.AccessRequestData, messageData SentMessages, reviews []types.AccessReview) error // NotifyUser notifies the user if their access request status has changed NotifyUser(ctx context.Context, reqID string, ard pd.AccessRequestData) error + // FetchOncallUsers fetches on-call users filtered by the provided annotations + FetchOncallUsers(ctx context.Context, req types.AccessRequest) ([]string, error) } diff --git a/integrations/access/common/annotations.go b/integrations/access/common/annotations.go index 26a066481850..c1caf18e7a24 100644 --- a/integrations/access/common/annotations.go +++ b/integrations/access/common/annotations.go @@ -26,17 +26,17 @@ import ( "github.com/gravitational/teleport/api/types" ) -// GetServiceNamesFromAnnotations reads systems annotations from an access -// requests and returns the services to notify/use for approval. +// GetNamesFromAnnotations reads system annotations from an access request and +// returns the services/teams to notify/use for approval. // The list is sorted and duplicates are removed. -func GetServiceNamesFromAnnotations(req types.AccessRequest, annotationKey string) ([]string, error) { - serviceNames, ok := req.GetSystemAnnotations()[annotationKey] +func GetNamesFromAnnotations(req types.AccessRequest, annotationKey string) ([]string, error) { + names, ok := req.GetSystemAnnotations()[annotationKey] if !ok { return nil, trace.NotFound("request annotation %s is missing", annotationKey) } - if len(serviceNames) == 0 { + if len(names) == 0 { return nil, trace.BadParameter("request annotation %s is present but empty", annotationKey) } - slices.Sort(serviceNames) - return slices.Compact(serviceNames), nil + slices.Sort(names) + return slices.Compact(names), nil } diff --git a/integrations/access/common/annotations_test.go b/integrations/access/common/annotations_test.go index daa144a733d6..586df608c51f 100644 --- a/integrations/access/common/annotations_test.go +++ b/integrations/access/common/annotations_test.go @@ -27,7 +27,7 @@ import ( "github.com/gravitational/teleport/api/types" ) -func TestGetServiceNamesFromAnnotations(t *testing.T) { +func TestGetNamesFromAnnotations(t *testing.T) { testAnnotationKey := "test-key" tests := []struct { name string @@ -75,7 +75,7 @@ func TestGetServiceNamesFromAnnotations(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { request := &types.AccessRequestV3{Spec: types.AccessRequestSpecV3{SystemAnnotations: tt.annotations}} - got, err := GetServiceNamesFromAnnotations(request, testAnnotationKey) + got, err := GetNamesFromAnnotations(request, testAnnotationKey) tt.assertErr(t, err) require.ElementsMatch(t, tt.want, got) }) diff --git a/integrations/access/common/config.go b/integrations/access/common/config.go index 0a3dd48ba546..8e564559f0ad 100644 --- a/integrations/access/common/config.go +++ b/integrations/access/common/config.go @@ -37,6 +37,9 @@ type PluginConfiguration interface { GetRecipients() RawRecipientsMap NewBot(clusterName string, webProxyAddr string) (MessagingBot, error) GetPluginType() types.PluginType + // GetTeleportUser returns the name of the teleport user that acts as the + // access request approver. + GetTeleportUser() string } type BaseConfig struct { @@ -44,6 +47,9 @@ type BaseConfig struct { Recipients RawRecipientsMap `toml:"role_to_recipients"` Log logger.Config `toml:"log"` PluginType types.PluginType + // TeleportUser is the name of the teleport user that acts as the + // access request approver. + TeleportUser string } func (c BaseConfig) GetRecipients() RawRecipientsMap { @@ -90,6 +96,12 @@ func (c BaseConfig) GetPluginType() types.PluginType { return c.PluginType } +// GetTeleportUser returns the name of the teleport user that acts as the +// access request approver. +func (c BaseConfig) GetTeleportUser() string { + return c.TeleportUser +} + // GenericAPIConfig holds common configuration use by a messaging service. // MessagingBots requiring more custom configuration (MSTeams for example) can // implement their own APIConfig instead. diff --git a/integrations/access/datadog/bot.go b/integrations/access/datadog/bot.go index c7587480318b..e92dbbb524a2 100644 --- a/integrations/access/datadog/bot.go +++ b/integrations/access/datadog/bot.go @@ -22,6 +22,7 @@ import ( "context" "fmt" "net/url" + "slices" "strings" "text/template" @@ -32,6 +33,7 @@ import ( "github.com/gravitational/teleport/integrations/access/accessrequest" "github.com/gravitational/teleport/integrations/access/common" "github.com/gravitational/teleport/integrations/lib" + "github.com/gravitational/teleport/integrations/lib/logger" pd "github.com/gravitational/teleport/integrations/lib/plugindata" ) @@ -153,6 +155,45 @@ func (b Bot) FetchRecipient(ctx context.Context, name string) (*common.Recipient }, nil } +// FetchOncallUsers fetches on-call users filtered by the provided annotations. +func (b Bot) FetchOncallUsers(ctx context.Context, req types.AccessRequest) ([]string, error) { + log := logger.Get(ctx) + + annotationKey := types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel + teamNames, err := common.GetNamesFromAnnotations(req, annotationKey) + if err != nil { + log.Debug("Automatic approvals annotation is empty or unspecified.") + return nil, nil + } + + // Fetch all on-call teams + oncallTeams, err := b.datadog.GetOncallTeams(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + var oncallUserIDs []string + for _, oncallTeam := range oncallTeams.Data { + // Filter the list of teams to only the teams that match the datadog annotation. + if !slices.Contains(teamNames, oncallTeam.Attributes.Handle) && + !slices.Contains(teamNames, oncallTeam.Attributes.Name) { + continue + } + // Collect users that are on-call for the specified team. + for _, oncallUser := range oncallTeam.Relationships.OncallUsers.Data { + oncallUserIDs = append(oncallUserIDs, oncallUser.ID) + } + } + + var oncallUserEmails []string + for _, user := range oncallTeams.Included { + if slices.Contains(oncallUserIDs, user.ID) { + oncallUserEmails = append(oncallUserEmails, user.Attributes.Email) + } + } + return oncallUserEmails, nil +} + func buildIncidentSummary(clusterName, reqID string, reqData pd.AccessRequestData, webProxyURL *url.URL) (string, error) { var requestLink string if webProxyURL != nil { diff --git a/integrations/access/datadog/client.go b/integrations/access/datadog/client.go index 2dbf0131a2a0..340df7741853 100644 --- a/integrations/access/datadog/client.go +++ b/integrations/access/datadog/client.go @@ -58,6 +58,10 @@ type Datadog struct { // simpler to integrate with the existing framework. Consider using the official // datadog api client package: https://github.com/DataDog/datadog-api-client-go. client *resty.Client + + // TODO: Remove clientUnstable once on-call API is merged into official API. + // See: https://docs.datadoghq.com/api/latest/ + clientUnstable *resty.Client } // NewDatadogClient creates a new Datadog client for managing incidents. @@ -83,9 +87,30 @@ func NewDatadogClient(conf DatadogConfig, webProxyAddr string, statusSink common }). OnAfterResponse(onAfterDatadogResponse(statusSink)) + apiEndpointUnstable, err := url.JoinPath(conf.APIEndpoint, APIUnstable) + if err != nil { + return nil, trace.Wrap(err) + } + clientUnstable := resty.NewWithClient(&http.Client{ + Timeout: datadogHTTPTimeout, + Transport: &http.Transport{ + MaxConnsPerHost: datadogMaxConns, + MaxIdleConnsPerHost: datadogMaxConns, + }}). + SetBaseURL(apiEndpointUnstable). + SetHeader("Accept", "application/json"). + SetHeader("Content-Type", "application/json"). + SetHeader("DD-API-KEY", conf.APIKey). + SetHeader("DD-APPLICATION-KEY", conf.ApplicationKey). + OnBeforeRequest(func(_ *resty.Client, req *resty.Request) error { + req.SetError(&ErrorResult{}) + return nil + }).OnAfterResponse(onAfterDatadogResponse(statusSink)) + return &Datadog{ - DatadogConfig: conf, - client: client, + DatadogConfig: conf, + client: client, + clientUnstable: clientUnstable, }, nil } @@ -129,6 +154,8 @@ func (d *Datadog) CheckHealth(ctx context.Context) error { return trace.Wrap(err) } for _, permission := range result.Data { + // TODO: Verify on-call/teams permissions once required permissions have + // been published. if permission.Attributes.Name == IncidentWritePermissions { if permission.Attributes.Restricted { return trace.AccessDenied("missing incident_write permissions") @@ -250,3 +277,13 @@ func (d *Datadog) ResolveIncident(ctx context.Context, incidentID, state string) Patch("incidents/{incident_id}") return trace.Wrap(err) } + +// GetOncallTeams gets current on call teams. +func (d *Datadog) GetOncallTeams(ctx context.Context) (OncallTeamsBody, error) { + var result OncallTeamsBody + _, err := d.clientUnstable.NewRequest(). + SetContext(ctx). + SetResult(&result). + Get("on-call/teams") + return result, trace.Wrap(err) +} diff --git a/integrations/access/datadog/config.go b/integrations/access/datadog/config.go index c05b611e1afb..a3bf188b42ee 100644 --- a/integrations/access/datadog/config.go +++ b/integrations/access/datadog/config.go @@ -36,6 +36,10 @@ const ( APIEndpointDefaultURL = "https://api.datadoghq.com" // APIVersion specifies the api version. APIVersion = "api/v2" + // APIUnstable specifies the unstable api endpoint. + // + // TODO: Remove once on-call API is merged into official API. + APIUnstable = "api/unstable" // SeverityDefault specifies the default incident severity. SeverityDefault = "SEV-3" ) diff --git a/integrations/access/datadog/testlib/fake_datadog.go b/integrations/access/datadog/testlib/fake_datadog.go index 86880379c1b1..64ef2e35b93b 100644 --- a/integrations/access/datadog/testlib/fake_datadog.go +++ b/integrations/access/datadog/testlib/fake_datadog.go @@ -25,6 +25,7 @@ import ( "net/http" "net/http/httptest" "runtime/debug" + "strconv" "strings" "sync" "sync/atomic" @@ -46,6 +47,7 @@ type FakeDatadog struct { objects sync.Map incidentIDCounter uint64 incidentNoteIDCounter uint64 + userIDCounter uint64 } func NewFakeDatadog(concurrency int) *FakeDatadog { @@ -60,11 +62,16 @@ func NewFakeDatadog(concurrency int) *FakeDatadog { // Ignore api version for tests const apiPrefix = "/" + datadog.APIVersion + const unstablePrefix = "/" + datadog.APIUnstable router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, apiPrefix) { http.StripPrefix(apiPrefix, router).ServeHTTP(w, r) return } + if strings.HasPrefix(r.URL.Path, unstablePrefix) { + http.StripPrefix(unstablePrefix, router).ServeHTTP(w, r) + return + } http.NotFound(w, r) }) @@ -136,6 +143,58 @@ func NewFakeDatadog(concurrency int) *FakeDatadog { panicIf(err) }) + router.GET("/on-call/teams", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { + rw.Header().Add("Content-Type", "application/json") + + oncallTeams, found := mock.GetOncallTeams() + if !found { + rw.WriteHeader(http.StatusNotFound) + err := json.NewEncoder(rw).Encode(&datadog.ErrorResult{Errors: []string{"On-call Teams not found"}}) + panicIf(err) + return + } + + body := datadog.OncallTeamsBody{ + Data: []datadog.OncallTeamsData{}, + Included: []datadog.OncallTeamsIncluded{}, + } + + for team, users := range oncallTeams { + oncallUsers := make([]datadog.OncallUsersData, 0, len(users)) + + for _, user := range users { + userID := strconv.FormatUint(atomic.AddUint64(&mock.userIDCounter, 1), 10) + oncallUsers = append(oncallUsers, datadog.OncallUsersData{ + Metadata: datadog.Metadata{ + ID: userID, + }, + }) + body.Included = append(body.Included, datadog.OncallTeamsIncluded{ + Metadata: datadog.Metadata{ + ID: userID, + }, + Attributes: datadog.OncallTeamsIncludedAttributes{ + Email: user, + }, + }) + } + + body.Data = append(body.Data, datadog.OncallTeamsData{ + Attributes: datadog.OncallTeamsAttributes{ + Handle: team, + }, + Relationships: datadog.OncallTeamsRelationships{ + OncallUsers: datadog.OncallUsers{ + Data: oncallUsers, + }, + }, + }) + } + + err := json.NewEncoder(rw).Encode(body) + panicIf(err) + }) + return mock } @@ -201,6 +260,25 @@ func (d *FakeDatadog) CheckNewIncidentNote(ctx context.Context) (datadog.Timelin } } +func (d *FakeDatadog) StoreOncallTeams(teamName string, users []string) map[string][]string { + oncallTeams, ok := d.GetOncallTeams() + if !ok { + oncallTeams = make(map[string][]string) + } + oncallTeams[teamName] = users + + d.objects.Store("on-call-teams", oncallTeams) + return oncallTeams +} + +func (d *FakeDatadog) GetOncallTeams() (map[string][]string, bool) { + if obj, ok := d.objects.Load("on-call-teams"); ok { + oncallTeams, ok := obj.(map[string][]string) + return oncallTeams, ok + } + return nil, false +} + func panicIf(err error) { if err != nil { log.Panicf("%v at %v", err, string(debug.Stack())) diff --git a/integrations/access/datadog/testlib/suite.go b/integrations/access/datadog/testlib/suite.go index c3947bd8ac1c..9d3225954bd7 100644 --- a/integrations/access/datadog/testlib/suite.go +++ b/integrations/access/datadog/testlib/suite.go @@ -38,6 +38,10 @@ import ( "github.com/gravitational/teleport/integrations/lib/testing/integration" ) +const ( + ApprovalTeamName = "teleport-approval" +) + // DatadogBaseSuite is the Datadog Incident Management plugin test suite. // It implements the testify.TestingSuite interface. type DatadogBaseSuite struct { @@ -484,7 +488,7 @@ func (s *DatadogSuiteEnterprise) TestApprovalByReview() { content = note.Data.Attributes.Content.Content assert.Contains(t, content, integration.Reviewer2UserName+" reviewed the request", "note must contain a review author") - // Validate the alert got resolved, and a final note was added describing the resolution. + // Validate the incident got resolved, and a final note was added describing the resolution. pluginData := s.checkPluginData(ctx, req.GetName(), func(data accessrequest.PluginData) bool { return data.ReviewsCount == 2 && data.ResolutionTag != plugindata.Unresolved }) @@ -551,7 +555,7 @@ func (s *DatadogSuiteEnterprise) TestDenialByReview() { content = note.Data.Attributes.Content.Content assert.Contains(t, content, integration.Reviewer2UserName+" reviewed the request", "note must contain a review author") - // Validate the alert got resolved, and a final note was added describing the resolution. + // Validate the incident got resolved, and a final note was added describing the resolution. pluginData := s.checkPluginData(ctx, req.GetName(), func(data accessrequest.PluginData) bool { return data.ReviewsCount == 2 && data.ResolutionTag != plugindata.Unresolved }) @@ -569,3 +573,86 @@ func (s *DatadogSuiteEnterprise) TestDenialByReview() { require.NoError(t, err) assert.Equal(t, "resolved", incidentUpdate.Data.Attributes.Fields.State.Value) } + +// TestAutoApprovalWhenNotOnCall tests that access requests are not automatically +// approved when the user is not on-call. +func (s *DatadogSuiteEnterprise) TestAutoApprovalWhenNotOnCall() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + // Test setup: store an on-call team matching the annotation, but with a different + // user than the requesting user. + s.fakeDatadog.StoreOncallTeams(ApprovalTeamName, []string{"not-on-call@example.com"}) + + // Test setup: store an on-call team with the requesting user, but with a team + // that does not match the annotation. + s.fakeDatadog.StoreOncallTeams("dev-team", []string{integration.RequesterOSSUserName}) + s.AnnotateRequesterRoleAccessRequests( + ctx, + types.TeleportNamespace+types.ReqAnnotationApproveSchedulesLabel, + []string{ApprovalTeamName}, + ) + + s.startApp() + + // Test setup: we create an access request and wait for its incident. + req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, []string{ + integration.Reviewer1UserName, + }) + + // Validate the incident has been created in Datadog and its ID is stored in + // the plugin_data. + _ = s.checkPluginData(ctx, req.GetName(), func(data accessrequest.PluginData) bool { + return len(data.SentMessages) > 0 + }) + + _, err := s.fakeDatadog.CheckNewIncident(ctx) + require.NoError(t, err, "no new incidents 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 on-call. +func (s *DatadogSuiteEnterprise) TestAutoApprovalWhenOnCall() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + // Test setup: store an on-call team with the requesting user in it. + s.fakeDatadog.StoreOncallTeams(ApprovalTeamName, []string{integration.RequesterOSSUserName}) + s.AnnotateRequesterRoleAccessRequests( + ctx, + types.TeleportNamespace+types.ReqAnnotationApproveSchedulesLabel, + []string{ApprovalTeamName}, + ) + + s.startApp() + + // Test setup: we create an access request and wait for its incident. + req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, []string{ + integration.Reviewer1UserName, + }) + + // Validate the incident has been created in Datadog and its ID is stored in + // the plugin_data. + _ = s.checkPluginData(ctx, req.GetName(), func(data accessrequest.PluginData) bool { + return len(data.SentMessages) > 0 + }) + + _, err := s.fakeDatadog.CheckNewIncident(ctx) + require.NoError(t, err, "no new incidents 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/datadog/types.go b/integrations/access/datadog/types.go index a22480431150..194aa2f884a1 100644 --- a/integrations/access/datadog/types.go +++ b/integrations/access/datadog/types.go @@ -117,6 +117,53 @@ type TimelineContent struct { Content string `json:"content,omitempty"` } +// OncallTeamsBody contains the response body for an on-call teams request. +type OncallTeamsBody struct { + Data []OncallTeamsData `json:"data,omitempty"` + Included []OncallTeamsIncluded `json:"included,omitempty"` +} + +// OncallTeamsData contains the on-call teams data. +type OncallTeamsData struct { + Metadata + Attributes OncallTeamsAttributes `json:"attributes,omitempty"` + Relationships OncallTeamsRelationships `json:"relationships,omitempty"` +} + +// OncallTeamsAttributes contains the on-call teams attributes. +type OncallTeamsAttributes struct { + Name string `json:"name,omitempty"` + Handle string `json:"handle,omitempty"` +} + +// OncallTeamsRelationships contains the on-call teams relationships. +type OncallTeamsRelationships struct { + OncallUsers OncallUsers `json:"oncall_users,omitempty"` +} + +// OncallUsers contains the list of on-call users. +type OncallUsers struct { + Data []OncallUsersData `json:"data,omitempty"` +} + +// OncallUsersData contains the on-call user data. +type OncallUsersData struct { + Metadata +} + +// OncallTeamsIncluded contains the on-call teams included related resources. +type OncallTeamsIncluded struct { + Metadata + Attributes OncallTeamsIncludedAttributes `json:"attributes,omitempty"` +} + +// OncallTeamsIncludedAttributes contains the on-call teams included related resource +// attributes. +type OncallTeamsIncludedAttributes struct { + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` +} + // ErrorResult contains the error response. type ErrorResult struct { Errors []string `json:"errors"` diff --git a/integrations/access/discord/bot.go b/integrations/access/discord/bot.go index 40309f310422..ca231bdf83a9 100644 --- a/integrations/access/discord/bot.go +++ b/integrations/access/discord/bot.go @@ -231,3 +231,8 @@ func (b DiscordBot) FetchRecipient(ctx context.Context, name string) (*common.Re Data: nil, }, nil } + +// FetchOncallUsers fetches on-call users filtered by the provided annotations. +func (b DiscordBot) FetchOncallUsers(ctx context.Context, req types.AccessRequest) ([]string, error) { + return nil, trace.NotImplemented("fetch oncall users not implemented for plugin") +} diff --git a/integrations/access/mattermost/bot.go b/integrations/access/mattermost/bot.go index dbfbc2052af0..078286545c86 100644 --- a/integrations/access/mattermost/bot.go +++ b/integrations/access/mattermost/bot.go @@ -519,6 +519,11 @@ func (b Bot) FetchRecipient(ctx context.Context, name string) (*common.Recipient }, nil } +// FetchOncallUsers fetches on-call users filtered by the provided annotations. +func (b Bot) FetchOncallUsers(ctx context.Context, req types.AccessRequest) ([]string, error) { + return nil, trace.NotImplemented("fetch oncall users not implemented for plugin") +} + func userResult(resp *resty.Response) (User, error) { result := resp.Result() ptr, ok := result.(*User) diff --git a/integrations/access/opsgenie/app.go b/integrations/access/opsgenie/app.go index cfa53e132235..45ab138811da 100644 --- a/integrations/access/opsgenie/app.go +++ b/integrations/access/opsgenie/app.go @@ -308,13 +308,13 @@ func (a *App) getNotifySchedulesAndTeams(ctx context.Context, req types.AccessRe log := logger.Get(ctx) scheduleAnnotationKey := types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel - schedules, err = common.GetServiceNamesFromAnnotations(req, scheduleAnnotationKey) + schedules, err = common.GetNamesFromAnnotations(req, scheduleAnnotationKey) if err != nil { log.Debugf("No schedules to notify in %s", scheduleAnnotationKey) } teamAnnotationKey := types.TeleportNamespace + types.ReqAnnotationTeamsLabel - teams, err = common.GetServiceNamesFromAnnotations(req, teamAnnotationKey) + teams, err = common.GetNamesFromAnnotations(req, teamAnnotationKey) if err != nil { log.Debugf("No teams to notify in %s", teamAnnotationKey) } @@ -328,7 +328,7 @@ func (a *App) getNotifySchedulesAndTeams(ctx context.Context, req types.AccessRe func (a *App) getOnCallServiceNames(req types.AccessRequest) ([]string, error) { annotationKey := types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel - return common.GetServiceNamesFromAnnotations(req, annotationKey) + return common.GetNamesFromAnnotations(req, annotationKey) } func (a *App) tryNotifyService(ctx context.Context, req types.AccessRequest) (bool, error) { diff --git a/integrations/access/opsgenie/bot.go b/integrations/access/opsgenie/bot.go index 745d34fb6c54..1a8b32dc0b48 100644 --- a/integrations/access/opsgenie/bot.go +++ b/integrations/access/opsgenie/bot.go @@ -139,3 +139,8 @@ func createScheduleRecipient(ctx context.Context, name string) (*common.Recipien Kind: common.RecipientKindSchedule, }, nil } + +// FetchOncallUsers fetches on-call users filtered by the provided annotations. +func (b Bot) FetchOncallUsers(ctx context.Context, req types.AccessRequest) ([]string, error) { + return nil, trace.NotImplemented("fetch oncall users not implemented for plugin") +} diff --git a/integrations/access/pagerduty/app.go b/integrations/access/pagerduty/app.go index 6453f0e16f91..c2ad2a270ffb 100644 --- a/integrations/access/pagerduty/app.go +++ b/integrations/access/pagerduty/app.go @@ -340,7 +340,7 @@ func (a *App) getNotifyServiceName(ctx context.Context, req types.AccessRequest) return recipientSetService.ToSlice()[0].Name, nil } annotationKey := a.conf.Pagerduty.RequestAnnotations.NotifyService - // We cannot use common.GetServiceNamesFromAnnotations here as it sorts the + // We cannot use common.GetNamesFromAnnotations here as it sorts the // list and might change the first element. // The proper way would be to support notifying multiple services slice, ok := req.GetSystemAnnotations()[annotationKey] @@ -359,7 +359,7 @@ func (a *App) getNotifyServiceName(ctx context.Context, req types.AccessRequest) func (a *App) getOnCallServiceNames(req types.AccessRequest) ([]string, error) { annotationKey := a.conf.Pagerduty.RequestAnnotations.Services - return common.GetServiceNamesFromAnnotations(req, annotationKey) + return common.GetNamesFromAnnotations(req, annotationKey) } func (a *App) tryNotifyService(ctx context.Context, req types.AccessRequest) (bool, error) { diff --git a/integrations/access/servicenow/app.go b/integrations/access/servicenow/app.go index 82548ff5cfc8..e1a94d60e1a2 100644 --- a/integrations/access/servicenow/app.go +++ b/integrations/access/servicenow/app.go @@ -366,7 +366,7 @@ func (a *App) onDeletedRequest(ctx context.Context, reqID string) error { func (a *App) getOnCallServiceNames(req types.AccessRequest) ([]string, error) { annotationKey := types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel - return common.GetServiceNamesFromAnnotations(req, annotationKey) + return common.GetNamesFromAnnotations(req, annotationKey) } // createIncident posts an incident with request information. diff --git a/integrations/access/servicenow/bot.go b/integrations/access/servicenow/bot.go index 71fe94aa6933..60ebac045e3c 100644 --- a/integrations/access/servicenow/bot.go +++ b/integrations/access/servicenow/bot.go @@ -120,3 +120,8 @@ func (b *Bot) UpdateMessages(ctx context.Context, reqID string, data pd.AccessRe func (b *Bot) FetchRecipient(ctx context.Context, recipient string) (*common.Recipient, error) { return nil, trace.NotImplemented("ServiceNow plugin does not use recipients") } + +// FetchOncallUsers fetches on-call users filtered by the provided annotations. +func (b Bot) FetchOncallUsers(ctx context.Context, req types.AccessRequest) ([]string, error) { + return nil, trace.NotImplemented("fetch oncall users not implemented for plugin") +} diff --git a/integrations/access/slack/bot.go b/integrations/access/slack/bot.go index 0c6969d0bd35..dff6c17bbad2 100644 --- a/integrations/access/slack/bot.go +++ b/integrations/access/slack/bot.go @@ -278,6 +278,11 @@ func (b Bot) FetchRecipient(ctx context.Context, name string) (*common.Recipient }, nil } +// FetchOncallUsers fetches on-call users filtered by the provided annotations. +func (b Bot) FetchOncallUsers(ctx context.Context, req types.AccessRequest) ([]string, error) { + return nil, trace.NotImplemented("fetch oncall users not implemented for plugin") +} + // slackAccessListReminderMsgSection builds an access list reminder Slack message section (obeys markdown). func (b Bot) slackAccessListReminderMsgSection(accessList *accesslist.AccessList) []BlockItem { nextAuditDate := accessList.Spec.Audit.NextAuditDate