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:
@@ -148,6 +148,9 @@ function mapAPIKeyExtraSettingToField(
|
|||||||
case "OKTA":
|
case "OKTA":
|
||||||
if (settingKey === "domain") return "oktaDomain";
|
if (settingKey === "domain") return "oktaDomain";
|
||||||
break;
|
break;
|
||||||
|
case "BETTER_STACK":
|
||||||
|
if (settingKey === "teamName") return "betterStackTeamName";
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
16
packages/ui/src/Atoms/ThirdParties/BetterStack.tsx
Normal file
16
packages/ui/src/Atoms/ThirdParties/BetterStack.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function BetterStack(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m.7792 10.7479-.7654 6.6384a2.0957 2.0957 0 0 0 .696 1.8122l1.8965 1.672c.6494.5725 1.658.0145 1.5185-.84L2.6039 10.705c-.1723-1.056-1.7022-1.02-1.8247.0429Zm12.3733 8.714L8.63 6.431c-.5023-1.4472-2.6082-1.3845-3.0203.0898l-1.376 4.9234c-.156.559-.216.8822.0005 1.4212h.5225l1.8993 6.0694c.294.7324.9017 1.3009 1.6611 1.5538l2.8315.9435c1.2417.4137 2.4268-.7513 2.004-1.97zm10.6297-1.0332L15.7907 3.4433c-.6971-1.3072-2.5779-1.2727-3.227.0589l-1.9652 3.9555c-.2375.487-.1274.6608.07.9435.1585.2268.526.2447.6758.012.147-.2287.488-.2076.6058.0375l5.1379 10.687a2.735 2.735 0 0 0 2.1416 1.6016l2.7183.3476c1.4628.1924 2.5299-1.3539 1.8343-2.6582z"
|
||||||
|
fill="#000000"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import type { ComponentProps, FC } from "react";
|
|||||||
|
|
||||||
import { Anthropic } from "./Anthropic";
|
import { Anthropic } from "./Anthropic";
|
||||||
import { Asana } from "./Asana";
|
import { Asana } from "./Asana";
|
||||||
|
import { BetterStack } from "./BetterStack";
|
||||||
import { Bitbucket } from "./Bitbucket";
|
import { Bitbucket } from "./Bitbucket";
|
||||||
import { Brex } from "./Brex";
|
import { Brex } from "./Brex";
|
||||||
import { Clerk } from "./Clerk";
|
import { Clerk } from "./Clerk";
|
||||||
@@ -57,6 +58,7 @@ import { Zendesk } from "./Zendesk";
|
|||||||
const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
|
const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
|
||||||
ANTHROPIC: Anthropic,
|
ANTHROPIC: Anthropic,
|
||||||
ASANA: Asana,
|
ASANA: Asana,
|
||||||
|
BETTER_STACK: BetterStack,
|
||||||
BITBUCKET: Bitbucket,
|
BITBUCKET: Bitbucket,
|
||||||
BREX: Brex,
|
BREX: Brex,
|
||||||
CLERK: Clerk,
|
CLERK: Clerk,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { Anthropic } from "./Anthropic";
|
export { Anthropic } from "./Anthropic";
|
||||||
export { Asana } from "./Asana";
|
export { Asana } from "./Asana";
|
||||||
|
export { BetterStack } from "./BetterStack";
|
||||||
export { Bitbucket } from "./Bitbucket";
|
export { Bitbucket } from "./Bitbucket";
|
||||||
export { Brex } from "./Brex";
|
export { Brex } from "./Brex";
|
||||||
export { Clerk } from "./Clerk";
|
export { Clerk } from "./Clerk";
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
135
pkg/accessreview/drivers/better_stack_test.go
Normal file
135
pkg/accessreview/drivers/better_stack_test.go
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -706,6 +706,21 @@ func (r *resendNameResolver) ResolveInstanceName(_ context.Context) (string, err
|
|||||||
return "Resend", nil
|
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.
|
// gitlabNameResolver resolves the GitLab group name.
|
||||||
type gitlabNameResolver struct {
|
type gitlabNameResolver struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
|||||||
33
pkg/accessreview/drivers/testdata/better_stack.yaml
vendored
Normal file
33
pkg/accessreview/drivers/testdata/better_stack.yaml
vendored
Normal 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
|
||||||
65
pkg/connector/provider/better_stack.go
Normal file
65
pkg/connector/provider/better_stack.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// 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 provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
"go.probo.inc/probo/pkg/accessreview/drivers"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// betterStackRegistration wires the Better Stack Uptime access-review
|
||||||
|
// connector. Better Stack has no third-party OAuth app for listing team
|
||||||
|
// members (its OAuth is an end-user MCP sign-in), so the connector is
|
||||||
|
// API-key only: the operator supplies a Bearer API token plus the team
|
||||||
|
// name that scopes the team-members listing.
|
||||||
|
func betterStackRegistration() *Registration {
|
||||||
|
return &Registration{
|
||||||
|
Provider: coredata.ConnectorProviderBetterStack,
|
||||||
|
DisplayName: "Better Stack",
|
||||||
|
SupportsAPIKey: true,
|
||||||
|
ProbeURL: "https://betterstack.com/api/v2/team-members",
|
||||||
|
ExtraSettings: []ExtraSetting{
|
||||||
|
{Key: "teamName", Label: "Team Name", Required: true},
|
||||||
|
},
|
||||||
|
NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
||||||
|
s, err := coredata.ConnectorSettings[coredata.BetterStackConnectorSettings](conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read better stack connector settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
teamName := strings.TrimSpace(s.TeamName)
|
||||||
|
if teamName == "" {
|
||||||
|
return nil, fmt.Errorf("cannot create better stack driver: team_name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return drivers.NewBetterStackDriver(c, teamName), nil
|
||||||
|
},
|
||||||
|
NewNameResolver: func(ctx context.Context, _ *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver {
|
||||||
|
s, err := coredata.ConnectorSettings[coredata.BetterStackConnectorSettings](conn)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCtx(ctx, "cannot read better stack connector settings", log.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return drivers.NewBetterStackNameResolver(strings.TrimSpace(s.TeamName))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ func NewBuiltinRegistry() *Registry {
|
|||||||
for _, reg := range []*Registration{
|
for _, reg := range []*Registration{
|
||||||
anthropicRegistration(),
|
anthropicRegistration(),
|
||||||
asanaRegistration(),
|
asanaRegistration(),
|
||||||
|
betterStackRegistration(),
|
||||||
bitbucketRegistration(),
|
bitbucketRegistration(),
|
||||||
brexRegistration(),
|
brexRegistration(),
|
||||||
clerkRegistration(),
|
clerkRegistration(),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const (
|
|||||||
ConnectorProviderSentry ConnectorProvider = "SENTRY"
|
ConnectorProviderSentry ConnectorProvider = "SENTRY"
|
||||||
ConnectorProviderSigNoz ConnectorProvider = "SIGNOZ"
|
ConnectorProviderSigNoz ConnectorProvider = "SIGNOZ"
|
||||||
ConnectorProviderSupabase ConnectorProvider = "SUPABASE"
|
ConnectorProviderSupabase ConnectorProvider = "SUPABASE"
|
||||||
|
ConnectorProviderBetterStack ConnectorProvider = "BETTER_STACK"
|
||||||
ConnectorProviderGitHub ConnectorProvider = "GITHUB"
|
ConnectorProviderGitHub ConnectorProvider = "GITHUB"
|
||||||
ConnectorProviderIntercom ConnectorProvider = "INTERCOM"
|
ConnectorProviderIntercom ConnectorProvider = "INTERCOM"
|
||||||
ConnectorProviderResend ConnectorProvider = "RESEND"
|
ConnectorProviderResend ConnectorProvider = "RESEND"
|
||||||
@@ -87,6 +88,7 @@ func ConnectorProviders() []ConnectorProvider {
|
|||||||
ConnectorProviderSentry,
|
ConnectorProviderSentry,
|
||||||
ConnectorProviderSigNoz,
|
ConnectorProviderSigNoz,
|
||||||
ConnectorProviderSupabase,
|
ConnectorProviderSupabase,
|
||||||
|
ConnectorProviderBetterStack,
|
||||||
ConnectorProviderGitHub,
|
ConnectorProviderGitHub,
|
||||||
ConnectorProviderIntercom,
|
ConnectorProviderIntercom,
|
||||||
ConnectorProviderResend,
|
ConnectorProviderResend,
|
||||||
@@ -131,6 +133,7 @@ func (v ConnectorProvider) IsValid() bool {
|
|||||||
ConnectorProviderSentry,
|
ConnectorProviderSentry,
|
||||||
ConnectorProviderSigNoz,
|
ConnectorProviderSigNoz,
|
||||||
ConnectorProviderSupabase,
|
ConnectorProviderSupabase,
|
||||||
|
ConnectorProviderBetterStack,
|
||||||
ConnectorProviderGitHub,
|
ConnectorProviderGitHub,
|
||||||
ConnectorProviderIntercom,
|
ConnectorProviderIntercom,
|
||||||
ConnectorProviderResend,
|
ConnectorProviderResend,
|
||||||
|
|||||||
@@ -136,6 +136,10 @@ type (
|
|||||||
ZendeskConnectorSettings struct {
|
ZendeskConnectorSettings struct {
|
||||||
Subdomain string `json:"subdomain"`
|
Subdomain string `json:"subdomain"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BetterStackConnectorSettings struct {
|
||||||
|
TeamName string `json:"team_name"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// GrantType returns the OAuth2 grant type recorded on the connector's
|
// GrantType returns the OAuth2 grant type recorded on the connector's
|
||||||
|
|||||||
15
pkg/coredata/migrations/20260608T417263Z.sql
Normal file
15
pkg/coredata/migrations/20260608T417263Z.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- 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.
|
||||||
|
|
||||||
|
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'BETTER_STACK';
|
||||||
@@ -154,6 +154,12 @@ func apiKeyConnectorSettings(input types.CreateAPIKeyConnectorInput) (json.RawMe
|
|||||||
}
|
}
|
||||||
|
|
||||||
return json.Marshal(&coredata.OktaConnectorSettings{Domain: domain})
|
return json.Marshal(&coredata.OktaConnectorSettings{Domain: domain})
|
||||||
|
case coredata.ConnectorProviderBetterStack:
|
||||||
|
if input.BetterStackTeamName == nil || *input.BetterStackTeamName == "" {
|
||||||
|
return nil, fmt.Errorf("cannot create better stack connector: betterStackTeamName is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(&coredata.BetterStackConnectorSettings{TeamName: *input.BetterStackTeamName})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ enum ConnectorProvider
|
|||||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSigNoz")
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSigNoz")
|
||||||
SUPABASE
|
SUPABASE
|
||||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSupabase")
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSupabase")
|
||||||
|
BETTER_STACK
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderBetterStack"
|
||||||
|
)
|
||||||
GITHUB @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGitHub")
|
GITHUB @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGitHub")
|
||||||
INTERCOM
|
INTERCOM
|
||||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderIntercom")
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderIntercom")
|
||||||
@@ -141,6 +145,7 @@ input CreateAPIKeyConnectorInput {
|
|||||||
posthogRegion: String
|
posthogRegion: String
|
||||||
posthogInstanceUrl: String
|
posthogInstanceUrl: String
|
||||||
oktaDomain: String
|
oktaDomain: String
|
||||||
|
betterStackTeamName: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateAPIKeyConnectorPayload {
|
type CreateAPIKeyConnectorPayload {
|
||||||
|
|||||||
Reference in New Issue
Block a user