Add Datadog access-review driver and name resolver

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-05-29 14:55:43 +02:00
parent f10d29a76c
commit 245ad1f434
4 changed files with 323 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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.<domain>), 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
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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)
}

View File

@@ -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 <region>".
// 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

View File

@@ -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