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:
225
pkg/accessreview/drivers/better_stack.go
Normal file
225
pkg/accessreview/drivers/better_stack.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user