Add access source drivers
Add Driver interface and implementations for Google Workspace, Linear, Slack, 1Password, HubSpot, DocuSign, Notion, Brex, Tally, Cloudflare, CSV, Probo memberships, Sentry, OpenAI, Supabase, GitHub, Intercom, and Resend. Include name resolvers, VCR test infrastructure with cassettes, and RFC 5988 link header parser. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
127
pkg/accessreview/drivers/brex.go
Normal file
127
pkg/accessreview/drivers/brex.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// 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"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// BrexDriver fetches users from Brex via OAuth2-authenticated REST API
|
||||
// requests.
|
||||
type BrexDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*BrexDriver)(nil)
|
||||
|
||||
type brexUsersResponse struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
Role string `json:"role"`
|
||||
} `json:"items"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
}
|
||||
|
||||
const brexUsersEndpoint = "https://platform.brexapis.com/v2/users"
|
||||
|
||||
func NewBrexDriver(httpClient *http.Client) *BrexDriver {
|
||||
return &BrexDriver{
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *BrexDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var (
|
||||
records []AccountRecord
|
||||
cursor *string
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.queryUsers(ctx, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Items {
|
||||
record := AccountRecord{
|
||||
Email: u.Email,
|
||||
FullName: u.FirstName + " " + u.LastName,
|
||||
Role: u.Role,
|
||||
Active: u.Status == "ACTIVE",
|
||||
IsAdmin: false,
|
||||
ExternalID: u.ID,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
|
||||
if record.Email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
if resp.NextCursor == "" {
|
||||
return records, nil
|
||||
}
|
||||
nextCursor := resp.NextCursor
|
||||
cursor = &nextCursor
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all brex accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *BrexDriver) queryUsers(ctx context.Context, cursor *string) (*brexUsersResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, brexUsersEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create brex users request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if cursor != nil {
|
||||
q := req.URL.Query()
|
||||
q.Set("cursor", *cursor)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute brex users request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch brex users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp brexUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode brex users response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
41
pkg/accessreview/drivers/brex_test.go
Normal file
41
pkg/accessreview/drivers/brex_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// 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 TestBrexDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/brex", "BREX_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("BREX_TOKEN")))
|
||||
|
||||
driver := NewBrexDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.Email)
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
}
|
||||
241
pkg/accessreview/drivers/cloudflare.go
Normal file
241
pkg/accessreview/drivers/cloudflare.go
Normal file
@@ -0,0 +1,241 @@
|
||||
// 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"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// CloudflareDriver fetches account members from the Cloudflare API.
|
||||
type CloudflareDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*CloudflareDriver)(nil)
|
||||
|
||||
type cloudflareAccount struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type cloudflareListAccountsResponse struct {
|
||||
Result []cloudflareAccount `json:"result"`
|
||||
ResultInfo cloudflareResultInfo `json:"result_info"`
|
||||
}
|
||||
|
||||
type cloudflareResultInfo struct {
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
Count int `json:"count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
|
||||
type cloudflareListMembersResponse struct {
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Email string `json:"email"`
|
||||
TwoFactorEnabled bool `json:"two_factor_authentication_enabled"`
|
||||
} `json:"user"`
|
||||
Roles []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"roles"`
|
||||
} `json:"result"`
|
||||
ResultInfo cloudflareResultInfo `json:"result_info"`
|
||||
}
|
||||
|
||||
func NewCloudflareDriver(httpClient *http.Client) *CloudflareDriver {
|
||||
return &CloudflareDriver{
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *CloudflareDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
accounts, err := d.queryAllAccounts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var records []AccountRecord
|
||||
|
||||
for _, account := range accounts {
|
||||
members, err := d.queryAllMembers(ctx, account.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch members for cloudflare account %s: %w", account.ID, err)
|
||||
}
|
||||
|
||||
records = append(records, members...)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (d *CloudflareDriver) queryAllAccounts(ctx context.Context) ([]cloudflareAccount, error) {
|
||||
var accounts []cloudflareAccount
|
||||
|
||||
for page := range maxPaginationPages {
|
||||
resp, err := d.queryAccounts(ctx, page+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accounts = append(accounts, resp.Result...)
|
||||
|
||||
if page+1 >= resp.ResultInfo.TotalPages {
|
||||
return accounts, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all cloudflare accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *CloudflareDriver) queryAccounts(ctx context.Context, page int) (*cloudflareListAccountsResponse, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://api.cloudflare.com/client/v4/accounts?page=%d&per_page=50",
|
||||
page,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create cloudflare accounts request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute cloudflare accounts request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch cloudflare accounts: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp cloudflareListAccountsResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode cloudflare accounts response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (d *CloudflareDriver) queryAllMembers(ctx context.Context, accountID string) ([]AccountRecord, error) {
|
||||
var records []AccountRecord
|
||||
|
||||
for page := range maxPaginationPages {
|
||||
resp, err := d.queryMembers(ctx, accountID, page+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range resp.Result {
|
||||
roles := make([]string, 0, len(m.Roles))
|
||||
for _, r := range m.Roles {
|
||||
roles = append(roles, r.Name)
|
||||
}
|
||||
|
||||
role := "Member"
|
||||
if len(roles) > 0 {
|
||||
role = strings.Join(roles, ", ")
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
for _, r := range m.Roles {
|
||||
if r.Name == "Super Administrator - All Privileges" || r.Name == "Administrator" {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
mfaStatus := coredata.MFAStatusUnknown
|
||||
if m.User.TwoFactorEnabled {
|
||||
mfaStatus = coredata.MFAStatusEnabled
|
||||
}
|
||||
|
||||
record := AccountRecord{
|
||||
Email: m.User.Email,
|
||||
FullName: m.User.FirstName + " " + m.User.LastName,
|
||||
Role: role,
|
||||
Active: m.Status == "accepted",
|
||||
IsAdmin: isAdmin,
|
||||
ExternalID: m.ID,
|
||||
MFAStatus: mfaStatus,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
|
||||
if record.Email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
if page+1 >= resp.ResultInfo.TotalPages {
|
||||
return records, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all cloudflare members: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *CloudflareDriver) queryMembers(ctx context.Context, accountID string, page int) (*cloudflareListMembersResponse, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://api.cloudflare.com/client/v4/accounts/%s/members?page=%d&per_page=50",
|
||||
accountID,
|
||||
page,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create cloudflare members request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute cloudflare members request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch cloudflare members: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp cloudflareListMembersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode cloudflare members response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
42
pkg/accessreview/drivers/cloudflare_test.go
Normal file
42
pkg/accessreview/drivers/cloudflare_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// 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 TestCloudflareDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/cloudflare", "CLOUDFLARE_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("CLOUDFLARE_TOKEN")))
|
||||
|
||||
driver := NewCloudflareDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.Email)
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
assert.NotEmpty(t, r.Role)
|
||||
}
|
||||
108
pkg/accessreview/drivers/csv.go
Normal file
108
pkg/accessreview/drivers/csv.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// 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/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// CSVDriver supports both identity and access use cases from uploaded CSV
|
||||
// files. No external connector is needed.
|
||||
//
|
||||
// Expected CSV columns (header required): email, full_name, role, job_title,
|
||||
// is_admin, active, external_id
|
||||
type CSVDriver struct {
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func NewCSVDriver(reader io.Reader) *CSVDriver {
|
||||
return &CSVDriver{reader: reader}
|
||||
}
|
||||
|
||||
func (d *CSVDriver) ListAccounts(_ context.Context) ([]AccountRecord, error) {
|
||||
r := csv.NewReader(d.reader)
|
||||
r.FieldsPerRecord = -1
|
||||
|
||||
// Read header
|
||||
header, err := r.Read()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read CSV header: %w", err)
|
||||
}
|
||||
|
||||
colIndex := make(map[string]int)
|
||||
for i, col := range header {
|
||||
colIndex[strings.TrimSpace(strings.ToLower(col))] = i
|
||||
}
|
||||
if _, ok := colIndex["email"]; !ok {
|
||||
return nil, fmt.Errorf("cannot parse CSV: missing required column email")
|
||||
}
|
||||
|
||||
var records []AccountRecord
|
||||
|
||||
for {
|
||||
row, err := r.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read CSV row: %w", err)
|
||||
}
|
||||
|
||||
record := AccountRecord{
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
|
||||
if idx, ok := colIndex["email"]; ok && idx < len(row) {
|
||||
record.Email = strings.TrimSpace(row[idx])
|
||||
}
|
||||
if idx, ok := colIndex["full_name"]; ok && idx < len(row) {
|
||||
record.FullName = strings.TrimSpace(row[idx])
|
||||
}
|
||||
if idx, ok := colIndex["role"]; ok && idx < len(row) {
|
||||
record.Role = strings.TrimSpace(row[idx])
|
||||
}
|
||||
if idx, ok := colIndex["job_title"]; ok && idx < len(row) {
|
||||
record.JobTitle = strings.TrimSpace(row[idx])
|
||||
}
|
||||
if idx, ok := colIndex["is_admin"]; ok && idx < len(row) {
|
||||
record.IsAdmin = strings.TrimSpace(strings.ToLower(row[idx])) == "true"
|
||||
}
|
||||
if idx, ok := colIndex["active"]; ok && idx < len(row) {
|
||||
record.Active = strings.TrimSpace(strings.ToLower(row[idx])) == "true"
|
||||
}
|
||||
if idx, ok := colIndex["external_id"]; ok && idx < len(row) {
|
||||
record.ExternalID = strings.TrimSpace(row[idx])
|
||||
}
|
||||
if idx, ok := colIndex["account_type"]; ok && idx < len(row) {
|
||||
if strings.TrimSpace(strings.ToUpper(row[idx])) == "SERVICE_ACCOUNT" {
|
||||
record.AccountType = coredata.AccessEntryAccountTypeServiceAccount
|
||||
}
|
||||
}
|
||||
|
||||
if record.Email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
52
pkg/accessreview/drivers/csv_test.go
Normal file
52
pkg/accessreview/drivers/csv_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// 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"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCSVDriverRequiresEmailHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
driver := NewCSVDriver(strings.NewReader("full_name,role\nJane Doe,Admin\n"))
|
||||
_, err := driver.ListAccounts(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when email header is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVDriverParsesRequiredAndOptionalColumns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
driver := NewCSVDriver(strings.NewReader(
|
||||
"email,full_name,role,external_id\njane@example.com,Jane Doe,Admin,42\n",
|
||||
))
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(records))
|
||||
}
|
||||
if records[0].Email != "jane@example.com" {
|
||||
t.Fatalf("unexpected email: %s", records[0].Email)
|
||||
}
|
||||
if records[0].ExternalID != "42" {
|
||||
t.Fatalf("unexpected external id: %s", records[0].ExternalID)
|
||||
}
|
||||
}
|
||||
206
pkg/accessreview/drivers/docusign.go
Normal file
206
pkg/accessreview/drivers/docusign.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// 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"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// DocuSignDriver fetches account users from DocuSign via OAuth2-authenticated
|
||||
// REST API requests. It auto-discovers the account ID and base URI from the
|
||||
// OAuth2 userinfo endpoint, then paginates through the eSignature Users API.
|
||||
type DocuSignDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*DocuSignDriver)(nil)
|
||||
|
||||
type docusignUserInfoResponse struct {
|
||||
Accounts []struct {
|
||||
AccountID string `json:"account_id"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
BaseURI string `json:"base_uri"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
|
||||
type docusignUsersResponse struct {
|
||||
Users []struct {
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
Email string `json:"email"`
|
||||
UserStatus string `json:"userStatus"`
|
||||
IsAdmin string `json:"isAdmin"`
|
||||
CreatedDateTime string `json:"createdDateTime"`
|
||||
LastLogin string `json:"lastLogin"`
|
||||
PermissionProfileName string `json:"permissionProfileName"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
} `json:"users"`
|
||||
ResultSetSize string `json:"resultSetSize"`
|
||||
TotalSetSize string `json:"totalSetSize"`
|
||||
StartPosition string `json:"startPosition"`
|
||||
EndPosition string `json:"endPosition"`
|
||||
}
|
||||
|
||||
const (
|
||||
docusignUserInfoEndpoint = "https://account.docusign.com/oauth/userinfo"
|
||||
docusignUsersPageSize = 100
|
||||
)
|
||||
|
||||
func NewDocuSignDriver(httpClient *http.Client) *DocuSignDriver {
|
||||
return &DocuSignDriver{
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DocuSignDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
accountID, baseURI, err := d.discoverAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot discover docusign account: %w", err)
|
||||
}
|
||||
|
||||
var records []AccountRecord
|
||||
startPosition := 0
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.queryUsers(ctx, baseURI, accountID, startPosition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Users {
|
||||
record := AccountRecord{
|
||||
Email: u.Email,
|
||||
FullName: u.UserName,
|
||||
Role: u.PermissionProfileName,
|
||||
JobTitle: u.JobTitle,
|
||||
Active: strings.EqualFold(u.UserStatus, "active"),
|
||||
IsAdmin: strings.EqualFold(u.IsAdmin, "True"),
|
||||
ExternalID: u.UserID,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
|
||||
if u.LastLogin != "" {
|
||||
if t, err := time.Parse(time.RFC3339, u.LastLogin); err == nil {
|
||||
record.LastLogin = &t
|
||||
}
|
||||
}
|
||||
|
||||
if u.CreatedDateTime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, u.CreatedDateTime); err == nil {
|
||||
record.CreatedAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
if record.Email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
totalSetSize, err := strconv.Atoi(resp.TotalSetSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse docusign total set size %q: %w", resp.TotalSetSize, err)
|
||||
}
|
||||
|
||||
endPosition, err := strconv.Atoi(resp.EndPosition)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse docusign end position %q: %w", resp.EndPosition, err)
|
||||
}
|
||||
|
||||
if totalSetSize == 0 || endPosition >= totalSetSize-1 {
|
||||
return records, nil
|
||||
}
|
||||
|
||||
startPosition = endPosition + 1
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all docusign accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *DocuSignDriver) discoverAccount(ctx context.Context) (accountID string, baseURI string, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, docusignUserInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("cannot create docusign userinfo request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("cannot execute docusign userinfo request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", "", fmt.Errorf("cannot fetch docusign userinfo: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp docusignUserInfoResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", "", fmt.Errorf("cannot decode docusign userinfo response: %w", err)
|
||||
}
|
||||
|
||||
for _, account := range resp.Accounts {
|
||||
if account.IsDefault {
|
||||
return account.AccountID, account.BaseURI, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(resp.Accounts) > 0 {
|
||||
return resp.Accounts[0].AccountID, resp.Accounts[0].BaseURI, nil
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("no docusign accounts found in userinfo response")
|
||||
}
|
||||
|
||||
func (d *DocuSignDriver) queryUsers(ctx context.Context, baseURI string, accountID string, startPosition int) (*docusignUsersResponse, error) {
|
||||
url := fmt.Sprintf("%s/restapi/v2.1/accounts/%s/users?additional_info=true&count=%d&start_position=%d",
|
||||
baseURI, accountID, docusignUsersPageSize, startPosition)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create docusign users request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute docusign users request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch docusign users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp docusignUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode docusign users response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
42
pkg/accessreview/drivers/docusign_test.go
Normal file
42
pkg/accessreview/drivers/docusign_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// 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 TestDocuSignDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/docusign", "DOCUSIGN_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("DOCUSIGN_TOKEN")))
|
||||
driver := NewDocuSignDriver(client)
|
||||
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.Email)
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
assert.NotEmpty(t, r.Role)
|
||||
}
|
||||
59
pkg/accessreview/drivers/driver.go
Normal file
59
pkg/accessreview/drivers/driver.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// AccountRecord represents a single account from an access source or identity
|
||||
// source. All fields are best-effort; sources populate what they can.
|
||||
type AccountRecord struct {
|
||||
Email string
|
||||
FullName string
|
||||
Role string // system role/permission (e.g. "Admin", "Viewer")
|
||||
JobTitle string // HR job title / department (e.g. "Software Engineer")
|
||||
Active bool
|
||||
IsAdmin bool
|
||||
MFAStatus coredata.MFAStatus
|
||||
AuthMethod coredata.AccessEntryAuthMethod
|
||||
AccountType coredata.AccessEntryAccountType
|
||||
LastLogin *time.Time
|
||||
CreatedAt *time.Time
|
||||
ExternalID string // system-specific user ID
|
||||
}
|
||||
|
||||
// maxPaginationPages is the upper bound on the number of pages a driver will
|
||||
// fetch from an external API. This prevents infinite loops if an API returns
|
||||
// a non-empty cursor on every response.
|
||||
const maxPaginationPages = 500
|
||||
|
||||
// ErrPaginationLimitReached is returned when a driver exhausts the maximum
|
||||
// number of pagination pages without reaching the end of the result set.
|
||||
var ErrPaginationLimitReached = fmt.Errorf("pagination limit of %d pages reached", maxPaginationPages)
|
||||
|
||||
// Driver defines the interface for fetching accounts from an access or
|
||||
// identity source. Each driver implementation corresponds to a specific
|
||||
// system (e.g. Google Workspace, AWS IAM, Probo memberships, CSV).
|
||||
//
|
||||
// All sources in a campaign's scope return "who actually has access" data.
|
||||
type Driver interface {
|
||||
// ListAccounts returns all accounts from the source system.
|
||||
ListAccounts(ctx context.Context) ([]AccountRecord, error)
|
||||
}
|
||||
286
pkg/accessreview/drivers/github.go
Normal file
286
pkg/accessreview/drivers/github.go
Normal file
@@ -0,0 +1,286 @@
|
||||
// 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"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/rfc5988"
|
||||
)
|
||||
|
||||
// GitHubDriver fetches organization members from the GitHub REST API
|
||||
// using a pre-authenticated HTTP client (Bearer token).
|
||||
type GitHubDriver struct {
|
||||
httpClient *http.Client
|
||||
org string
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
var _ Driver = (*GitHubDriver)(nil)
|
||||
|
||||
type githubMember struct {
|
||||
Login string `json:"login"`
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type githubMembership struct {
|
||||
Role string `json:"role"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
type githubUserProfile struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func NewGitHubDriver(httpClient *http.Client, org string, logger *log.Logger) *GitHubDriver {
|
||||
return &GitHubDriver{
|
||||
httpClient: httpClient,
|
||||
org: org,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *GitHubDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
members, err := d.fetchAllMembers(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch github org members: %w", err)
|
||||
}
|
||||
|
||||
no2FASet, err := d.fetchAll2FADisabledLogins(ctx)
|
||||
if err != nil {
|
||||
// If the 2FA list fetch fails (e.g. insufficient permissions),
|
||||
// we still proceed but mark MFA as Unknown for all members.
|
||||
no2FASet = nil
|
||||
}
|
||||
|
||||
var records []AccountRecord
|
||||
|
||||
for _, m := range members {
|
||||
membership, err := d.fetchMembership(ctx, m.Login)
|
||||
if err != nil {
|
||||
d.logger.WarnCtx(ctx, "cannot fetch github membership, skipping member",
|
||||
log.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
profile, err := d.fetchUserProfile(ctx, m.Login)
|
||||
if err != nil {
|
||||
d.logger.WarnCtx(ctx, "cannot fetch github user profile, skipping member",
|
||||
log.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
fullName := profile.Name
|
||||
if fullName == "" {
|
||||
fullName = m.Login
|
||||
}
|
||||
|
||||
accountType := coredata.AccessEntryAccountTypeUser
|
||||
if m.Type == "Bot" {
|
||||
accountType = coredata.AccessEntryAccountTypeServiceAccount
|
||||
}
|
||||
|
||||
mfaStatus := coredata.MFAStatusUnknown
|
||||
if no2FASet != nil {
|
||||
if no2FASet[m.Login] {
|
||||
mfaStatus = coredata.MFAStatusDisabled
|
||||
} else {
|
||||
mfaStatus = coredata.MFAStatusEnabled
|
||||
}
|
||||
}
|
||||
|
||||
record := AccountRecord{
|
||||
Email: profile.Email,
|
||||
FullName: fullName,
|
||||
Role: membership.Role,
|
||||
Active: membership.State == "active",
|
||||
IsAdmin: membership.Role == "admin",
|
||||
MFAStatus: mfaStatus,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: accountType,
|
||||
ExternalID: strconv.FormatInt(m.ID, 10),
|
||||
}
|
||||
|
||||
if profile.CreatedAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, profile.CreatedAt); err == nil {
|
||||
record.CreatedAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (d *GitHubDriver) fetchAllMembers(ctx context.Context) ([]githubMember, error) {
|
||||
var members []githubMember
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"https://api.github.com/orgs/%s/members?per_page=100",
|
||||
d.org,
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
page, nextURL, err := d.fetchMembersPage(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
members = append(members, page...)
|
||||
|
||||
if nextURL == "" {
|
||||
return members, nil
|
||||
}
|
||||
url = nextURL
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all github members: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *GitHubDriver) fetchMembersPage(ctx context.Context, url string) ([]githubMember, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("cannot create github members request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("cannot execute github members request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("cannot fetch github members: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var members []githubMember
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&members); err != nil {
|
||||
return nil, "", fmt.Errorf("cannot decode github members response: %w", err)
|
||||
}
|
||||
|
||||
nextURL := rfc5988.FindByRel(httpResp.Header.Get("Link"), "next")
|
||||
|
||||
return members, nextURL, nil
|
||||
}
|
||||
|
||||
func (d *GitHubDriver) fetchAll2FADisabledLogins(ctx context.Context) (map[string]bool, error) {
|
||||
set := make(map[string]bool)
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"https://api.github.com/orgs/%s/members?filter=2fa_disabled&per_page=100",
|
||||
d.org,
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
page, nextURL, err := d.fetchMembersPage(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range page {
|
||||
set[m.Login] = true
|
||||
}
|
||||
|
||||
if nextURL == "" {
|
||||
return set, nil
|
||||
}
|
||||
url = nextURL
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all github 2fa-disabled members: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *GitHubDriver) fetchMembership(ctx context.Context, login string) (*githubMembership, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://api.github.com/orgs/%s/memberships/%s",
|
||||
d.org,
|
||||
login,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create github membership request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute github membership request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch github membership for %s: unexpected status %d", login, httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var membership githubMembership
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&membership); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode github membership response: %w", err)
|
||||
}
|
||||
|
||||
return &membership, nil
|
||||
}
|
||||
|
||||
func (d *GitHubDriver) fetchUserProfile(ctx context.Context, login string) (*githubUserProfile, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/users/%s", login)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create github user profile request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute github user profile request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch github user profile for %s: unexpected status %d", login, httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var profile githubUserProfile
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&profile); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode github user profile response: %w", err)
|
||||
}
|
||||
|
||||
return &profile, nil
|
||||
}
|
||||
47
pkg/accessreview/drivers/github_test.go
Normal file
47
pkg/accessreview/drivers/github_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// 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"
|
||||
"go.gearno.de/kit/log"
|
||||
)
|
||||
|
||||
func TestGitHubDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/github", "GITHUB_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("GITHUB_TOKEN")))
|
||||
|
||||
org := os.Getenv("GITHUB_ORG")
|
||||
if org == "" {
|
||||
org = "acme-corp"
|
||||
}
|
||||
|
||||
driver := NewGitHubDriver(client, org, log.NewLogger(log.WithName("test")))
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
assert.NotEmpty(t, r.Role)
|
||||
}
|
||||
157
pkg/accessreview/drivers/google_workspace.go
Normal file
157
pkg/accessreview/drivers/google_workspace.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
admin "google.golang.org/api/admin/directory/v1"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// GoogleWorkspaceDriver fetches user accounts from Google Workspace
|
||||
// using the Admin Directory API via an OAuth2-authenticated HTTP client.
|
||||
type GoogleWorkspaceDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewGoogleWorkspaceDriver(httpClient *http.Client) *GoogleWorkspaceDriver {
|
||||
return &GoogleWorkspaceDriver{
|
||||
httpClient: &http.Client{
|
||||
Transport: &retryRoundTripper{
|
||||
next: httpClient.Transport,
|
||||
maxRetries: 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// retryRoundTripper retries requests that receive 5xx or 429 responses
|
||||
// with exponential backoff.
|
||||
type retryRoundTripper struct {
|
||||
next http.RoundTripper
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
func (rt *retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
transport := rt.next
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
|
||||
var lastResp *http.Response
|
||||
for attempt := range rt.maxRetries {
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode < 500 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
lastResp = resp
|
||||
|
||||
backoff := time.Duration(250*(1<<attempt)) * time.Millisecond
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
return nil, req.Context().Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
|
||||
return lastResp, nil
|
||||
}
|
||||
|
||||
func (d *GoogleWorkspaceDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
adminService, err := admin.NewService(ctx, option.WithHTTPClient(d.httpClient))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create google admin service: %w", err)
|
||||
}
|
||||
|
||||
var records []AccountRecord
|
||||
pageToken := ""
|
||||
|
||||
for range maxPaginationPages {
|
||||
call := adminService.Users.List().
|
||||
Customer("my_customer").
|
||||
MaxResults(500).
|
||||
Projection("full").
|
||||
Context(ctx)
|
||||
if pageToken != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot list google workspace users: %w", err)
|
||||
}
|
||||
|
||||
for _, u := range resp.Users {
|
||||
rec := AccountRecord{
|
||||
Email: u.PrimaryEmail,
|
||||
FullName: u.Name.FullName,
|
||||
Active: !u.Suspended && !u.Archived,
|
||||
IsAdmin: u.IsAdmin,
|
||||
ExternalID: u.Id,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodSSO,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
|
||||
if u.IsEnrolledIn2Sv {
|
||||
rec.MFAStatus = coredata.MFAStatusEnabled
|
||||
} else {
|
||||
rec.MFAStatus = coredata.MFAStatusDisabled
|
||||
}
|
||||
|
||||
if u.CreationTime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, u.CreationTime); err == nil {
|
||||
rec.CreatedAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
if u.LastLoginTime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, u.LastLoginTime); err == nil {
|
||||
rec.LastLogin = &t
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case u.IsAdmin:
|
||||
rec.Role = "Super Admin"
|
||||
case u.IsDelegatedAdmin:
|
||||
rec.Role = "Delegated Admin"
|
||||
default:
|
||||
rec.Role = "User"
|
||||
}
|
||||
|
||||
records = append(records, rec)
|
||||
}
|
||||
|
||||
pageToken = resp.NextPageToken
|
||||
if pageToken == "" {
|
||||
return records, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all google workspace accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
42
pkg/accessreview/drivers/google_workspace_test.go
Normal file
42
pkg/accessreview/drivers/google_workspace_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// 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 TestGoogleWorkspaceDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/google_workspace", "GOOGLE_WORKSPACE_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("GOOGLE_WORKSPACE_TOKEN")))
|
||||
|
||||
driver := NewGoogleWorkspaceDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.Email)
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
assert.NotEmpty(t, r.Role)
|
||||
}
|
||||
190
pkg/accessreview/drivers/hubspot.go
Normal file
190
pkg/accessreview/drivers/hubspot.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// 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"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// HubSpotDriver fetches account users from HubSpot via OAuth2-authenticated
|
||||
// REST requests.
|
||||
type HubSpotDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*HubSpotDriver)(nil)
|
||||
|
||||
type hubspotRolesResponse struct {
|
||||
Results []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
type hubspotUsersResponse struct {
|
||||
Results []struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
RoleID string `json:"roleId"`
|
||||
PrimaryTeamID string `json:"primaryTeamId"`
|
||||
SuperAdmin bool `json:"superAdmin"`
|
||||
} `json:"results"`
|
||||
Paging *struct {
|
||||
Next *struct {
|
||||
After string `json:"after"`
|
||||
} `json:"next"`
|
||||
} `json:"paging"`
|
||||
}
|
||||
|
||||
const (
|
||||
hubspotUsersEndpoint = "https://api.hubapi.com/settings/v3/users"
|
||||
hubspotRolesEndpoint = "https://api.hubapi.com/settings/v3/users/roles"
|
||||
)
|
||||
|
||||
func NewHubSpotDriver(httpClient *http.Client) *HubSpotDriver {
|
||||
return &HubSpotDriver{
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *HubSpotDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
roleMap, _ := d.fetchRoles(ctx)
|
||||
|
||||
var (
|
||||
records []AccountRecord
|
||||
after string
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.fetchUsers(ctx, after)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Results {
|
||||
role := "User"
|
||||
if roleMap != nil && u.RoleID != "" {
|
||||
if name, ok := roleMap[u.RoleID]; ok {
|
||||
role = name
|
||||
} else if u.SuperAdmin {
|
||||
role = "Super Admin"
|
||||
}
|
||||
} else if u.SuperAdmin {
|
||||
role = "Super Admin"
|
||||
}
|
||||
|
||||
fullName := strings.TrimSpace(u.FirstName + " " + u.LastName)
|
||||
|
||||
record := AccountRecord{
|
||||
Email: u.Email,
|
||||
FullName: fullName,
|
||||
Role: role,
|
||||
Active: true,
|
||||
IsAdmin: u.SuperAdmin,
|
||||
ExternalID: u.ID,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
|
||||
if record.Email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
if resp.Paging == nil || resp.Paging.Next == nil || resp.Paging.Next.After == "" {
|
||||
return records, nil
|
||||
}
|
||||
after = resp.Paging.Next.After
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all hubspot accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *HubSpotDriver) fetchUsers(ctx context.Context, after string) (*hubspotUsersResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hubspotUsersEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create hubspot users request: %w", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("limit", "100")
|
||||
if after != "" {
|
||||
q.Set("after", after)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute hubspot users request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch hubspot users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp hubspotUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode hubspot users response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (d *HubSpotDriver) fetchRoles(ctx context.Context) (map[string]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hubspotRolesEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create hubspot roles request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute hubspot roles request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch hubspot roles: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp hubspotRolesResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode hubspot roles response: %w", err)
|
||||
}
|
||||
|
||||
roleMap := make(map[string]string, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
roleMap[r.ID] = r.Name
|
||||
}
|
||||
|
||||
return roleMap, nil
|
||||
}
|
||||
41
pkg/accessreview/drivers/hubspot_test.go
Normal file
41
pkg/accessreview/drivers/hubspot_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// 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 TestHubSpotDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/hubspot", "HUBSPOT_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("HUBSPOT_TOKEN")))
|
||||
driver := NewHubSpotDriver(client)
|
||||
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.Email)
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
}
|
||||
124
pkg/accessreview/drivers/intercom.go
Normal file
124
pkg/accessreview/drivers/intercom.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// 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"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// IntercomDriver fetches workspace admins from Intercom via Bearer
|
||||
// token-authenticated REST API requests.
|
||||
type IntercomDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*IntercomDriver)(nil)
|
||||
|
||||
type intercomAdminsResponse struct {
|
||||
Type string `json:"type"`
|
||||
Admins []struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
JobTitle string `json:"job_title"`
|
||||
HasInboxSeat bool `json:"has_inbox_seat"`
|
||||
} `json:"admins"`
|
||||
}
|
||||
|
||||
const (
|
||||
intercomAdminsEndpoint = "https://api.intercom.io/admins"
|
||||
intercomAPIVersion = "2.11"
|
||||
)
|
||||
|
||||
func NewIntercomDriver(httpClient *http.Client) *IntercomDriver {
|
||||
return &IntercomDriver{
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *IntercomDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
resp, err := d.fetchAdmins(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var records []AccountRecord
|
||||
for _, a := range resp.Admins {
|
||||
record := AccountRecord{
|
||||
Email: a.Email,
|
||||
FullName: a.Name,
|
||||
Role: intercomRole(a.HasInboxSeat),
|
||||
JobTitle: a.JobTitle,
|
||||
Active: true,
|
||||
IsAdmin: false, // Intercom API does not expose admin role information
|
||||
ExternalID: a.ID,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
|
||||
if record.Email != "" || record.FullName != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (d *IntercomDriver) fetchAdmins(ctx context.Context) (*intercomAdminsResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, intercomAdminsEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create intercom admins request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Intercom-Version", intercomAPIVersion)
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute intercom admins request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch intercom admins: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp intercomAdminsResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode intercom admins response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// intercomRole returns a role label based on whether the admin has an inbox
|
||||
// seat. The Intercom API does not expose a proper role field, so this is the
|
||||
// best approximation available: users with inbox seats are active agents,
|
||||
// those without are limited/viewer users.
|
||||
func intercomRole(hasInboxSeat bool) string {
|
||||
if hasInboxSeat {
|
||||
return "Agent"
|
||||
}
|
||||
return "Viewer"
|
||||
}
|
||||
41
pkg/accessreview/drivers/intercom_test.go
Normal file
41
pkg/accessreview/drivers/intercom_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// 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 TestIntercomDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/intercom", "INTERCOM_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("INTERCOM_TOKEN")))
|
||||
driver := NewIntercomDriver(client)
|
||||
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.Email)
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
}
|
||||
208
pkg/accessreview/drivers/linear.go
Normal file
208
pkg/accessreview/drivers/linear.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// LinearDriver fetches workspace users from Linear via OAuth2-authenticated
|
||||
// GraphQL requests.
|
||||
type LinearDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*LinearDriver)(nil)
|
||||
|
||||
type linearUsersRequest struct {
|
||||
Query string `json:"query"`
|
||||
Variables linearUsersVariables `json:"variables"`
|
||||
}
|
||||
|
||||
type linearUsersVariables struct {
|
||||
After *string `json:"after"`
|
||||
}
|
||||
|
||||
type linearUsersResponse struct {
|
||||
Data struct {
|
||||
Users struct {
|
||||
Nodes []struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
Admin bool `json:"admin"`
|
||||
Guest bool `json:"guest"`
|
||||
LastSeen string `json:"lastSeen"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
} `json:"nodes"`
|
||||
PageInfo struct {
|
||||
HasNextPage bool `json:"hasNextPage"`
|
||||
EndCursor string `json:"endCursor"`
|
||||
} `json:"pageInfo"`
|
||||
} `json:"users"`
|
||||
} `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
const linearGraphQLEndpoint = "https://api.linear.app/graphql"
|
||||
|
||||
func NewLinearDriver(httpClient *http.Client) *LinearDriver {
|
||||
return &LinearDriver{
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *LinearDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var (
|
||||
records []AccountRecord
|
||||
after *string
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.queryUsers(ctx, after)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Data.Users.Nodes {
|
||||
accountType := coredata.AccessEntryAccountTypeUser
|
||||
if strings.HasSuffix(u.Email, ".linear.app") {
|
||||
accountType = coredata.AccessEntryAccountTypeServiceAccount
|
||||
}
|
||||
|
||||
record := AccountRecord{
|
||||
Email: u.Email,
|
||||
FullName: u.Name,
|
||||
Role: linearRole(u.Admin, u.Guest),
|
||||
Active: u.Active,
|
||||
IsAdmin: u.Admin,
|
||||
ExternalID: u.ID,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: accountType,
|
||||
}
|
||||
|
||||
if u.LastSeen != "" {
|
||||
if t, err := time.Parse(time.RFC3339, u.LastSeen); err == nil {
|
||||
record.LastLogin = &t
|
||||
}
|
||||
}
|
||||
|
||||
if u.CreatedAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, u.CreatedAt); err == nil {
|
||||
record.CreatedAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
if record.Email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
if !resp.Data.Users.PageInfo.HasNextPage || resp.Data.Users.PageInfo.EndCursor == "" {
|
||||
return records, nil
|
||||
}
|
||||
nextCursor := resp.Data.Users.PageInfo.EndCursor
|
||||
after = &nextCursor
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all linear accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *LinearDriver) queryUsers(ctx context.Context, after *string) (*linearUsersResponse, error) {
|
||||
const query = `
|
||||
query AccessReviewLinearUsers($after: String) {
|
||||
users(first: 100, after: $after) {
|
||||
nodes {
|
||||
id
|
||||
email
|
||||
name
|
||||
active
|
||||
admin
|
||||
guest
|
||||
lastSeen
|
||||
createdAt
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
body := linearUsersRequest{
|
||||
Query: query,
|
||||
Variables: linearUsersVariables{
|
||||
After: after,
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal linear users query: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, linearGraphQLEndpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create linear users request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute linear users request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch linear users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp linearUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode linear users response: %w", err)
|
||||
}
|
||||
if len(resp.Errors) > 0 {
|
||||
return nil, fmt.Errorf("linear graphql error: %s", resp.Errors[0].Message)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func linearRole(admin, guest bool) string {
|
||||
switch {
|
||||
case admin:
|
||||
return "Admin"
|
||||
case guest:
|
||||
return "Guest"
|
||||
default:
|
||||
return "Member"
|
||||
}
|
||||
}
|
||||
42
pkg/accessreview/drivers/linear_test.go
Normal file
42
pkg/accessreview/drivers/linear_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// 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 TestLinearDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/linear", "LINEAR_TOKEN")
|
||||
client := newVCRClient(rec, os.Getenv("LINEAR_TOKEN"))
|
||||
|
||||
driver := NewLinearDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.Email)
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
assert.NotEmpty(t, r.Role)
|
||||
}
|
||||
590
pkg/accessreview/drivers/name_resolver.go
Normal file
590
pkg/accessreview/drivers/name_resolver.go
Normal file
@@ -0,0 +1,590 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
admin "google.golang.org/api/admin/directory/v1"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// NameResolver fetches the human-readable instance name from a provider
|
||||
// (e.g. Slack workspace name, Google Workspace domain).
|
||||
type NameResolver interface {
|
||||
ResolveInstanceName(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
var providerDisplayNames = map[coredata.ConnectorProvider]string{
|
||||
coredata.ConnectorProviderSlack: "Slack",
|
||||
coredata.ConnectorProviderGoogleWorkspace: "Google Workspace",
|
||||
coredata.ConnectorProviderLinear: "Linear",
|
||||
coredata.ConnectorProviderOnePassword: "1Password",
|
||||
coredata.ConnectorProviderHubSpot: "HubSpot",
|
||||
coredata.ConnectorProviderDocuSign: "DocuSign",
|
||||
coredata.ConnectorProviderNotion: "Notion",
|
||||
coredata.ConnectorProviderBrex: "Brex",
|
||||
coredata.ConnectorProviderTally: "Tally",
|
||||
coredata.ConnectorProviderCloudflare: "Cloudflare",
|
||||
coredata.ConnectorProviderOpenAI: "OpenAI",
|
||||
coredata.ConnectorProviderSentry: "Sentry",
|
||||
coredata.ConnectorProviderSupabase: "Supabase",
|
||||
coredata.ConnectorProviderGitHub: "GitHub",
|
||||
coredata.ConnectorProviderIntercom: "Intercom",
|
||||
coredata.ConnectorProviderResend: "Resend",
|
||||
}
|
||||
|
||||
// ProviderDisplayName returns the human-readable label for a connector provider.
|
||||
func ProviderDisplayName(provider coredata.ConnectorProvider) string {
|
||||
if name, ok := providerDisplayNames[provider]; ok {
|
||||
return name
|
||||
}
|
||||
return string(provider)
|
||||
}
|
||||
|
||||
// slackNameResolver resolves the Slack workspace name via auth.test.
|
||||
type slackNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewSlackNameResolver(httpClient *http.Client) NameResolver {
|
||||
return &slackNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *slackNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://slack.com/api/auth.test", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create slack auth.test request: %w", err)
|
||||
}
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute slack auth.test request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Team string `json:"team"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode slack auth.test response: %w", err)
|
||||
}
|
||||
|
||||
if !resp.OK {
|
||||
return "", fmt.Errorf("slack auth.test returned ok=false")
|
||||
}
|
||||
|
||||
return resp.Team, nil
|
||||
}
|
||||
|
||||
// googleWorkspaceNameResolver resolves the Google Workspace primary domain.
|
||||
type googleWorkspaceNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewGoogleWorkspaceNameResolver(httpClient *http.Client) NameResolver {
|
||||
return &googleWorkspaceNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *googleWorkspaceNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
adminService, err := admin.NewService(ctx, option.WithHTTPClient(r.httpClient))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create google admin service: %w", err)
|
||||
}
|
||||
|
||||
customer, err := adminService.Customers.Get("my_customer").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot fetch google workspace customer: %w", err)
|
||||
}
|
||||
|
||||
return customer.CustomerDomain, nil
|
||||
}
|
||||
|
||||
// linearNameResolver resolves the Linear organization name via GraphQL.
|
||||
type linearNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewLinearNameResolver(httpClient *http.Client) NameResolver {
|
||||
return &linearNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *linearNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
body := struct {
|
||||
Query string `json:"query"`
|
||||
}{
|
||||
Query: `{ organization { name } }`,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot marshal linear organization query: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, linearGraphQLEndpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create linear organization request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute linear organization request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch linear organization: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Organization struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"organization"`
|
||||
} `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode linear organization response: %w", err)
|
||||
}
|
||||
if len(resp.Errors) > 0 {
|
||||
return "", fmt.Errorf("linear graphql error: %s", resp.Errors[0].Message)
|
||||
}
|
||||
|
||||
return resp.Data.Organization.Name, nil
|
||||
}
|
||||
|
||||
// cloudflareNameResolver resolves the Cloudflare account name.
|
||||
type cloudflareNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewCloudflareNameResolver(httpClient *http.Client) NameResolver {
|
||||
return &cloudflareNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *cloudflareNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
"https://api.cloudflare.com/client/v4/accounts?page=1&per_page=1",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create cloudflare accounts request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute cloudflare accounts request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch cloudflare accounts: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Result []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode cloudflare accounts response: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Result) == 0 {
|
||||
return "", fmt.Errorf("no cloudflare accounts found")
|
||||
}
|
||||
|
||||
return resp.Result[0].Name, nil
|
||||
}
|
||||
|
||||
// brexNameResolver resolves the Brex company name.
|
||||
type brexNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewBrexNameResolver(httpClient *http.Client) NameResolver {
|
||||
return &brexNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *brexNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
"https://platform.brexapis.com/v2/company",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create brex company request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute brex company request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch brex company: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
LegalName string `json:"legal_name"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode brex company response: %w", err)
|
||||
}
|
||||
|
||||
return resp.LegalName, nil
|
||||
}
|
||||
|
||||
// tallyNameResolver resolves the Tally organization name.
|
||||
type tallyNameResolver struct {
|
||||
httpClient *http.Client
|
||||
organizationID string
|
||||
}
|
||||
|
||||
func NewTallyNameResolver(httpClient *http.Client, organizationID string) NameResolver {
|
||||
return &tallyNameResolver{
|
||||
httpClient: httpClient,
|
||||
organizationID: organizationID,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *tallyNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
url := fmt.Sprintf("https://api.tally.so/organizations/%s", r.organizationID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create tally organization request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute tally organization request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch tally organization: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode tally organization response: %w", err)
|
||||
}
|
||||
|
||||
return resp.Name, nil
|
||||
}
|
||||
|
||||
// hubspotNameResolver resolves the HubSpot account name.
|
||||
type hubspotNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewHubSpotNameResolver(httpClient *http.Client) NameResolver {
|
||||
return &hubspotNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *hubspotNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
"https://api.hubapi.com/account-info/v3/details",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create hubspot account-info request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute hubspot account-info request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch hubspot account info: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
PortalID int `json:"portalId"`
|
||||
AccountName string `json:"accountName"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode hubspot account-info response: %w", err)
|
||||
}
|
||||
|
||||
return resp.AccountName, nil
|
||||
}
|
||||
|
||||
// docusignNameResolver resolves the DocuSign account name from userinfo.
|
||||
type docusignNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewDocuSignNameResolver(httpClient *http.Client) NameResolver {
|
||||
return &docusignNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *docusignNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, docusignUserInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create docusign userinfo request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute docusign userinfo request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch docusign userinfo: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Accounts []struct {
|
||||
AccountName string `json:"account_name"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode docusign userinfo response: %w", err)
|
||||
}
|
||||
|
||||
for _, account := range resp.Accounts {
|
||||
if account.IsDefault {
|
||||
return account.AccountName, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(resp.Accounts) > 0 {
|
||||
return resp.Accounts[0].AccountName, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// openaiNameResolver resolves the OpenAI organization name.
|
||||
type openaiNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewOpenAINameResolver(httpClient *http.Client) NameResolver {
|
||||
return &openaiNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *openaiNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
"https://api.openai.com/v1/organization",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create openai organization request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute openai organization request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
// OpenAI may not support this endpoint for all token types.
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode openai organization response: %w", err)
|
||||
}
|
||||
|
||||
return resp.Name, nil
|
||||
}
|
||||
|
||||
// sentryNameResolver resolves the Sentry organization name.
|
||||
type sentryNameResolver struct {
|
||||
httpClient *http.Client
|
||||
orgSlug string
|
||||
}
|
||||
|
||||
func NewSentryNameResolver(httpClient *http.Client, orgSlug string) NameResolver {
|
||||
return &sentryNameResolver{httpClient: httpClient, orgSlug: orgSlug}
|
||||
}
|
||||
|
||||
func (r *sentryNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
if r.orgSlug == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://sentry.io/api/0/organizations/%s/", r.orgSlug)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create sentry organization request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute sentry organization request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch sentry organization: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode sentry organization response: %w", err)
|
||||
}
|
||||
|
||||
return resp.Name, nil
|
||||
}
|
||||
|
||||
// githubNameResolver resolves the GitHub organization name.
|
||||
type githubNameResolver struct {
|
||||
httpClient *http.Client
|
||||
org string
|
||||
}
|
||||
|
||||
func NewGitHubNameResolver(httpClient *http.Client, org string) NameResolver {
|
||||
return &githubNameResolver{httpClient: httpClient, org: org}
|
||||
}
|
||||
|
||||
func (r *githubNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/orgs/%s", r.org)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create github organization request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute github organization request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch github organization: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode github organization response: %w", err)
|
||||
}
|
||||
|
||||
if resp.Name == "" {
|
||||
return r.org, nil
|
||||
}
|
||||
|
||||
return resp.Name, nil
|
||||
}
|
||||
|
||||
// supabaseNameResolver returns the Supabase organization slug as the name.
|
||||
type supabaseNameResolver struct {
|
||||
orgSlug string
|
||||
}
|
||||
|
||||
func NewSupabaseNameResolver(orgSlug string) NameResolver {
|
||||
return &supabaseNameResolver{orgSlug: orgSlug}
|
||||
}
|
||||
|
||||
func (r *supabaseNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
|
||||
return r.orgSlug, nil
|
||||
}
|
||||
|
||||
// intercomNameResolver resolves the Intercom app name.
|
||||
type intercomNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewIntercomNameResolver(httpClient *http.Client) NameResolver {
|
||||
return &intercomNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *intercomNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.intercom.io/me", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create intercom me request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Intercom-Version", "2.11")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute intercom me request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
App struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"app"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode intercom me response: %w", err)
|
||||
}
|
||||
|
||||
return resp.App.Name, nil
|
||||
}
|
||||
|
||||
// resendNameResolver returns a static name for Resend.
|
||||
type resendNameResolver struct{}
|
||||
|
||||
func NewResendNameResolver() NameResolver {
|
||||
return &resendNameResolver{}
|
||||
}
|
||||
|
||||
func (r *resendNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
|
||||
return "Resend", nil
|
||||
}
|
||||
141
pkg/accessreview/drivers/notion.go
Normal file
141
pkg/accessreview/drivers/notion.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// 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"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
type NotionDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*NotionDriver)(nil)
|
||||
|
||||
type notionUsersResponse struct {
|
||||
Results []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Person struct {
|
||||
Email string `json:"email"`
|
||||
} `json:"person"`
|
||||
Bot struct{} `json:"bot"`
|
||||
} `json:"results"`
|
||||
HasMore bool `json:"has_more"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
}
|
||||
|
||||
const (
|
||||
notionUsersEndpoint = "https://api.notion.com/v1/users"
|
||||
notionAPIVersion = "2022-06-28"
|
||||
)
|
||||
|
||||
func NewNotionDriver(httpClient *http.Client) *NotionDriver {
|
||||
return &NotionDriver{
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *NotionDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var (
|
||||
records []AccountRecord
|
||||
startCursor *string
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.queryUsers(ctx, startCursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Results {
|
||||
accountType := coredata.AccessEntryAccountTypeUser
|
||||
if u.Type == "bot" {
|
||||
accountType = coredata.AccessEntryAccountTypeServiceAccount
|
||||
}
|
||||
|
||||
var email string
|
||||
if u.Type == "person" {
|
||||
email = u.Person.Email
|
||||
}
|
||||
|
||||
record := AccountRecord{
|
||||
Email: email,
|
||||
FullName: u.Name,
|
||||
Role: "Member",
|
||||
Active: true,
|
||||
IsAdmin: false,
|
||||
ExternalID: u.ID,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: accountType,
|
||||
}
|
||||
|
||||
if record.Email != "" || record.FullName != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
if !resp.HasMore || resp.NextCursor == "" {
|
||||
return records, nil
|
||||
}
|
||||
nextCursor := resp.NextCursor
|
||||
startCursor = &nextCursor
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all notion accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *NotionDriver) queryUsers(ctx context.Context, startCursor *string) (*notionUsersResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, notionUsersEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create notion users request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Notion-Version", notionAPIVersion)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("page_size", "100")
|
||||
if startCursor != nil {
|
||||
q.Set("start_cursor", *startCursor)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute notion users request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch notion users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp notionUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode notion users response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
40
pkg/accessreview/drivers/notion_test.go
Normal file
40
pkg/accessreview/drivers/notion_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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 TestNotionDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/notion", "NOTION_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("NOTION_TOKEN")))
|
||||
driver := NewNotionDriver(client)
|
||||
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
}
|
||||
172
pkg/accessreview/drivers/onepassword.go
Normal file
172
pkg/accessreview/drivers/onepassword.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// OnePasswordDriver fetches user accounts from a 1Password SCIM bridge.
|
||||
type OnePasswordDriver struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
var _ Driver = (*OnePasswordDriver)(nil)
|
||||
|
||||
type onePasswordSCIMListResponse struct {
|
||||
TotalResults int `json:"totalResults"`
|
||||
StartIndex int `json:"startIndex"`
|
||||
ItemsPerPage int `json:"itemsPerPage"`
|
||||
Resources []onePasswordSCIMUser `json:"Resources"`
|
||||
}
|
||||
|
||||
type onePasswordSCIMUser struct {
|
||||
ID string `json:"id"`
|
||||
UserName string `json:"userName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Title string `json:"title"`
|
||||
Active bool `json:"active"`
|
||||
Name struct {
|
||||
Formatted string `json:"formatted"`
|
||||
GivenName string `json:"givenName"`
|
||||
FamilyName string `json:"familyName"`
|
||||
} `json:"name"`
|
||||
Emails []struct {
|
||||
Value string `json:"value"`
|
||||
Primary bool `json:"primary"`
|
||||
} `json:"emails"`
|
||||
Meta struct {
|
||||
Created string `json:"created"`
|
||||
LastModified string `json:"lastModified"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
func NewOnePasswordDriver(httpClient *http.Client, baseURL string) *OnePasswordDriver {
|
||||
return &OnePasswordDriver{
|
||||
httpClient: httpClient,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OnePasswordDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var records []AccountRecord
|
||||
startIndex := 1
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.queryUsers(ctx, startIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Resources {
|
||||
email := u.UserName
|
||||
if email == "" {
|
||||
for _, e := range u.Emails {
|
||||
if e.Primary {
|
||||
email = e.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record := AccountRecord{
|
||||
Email: email,
|
||||
FullName: u.DisplayName,
|
||||
Active: u.Active,
|
||||
ExternalID: u.ID,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
|
||||
if record.FullName == "" && u.Name.Formatted != "" {
|
||||
record.FullName = u.Name.Formatted
|
||||
}
|
||||
if record.FullName == "" && (u.Name.GivenName != "" || u.Name.FamilyName != "") {
|
||||
record.FullName = u.Name.GivenName + " " + u.Name.FamilyName
|
||||
}
|
||||
|
||||
if u.Title != "" {
|
||||
record.JobTitle = u.Title
|
||||
}
|
||||
|
||||
if u.Meta.Created != "" {
|
||||
if t, err := time.Parse(time.RFC3339, u.Meta.Created); err == nil {
|
||||
record.CreatedAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Note: SCIM Meta.LastModified is the profile update time, not
|
||||
// the last login time, so we intentionally do not map it.
|
||||
|
||||
if email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
if len(resp.Resources) == 0 || resp.ItemsPerPage <= 0 || startIndex+resp.ItemsPerPage > resp.TotalResults {
|
||||
return records, nil
|
||||
}
|
||||
startIndex += resp.ItemsPerPage
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all 1password accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *OnePasswordDriver) queryUsers(ctx context.Context, startIndex int) (*onePasswordSCIMListResponse, error) {
|
||||
u, err := url.Parse(d.baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse 1password base url: %w", err)
|
||||
}
|
||||
u = u.JoinPath("scim", "v2", "Users")
|
||||
q := u.Query()
|
||||
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||
q.Set("count", "100")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create 1password users request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/scim+json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute 1password users request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch 1password users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp onePasswordSCIMListResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode 1password users response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
46
pkg/accessreview/drivers/onepassword_test.go
Normal file
46
pkg/accessreview/drivers/onepassword_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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 TestOnePasswordDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/onepassword", "ONEPASSWORD_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("ONEPASSWORD_TOKEN")))
|
||||
|
||||
scimURL := os.Getenv("ONEPASSWORD_SCIM_URL")
|
||||
if scimURL == "" {
|
||||
scimURL = "https://scim.example.com"
|
||||
}
|
||||
|
||||
driver := NewOnePasswordDriver(client, scimURL)
|
||||
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
}
|
||||
155
pkg/accessreview/drivers/onepassword_users_api.go
Normal file
155
pkg/accessreview/drivers/onepassword_users_api.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// OnePasswordUsersAPIDriver fetches user accounts from the 1Password
|
||||
// Users API (v1beta1). This is distinct from the SCIM-based
|
||||
// OnePasswordDriver and uses the native 1Password API with
|
||||
// token-based pagination.
|
||||
type OnePasswordUsersAPIDriver struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
accountID string
|
||||
}
|
||||
|
||||
var _ Driver = (*OnePasswordUsersAPIDriver)(nil)
|
||||
|
||||
type onePasswordUsersAPIResponse struct {
|
||||
Users []onePasswordUsersAPIUser `json:"users"`
|
||||
NextPageToken string `json:"next_page_token"`
|
||||
}
|
||||
|
||||
type onePasswordUsersAPIUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
State string `json:"state"`
|
||||
CreateTime string `json:"create_time"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func NewOnePasswordUsersAPIDriver(httpClient *http.Client, accountID string, region string) *OnePasswordUsersAPIDriver {
|
||||
return &OnePasswordUsersAPIDriver{
|
||||
httpClient: httpClient,
|
||||
baseURL: onePasswordBaseURL(region),
|
||||
accountID: accountID,
|
||||
}
|
||||
}
|
||||
|
||||
func onePasswordBaseURL(region string) string {
|
||||
switch region {
|
||||
case "CA", "ca":
|
||||
return "https://api.1password.ca"
|
||||
case "EU", "eu":
|
||||
return "https://api.1password.eu"
|
||||
default:
|
||||
return "https://api.1password.com"
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OnePasswordUsersAPIDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var (
|
||||
records []AccountRecord
|
||||
pageToken string
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.queryUsers(ctx, pageToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Users {
|
||||
record := AccountRecord{
|
||||
Email: u.Email,
|
||||
FullName: u.DisplayName,
|
||||
Active: u.State == "ACTIVE",
|
||||
ExternalID: u.ID,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
|
||||
if u.CreateTime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, u.CreateTime); err == nil {
|
||||
record.CreatedAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
if record.Email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
if resp.NextPageToken == "" {
|
||||
return records, nil
|
||||
}
|
||||
pageToken = resp.NextPageToken
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all 1password users api accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *OnePasswordUsersAPIDriver) queryUsers(ctx context.Context, pageToken string) (*onePasswordUsersAPIResponse, error) {
|
||||
u, err := url.Parse(d.baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse 1password users api base url: %w", err)
|
||||
}
|
||||
u = u.JoinPath("v1beta1", "accounts", d.accountID, "users")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create 1password users api request: %w", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("max_page_size", "100")
|
||||
if pageToken != "" {
|
||||
q.Set("page_token", pageToken)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute 1password users api request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch 1password users api: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp onePasswordUsersAPIResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode 1password users api response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
142
pkg/accessreview/drivers/openai.go
Normal file
142
pkg/accessreview/drivers/openai.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
type OpenAIDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*OpenAIDriver)(nil)
|
||||
|
||||
type openaiUsersResponse struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
AddedAt int64 `json:"added_at"`
|
||||
Disabled bool `json:"disabled"`
|
||||
} `json:"data"`
|
||||
HasMore bool `json:"has_more"`
|
||||
LastID string `json:"last_id"`
|
||||
}
|
||||
|
||||
const openaiUsersEndpoint = "https://api.openai.com/v1/organization/users"
|
||||
|
||||
func NewOpenAIDriver(httpClient *http.Client) *OpenAIDriver {
|
||||
return &OpenAIDriver{
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OpenAIDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var (
|
||||
records []AccountRecord
|
||||
after string
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.fetchUsers(ctx, after)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Data {
|
||||
record := AccountRecord{
|
||||
Email: u.Email,
|
||||
FullName: u.Name,
|
||||
Role: openaiRole(u.Role),
|
||||
Active: !u.Disabled,
|
||||
IsAdmin: u.Role == "owner",
|
||||
ExternalID: u.ID,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
|
||||
if u.AddedAt != 0 {
|
||||
t := time.Unix(u.AddedAt, 0)
|
||||
record.CreatedAt = &t
|
||||
}
|
||||
|
||||
if record.Email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
if !resp.HasMore || resp.LastID == "" {
|
||||
return records, nil
|
||||
}
|
||||
after = resp.LastID
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all openai accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *OpenAIDriver) fetchUsers(ctx context.Context, after string) (*openaiUsersResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, openaiUsersEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create openai users request: %w", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("limit", "100")
|
||||
if after != "" {
|
||||
q.Set("after", after)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute openai users request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch openai users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp openaiUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode openai users response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func openaiRole(role string) string {
|
||||
switch role {
|
||||
case "owner":
|
||||
return "Owner"
|
||||
case "reader":
|
||||
return "Reader"
|
||||
default:
|
||||
return "Member"
|
||||
}
|
||||
}
|
||||
42
pkg/accessreview/drivers/openai_test.go
Normal file
42
pkg/accessreview/drivers/openai_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// 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 TestOpenAIDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/openai", "OPENAI_ADMIN_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("OPENAI_ADMIN_TOKEN")))
|
||||
|
||||
driver := NewOpenAIDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.Email)
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
assert.NotEmpty(t, r.Role)
|
||||
}
|
||||
90
pkg/accessreview/drivers/probo_memberships.go
Normal file
90
pkg/accessreview/drivers/probo_memberships.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
// ProboMembershipsDriver is a built-in identity source that queries
|
||||
// iam_memberships + identities for the organization. No external
|
||||
// connector is needed.
|
||||
type ProboMembershipsDriver struct {
|
||||
pg *pg.Client
|
||||
scope coredata.Scoper
|
||||
organizationID gid.GID
|
||||
}
|
||||
|
||||
func NewProboMembershipsDriver(
|
||||
pgClient *pg.Client,
|
||||
scope coredata.Scoper,
|
||||
organizationID gid.GID,
|
||||
) *ProboMembershipsDriver {
|
||||
return &ProboMembershipsDriver{
|
||||
pg: pgClient,
|
||||
scope: scope,
|
||||
organizationID: organizationID,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *ProboMembershipsDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var records []AccountRecord
|
||||
|
||||
err := d.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
accounts, err := coredata.LoadMembershipAccountsByOrganizationID(
|
||||
ctx,
|
||||
conn,
|
||||
d.scope,
|
||||
d.organizationID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load membership accounts: %w", err)
|
||||
}
|
||||
|
||||
for _, account := range accounts {
|
||||
role := account.Role
|
||||
isAdmin := role == string(coredata.MembershipRoleOwner) || role == string(coredata.MembershipRoleAdmin)
|
||||
createdAt := account.CreatedAt
|
||||
|
||||
records = append(records, AccountRecord{
|
||||
Email: account.Email,
|
||||
FullName: account.FullName,
|
||||
Role: role,
|
||||
Active: account.State == string(coredata.ProfileStateActive),
|
||||
IsAdmin: isAdmin,
|
||||
ExternalID: account.ID.String(),
|
||||
CreatedAt: &createdAt,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot list probo membership accounts: %w", err)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
114
pkg/accessreview/drivers/resend.go
Normal file
114
pkg/accessreview/drivers/resend.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
type ResendDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*ResendDriver)(nil)
|
||||
|
||||
type resendAPIKeysResponse struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt *string `json:"last_used_at"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
const resendAPIKeysEndpoint = "https://api.resend.com/api-keys"
|
||||
|
||||
func NewResendDriver(httpClient *http.Client) *ResendDriver {
|
||||
return &ResendDriver{
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *ResendDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
resp, err := d.fetchAPIKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var records []AccountRecord
|
||||
for _, k := range resp.Data {
|
||||
record := AccountRecord{
|
||||
FullName: k.Name,
|
||||
Active: true,
|
||||
IsAdmin: false,
|
||||
ExternalID: k.ID,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeServiceAccount,
|
||||
}
|
||||
|
||||
if k.CreatedAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, k.CreatedAt); err == nil {
|
||||
record.CreatedAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
if k.LastUsedAt != nil {
|
||||
if t, err := time.Parse(time.RFC3339, *k.LastUsedAt); err == nil {
|
||||
record.LastLogin = &t
|
||||
}
|
||||
}
|
||||
|
||||
if record.FullName != "" || record.Email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (d *ResendDriver) fetchAPIKeys(ctx context.Context) (*resendAPIKeysResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resendAPIKeysEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create resend api-keys request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute resend api-keys request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch resend api-keys: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp resendAPIKeysResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode resend api-keys response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
42
pkg/accessreview/drivers/resend_test.go
Normal file
42
pkg/accessreview/drivers/resend_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// 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"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestResendDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/resend", "RESEND_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("RESEND_TOKEN")))
|
||||
driver := NewResendDriver(client)
|
||||
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
assert.Equal(t, coredata.AccessEntryAccountTypeServiceAccount, r.AccountType)
|
||||
}
|
||||
228
pkg/accessreview/drivers/sentry.go
Normal file
228
pkg/accessreview/drivers/sentry.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/rfc5988"
|
||||
)
|
||||
|
||||
// SentryDriver fetches organization members from Sentry via Bearer
|
||||
// token-authenticated REST API requests.
|
||||
type SentryDriver struct {
|
||||
httpClient *http.Client
|
||||
orgSlug string
|
||||
}
|
||||
|
||||
var _ Driver = (*SentryDriver)(nil)
|
||||
|
||||
type sentryMember struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Pending bool `json:"pending"`
|
||||
OrgRole string `json:"orgRole"`
|
||||
DateCreated string `json:"dateCreated"`
|
||||
Flags map[string]bool `json:"flags"`
|
||||
User *sentryUser `json:"user"`
|
||||
}
|
||||
|
||||
type sentryUser struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
IsActive bool `json:"isActive"`
|
||||
Has2FA bool `json:"has2fa"`
|
||||
LastLogin string `json:"lastLogin"`
|
||||
HasPasswordAuth bool `json:"hasPasswordAuth"`
|
||||
}
|
||||
|
||||
func NewSentryDriver(httpClient *http.Client, orgSlug string) *SentryDriver {
|
||||
return &SentryDriver{
|
||||
httpClient: httpClient,
|
||||
orgSlug: orgSlug,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SentryDriver) resolveOrgSlug(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://sentry.io/api/0/organizations/?member=true", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create sentry organizations request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot fetch sentry organizations: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("cannot fetch sentry organizations: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var orgs []struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil {
|
||||
return "", fmt.Errorf("cannot decode sentry organizations response: %w", err)
|
||||
}
|
||||
|
||||
if len(orgs) == 0 {
|
||||
return "", fmt.Errorf("no sentry organizations found for this token")
|
||||
}
|
||||
|
||||
return orgs[0].Slug, nil
|
||||
}
|
||||
|
||||
func (d *SentryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
orgSlug := d.orgSlug
|
||||
if orgSlug == "" {
|
||||
slug, err := d.resolveOrgSlug(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot resolve sentry organization slug: %w", err)
|
||||
}
|
||||
orgSlug = slug
|
||||
}
|
||||
|
||||
var records []AccountRecord
|
||||
|
||||
nextURL := fmt.Sprintf(
|
||||
"https://sentry.io/api/0/organizations/%s/members/",
|
||||
orgSlug,
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
members, linkHeader, err := d.queryMembers(ctx, nextURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
fullName := m.Name
|
||||
if fullName == "" && m.User != nil {
|
||||
fullName = m.User.Name
|
||||
}
|
||||
|
||||
active := !m.Pending
|
||||
if m.User != nil {
|
||||
active = active && m.User.IsActive
|
||||
}
|
||||
|
||||
isAdmin := m.OrgRole == "admin" || m.OrgRole == "owner"
|
||||
|
||||
mfaStatus := coredata.MFAStatusUnknown
|
||||
if m.User != nil {
|
||||
if m.User.Has2FA {
|
||||
mfaStatus = coredata.MFAStatusEnabled
|
||||
} else {
|
||||
mfaStatus = coredata.MFAStatusDisabled
|
||||
}
|
||||
}
|
||||
|
||||
authMethod := sentryAuthMethod(m.Flags, m.User)
|
||||
|
||||
record := AccountRecord{
|
||||
Email: m.Email,
|
||||
FullName: fullName,
|
||||
Role: m.OrgRole,
|
||||
Active: active,
|
||||
IsAdmin: isAdmin,
|
||||
ExternalID: m.ID,
|
||||
MFAStatus: mfaStatus,
|
||||
AuthMethod: authMethod,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
|
||||
if m.User != nil && m.User.LastLogin != "" {
|
||||
if t, err := time.Parse(time.RFC3339, m.User.LastLogin); err == nil {
|
||||
record.LastLogin = &t
|
||||
}
|
||||
}
|
||||
|
||||
if m.DateCreated != "" {
|
||||
if t, err := time.Parse(time.RFC3339, m.DateCreated); err == nil {
|
||||
record.CreatedAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
if record.Email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
nextURL = sentryNextLink(linkHeader)
|
||||
if nextURL == "" {
|
||||
return records, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all sentry accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *SentryDriver) queryMembers(ctx context.Context, url string) ([]sentryMember, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("cannot create sentry members request: %w", err)
|
||||
}
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("cannot execute sentry members request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("cannot fetch sentry members: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var members []sentryMember
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&members); err != nil {
|
||||
return nil, "", fmt.Errorf("cannot decode sentry members response: %w", err)
|
||||
}
|
||||
|
||||
return members, httpResp.Header.Get("Link"), nil
|
||||
}
|
||||
|
||||
// sentryNextLink extracts the next page URL from a Sentry Link header.
|
||||
// It returns the URL for the entry with rel="next" and results="true", or
|
||||
// an empty string if no such entry exists.
|
||||
func sentryNextLink(header string) string {
|
||||
for _, link := range rfc5988.Parse(header) {
|
||||
if link.Params["rel"] == "next" && link.Params["results"] == "true" {
|
||||
return link.URL
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func sentryAuthMethod(flags map[string]bool, user *sentryUser) coredata.AccessEntryAuthMethod {
|
||||
if flags["sso:linked"] {
|
||||
return coredata.AccessEntryAuthMethodSSO
|
||||
}
|
||||
if user != nil && user.HasPasswordAuth {
|
||||
return coredata.AccessEntryAuthMethodPassword
|
||||
}
|
||||
return coredata.AccessEntryAuthMethodUnknown
|
||||
}
|
||||
47
pkg/accessreview/drivers/sentry_test.go
Normal file
47
pkg/accessreview/drivers/sentry_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// 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 TestSentryDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/sentry", "SENTRY_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("SENTRY_TOKEN")))
|
||||
|
||||
orgSlug := os.Getenv("SENTRY_ORG_SLUG")
|
||||
if orgSlug == "" {
|
||||
orgSlug = "acme-corp"
|
||||
}
|
||||
|
||||
driver := NewSentryDriver(client, orgSlug)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.Email)
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
assert.NotEmpty(t, r.Role)
|
||||
}
|
||||
184
pkg/accessreview/drivers/slack.go
Normal file
184
pkg/accessreview/drivers/slack.go
Normal 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"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
type SlackDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*SlackDriver)(nil)
|
||||
|
||||
type slackUsersListResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Members []slackMember `json:"members"`
|
||||
ResponseMetadata slackResponseMetadata `json:"response_metadata"`
|
||||
}
|
||||
|
||||
type slackResponseMetadata struct {
|
||||
NextCursor string `json:"next_cursor"`
|
||||
}
|
||||
|
||||
type slackMember struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RealName string `json:"real_name"`
|
||||
Deleted bool `json:"deleted"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
IsOwner bool `json:"is_owner"`
|
||||
IsPrimaryOwner bool `json:"is_primary_owner"`
|
||||
IsRestricted bool `json:"is_restricted"`
|
||||
IsUltraRestricted bool `json:"is_ultra_restricted"`
|
||||
IsBot bool `json:"is_bot"`
|
||||
IsAppUser bool `json:"is_app_user"`
|
||||
Has2FA bool `json:"has_2fa"`
|
||||
Updated int `json:"updated"`
|
||||
Profile slackProfile `json:"profile"`
|
||||
}
|
||||
|
||||
type slackProfile struct {
|
||||
Email string `json:"email"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
const slackUsersListEndpoint = "https://slack.com/api/users.list"
|
||||
|
||||
func NewSlackDriver(httpClient *http.Client) *SlackDriver {
|
||||
return &SlackDriver{
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SlackDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var (
|
||||
records []AccountRecord
|
||||
cursor string
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.queryUsers(ctx, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resp.OK {
|
||||
return nil, fmt.Errorf("slack users.list request failed: %s", resp.Error)
|
||||
}
|
||||
|
||||
for _, m := range resp.Members {
|
||||
if m.ID == "USLACKBOT" {
|
||||
continue
|
||||
}
|
||||
|
||||
accountType := coredata.AccessEntryAccountTypeUser
|
||||
if m.IsBot || m.IsAppUser {
|
||||
accountType = coredata.AccessEntryAccountTypeServiceAccount
|
||||
}
|
||||
|
||||
record := AccountRecord{
|
||||
Email: m.Profile.Email,
|
||||
FullName: m.RealName,
|
||||
JobTitle: m.Profile.Title,
|
||||
Role: slackRole(m),
|
||||
Active: !m.Deleted,
|
||||
IsAdmin: m.IsAdmin || m.IsOwner || m.IsPrimaryOwner,
|
||||
ExternalID: m.ID,
|
||||
MFAStatus: slackMFAStatus(m.Has2FA),
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: accountType,
|
||||
}
|
||||
|
||||
// Note: Slack's Updated field is the profile update time, not
|
||||
// the last login time, so we intentionally do not map it.
|
||||
|
||||
if record.Email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
if resp.ResponseMetadata.NextCursor == "" {
|
||||
return records, nil
|
||||
}
|
||||
cursor = resp.ResponseMetadata.NextCursor
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all slack accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *SlackDriver) queryUsers(ctx context.Context, cursor string) (*slackUsersListResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, slackUsersListEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create slack users.list request: %w", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("limit", "200")
|
||||
if cursor != "" {
|
||||
q.Set("cursor", cursor)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute slack users.list request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch slack users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp slackUsersListResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode slack users.list response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func slackRole(m slackMember) string {
|
||||
switch {
|
||||
case m.IsPrimaryOwner:
|
||||
return "Primary Owner"
|
||||
case m.IsOwner:
|
||||
return "Owner"
|
||||
case m.IsAdmin:
|
||||
return "Admin"
|
||||
case m.IsUltraRestricted:
|
||||
return "Ultra Restricted"
|
||||
case m.IsRestricted:
|
||||
return "Restricted"
|
||||
default:
|
||||
return "Member"
|
||||
}
|
||||
}
|
||||
|
||||
func slackMFAStatus(has2FA bool) coredata.MFAStatus {
|
||||
if has2FA {
|
||||
return coredata.MFAStatusEnabled
|
||||
}
|
||||
return coredata.MFAStatusDisabled
|
||||
}
|
||||
48
pkg/accessreview/drivers/slack_test.go
Normal file
48
pkg/accessreview/drivers/slack_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// 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 TestSlackDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/slack", "SLACK_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("SLACK_TOKEN")))
|
||||
|
||||
driver := NewSlackDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
// Find the first human user (bots may not have email).
|
||||
var r AccountRecord
|
||||
for _, rec := range records {
|
||||
if rec.Email != "" {
|
||||
r = rec
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, r.Email, "expected at least one record with an email")
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
assert.NotEmpty(t, r.Role)
|
||||
}
|
||||
117
pkg/accessreview/drivers/supabase.go
Normal file
117
pkg/accessreview/drivers/supabase.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// 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"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
type SupabaseDriver struct {
|
||||
httpClient *http.Client
|
||||
orgSlug string
|
||||
}
|
||||
|
||||
var _ Driver = (*SupabaseDriver)(nil)
|
||||
|
||||
type supabaseMember struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
UserName string `json:"user_name"`
|
||||
RoleName string `json:"role_name"`
|
||||
MFAEnabled bool `json:"mfa_enabled"`
|
||||
}
|
||||
|
||||
func NewSupabaseDriver(httpClient *http.Client, orgSlug string) *SupabaseDriver {
|
||||
return &SupabaseDriver{
|
||||
httpClient: httpClient,
|
||||
orgSlug: orgSlug,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SupabaseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
members, err := d.queryMembers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var records []AccountRecord
|
||||
for _, m := range members {
|
||||
mfaStatus := coredata.MFAStatusDisabled
|
||||
if m.MFAEnabled {
|
||||
mfaStatus = coredata.MFAStatusEnabled
|
||||
}
|
||||
|
||||
isAdmin := m.RoleName == "Owner" || m.RoleName == "Administrator"
|
||||
|
||||
record := AccountRecord{
|
||||
Email: m.Email,
|
||||
FullName: m.UserName,
|
||||
Role: m.RoleName,
|
||||
Active: true,
|
||||
IsAdmin: isAdmin,
|
||||
ExternalID: m.UserID,
|
||||
MFAStatus: mfaStatus,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (d *SupabaseDriver) queryMembers(ctx context.Context) ([]supabaseMember, error) {
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.supabase.com",
|
||||
}
|
||||
u = u.JoinPath("v1", "organizations", d.orgSlug, "members")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create supabase 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 supabase members request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf(
|
||||
"cannot fetch supabase members: unexpected status %d",
|
||||
httpResp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
var members []supabaseMember
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&members); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode supabase members response: %w", err)
|
||||
}
|
||||
|
||||
return members, nil
|
||||
}
|
||||
46
pkg/accessreview/drivers/supabase_test.go
Normal file
46
pkg/accessreview/drivers/supabase_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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 TestSupabaseDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/supabase", "SUPABASE_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("SUPABASE_TOKEN")))
|
||||
|
||||
orgSlug := os.Getenv("SUPABASE_ORG_SLUG")
|
||||
if orgSlug == "" {
|
||||
orgSlug = "acme-corp"
|
||||
}
|
||||
|
||||
driver := NewSupabaseDriver(client, orgSlug)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
assert.NotEmpty(t, r.Role)
|
||||
}
|
||||
186
pkg/accessreview/drivers/tally.go
Normal file
186
pkg/accessreview/drivers/tally.go
Normal file
@@ -0,0 +1,186 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
type TallyDriver struct {
|
||||
httpClient *http.Client
|
||||
organizationID string
|
||||
}
|
||||
|
||||
var _ Driver = (*TallyDriver)(nil)
|
||||
|
||||
type tallyUser struct {
|
||||
ID string `json:"id"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
FullName string `json:"fullName"`
|
||||
Email string `json:"email"`
|
||||
IsDeleted bool `json:"isDeleted"`
|
||||
HasTwoFactorEnabled bool `json:"hasTwoFactorEnabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type tallyInvite struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func NewTallyDriver(httpClient *http.Client, organizationID string) *TallyDriver {
|
||||
return &TallyDriver{
|
||||
httpClient: httpClient,
|
||||
organizationID: organizationID,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *TallyDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
records, err := d.listUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inviteRecords, err := d.listInvites(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
records = append(records, inviteRecords...)
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (d *TallyDriver) listUsers(ctx context.Context) ([]AccountRecord, error) {
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.tally.so",
|
||||
}
|
||||
u = u.JoinPath("organizations", d.organizationID, "users")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create tally users request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute tally users request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf(
|
||||
"cannot fetch tally users: unexpected status %d",
|
||||
httpResp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
var users []tallyUser
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&users); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode tally users response: %w", err)
|
||||
}
|
||||
|
||||
var records []AccountRecord
|
||||
for _, u := range users {
|
||||
mfaStatus := coredata.MFAStatusDisabled
|
||||
if u.HasTwoFactorEnabled {
|
||||
mfaStatus = coredata.MFAStatusEnabled
|
||||
}
|
||||
|
||||
record := AccountRecord{
|
||||
Email: u.Email,
|
||||
FullName: u.FullName,
|
||||
Active: !u.IsDeleted,
|
||||
ExternalID: u.ID,
|
||||
MFAStatus: mfaStatus,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
CreatedAt: new(u.CreatedAt),
|
||||
}
|
||||
|
||||
if record.Email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (d *TallyDriver) listInvites(ctx context.Context) ([]AccountRecord, error) {
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.tally.so",
|
||||
}
|
||||
u = u.JoinPath("organizations", d.organizationID, "invites")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create tally invites request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute tally invites request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf(
|
||||
"cannot fetch tally invites: unexpected status %d",
|
||||
httpResp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
var invites []tallyInvite
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&invites); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode tally invites response: %w", err)
|
||||
}
|
||||
|
||||
var records []AccountRecord
|
||||
for _, inv := range invites {
|
||||
record := AccountRecord{
|
||||
Email: inv.Email,
|
||||
Active: false,
|
||||
ExternalID: inv.ID,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
Role: "Invited",
|
||||
}
|
||||
|
||||
if record.Email != "" {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
46
pkg/accessreview/drivers/tally_test.go
Normal file
46
pkg/accessreview/drivers/tally_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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 TestTallyDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/tally", "TALLY_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("TALLY_TOKEN")))
|
||||
|
||||
orgID := os.Getenv("TALLY_ORG_ID")
|
||||
if orgID == "" {
|
||||
orgID = "wvBzxD"
|
||||
}
|
||||
|
||||
driver := NewTallyDriver(client, orgID)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.Email)
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
}
|
||||
42
pkg/accessreview/drivers/testdata/brex.yaml
vendored
Normal file
42
pkg/accessreview/drivers/testdata/brex.yaml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: platform.brexapis.com
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
url: https://platform.brexapis.com/v2/users
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"items":[{"id":"cuuser_000000000000000000000001","first_name":"Alice","last_name":"Martin","email":"alice@example.com","status":"ACTIVE","manager_id":"cuuser_000000000000000000000006","user_role":"EMPLOYEE"},{"id":"cuuser_000000000000000000000002","first_name":"Bob","last_name":"Wilson","email":"bob@example.com","status":"ACTIVE","manager_id":"cuuser_000000000000000000000005","user_role":"EMPLOYEE"},{"id":"cuuser_000000000000000000000003","first_name":"Charlie","last_name":"Brown","email":"charlie@example.com","status":"ACTIVE","manager_id":"cuuser_000000000000000000000006","user_role":"EMPLOYEE"},{"id":"cuuser_000000000000000000000004","first_name":"Dana","last_name":"Contractor","email":"dana@contractor.example.com","status":"ACTIVE","user_role":"BOOKKEEPER"},{"id":"cuuser_000000000000000000000005","first_name":"John","last_name":"Smith","email":"john@example.com","status":"ACTIVE","user_role":"ACCOUNT_ADMIN"},{"id":"cuuser_000000000000000000000006","first_name":"Jane","last_name":"Doe","email":"jane@example.com","status":"ACTIVE","user_role":"ACCOUNT_ADMIN"}]}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 12:54:04 GMT
|
||||
Server:
|
||||
- istio-envoy
|
||||
X-Brex-Parent-Id:
|
||||
- "2660134401200686227"
|
||||
X-Brex-Sampling-Priority:
|
||||
- "1"
|
||||
X-Brex-Trace-Id:
|
||||
- "17144222899071086561"
|
||||
X-Envoy-Upstream-Service-Time:
|
||||
- "590"
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.095966125s
|
||||
130
pkg/accessreview/drivers/testdata/cloudflare.yaml
vendored
Normal file
130
pkg/accessreview/drivers/testdata/cloudflare.yaml
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.cloudflare.com
|
||||
form:
|
||||
page:
|
||||
- "1"
|
||||
per_page:
|
||||
- "50"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
url: https://api.cloudflare.com/client/v4/accounts?page=1&per_page=50
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"result":[{"id":"a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0","name":"Acme Corp Account","type":"standard","settings":{"enforce_twofactor":false,"api_access_enabled":null,"access_approval_expiry":null,"abuse_contact_email":null},"legacy_flags":{"enterprise_zone_quota":{"maximum":0,"current":0,"available":0}},"created_on":"2024-08-01T13:33:08.703547Z"}],"result_info":{"page":1,"per_page":50,"total_pages":1,"count":1,"total_count":1},"success":true,"errors":[],"messages":[]}'
|
||||
headers:
|
||||
Api-Version:
|
||||
- "2026-03-26"
|
||||
Cache-Control:
|
||||
- no-store, no-cache, must-revalidate, post-check=0, pre-check=0
|
||||
Cf-Auditlog-Id:
|
||||
- 019d2a35-91c4-7736-896b-b006409d7e6f
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Cf-Ray:
|
||||
- 9e264d55fea00272-CDG
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 12:54:08 GMT
|
||||
Expires:
|
||||
- Sun, 25 Jan 1981 05:00:00 GMT
|
||||
Pragma:
|
||||
- no-cache
|
||||
Ratelimit:
|
||||
- '"default";r=1199;t=1'
|
||||
Ratelimit-Policy:
|
||||
- '"default";q=1200;w=300'
|
||||
Server:
|
||||
- cloudflare
|
||||
Set-Cookie:
|
||||
- __cflb=04dTob1Z8hkaUxg6DoHNs8KRCyeFsheZBgh4Z4g8w7; SameSite=Lax; path=/; expires=Thu, 26-Mar-26 15:24:09 GMT; HttpOnly
|
||||
- __cf_bm=LyuTKDqNQ5tvh.PHF8_9EdNmXMvJqY4R5hcWPgVvMzU-1774529647.0347695-1.0.1.1-UnR7cgawFlfSt.v3DMn.2_k9YyICl.HC97ZYLn4wz_18Abz7sbBAHiVmjrFTui2_2Yv6xMPC5aQHLne9dkWG9AEfHjgOciGnCcIxV_5R3cU9fx1I.X6S2ULi.g02JOhT; HttpOnly; Secure; Path=/; Domain=api.cloudflare.com; Expires=Thu, 26 Mar 2026 13:24:08 GMT
|
||||
- _cfuvid=RPBCBz3UI_HliU0tQFxDPvA0iEo4L.Sl9rAKMd.y6gk-1774529647.0347695-1.0.1.1-pURMyXvOZFChPV9dMfEZBk7C500xxDGyZbysJ0p1SlM; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.cloudflare.com
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- SAMEORIGIN
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.137052667s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.cloudflare.com
|
||||
form:
|
||||
page:
|
||||
- "1"
|
||||
per_page:
|
||||
- "50"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
url: https://api.cloudflare.com/client/v4/accounts/a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0/members?page=1&per_page=50
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: |
|
||||
{"result":[{"id":"b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0","email":"john@example.com","user":{"id":"c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0","first_name":null,"last_name":null,"email":"john@example.com","two_factor_authentication_enabled":true},"status":"accepted","api_access_enabled":null,"policies":[{"id":"94f6cdbcf2914b68b69a1c6ccb407ff2","access":"allow","permission_groups":[{"id":"8e23b19e4e0d44c29d239c5688ba8cbb","name":"Super Administrator - All Privileges","meta":{"category":"general","description":"Can edit any Cloudflare setting, make purchases, update billing, and manage memberships. Super Administrators can revoke the access of other Super Administrators.","editable":"false","label":"all_privileges","scopes":"com.cloudflare.api.account"}}],"resource_groups":[{"id":"4a190dd8042e46bfb5c86663050c37bb","name":"com.cloudflare.api.account.a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0","meta":{"editable":"false"},"scope":{"key":"com.cloudflare.api.account.a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0","objects":[{"key":"*"}]}}]}],"roles":[{"id":"33666b9c79b9a5273fc7344ff42f953d","name":"Super Administrator - All Privileges","description":"Can edit any Cloudflare setting, make purchases, update billing, and manage memberships. Super Administrators can revoke the access of other Super Administrators.","permissions":{"access":{"edit":true,"read":true},"analytics":{"edit":false,"read":true},"api_gateway":{"edit":true,"read":true},"app":{"edit":true,"read":false},"auditlogs":{"edit":false,"read":true},"billing":{"edit":true,"read":true},"blocks":{"edit":true,"read":true},"cache_purge":{"edit":true,"read":false},"casb":{"edit":true,"read":true},"cds":{"edit":true,"read":true},"cds_compute_account":{"edit":true,"read":true},"ces_analytics":{"edit":false,"read":true},"ces_integration":{"edit":true,"read":true},"ces_phishguard":{"edit":false,"read":true},"ces_policies":{"edit":true,"read":true},"ces_pra_report":{"edit":true,"read":true},"ces_search":{"action":true,"edit":false,"preview":true,"raw":true,"read":true,"trace":true},"ces_settings":{"edit":true,"read":true},"ces_submissions":{"edit":true,"read":true},"cf1_integration":{"casb":true,"ces":true,"edit":true,"read":true},"d1":{"edit":true,"read":false},"dash_sso":{"edit":true,"read":true},"dex":{"edit":true,"read":true},"dns_records":{"edit":true,"read":true},"domain":{"edit":false,"read":true},"fbm":{"edit":true,"read":true},"fbm_acc":{"edit":true,"read":false},"healthchecks":{"edit":true,"read":true},"http_applications":{"edit":true,"read":true},"image":{"edit":true,"read":true},"integration":{"edit":true,"install":true,"read":true},"lb":{"edit":true,"read":true},"legal":{"edit":true,"read":true},"logs":{"edit":true,"read":true},"magic":{"edit":true,"read":true},"member":{"edit":true,"read":true},"organization":{"edit":true,"read":true},"page_shield":{"edit":true,"read":true},"query_cache":{"edit":true,"read":true},"r2_bucket":{"edit":true,"read":true},"r2_bucket_item":{"edit":true,"read":true},"r2_bucket_warehouse":{"edit":true,"read":true},"r2_bucket_warehouse_sql":{"edit":false,"read":true},"resilience":{"edit":true,"read":true},"ssl":{"edit":true,"read":true},"stream":{"edit":true,"read":true},"subscription":{"edit":true,"read":true},"teams":{"edit":true,"pii":true,"read":true,"report":true},"teams_device":{"edit":false,"read":true},"vectorize":{"edit":true,"read":true},"waf":{"edit":true,"read":true},"waitingroom":{"edit":true,"read":true},"web3":{"edit":true,"read":true},"worker":{"edit":true,"read":true},"zaraz":{"edit":true,"publish":true,"read":true},"zone":{"edit":true,"read":true},"zone_settings":{"edit":true,"read":true},"zone_versioning":{"edit":true,"read":true}}}]}],"result_info":{"page":1,"per_page":50,"total_pages":1,"count":1,"total_count":1},"success":true,"errors":[],"messages":[]}
|
||||
headers:
|
||||
Allow:
|
||||
- GET, POST
|
||||
Api-Version:
|
||||
- "2026-03-26"
|
||||
Cache-Control:
|
||||
- private,no-cache,no-store
|
||||
Cf-Auditlog-Id:
|
||||
- 019d2a35-9614-75cb-bdee-bd4b7032bff2
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Cf-Ray:
|
||||
- 9e264d5cdd890272-CDG
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 12:54:08 GMT
|
||||
Pragma:
|
||||
- no-cache
|
||||
Ratelimit:
|
||||
- '"default";r=1199;t=1'
|
||||
Ratelimit-Policy:
|
||||
- '"default";q=1200;w=300'
|
||||
Server:
|
||||
- cloudflare
|
||||
Set-Cookie:
|
||||
- __cflb=04dTob1Z8hkaUxg6DoHNs8KRCyeFsheZMPN5MtAkCd; SameSite=Lax; path=/; expires=Thu, 26-Mar-26 15:24:09 GMT; HttpOnly
|
||||
- __cf_bm=wb_wrUP01dJHoUzazZm1.uiyEnIDd9T4FfBjy0TCTA8-1774529648.1355844-1.0.1.1-U1ANV4vetzyj.IHgptRMPsZRo425I1beWBhJj9Sk6fTCNbehiJ5wQyZfEXPnzaLii5IkN8TZ0e.I7RGjjZof01_fwKAA46tNZ17xG032QXlmKnnmU4DrxL8cvpogHIFL; HttpOnly; Secure; Path=/; Domain=api.cloudflare.com; Expires=Thu, 26 Mar 2026 13:24:08 GMT
|
||||
- _cfuvid=sUYD5zj5lijQFwgMQsqTkqNzvzAH20wbHjXkOak6_V0-1774529648.1355844-1.0.1.1-b3hM8gyNj.5d_wxoQiozNeDeAi_pUUdL6KSJTAHjBSU; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.cloudflare.com
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 739.361791ms
|
||||
296
pkg/accessreview/drivers/testdata/github.yaml
vendored
Normal file
296
pkg/accessreview/drivers/testdata/github.yaml
vendored
Normal file
@@ -0,0 +1,296 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.github.com
|
||||
form:
|
||||
per_page:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/vnd.github+json
|
||||
url: https://api.github.com/orgs/acme-corp/members?per_page=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '[{"login":"jdoe","id":100001,"node_id":"XYZQ6VXNlcjEwMDAw","avatar_url":"","gravatar_id":"","url":"https://api.github.com/users/jdoe","html_url":"https://github.com/jdoe","followers_url":"https://api.github.com/users/jdoe/followers","following_url":"https://api.github.com/users/jdoe/following{/other_user}","gists_url":"https://api.github.com/users/jdoe/gists{/gist_id}","starred_url":"https://api.github.com/users/jdoe/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/jdoe/subscriptions","organizations_url":"https://api.github.com/users/jdoe/orgs","repos_url":"https://api.github.com/users/jdoe/repos","events_url":"https://api.github.com/users/jdoe/events{/privacy}","received_events_url":"https://api.github.com/users/jdoe/received_events","type":"User","user_view_type":"public","site_admin":false}]'
|
||||
headers:
|
||||
Access-Control-Allow-Origin:
|
||||
- '*'
|
||||
Access-Control-Expose-Headers:
|
||||
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
|
||||
Cache-Control:
|
||||
- private, max-age=60, s-maxage=60
|
||||
Content-Security-Policy:
|
||||
- default-src 'none'
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 13:20:24 GMT
|
||||
Etag:
|
||||
- W/"ff2048ab2918047a22ee474a92af74dff9570f18dc6b59556889b02a90e5291f"
|
||||
Github-Authentication-Token-Expiration:
|
||||
- 2026-04-25 13:21:33 +0200
|
||||
Referrer-Policy:
|
||||
- origin-when-cross-origin, strict-origin-when-cross-origin
|
||||
Server:
|
||||
- github.com
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubdomains; preload
|
||||
Vary:
|
||||
- Accept, Authorization, Cookie, X-GitHub-OTP,Accept-Encoding, Accept, X-Requested-With
|
||||
X-Accepted-Github-Permissions:
|
||||
- members=read
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- deny
|
||||
X-Github-Api-Version-Selected:
|
||||
- "2022-11-28"
|
||||
X-Github-Media-Type:
|
||||
- github.v3; format=json
|
||||
X-Github-Request-Id:
|
||||
- E712:31AC1B:623D7E5:56C7B3B:69C53298
|
||||
X-Ratelimit-Limit:
|
||||
- "5000"
|
||||
X-Ratelimit-Remaining:
|
||||
- "4999"
|
||||
X-Ratelimit-Reset:
|
||||
- "1774534824"
|
||||
X-Ratelimit-Resource:
|
||||
- core
|
||||
X-Ratelimit-Used:
|
||||
- "1"
|
||||
X-Xss-Protection:
|
||||
- "0"
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 282.34525ms
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.github.com
|
||||
form:
|
||||
filter:
|
||||
- 2fa_disabled
|
||||
per_page:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/vnd.github+json
|
||||
url: https://api.github.com/orgs/acme-corp/members?filter=2fa_disabled&per_page=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: 2
|
||||
body: '[]'
|
||||
headers:
|
||||
Access-Control-Allow-Origin:
|
||||
- '*'
|
||||
Access-Control-Expose-Headers:
|
||||
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
|
||||
Cache-Control:
|
||||
- private, max-age=60, s-maxage=60
|
||||
Content-Length:
|
||||
- "2"
|
||||
Content-Security-Policy:
|
||||
- default-src 'none'
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 13:20:24 GMT
|
||||
Etag:
|
||||
- '"66d4a6c8d79df8b01adad18bc0608ce26f32b16b3bf0d61ec689cc3a8cda2c37"'
|
||||
Github-Authentication-Token-Expiration:
|
||||
- 2026-04-25 13:21:33 +0200
|
||||
Referrer-Policy:
|
||||
- origin-when-cross-origin, strict-origin-when-cross-origin
|
||||
Server:
|
||||
- github.com
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubdomains; preload
|
||||
Vary:
|
||||
- Accept, Authorization, Cookie, X-GitHub-OTP,Accept-Encoding, Accept, X-Requested-With
|
||||
X-Accepted-Github-Permissions:
|
||||
- members=read
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- deny
|
||||
X-Github-Api-Version-Selected:
|
||||
- "2022-11-28"
|
||||
X-Github-Media-Type:
|
||||
- github.v3; format=json
|
||||
X-Github-Request-Id:
|
||||
- E712:31AC1B:623DA14:56C7D1F:69C53298
|
||||
X-Ratelimit-Limit:
|
||||
- "5000"
|
||||
X-Ratelimit-Remaining:
|
||||
- "4998"
|
||||
X-Ratelimit-Reset:
|
||||
- "1774534824"
|
||||
X-Ratelimit-Resource:
|
||||
- core
|
||||
X-Ratelimit-Used:
|
||||
- "2"
|
||||
X-Xss-Protection:
|
||||
- "0"
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 222.556791ms
|
||||
- id: 2
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.github.com
|
||||
headers:
|
||||
Accept:
|
||||
- application/vnd.github+json
|
||||
url: https://api.github.com/orgs/acme-corp/memberships/jdoe
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"url":"https://api.github.com/orgs/acme-corp/memberships/jdoe","state":"active","role":"admin","organization_url":"https://api.github.com/orgs/acme-corp","user":{"login":"jdoe","id":100001,"node_id":"XYZQ6VXNlcjEwMDAw","avatar_url":"","gravatar_id":"","url":"https://api.github.com/users/jdoe","html_url":"https://github.com/jdoe","followers_url":"https://api.github.com/users/jdoe/followers","following_url":"https://api.github.com/users/jdoe/following{/other_user}","gists_url":"https://api.github.com/users/jdoe/gists{/gist_id}","starred_url":"https://api.github.com/users/jdoe/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/jdoe/subscriptions","organizations_url":"https://api.github.com/users/jdoe/orgs","repos_url":"https://api.github.com/users/jdoe/repos","events_url":"https://api.github.com/users/jdoe/events{/privacy}","received_events_url":"https://api.github.com/users/jdoe/received_events","type":"User","user_view_type":"public","site_admin":false},"direct_membership":true,"enterprise_teams_providing_indirect_membership":[],"organization":{"login":"acme-corp","id":100002,"node_id":"O_kgDOFake0Rg","url":"https://api.github.com/orgs/acme-corp","repos_url":"https://api.github.com/orgs/acme-corp/repos","events_url":"https://api.github.com/orgs/acme-corp/events","hooks_url":"https://api.github.com/orgs/acme-corp/hooks","issues_url":"https://api.github.com/orgs/acme-corp/issues","members_url":"https://api.github.com/orgs/acme-corp/members{/member}","public_members_url":"https://api.github.com/orgs/acme-corp/public_members{/member}","avatar_url":"","description":""}}'
|
||||
headers:
|
||||
Access-Control-Allow-Origin:
|
||||
- '*'
|
||||
Access-Control-Expose-Headers:
|
||||
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
|
||||
Cache-Control:
|
||||
- private, max-age=60, s-maxage=60
|
||||
Content-Security-Policy:
|
||||
- default-src 'none'
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 13:20:25 GMT
|
||||
Etag:
|
||||
- W/"5f52c04e58dd224bb535f57f82fa45937ab13a419b2fafa841ee9392cc8dc444"
|
||||
Github-Authentication-Token-Expiration:
|
||||
- 2026-04-25 13:21:33 +0200
|
||||
Referrer-Policy:
|
||||
- origin-when-cross-origin, strict-origin-when-cross-origin
|
||||
Server:
|
||||
- github.com
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubdomains; preload
|
||||
Vary:
|
||||
- Accept, Authorization, Cookie, X-GitHub-OTP,Accept-Encoding, Accept, X-Requested-With
|
||||
X-Accepted-Github-Permissions:
|
||||
- members=read
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- deny
|
||||
X-Github-Api-Version-Selected:
|
||||
- "2022-11-28"
|
||||
X-Github-Media-Type:
|
||||
- github.v3; format=json
|
||||
X-Github-Request-Id:
|
||||
- E712:31AC1B:623DC2B:56C7EF5:69C53298
|
||||
X-Ratelimit-Limit:
|
||||
- "5000"
|
||||
X-Ratelimit-Remaining:
|
||||
- "4997"
|
||||
X-Ratelimit-Reset:
|
||||
- "1774534824"
|
||||
X-Ratelimit-Resource:
|
||||
- core
|
||||
X-Ratelimit-Used:
|
||||
- "3"
|
||||
X-Xss-Protection:
|
||||
- "0"
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 207.5905ms
|
||||
- id: 3
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.github.com
|
||||
headers:
|
||||
Accept:
|
||||
- application/vnd.github+json
|
||||
url: https://api.github.com/users/jdoe
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"login":"jdoe","id":100001,"node_id":"XYZQ6VXNlcjEwMDAw","avatar_url":"","gravatar_id":"","url":"https://api.github.com/users/jdoe","html_url":"https://github.com/jdoe","followers_url":"https://api.github.com/users/jdoe/followers","following_url":"https://api.github.com/users/jdoe/following{/other_user}","gists_url":"https://api.github.com/users/jdoe/gists{/gist_id}","starred_url":"https://api.github.com/users/jdoe/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/jdoe/subscriptions","organizations_url":"https://api.github.com/users/jdoe/orgs","repos_url":"https://api.github.com/users/jdoe/repos","events_url":"https://api.github.com/users/jdoe/events{/privacy}","received_events_url":"https://api.github.com/users/jdoe/received_events","type":"User","user_view_type":"public","site_admin":false,"name":"Jane Doe","company":null,"blog":"","location":"","email":null,"hireable":null,"bio":null,"twitter_username":null,"public_repos":12,"public_gists":3,"followers":10,"following":5,"created_at":"2009-05-06T20:34:11Z","updated_at":"2026-02-24T10:26:18Z"}'
|
||||
headers:
|
||||
Access-Control-Allow-Origin:
|
||||
- '*'
|
||||
Access-Control-Expose-Headers:
|
||||
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
|
||||
Cache-Control:
|
||||
- private, max-age=60, s-maxage=60
|
||||
Content-Security-Policy:
|
||||
- default-src 'none'
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 13:20:25 GMT
|
||||
Etag:
|
||||
- W/"c747d94dd20c979144fc5f2bdd4e88145cf97bb5c5386d56c1c779e980671332"
|
||||
Github-Authentication-Token-Expiration:
|
||||
- 2026-04-25 13:21:33 +0200
|
||||
Last-Modified:
|
||||
- Tue, 24 Feb 2026 10:26:18 GMT
|
||||
Referrer-Policy:
|
||||
- origin-when-cross-origin, strict-origin-when-cross-origin
|
||||
Server:
|
||||
- github.com
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubdomains; preload
|
||||
Vary:
|
||||
- Accept, Authorization, Cookie, X-GitHub-OTP,Accept-Encoding, Accept, X-Requested-With
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- deny
|
||||
X-Github-Api-Version-Selected:
|
||||
- "2022-11-28"
|
||||
X-Github-Media-Type:
|
||||
- github.v3; format=json
|
||||
X-Github-Request-Id:
|
||||
- E712:31AC1B:623DE37:56C80BC:69C53299
|
||||
X-Ratelimit-Limit:
|
||||
- "5000"
|
||||
X-Ratelimit-Remaining:
|
||||
- "4996"
|
||||
X-Ratelimit-Reset:
|
||||
- "1774534824"
|
||||
X-Ratelimit-Resource:
|
||||
- core
|
||||
X-Ratelimit-Used:
|
||||
- "4"
|
||||
X-Xss-Protection:
|
||||
- "0"
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 190.346042ms
|
||||
59
pkg/accessreview/drivers/testdata/google_workspace.yaml
vendored
Normal file
59
pkg/accessreview/drivers/testdata/google_workspace.yaml
vendored
Normal file
File diff suppressed because one or more lines are too long
137
pkg/accessreview/drivers/testdata/hubspot.yaml
vendored
Normal file
137
pkg/accessreview/drivers/testdata/hubspot.yaml
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.hubapi.com
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.hubapi.com/settings/v3/users/roles
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: 153
|
||||
body: '{"status":"error","message":"Account doesn''t have access to roles.","correlationId":"019d2a35-7d8f-7ae2-bbf6-6342ceb0b095","category":"VALIDATION_ERROR"}'
|
||||
headers:
|
||||
Access-Control-Allow-Credentials:
|
||||
- "false"
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Cf-Ray:
|
||||
- 9e264d34984eef36-CDG
|
||||
Content-Length:
|
||||
- "153"
|
||||
Content-Type:
|
||||
- application/json;charset=utf-8
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 12:54:01 GMT
|
||||
Nel:
|
||||
- '{"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}'
|
||||
Report-To:
|
||||
- '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=AQJsKvuHIV2QL3QAbod63rryJIMLkiSMUEWj9GIPyGtL0x2nmDjJTT2jTK5wZhmW%2BbrKrp7%2BGNHq5GAesRD%2F%2Bm%2FQb02pCiKGoMf%2BaJDy9IrNH249nETyQPjc58vdXA%2FJ"}],"group":"cf-nel","max_age":604800}'
|
||||
Server:
|
||||
- cloudflare
|
||||
Server-Timing:
|
||||
- hcid;desc="019d2a35-7d8f-7ae2-bbf6-6342ceb0b095", cfr;desc="9e264d357357ef36-CDG"
|
||||
Set-Cookie:
|
||||
- __cf_bm=4RLJK.6JUyiG8B7tN3fIH7ZsNHaHg7.NT4d45o29LdQ-1774529641-1.0.1.1-ci1VEnqgpmPAVxsm.Wfu6qz01Wk70GNl3tX5MYFIbnvwmVi1heY0S0T_19KVQa_TJUawOFrHHmuOhUkBMaoIFtLB9PpI0zIry.4Lfx6Pmps; path=/; expires=Thu, 26-Mar-26 13:24:01 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
Vary:
|
||||
- origin, Accept-Encoding
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Hubspot-Correlation-Id:
|
||||
- 019d2a35-7d8f-7ae2-bbf6-6342ceb0b095
|
||||
X-Hubspot-Ratelimit-Daily:
|
||||
- "250000"
|
||||
X-Hubspot-Ratelimit-Daily-Remaining:
|
||||
- "249999"
|
||||
X-Hubspot-Ratelimit-Interval-Milliseconds:
|
||||
- "10000"
|
||||
X-Hubspot-Ratelimit-Max:
|
||||
- "100"
|
||||
X-Hubspot-Ratelimit-Remaining:
|
||||
- "99"
|
||||
X-Hubspot-Ratelimit-Secondly:
|
||||
- "10"
|
||||
X-Hubspot-Ratelimit-Secondly-Remaining:
|
||||
- "9"
|
||||
status: 400 Bad Request
|
||||
code: 400
|
||||
duration: 260.217958ms
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.hubapi.com
|
||||
form:
|
||||
limit:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.hubapi.com/settings/v3/users?limit=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"results":[{"id":"10000001","email":"john@example.com","firstName":"John","lastName":"Smith","roleIds":[],"superAdmin":true},{"id":"10000002","email":"jane@example.com","firstName":"Jane","lastName":"Doe","roleIds":[],"superAdmin":true}]}'
|
||||
headers:
|
||||
Access-Control-Allow-Credentials:
|
||||
- "false"
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Cf-Ray:
|
||||
- 9e264d35eb14ef36-CDG
|
||||
Content-Type:
|
||||
- application/json;charset=utf-8
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 12:54:02 GMT
|
||||
Nel:
|
||||
- '{"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}'
|
||||
Report-To:
|
||||
- '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=80JrXPVKlK%2FOAlE%2FWZDhkwEsCPYQItel9E9qSgo%2BqSi8R5j7hnJLtXpH%2BBffnffkPQZoF5lX6qLfTBh65dNj8v1usNa%2Fz%2FMN33nOy4vjJBWW2LUwqQdb84tL5Ibm2Ji6"}],"group":"cf-nel","max_age":604800}'
|
||||
Server:
|
||||
- cloudflare
|
||||
Server-Timing:
|
||||
- hcid;desc="019d2a35-7e38-7d81-a93c-7babb6730cf2", cfr;desc="9e264d36a3eeef36-CDG"
|
||||
Set-Cookie:
|
||||
- __cf_bm=S9t6TJ6fbQ40SO.aEIBymxjviee3C3rcHuiTajw_cr4-1774529642-1.0.1.1-tOR6PEVlUL.2ttF4.l9IsAS11oSsHi8IFgeXmDCgipjPPY.ztKXNvEE1p26R25qVs8jfiUZbRmbRcuOEn64SLXUX6uDGgsetl2X2.JoiLmg; path=/; expires=Thu, 26-Mar-26 13:24:02 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
Vary:
|
||||
- origin, Accept-Encoding
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Hubspot-Correlation-Id:
|
||||
- 019d2a35-7e38-7d81-a93c-7babb6730cf2
|
||||
X-Hubspot-Ratelimit-Daily:
|
||||
- "250000"
|
||||
X-Hubspot-Ratelimit-Daily-Remaining:
|
||||
- "249998"
|
||||
X-Hubspot-Ratelimit-Interval-Milliseconds:
|
||||
- "10000"
|
||||
X-Hubspot-Ratelimit-Max:
|
||||
- "100"
|
||||
X-Hubspot-Ratelimit-Remaining:
|
||||
- "98"
|
||||
X-Hubspot-Ratelimit-Secondly:
|
||||
- "10"
|
||||
X-Hubspot-Ratelimit-Secondly-Remaining:
|
||||
- "9"
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 212.332792ms
|
||||
65
pkg/accessreview/drivers/testdata/intercom.yaml
vendored
Normal file
65
pkg/accessreview/drivers/testdata/intercom.yaml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.intercom.io
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Intercom-Version:
|
||||
- "2.11"
|
||||
url: https://api.intercom.io/admins
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"type":"admin.list","admins":[{"type":"admin","email":"john@example.com","id":"1000001","name":"John Smith","away_mode_enabled":false,"away_mode_reassign":false,"has_inbox_seat":true,"team_ids":[],"team_priority_level":{}},{"type":"admin","email":"operator+abc12345@intercom.io","id":"1000002","name":"Fin","away_mode_enabled":false,"away_mode_reassign":false,"has_inbox_seat":false,"team_ids":[],"team_priority_level":{}},{"type":"admin","email":"jane@example.com","id":"1000003","name":"Jane Doe","away_mode_enabled":false,"away_mode_reassign":false,"has_inbox_seat":false,"team_ids":[],"team_priority_level":{}},{"type":"admin","email":"alice@example.com","id":"1000004","name":"Alice Martin","away_mode_enabled":false,"away_mode_reassign":false,"has_inbox_seat":true,"team_ids":[],"team_priority_level":{}},{"type":"admin","email":"bob@example.com","id":"1000005","name":"Bob Wilson","away_mode_enabled":false,"away_mode_reassign":false,"has_inbox_seat":true,"team_ids":[],"team_priority_level":{}}]}'
|
||||
headers:
|
||||
Cache-Control:
|
||||
- max-age=0, private, must-revalidate
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 12:54:52 GMT
|
||||
Etag:
|
||||
- W/"30ffa246aba4cabfe8d217fabf428f69"
|
||||
Intercom-Version:
|
||||
- "2.11"
|
||||
Referrer-Policy:
|
||||
- strict-origin-when-cross-origin
|
||||
Server:
|
||||
- nginx
|
||||
Status:
|
||||
- 200 OK
|
||||
Strict-Transport-Security:
|
||||
- max-age=31556952; includeSubDomains; preload
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
- Accept
|
||||
X-Ami-Version:
|
||||
- ami-050d1869df3666e48
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- SAMEORIGIN
|
||||
X-Intercom-Version:
|
||||
- 84c7300b1acdba8bde4ff55a5b497e18b5f93395
|
||||
X-Request-Id:
|
||||
- 003s581hrq58akdvapk0
|
||||
X-Request-Queueing:
|
||||
- "0"
|
||||
X-Runtime:
|
||||
- "0.150437"
|
||||
X-Xss-Protection:
|
||||
- 1; mode=block
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 430.684583ms
|
||||
66
pkg/accessreview/drivers/testdata/linear.yaml
vendored
Normal file
66
pkg/accessreview/drivers/testdata/linear.yaml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 323
|
||||
host: api.linear.app
|
||||
body: '{"query":"\nquery AccessReviewLinearUsers($after: String) {\n users(first: 100, after: $after) {\n nodes {\n id\n email\n name\n active\n admin\n guest\n lastSeen\n createdAt\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n","variables":{"after":null}}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
url: https://api.linear.app/graphql
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: |
|
||||
{"data":{"users":{"nodes":[{"id":"00000001-0000-0000-0000-000000000001","email":"bot-integration@linear.linear.app","name":"Linear","active":true,"admin":false,"guest":false,"lastSeen":"2026-03-24T17:27:44.095Z","createdAt":"2026-03-24T17:27:43.979Z"},{"id":"00000001-0000-0000-0000-000000000002","email":"bot-cursor@oauthapp.linear.app","name":"Cursor","active":true,"admin":false,"guest":false,"lastSeen":"2026-03-17T14:02:50.975Z","createdAt":"2026-03-17T14:02:50.855Z"},{"id":"00000001-0000-0000-0000-000000000003","email":"jane@example.com","name":"Jane Doe","active":true,"admin":false,"guest":false,"lastSeen":"2026-03-09T12:49:53.331Z","createdAt":"2025-01-20T09:12:11.959Z"},{"id":"00000001-0000-0000-0000-000000000004","email":"john@example.com","name":"John Smith","active":true,"admin":true,"guest":false,"lastSeen":"2026-03-26T12:33:50.957Z","createdAt":"2024-07-01T13:19:57.427Z"}],"pageInfo":{"hasNextPage":false,"endCursor":"00000001-0000-0000-0000-000000000004"}}}}
|
||||
headers:
|
||||
Alt-Svc:
|
||||
- h3=":443"; ma=86400
|
||||
Cache-Control:
|
||||
- no-store
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Cf-Ray:
|
||||
- 9e264c7e18b111f4-CDG
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 12:53:32 GMT
|
||||
Etag:
|
||||
- W/"41d-Oqy6gPJu1qx5Quzm0QLcNk/BcEY"
|
||||
Server:
|
||||
- cloudflare
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
Via:
|
||||
- 1.1 google
|
||||
X-Complexity:
|
||||
- "300"
|
||||
X-Ratelimit-Complexity-Limit:
|
||||
- "3000000"
|
||||
X-Ratelimit-Complexity-Remaining:
|
||||
- "2999700"
|
||||
X-Ratelimit-Complexity-Reset:
|
||||
- "1774533212671"
|
||||
X-Ratelimit-Requests-Limit:
|
||||
- "5000"
|
||||
X-Ratelimit-Requests-Remaining:
|
||||
- "4999"
|
||||
X-Ratelimit-Requests-Reset:
|
||||
- "1774533212671"
|
||||
X-Request-Id:
|
||||
- 9e264c7f056511f4-CDG
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 240.856ms
|
||||
69
pkg/accessreview/drivers/testdata/notion.yaml
vendored
Normal file
69
pkg/accessreview/drivers/testdata/notion.yaml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.notion.com
|
||||
form:
|
||||
page_size:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Notion-Version:
|
||||
- "2022-06-28"
|
||||
url: https://api.notion.com/v1/users?page_size=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"object":"list","results":[{"object":"user","id":"00000001-0000-0000-0000-000000000001","name":"Alice Martin","avatar_url":null,"type":"person","person":{"email":"alice@example.com"}},{"object":"user","id":"00000001-0000-0000-0000-000000000002","name":"Carol Davis","avatar_url":null,"type":"person","person":{"email":"carol@example.com"}},{"object":"user","id":"00000001-0000-0000-0000-000000000003","name":"John Smith","avatar_url":"","type":"person","person":{"email":"john@example.com"}},{"object":"user","id":"00000001-0000-0000-0000-000000000004","name":"Jane Doe","avatar_url":"","type":"person","person":{"email":"jane@example.com"}},{"object":"user","id":"00000001-0000-0000-0000-000000000005","name":"Acme-integration","avatar_url":"","type":"bot","bot":{}},{"object":"user","id":"00000001-0000-0000-0000-000000000006","name":"n8n","avatar_url":null,"type":"bot","bot":{"owner":{"type":"workspace","workspace":true},"workspace_name":"Acme Corp","workspace_id":"00000001-0000-0000-0000-000000000007","workspace_limits":{"max_file_upload_size_in_bytes":5368709120}}},{"object":"user","id":"00000001-0000-0000-0000-000000000008","name":"Notion MCP","avatar_url":"","type":"bot","bot":{}}],"next_cursor":null,"has_more":false,"type":"user","user":{},"request_id":"d5d5c5a0-f878-4ef7-bd30-adb4487ffc7c"}'
|
||||
headers:
|
||||
Alt-Svc:
|
||||
- h3=":443"; ma=86400
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Cf-Ray:
|
||||
- 9e266c0798647a6e-CDG
|
||||
Content-Security-Policy:
|
||||
- default-src 'none'
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 13:15:04 GMT
|
||||
Etag:
|
||||
- W/"6f0-og1hHr9nu7Wa4h56JWNgxaLRfmE"
|
||||
Referrer-Policy:
|
||||
- strict-origin-when-cross-origin
|
||||
Server:
|
||||
- cloudflare
|
||||
Set-Cookie:
|
||||
- __cf_bm=PVMwsYfB62uKcpEsG3V1HzbE.xOqBPReNn8aOFie8AA-1774530904.25279-1.0.1.1-ldfHtUZ4_QqMrAC2dGUWQqZHhJw0IAYehliPDKVCxokJqrAyLXX5WrKl3MSte2nb2RU3gmcBIWbzehtXpQtGl6O3sZDuu8kxj8HULQxfyoaF9ObyS_ak5KdaxQw60WQf; HttpOnly; Secure; Path=/; Domain=notion.com; Expires=Thu, 26 Mar 2026 13:45:04 GMT
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Dns-Prefetch-Control:
|
||||
- "off"
|
||||
X-Download-Options:
|
||||
- noopen
|
||||
X-Frame-Options:
|
||||
- SAMEORIGIN
|
||||
X-Notion-Request-Id:
|
||||
- d5d5c5a0-f878-4ef7-bd30-adb4487ffc7c
|
||||
X-Permitted-Cross-Domain-Policies:
|
||||
- none
|
||||
X-Xss-Protection:
|
||||
- "0"
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 401.460042ms
|
||||
83
pkg/accessreview/drivers/testdata/openai.yaml
vendored
Normal file
83
pkg/accessreview/drivers/testdata/openai.yaml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.openai.com
|
||||
form:
|
||||
limit:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.openai.com/v1/organization/users?limit=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: |-
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "user-aaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"object": "organization.user",
|
||||
"added_at": 1670256043,
|
||||
"email": "john@example.com",
|
||||
"name": "John Smith",
|
||||
"role": "owner"
|
||||
},
|
||||
{
|
||||
"id": "user-bbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"object": "organization.user",
|
||||
"added_at": 1734424828,
|
||||
"email": "jane@example.com",
|
||||
"name": "Jane Doe",
|
||||
"role": "reader"
|
||||
}
|
||||
],
|
||||
"first_id": "user-aaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"has_more": false,
|
||||
"last_id": "user-bbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
}
|
||||
headers:
|
||||
Alt-Svc:
|
||||
- h3=":443"; ma=86400
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Cf-Ray:
|
||||
- 9e264eaaae05343a-CDG
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 12:55:02 GMT
|
||||
Openai-Organization:
|
||||
- acme-corp
|
||||
Openai-Processing-Ms:
|
||||
- "349"
|
||||
Openai-Project:
|
||||
- proj_aaaabbbbccccddddeeeeeeee
|
||||
Openai-Version:
|
||||
- "2020-10-01"
|
||||
Server:
|
||||
- cloudflare
|
||||
Set-Cookie:
|
||||
- __cf_bm=OJPi1FQ3aQDosdow4APC.IrgvoFoniXhs7yLPvcgzEw-1774529701.5408235-1.0.1.1-1yRB5GUWfOrogA6r7_dfUryKqwAVzgcuryL2S2fsII1iUganS7LKKhscf9JADsEDaIn6UHXk9hxfH8P3Ue0ixIaVDSlZWf30CEPCJDI6nuiN.1YHejTkmH3llHLkfuLp; HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Thu, 26 Mar 2026 13:25:02 GMT
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Openai-Proxy-Wasm:
|
||||
- v0.1
|
||||
X-Request-Id:
|
||||
- 097c2db1-ecaa-44fe-a7bc-3b2b528acae4
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 529.968583ms
|
||||
58
pkg/accessreview/drivers/testdata/resend.yaml
vendored
Normal file
58
pkg/accessreview/drivers/testdata/resend.yaml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.resend.com
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.resend.com/api-keys
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"object":"list","has_more":false,"data":[{"id":"00000001-0000-0000-0000-000000000001","name":"test_key","created_at":"2026-03-26 12:18:58.314592+00","last_used_at":null},{"id":"00000001-0000-0000-0000-000000000002","name":"production","created_at":"2025-11-03 12:33:51.313755+00","last_used_at":"2026-03-05 18:57:57.73093+00"}]}'
|
||||
headers:
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Cf-Ray:
|
||||
- 9e264e7a8c8a024f-CDG
|
||||
Content-Security-Policy:
|
||||
- default-src 'none'; frame-ancestors 'none'
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 12:54:53 GMT
|
||||
Etag:
|
||||
- W/"146-DfTVyEh9Ui32i8/Mql/EiL8sQdI"
|
||||
Permissions-Policy:
|
||||
- camera=(), microphone=(), geolocation=(), payment=()
|
||||
Ratelimit-Limit:
|
||||
- "5"
|
||||
Ratelimit-Policy:
|
||||
- 5;w=1
|
||||
Ratelimit-Remaining:
|
||||
- "4"
|
||||
Ratelimit-Reset:
|
||||
- "1"
|
||||
Referrer-Policy:
|
||||
- strict-origin-when-cross-origin
|
||||
Server:
|
||||
- cloudflare
|
||||
Strict-Transport-Security:
|
||||
- max-age=63072000; includeSubDomains
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- DENY
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 192.141167ms
|
||||
81
pkg/accessreview/drivers/testdata/sentry.yaml
vendored
Normal file
81
pkg/accessreview/drivers/testdata/sentry.yaml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: sentry.io
|
||||
url: https://sentry.io/api/0/organizations/acme-corp/members/
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '[{"id":"100001","email":"john@example.com","name":"john@example.com","user":{"id":"1000001","name":"john@example.com","username":"john@example.com","email":"john@example.com","avatarUrl":"","isActive":true,"hasPasswordAuth":false,"isManaged":false,"dateJoined":"2024-10-18T09:11:59.290806Z","lastLogin":"2026-03-16T11:30:17.761122Z","has2fa":false,"lastActive":"2026-03-26T12:13:40.025328Z","isSuperuser":false,"isStaff":false,"emails":[],"experiments":{},"avatar":{"avatarType":"letter_avatar","avatarUuid":null,"avatarUrl":null}},"orgRole":"owner","pending":false,"expired":false,"flags":{"idp:provisioned":false,"idp:role-restricted":false,"sso:linked":false,"sso:invalid":false,"member-limit:restricted":false,"partnership:restricted":false},"dateCreated":"2024-10-18T09:12:00.476934Z","inviteStatus":"approved","inviterName":null,"role":"owner","roleName":"Owner"},{"id":"100002","email":"jane@example.com","name":"Jane Doe","user":{"id":"1000002","name":"Jane Doe","username":"jane@example.com","email":"jane@example.com","avatarUrl":"","isActive":true,"hasPasswordAuth":true,"isManaged":false,"dateJoined":"2025-04-01T16:34:31.237067Z","lastLogin":"2025-11-27T16:37:09.123872Z","has2fa":false,"lastActive":"2025-11-28T14:28:23.938416Z","isSuperuser":false,"isStaff":false,"emails":[],"experiments":{},"avatar":{"avatarType":"letter_avatar","avatarUuid":null,"avatarUrl":null}},"orgRole":"member","pending":false,"expired":false,"flags":{"idp:provisioned":false,"idp:role-restricted":false,"sso:linked":false,"sso:invalid":false,"member-limit:restricted":false,"partnership:restricted":false},"dateCreated":"2025-11-07T14:54:57.672332Z","inviteStatus":"approved","inviterName":"john@example.com","role":"member","roleName":"Member"}]'
|
||||
headers:
|
||||
Access-Control-Allow-Headers:
|
||||
- X-Sentry-Auth, X-Requested-With, Origin, Accept, Content-Type, Authentication, Authorization, Content-Encoding, sentry-trace, baggage, X-CSRFToken
|
||||
Access-Control-Allow-Methods:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
Access-Control-Allow-Origin:
|
||||
- '*'
|
||||
Access-Control-Expose-Headers:
|
||||
- X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, Endpoint, Retry-After, Link
|
||||
Allow:
|
||||
- GET, POST, HEAD, OPTIONS
|
||||
Alt-Svc:
|
||||
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
|
||||
Content-Language:
|
||||
- en
|
||||
Content-Security-Policy:
|
||||
- 'base-uri ''none''; style-src * ''unsafe-inline''; frame-ancestors ''self'' *.sentry.io; font-src * data:; connect-src ''self'' *.algolia.net *.algolianet.com *.algolia.io sentry.io *.sentry.io s1.sentry-cdn.com o1.ingest.sentry.io api2.amplitude.com app.pendo.io data.pendo.io reload.getsentry.net t687h3m0nh65.statuspage.io sentry.zendesk.com ekr.zdassets.com maps.googleapis.com; object-src ''none''; frame-src app.pendo.io demo.arcade.software js.stripe.com sentry.io ''self''; script-src ''self'' ''unsafe-inline'' ''report-sample'' s1.sentry-cdn.com js.sentry-cdn.com browser.sentry-cdn.com statuspage-production.s3.amazonaws.com static.zdassets.com aui-cdn.atlassian.com connect-cdn.atl-paas.net js.stripe.com ''strict-dynamic'' cdn.pendo.io data.pendo.io pendo-io-static.storage.googleapis.com pendo-static-5634074999128064.storage.googleapis.com; media-src *; default-src ''none''; img-src * blob: data:; worker-src blob:; report-uri https://o1.ingest.sentry.io/api/54785/security/?sentry_key=f724a8a027db45f5b21507e7142ff78e&sentry_release=8bd10e6776717c63689632c2a4b7de878fbfe6c2'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Cross-Origin-Opener-Policy-Report-Only:
|
||||
- same-origin; report-to="coop-endpoint"
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 12:53:44 GMT
|
||||
Link:
|
||||
- <https://sentry.io/api/0/organizations/acme-corp/members/?&cursor=100:-1:1>; rel="previous"; results="false"; cursor="100:-1:1", <https://sentry.io/api/0/organizations/acme-corp/members/?&cursor=100:1:0>; rel="next"; results="false"; cursor="100:1:0"
|
||||
Report-To:
|
||||
- '{"group":"coop-endpoint","max_age":86400,"endpoints":[{"url":"https://sentry-coop-302178938983.us-central1.run.app/coop"}]}'
|
||||
Server:
|
||||
- nginx
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
Vary:
|
||||
- Accept-Encoding,Accept-Language, Cookie
|
||||
Via:
|
||||
- 1.1 google
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Envoy-Attempt-Count:
|
||||
- "1"
|
||||
X-Envoy-Upstream-Service-Time:
|
||||
- "861"
|
||||
X-Frame-Options:
|
||||
- deny
|
||||
X-Sentry-Proxy-Url:
|
||||
- http://sentry-rpc-de.psc.control.sentry.internal:8999/api/0/organizations/acme-corp/members/
|
||||
X-Sentry-Rate-Limit-Concurrentlimit:
|
||||
- "25"
|
||||
X-Sentry-Rate-Limit-Concurrentremaining:
|
||||
- "24"
|
||||
X-Sentry-Rate-Limit-Limit:
|
||||
- "40"
|
||||
X-Sentry-Rate-Limit-Remaining:
|
||||
- "39"
|
||||
X-Sentry-Rate-Limit-Reset:
|
||||
- "1774529624"
|
||||
X-Served-By:
|
||||
- frontend-default-694c567cbb-w99ht
|
||||
X-Xss-Protection:
|
||||
- 1; mode=block
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.024781959s
|
||||
84
pkg/accessreview/drivers/testdata/slack.yaml
vendored
Normal file
84
pkg/accessreview/drivers/testdata/slack.yaml
vendored
Normal file
File diff suppressed because one or more lines are too long
56
pkg/accessreview/drivers/testdata/supabase.yaml
vendored
Normal file
56
pkg/accessreview/drivers/testdata/supabase.yaml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.supabase.com
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.supabase.com/v1/organizations/acme-corp/members
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '[{"user_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","user_name":"jdoe","email":"jdoe@example.com","role_name":"Owner","mfa_enabled":false}]'
|
||||
headers:
|
||||
Access-Control-Allow-Credentials:
|
||||
- "true"
|
||||
Access-Control-Expose-Headers:
|
||||
- x-connection-encrypted,x-forwarded-for,user-agent,CF-Connecting-IP,Retry-After
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Cf-Ray:
|
||||
- 000000000000000000-XXX
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 13:18:24 GMT
|
||||
Etag:
|
||||
- W/"94-2d3haQkIE5319yeX5GL6PU8Mh2o"
|
||||
Server:
|
||||
- cloudflare
|
||||
Set-Cookie:
|
||||
- __cf_bm=REDACTED; HttpOnly; Secure; Path=/; Domain=supabase.com; Expires=Thu, 26 Mar 2026 13:48:24 GMT
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
Vary:
|
||||
- Origin
|
||||
X-Powered-By:
|
||||
- Express
|
||||
X-Ratelimit-Limit:
|
||||
- "120"
|
||||
X-Ratelimit-Remaining:
|
||||
- "119"
|
||||
X-Ratelimit-Reset:
|
||||
- "60"
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 156.602041ms
|
||||
154
pkg/accessreview/drivers/testdata/tally.yaml
vendored
Normal file
154
pkg/accessreview/drivers/testdata/tally.yaml
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.tally.so
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.tally.so/organizations/wvBzxD/users
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '[{"id":"xA1bCD","firstName":"Alice","lastName":"Johnson","email":"alice@example.com","avatarUrl":"","isBlocked":false,"isDeleted":false,"timezone":"Europe/Paris","isUnknownDeviceVerificationDisabled":false,"createdAt":"2025-10-24T14:51:36.000Z","updatedAt":"2026-03-26T13:54:30.000Z","organizationId":"org001","fullName":"Alice Johnson","ssoIsConnectedWithGoogle":true,"ssoIsConnectedWithApple":false,"hasPasswordSet":true,"authenticationMethodsCount":2,"hasTwoFactorEnabled":true,"emailDomain":null},{"id":"yB2cDE","firstName":"Bob","lastName":"Smith","email":"bob@example.com","avatarUrl":"","isBlocked":false,"isDeleted":false,"timezone":"Europe/Paris","isUnknownDeviceVerificationDisabled":false,"createdAt":"2025-10-25T16:48:52.000Z","updatedAt":"2026-03-23T11:42:00.000Z","organizationId":"org001","fullName":"Bob Smith","ssoIsConnectedWithGoogle":true,"ssoIsConnectedWithApple":false,"hasPasswordSet":false,"authenticationMethodsCount":1,"hasTwoFactorEnabled":false,"emailDomain":null},{"id":"zC3dEF","firstName":"Carol","lastName":"Williams","email":"carol@example.com","avatarUrl":"","isBlocked":false,"isDeleted":false,"timezone":"Europe/Paris","isUnknownDeviceVerificationDisabled":false,"createdAt":"2025-12-01T13:38:59.000Z","updatedAt":"2026-03-06T09:47:20.000Z","organizationId":"org001","fullName":"Carol Williams","ssoIsConnectedWithGoogle":true,"ssoIsConnectedWithApple":false,"hasPasswordSet":false,"authenticationMethodsCount":1,"hasTwoFactorEnabled":false,"emailDomain":null}]'
|
||||
headers:
|
||||
Access-Control-Allow-Credentials:
|
||||
- "true"
|
||||
Access-Control-Expose-Headers:
|
||||
- Mcp-Session-Id
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Cf-Ray:
|
||||
- 9e26a8307c916f02-CDG
|
||||
Content-Security-Policy:
|
||||
- 'default-src ''self'';base-uri ''self'';font-src ''self'' https: data:;form-action ''self'';frame-ancestors ''self'';img-src ''self'' data:;object-src ''none'';script-src ''self'';script-src-attr ''none'';style-src ''self'' https: ''unsafe-inline'';upgrade-insecure-requests'
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Cross-Origin-Opener-Policy:
|
||||
- same-origin
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 13:56:08 GMT
|
||||
Etag:
|
||||
- W/"f19-hbZ8t5Tve+qpDeG+/EcW1dXDiZM"
|
||||
Nel:
|
||||
- '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}'
|
||||
Origin-Agent-Cluster:
|
||||
- ?1
|
||||
Referrer-Policy:
|
||||
- no-referrer
|
||||
Report-To:
|
||||
- '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=CocRUYJt6j9oAYgflV089%2BvXAR3wD10c6qleEoXbEMs%2FxN1a1DYwTeJ08xXieR%2F0nRHSBKZVVCUQLGiMHYrKJbCjfkSUlWLf%2BTR%2BW9a4V%2FhvMBzLQXaQsRb1ii1SIg%3D%3D"}]}'
|
||||
Server:
|
||||
- cloudflare
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains
|
||||
Vary:
|
||||
- Origin
|
||||
X-Cloud-Trace-Context:
|
||||
- 7337a79cb9f6d2c654df69076505ff5a
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Dns-Prefetch-Control:
|
||||
- "off"
|
||||
X-Download-Options:
|
||||
- noopen
|
||||
X-Frame-Options:
|
||||
- SAMEORIGIN
|
||||
X-Permitted-Cross-Domain-Policies:
|
||||
- none
|
||||
X-Ratelimit-Limit:
|
||||
- "100"
|
||||
X-Ratelimit-Remaining:
|
||||
- "95"
|
||||
X-Ratelimit-Reset:
|
||||
- "1774533375"
|
||||
X-Xss-Protection:
|
||||
- "0"
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 77.593208ms
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.tally.so
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.tally.so/organizations/wvBzxD/invites
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: 2
|
||||
body: '[]'
|
||||
headers:
|
||||
Access-Control-Allow-Credentials:
|
||||
- "true"
|
||||
Access-Control-Expose-Headers:
|
||||
- Mcp-Session-Id
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Cf-Ray:
|
||||
- 9e26a830cccb6f02-CDG
|
||||
Content-Length:
|
||||
- "2"
|
||||
Content-Security-Policy:
|
||||
- 'default-src ''self'';base-uri ''self'';font-src ''self'' https: data:;form-action ''self'';frame-ancestors ''self'';img-src ''self'' data:;object-src ''none'';script-src ''self'';script-src-attr ''none'';style-src ''self'' https: ''unsafe-inline'';upgrade-insecure-requests'
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Cross-Origin-Opener-Policy:
|
||||
- same-origin
|
||||
Date:
|
||||
- Thu, 26 Mar 2026 13:56:08 GMT
|
||||
Etag:
|
||||
- W/"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w"
|
||||
Nel:
|
||||
- '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}'
|
||||
Origin-Agent-Cluster:
|
||||
- ?1
|
||||
Referrer-Policy:
|
||||
- no-referrer
|
||||
Report-To:
|
||||
- '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=vfTMmGB%2FitCP4gkTcfhy9vOYzJZbTYp4a0km1df34sZL3TfZ17vXbuv4kl5nEwXSPgHv1si5IF8qaUW1z70vzxLUnCqaF0A7%2Fi6DgBVz1Xv%2BwAGOIUHAA6uyq2%2B76A%3D%3D"}]}'
|
||||
Server:
|
||||
- cloudflare
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains
|
||||
Vary:
|
||||
- Origin
|
||||
X-Cloud-Trace-Context:
|
||||
- 38b42db0f009bb00781397cecba8b838
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Dns-Prefetch-Control:
|
||||
- "off"
|
||||
X-Download-Options:
|
||||
- noopen
|
||||
X-Frame-Options:
|
||||
- SAMEORIGIN
|
||||
X-Permitted-Cross-Domain-Policies:
|
||||
- none
|
||||
X-Ratelimit-Limit:
|
||||
- "100"
|
||||
X-Ratelimit-Remaining:
|
||||
- "94"
|
||||
X-Ratelimit-Reset:
|
||||
- "1774533375"
|
||||
X-Xss-Protection:
|
||||
- "0"
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 48.255292ms
|
||||
99
pkg/accessreview/drivers/vcr_test.go
Normal file
99
pkg/accessreview/drivers/vcr_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// 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 (
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/dnaeon/go-vcr.v4/pkg/cassette"
|
||||
"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
|
||||
)
|
||||
|
||||
// newRecorder creates a go-vcr recorder for the given cassette path. When
|
||||
// the env var is non-empty the recorder runs in record mode, otherwise
|
||||
// it replays from the committed cassette. A BeforeSave hook strips the
|
||||
// Authorization header so tokens are never persisted.
|
||||
func newRecorder(t *testing.T, cassettePath string, envVar string) *recorder.Recorder {
|
||||
t.Helper()
|
||||
|
||||
mode := recorder.ModeReplayOnly
|
||||
if os.Getenv(envVar) != "" {
|
||||
mode = recorder.ModeRecordOnly
|
||||
}
|
||||
|
||||
rec, err := recorder.New(
|
||||
cassettePath,
|
||||
recorder.WithMode(mode),
|
||||
recorder.WithSkipRequestLatency(true),
|
||||
recorder.WithHook(func(i *cassette.Interaction) error {
|
||||
i.Request.Headers.Del("Authorization")
|
||||
return nil
|
||||
}, recorder.BeforeSaveHook),
|
||||
)
|
||||
if err != nil {
|
||||
if mode == recorder.ModeReplayOnly {
|
||||
t.Skipf("cassette not found (record with %s env var): %v", envVar, err)
|
||||
}
|
||||
t.Fatalf("cannot create vcr recorder: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
if err := rec.Stop(); err != nil {
|
||||
t.Errorf("cannot stop vcr recorder: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// authRoundTripper wraps a transport and injects an Authorization header
|
||||
// into each request. The authValue is set as-is (caller provides "Bearer xxx"
|
||||
// or a raw API key depending on the provider).
|
||||
type authRoundTripper struct {
|
||||
authValue string
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
func (rt *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if rt.authValue != "" {
|
||||
req.Header.Set("Authorization", rt.authValue)
|
||||
}
|
||||
return rt.transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
// bearerAuth returns "Bearer <token>" if the token is non-empty, or "" otherwise.
|
||||
func bearerAuth(token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
return "Bearer " + token
|
||||
}
|
||||
|
||||
// newVCRClient creates an *http.Client backed by the recorder's transport,
|
||||
// with an optional Authorization header injected into requests (for recording
|
||||
// mode). The authValue should be the complete header value, e.g.
|
||||
// "Bearer xxx" or a raw API key like "lin_api_xxx".
|
||||
func newVCRClient(rec *recorder.Recorder, authValue string) *http.Client {
|
||||
transport := rec.GetDefaultClient().Transport
|
||||
if authValue != "" {
|
||||
transport = &authRoundTripper{
|
||||
authValue: authValue,
|
||||
transport: transport,
|
||||
}
|
||||
}
|
||||
return &http.Client{Transport: transport}
|
||||
}
|
||||
Reference in New Issue
Block a user