From 245ad1f4348148085c4f2153b76c71a8341a3d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Fri, 29 May 2026 14:55:43 +0200 Subject: [PATCH] Add Datadog access-review driver and name resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/accessreview/drivers/datadog.go | 184 ++++++++++++++++++ pkg/accessreview/drivers/datadog_test.go | 45 +++++ pkg/accessreview/drivers/name_resolver.go | 17 ++ .../drivers/testdata/datadog.yaml | 77 ++++++++ 4 files changed, 323 insertions(+) create mode 100644 pkg/accessreview/drivers/datadog.go create mode 100644 pkg/accessreview/drivers/datadog_test.go create mode 100644 pkg/accessreview/drivers/testdata/datadog.yaml diff --git a/pkg/accessreview/drivers/datadog.go b/pkg/accessreview/drivers/datadog.go new file mode 100644 index 000000000..e56bd2ef0 --- /dev/null +++ b/pkg/accessreview/drivers/datadog.go @@ -0,0 +1,184 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// DatadogDriver lists Datadog org members via GET /api/v2/users. The API +// host is per-customer (api.), captured during the OAuth callback +// and stored on the connector settings. +type DatadogDriver struct { + httpClient *http.Client + domain string // e.g. "us3.datadoghq.com" +} + +var _ Driver = (*DatadogDriver)(nil) + +// NewDatadogDriver wraps the connection's SSRF-protected transport with a +// retrying transport for transient 5xx, matching the canonical sibling +// drivers (heroku.go, pagerduty.go). The caller's *http.Client is not +// mutated. +func NewDatadogDriver(httpClient *http.Client, domain string) *DatadogDriver { + return &DatadogDriver{ + httpClient: &http.Client{ + Transport: &retryRoundTripper{ + next: httpClient.Transport, + maxRetries: 3, + }, + }, + domain: domain, + } +} + +const datadogPageSize = 100 + +type datadogUsersResponse struct { + Data []struct { + ID string `json:"id"` + Attributes struct { + Email string `json:"email"` + Name string `json:"name"` + Handle string `json:"handle"` + Disabled bool `json:"disabled"` + Status string `json:"status"` + Verified bool `json:"verified"` + CreatedAt string `json:"created_at"` + ModifiedAt string `json:"modified_at"` + } `json:"attributes"` + Relationships struct { + Roles struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } `json:"roles"` + } `json:"relationships"` + } `json:"data"` + Included []struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes struct { + Name string `json:"name"` + } `json:"attributes"` + } `json:"included"` +} + +func (d *DatadogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + var records []AccountRecord + + for page := 0; page < maxPaginationPages; page++ { + resp, err := d.queryUsers(ctx, page) + if err != nil { + return nil, err + } + + roleNames := make(map[string]string, len(resp.Included)) + for _, inc := range resp.Included { + if inc.Type == "roles" { + roleNames[inc.ID] = inc.Attributes.Name + } + } + + for _, u := range resp.Data { + active := !u.Attributes.Disabled + + var role string + var isAdmin bool + for _, r := range u.Relationships.Roles.Data { + name := roleNames[r.ID] + if role == "" { + role = name + } + if strings.Contains(strings.ToLower(name), "admin") { + isAdmin = true + role = name + } + } + + records = append(records, AccountRecord{ + Email: u.Attributes.Email, + FullName: u.Attributes.Name, + Role: role, + Active: &active, + IsAdmin: isAdmin, + ExternalID: u.ID, + CreatedAt: parseDatadogTime(u.Attributes.CreatedAt), + }) + } + + if len(resp.Data) < datadogPageSize { + return records, nil + } + } + + return nil, fmt.Errorf("cannot list all datadog users: %w", ErrPaginationLimitReached) +} + +func (d *DatadogDriver) queryUsers(ctx context.Context, page int) (*datadogUsersResponse, error) { + q := url.Values{} + q.Set("page[size]", strconv.Itoa(datadogPageSize)) + q.Set("page[number]", strconv.Itoa(page)) + + endpoint := url.URL{ + Scheme: "https", + Host: "api." + d.domain, + Path: "/api/v2/users", + RawQuery: q.Encode(), + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("cannot create datadog users request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot list datadog users: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot list datadog users: unexpected status %d", resp.StatusCode) + } + + var out datadogUsersResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("cannot decode datadog users response: %w", err) + } + + return &out, nil +} + +func parseDatadogTime(s string) *time.Time { + if s == "" { + return nil + } + + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return nil + } + + return &t +} diff --git a/pkg/accessreview/drivers/datadog_test.go b/pkg/accessreview/drivers/datadog_test.go new file mode 100644 index 000000000..0728ec146 --- /dev/null +++ b/pkg/accessreview/drivers/datadog_test.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDatadogDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/datadog", "DATADOG_TOKEN") + client := newVCRClient(rec, bearerAuth(os.Getenv("DATADOG_TOKEN"))) + + driver := NewDatadogDriver(client, "datadoghq.com") + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + assert.Len(t, records, 2) + + r := records[0] + assert.Equal(t, "alice@example.com", r.Email) + assert.Equal(t, "Alice Example", r.FullName) + assert.Equal(t, "abc-111", r.ExternalID) + require.NotNil(t, r.Active) + assert.True(t, *r.Active) + assert.True(t, r.IsAdmin) + assert.Equal(t, "Datadog Admin Role", r.Role) +} diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index 5248e19e8..1230935e2 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -833,6 +833,23 @@ func (r *pagerdutyNameResolver) ResolveInstanceName(_ context.Context) (string, return r.subdomain, nil } +// datadogNameResolver returns the Datadog site/region label stored in +// connector settings (e.g. "US3"), captured during the OAuth callback. No +// HTTP call is required; the AccessSource title becomes "Datadog ". +// Org-name resolution is intentionally omitted to keep scopes to +// user_access_read (the org name endpoint needs org_management). +type datadogNameResolver struct { + region string +} + +func NewDatadogNameResolver(region string) NameResolver { + return &datadogNameResolver{region: region} +} + +func (r *datadogNameResolver) ResolveInstanceName(_ context.Context) (string, error) { + return r.region, nil +} + // asanaNameResolver resolves the Asana workspace name. type asanaNameResolver struct { httpClient *http.Client diff --git a/pkg/accessreview/drivers/testdata/datadog.yaml b/pkg/accessreview/drivers/testdata/datadog.yaml new file mode 100644 index 000000000..c0346b679 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/datadog.yaml @@ -0,0 +1,77 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: api.datadoghq.com + remote_addr: "" + request_uri: "" + body: "" + form: + page[number]: + - "0" + page[size]: + - "100" + headers: + Accept: + - application/json + url: https://api.datadoghq.com/api/v2/users?page%5Bnumber%5D=0&page%5Bsize%5D=100 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: true + body: | + { + "data": [ + { + "type": "users", + "id": "abc-111", + "attributes": { + "email": "alice@example.com", + "name": "Alice Example", + "handle": "alice@example.com", + "disabled": false, + "status": "Active", + "verified": true, + "created_at": "2025-01-02T03:04:05.000000+00:00" + }, + "relationships": { "roles": { "data": [ { "type": "roles", "id": "role-admin" } ] } } + }, + { + "type": "users", + "id": "abc-222", + "attributes": { + "email": "bob@example.com", + "name": "Bob Example", + "handle": "bob@example.com", + "disabled": true, + "status": "Disabled", + "verified": true, + "created_at": "2025-02-02T03:04:05.000000+00:00" + }, + "relationships": { "roles": { "data": [ { "type": "roles", "id": "role-standard" } ] } } + } + ], + "included": [ + { "type": "roles", "id": "role-admin", "attributes": { "name": "Datadog Admin Role" } }, + { "type": "roles", "id": "role-standard", "attributes": { "name": "Datadog Standard Role" } } + ], + "meta": { "page": { "total_count": 2, "total_filtered_count": 2 } } + } + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 1ms