Add Better Stack access review connector

Better Stack exposes team members and pending invitations through its
Uptime API. Wire it as an access-review connector so a Better Stack
team can be reviewed in access-review campaigns.

Better Stack has no third-party OAuth app for listing members (its
OAuth is an end-user MCP sign-in), so the connector authenticates with
a Bearer API token plus the team name that scopes the team-members
listing. The driver paginates /api/v2/team-members, maps roles and
invitation records into account records, and the source name is
resolved from the configured team.

This wires the full surface: the provider enum and migration, the
connector settings, the registry registration with the team-name extra
setting, the GraphQL input and resolver marshaling, the frontend field
mapping and connector logo, and cassette-backed driver tests.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-06-08 23:08:42 +02:00
parent c71a090fe4
commit 29b72ebc3b
15 changed files with 529 additions and 0 deletions

View File

@@ -0,0 +1,225 @@
// 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"
"go.probo.inc/probo/pkg/coredata"
)
// betterStackTeamMembersEndpoint is the Better Stack Uptime API team-members
// resource. The team-members reference documents this apex host and returns
// its pagination links on the same host, so the driver pins every page
// request to it (instead of following the response's `next` URL) to avoid a
// cross-host redirect that would drop the Authorization header.
const betterStackTeamMembersEndpoint = "https://betterstack.com/api/v2/team-members"
// BetterStackDriver fetches team members and pending invitations from the
// Better Stack Uptime API via Bearer token-authenticated REST requests. The
// teamName scopes the listing; it is required when authenticating with a
// global API token and ignored for team-scoped tokens.
type BetterStackDriver struct {
httpClient *http.Client
teamName string
}
var _ Driver = (*BetterStackDriver)(nil)
type betterStackTeamMembersResponse struct {
Data []struct {
ID string `json:"id"`
Type string `json:"type"`
Attributes struct {
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
CreatedAt string `json:"created_at"`
InvitedAt string `json:"invited_at"`
Role string `json:"role"`
} `json:"attributes"`
} `json:"data"`
Pagination struct {
Next *string `json:"next"`
} `json:"pagination"`
}
func NewBetterStackDriver(httpClient *http.Client, teamName string) *BetterStackDriver {
return &BetterStackDriver{
httpClient: &http.Client{
Transport: &retryRoundTripper{
next: httpClient.Transport,
maxRetries: 3,
},
},
teamName: teamName,
}
}
func (d *BetterStackDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
page := 1
for range maxPaginationPages {
resp, err := d.fetchTeamMembersPage(ctx, page)
if err != nil {
return nil, err
}
for _, member := range resp.Data {
if member.Attributes.Email == "" {
continue
}
record := AccountRecord{
Email: member.Attributes.Email,
FullName: strings.TrimSpace(member.Attributes.FirstName + " " + member.Attributes.LastName),
Role: betterStackRole(member.Attributes.Role),
Active: betterStackActive(member.Type),
IsAdmin: betterStackIsAdmin(member.Attributes.Role),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
ExternalID: member.ID,
}
if t, ok := parseBetterStackTimestamp(member.Attributes.CreatedAt); ok {
record.CreatedAt = &t
} else if t, ok := parseBetterStackTimestamp(member.Attributes.InvitedAt); ok {
// Invitations carry invited_at but no created_at; keep the
// first-seen timestamp in CreatedAt for review context.
record.CreatedAt = &t
}
records = append(records, record)
}
if resp.Pagination.Next == nil || *resp.Pagination.Next == "" {
return records, nil
}
page++
}
return nil, fmt.Errorf("cannot list all better stack team members: %w", ErrPaginationLimitReached)
}
func (d *BetterStackDriver) fetchTeamMembersPage(
ctx context.Context,
page int,
) (*betterStackTeamMembersResponse, error) {
endpoint, err := url.Parse(betterStackTeamMembersEndpoint)
if err != nil {
return nil, fmt.Errorf("cannot parse better stack team members URL: %w", err)
}
q := endpoint.Query()
q.Set("page", strconv.Itoa(page))
if d.teamName != "" {
q.Set("team_name", d.teamName)
}
endpoint.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("cannot create better stack team members request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute better stack team members request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch better stack team members: unexpected status %d", httpResp.StatusCode)
}
var resp betterStackTeamMembersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode better stack team members response: %w", err)
}
return &resp, nil
}
// betterStackRole maps a Better Stack role token to a human-readable label.
// Unknown roles (including the Enterprise "custom" roles) are passed through
// unchanged so the reviewer still sees the source value.
func betterStackRole(role string) string {
switch role {
case "admin":
return "Admin"
case "billing_admin":
return "Billing admin"
case "team_lead":
return "Team lead"
case "responder":
return "Responder"
case "member":
return "Member"
default:
return role
}
}
// betterStackIsAdmin flags roles with administrative control over team access:
// admin (full control) and team_lead (can manage team members and roles).
// billing_admin is billing-only, so it is not flagged.
func betterStackIsAdmin(role string) bool {
return role == "admin" || role == "team_lead"
}
// betterStackActive maps the member record type to an explicit active signal:
// confirmed members are active, pending invitations are not, and unknown
// types leave the signal nil rather than fabricate one.
func betterStackActive(memberType string) *bool {
switch memberType {
case "team_member":
return new(true)
case "team_member_invitation":
return new(false)
default:
return nil
}
}
func parseBetterStackTimestamp(value string) (time.Time, bool) {
if value == "" {
return time.Time{}, false
}
// time.Parse accepts a fractional second even when the layout omits it,
// so RFC3339 covers Better Stack's ".000Z" timestamps too.
t, err := time.Parse(time.RFC3339, value)
if err != nil {
return time.Time{}, false
}
return t, true
}

View File

@@ -0,0 +1,135 @@
// 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"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
)
func TestBetterStackDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/better_stack", "BETTER_STACK_TOKEN")
teamName := os.Getenv("BETTER_STACK_TEAM_NAME")
if teamName == "" {
teamName = "acme"
}
client := newVCRClient(rec, bearerAuth(os.Getenv("BETTER_STACK_TOKEN")))
records, err := NewBetterStackDriver(client, teamName).ListAccounts(context.Background())
require.NoError(t, err)
assert.Len(t, records, 1)
r := records[0]
assert.Equal(t, "alice@example.com", r.Email)
assert.Equal(t, "Alice Smith", r.FullName)
assert.Equal(t, "Admin", r.Role)
assert.True(t, r.IsAdmin)
assert.Equal(t, "101", r.ExternalID)
require.NotNil(t, r.Active)
assert.True(t, *r.Active)
require.NotNil(t, r.CreatedAt)
assert.Equal(t, coredata.MFAStatusUnknown, r.MFAStatus)
}
func TestBetterStackDriverPagination(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v2/team-members", r.URL.Path)
assert.Equal(t, "acme", r.URL.Query().Get("team_name"))
w.Header().Set("Content-Type", "application/json")
if r.URL.Query().Get("page") == "2" {
_, _ = w.Write([]byte(`{"data":[{"id":"201","type":"team_member_invitation","attributes":{"email":"charlie@example.com","invited_at":"2023-10-28T12:00:00.000Z","role":"member"}}],"pagination":{"next":null}}`))
return
}
_, _ = w.Write([]byte(`{"data":[{"id":"101","type":"team_member","attributes":{"email":"alice@example.com","first_name":"Alice","last_name":"Smith","created_at":"2023-10-26T10:00:00.000Z","role":"admin"}},{"id":"102","type":"team_member","attributes":{"email":"bob@example.com","first_name":"Bob","last_name":"","created_at":"2023-10-27T11:00:00.000Z","role":"responder"}}],"pagination":{"next":"https://betterstack.com/api/v2/team-members?page=2&team_name=acme"}}`))
}))
defer srv.Close()
client := &http.Client{Transport: &hostRewriter{target: srv.URL}}
records, err := NewBetterStackDriver(client, "acme").ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 3)
assert.Equal(t, "alice@example.com", records[0].Email)
assert.Equal(t, "Alice Smith", records[0].FullName)
assert.True(t, *records[0].Active)
assert.Equal(t, "bob@example.com", records[1].Email)
assert.Equal(t, "Bob", records[1].FullName)
assert.Equal(t, "charlie@example.com", records[2].Email)
require.NotNil(t, records[2].Active)
assert.False(t, *records[2].Active)
}
func TestBetterStackDriverListAccountsError(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"errors":"invalid token"}`))
}))
defer srv.Close()
client := &http.Client{Transport: &hostRewriter{target: srv.URL}}
_, err := NewBetterStackDriver(client, "acme").ListAccounts(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected status 401")
}
func TestBetterStackRole(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
isAdmin bool
}{
{"admin", "Admin", true},
{"billing_admin", "Billing admin", false},
{"team_lead", "Team lead", true},
{"responder", "Responder", false},
{"member", "Member", false},
{"custom", "custom", false},
{"future_role", "future_role", false},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.want, betterStackRole(c.in))
assert.Equal(t, c.isAdmin, betterStackIsAdmin(c.in))
})
}
}

View File

@@ -706,6 +706,21 @@ func (r *resendNameResolver) ResolveInstanceName(_ context.Context) (string, err
return "Resend", nil
}
// betterStackNameResolver returns the Better Stack team name captured when
// the API-key connector was created. The team name is the human-readable
// instance identifier, so no HTTP call is required.
type betterStackNameResolver struct {
teamName string
}
func NewBetterStackNameResolver(teamName string) NameResolver {
return &betterStackNameResolver{teamName: teamName}
}
func (r *betterStackNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
return r.teamName, nil
}
// gitlabNameResolver resolves the GitLab group name.
type gitlabNameResolver struct {
httpClient *http.Client

View File

@@ -0,0 +1,33 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: betterstack.com
form:
page:
- "1"
team_name:
- acme
headers:
Accept:
- application/json
url: https://betterstack.com/api/v2/team-members?page=1&team_name=acme
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"data":[{"id":"101","type":"team_member","attributes":{"email":"alice@example.com","first_name":"Alice","last_name":"Smith","created_at":"2026-06-09T13:24:53.712Z","role":"admin","mobile_app_platforms":[]}}],"pagination":{"first":"https://betterstack.com/api/v2/team-members?page=1&team_name=acme","last":"https://betterstack.com/api/v2/team-members?page=1&team_name=acme","prev":null,"next":null}}'
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 100ms