diff --git a/pkg/accessreview/drivers/brex.go b/pkg/accessreview/drivers/brex.go new file mode 100644 index 000000000..a286d34ab --- /dev/null +++ b/pkg/accessreview/drivers/brex.go @@ -0,0 +1,127 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/brex_test.go b/pkg/accessreview/drivers/brex_test.go new file mode 100644 index 000000000..710a48692 --- /dev/null +++ b/pkg/accessreview/drivers/brex_test.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/cloudflare.go b/pkg/accessreview/drivers/cloudflare.go new file mode 100644 index 000000000..9d5da2829 --- /dev/null +++ b/pkg/accessreview/drivers/cloudflare.go @@ -0,0 +1,241 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/cloudflare_test.go b/pkg/accessreview/drivers/cloudflare_test.go new file mode 100644 index 000000000..2204f3d94 --- /dev/null +++ b/pkg/accessreview/drivers/cloudflare_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/csv.go b/pkg/accessreview/drivers/csv.go new file mode 100644 index 000000000..d36f54e38 --- /dev/null +++ b/pkg/accessreview/drivers/csv.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/csv_test.go b/pkg/accessreview/drivers/csv_test.go new file mode 100644 index 000000000..145085ce5 --- /dev/null +++ b/pkg/accessreview/drivers/csv_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) + } +} diff --git a/pkg/accessreview/drivers/docusign.go b/pkg/accessreview/drivers/docusign.go new file mode 100644 index 000000000..f98c9be50 --- /dev/null +++ b/pkg/accessreview/drivers/docusign.go @@ -0,0 +1,206 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/docusign_test.go b/pkg/accessreview/drivers/docusign_test.go new file mode 100644 index 000000000..2c4666a13 --- /dev/null +++ b/pkg/accessreview/drivers/docusign_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/driver.go b/pkg/accessreview/drivers/driver.go new file mode 100644 index 000000000..ccc1f7407 --- /dev/null +++ b/pkg/accessreview/drivers/driver.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/github.go b/pkg/accessreview/drivers/github.go new file mode 100644 index 000000000..01530eca4 --- /dev/null +++ b/pkg/accessreview/drivers/github.go @@ -0,0 +1,286 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/github_test.go b/pkg/accessreview/drivers/github_test.go new file mode 100644 index 000000000..df4281591 --- /dev/null +++ b/pkg/accessreview/drivers/github_test.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/google_workspace.go b/pkg/accessreview/drivers/google_workspace.go new file mode 100644 index 000000000..ad679e5a8 --- /dev/null +++ b/pkg/accessreview/drivers/google_workspace.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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<. +// +// 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) +} diff --git a/pkg/accessreview/drivers/hubspot.go b/pkg/accessreview/drivers/hubspot.go new file mode 100644 index 000000000..2e1f8aa52 --- /dev/null +++ b/pkg/accessreview/drivers/hubspot.go @@ -0,0 +1,190 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/hubspot_test.go b/pkg/accessreview/drivers/hubspot_test.go new file mode 100644 index 000000000..c3a68c643 --- /dev/null +++ b/pkg/accessreview/drivers/hubspot_test.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/intercom.go b/pkg/accessreview/drivers/intercom.go new file mode 100644 index 000000000..8c5135302 --- /dev/null +++ b/pkg/accessreview/drivers/intercom.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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" +} diff --git a/pkg/accessreview/drivers/intercom_test.go b/pkg/accessreview/drivers/intercom_test.go new file mode 100644 index 000000000..cc5e0c625 --- /dev/null +++ b/pkg/accessreview/drivers/intercom_test.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/linear.go b/pkg/accessreview/drivers/linear.go new file mode 100644 index 000000000..92d6b929b --- /dev/null +++ b/pkg/accessreview/drivers/linear.go @@ -0,0 +1,208 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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" + } +} diff --git a/pkg/accessreview/drivers/linear_test.go b/pkg/accessreview/drivers/linear_test.go new file mode 100644 index 000000000..8f872ea31 --- /dev/null +++ b/pkg/accessreview/drivers/linear_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go new file mode 100644 index 000000000..52bdd2619 --- /dev/null +++ b/pkg/accessreview/drivers/name_resolver.go @@ -0,0 +1,590 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/notion.go b/pkg/accessreview/drivers/notion.go new file mode 100644 index 000000000..3f5d978d2 --- /dev/null +++ b/pkg/accessreview/drivers/notion.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/notion_test.go b/pkg/accessreview/drivers/notion_test.go new file mode 100644 index 000000000..702c3ae01 --- /dev/null +++ b/pkg/accessreview/drivers/notion_test.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/onepassword.go b/pkg/accessreview/drivers/onepassword.go new file mode 100644 index 000000000..74ba415ab --- /dev/null +++ b/pkg/accessreview/drivers/onepassword.go @@ -0,0 +1,172 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/onepassword_test.go b/pkg/accessreview/drivers/onepassword_test.go new file mode 100644 index 000000000..144e11b3a --- /dev/null +++ b/pkg/accessreview/drivers/onepassword_test.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/onepassword_users_api.go b/pkg/accessreview/drivers/onepassword_users_api.go new file mode 100644 index 000000000..e259da6b9 --- /dev/null +++ b/pkg/accessreview/drivers/onepassword_users_api.go @@ -0,0 +1,155 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/openai.go b/pkg/accessreview/drivers/openai.go new file mode 100644 index 000000000..00db43d67 --- /dev/null +++ b/pkg/accessreview/drivers/openai.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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" + } +} diff --git a/pkg/accessreview/drivers/openai_test.go b/pkg/accessreview/drivers/openai_test.go new file mode 100644 index 000000000..3bb2b7c0a --- /dev/null +++ b/pkg/accessreview/drivers/openai_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/probo_memberships.go b/pkg/accessreview/drivers/probo_memberships.go new file mode 100644 index 000000000..ab6020224 --- /dev/null +++ b/pkg/accessreview/drivers/probo_memberships.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/resend.go b/pkg/accessreview/drivers/resend.go new file mode 100644 index 000000000..6bcf294b1 --- /dev/null +++ b/pkg/accessreview/drivers/resend.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/resend_test.go b/pkg/accessreview/drivers/resend_test.go new file mode 100644 index 000000000..3b263636d --- /dev/null +++ b/pkg/accessreview/drivers/resend_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/sentry.go b/pkg/accessreview/drivers/sentry.go new file mode 100644 index 000000000..6255ed7da --- /dev/null +++ b/pkg/accessreview/drivers/sentry.go @@ -0,0 +1,228 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/sentry_test.go b/pkg/accessreview/drivers/sentry_test.go new file mode 100644 index 000000000..e3e28e8c2 --- /dev/null +++ b/pkg/accessreview/drivers/sentry_test.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/slack.go b/pkg/accessreview/drivers/slack.go new file mode 100644 index 000000000..5a460cf05 --- /dev/null +++ b/pkg/accessreview/drivers/slack.go @@ -0,0 +1,184 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/slack_test.go b/pkg/accessreview/drivers/slack_test.go new file mode 100644 index 000000000..07fedf425 --- /dev/null +++ b/pkg/accessreview/drivers/slack_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/supabase.go b/pkg/accessreview/drivers/supabase.go new file mode 100644 index 000000000..f31960ff4 --- /dev/null +++ b/pkg/accessreview/drivers/supabase.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/supabase_test.go b/pkg/accessreview/drivers/supabase_test.go new file mode 100644 index 000000000..182d297e6 --- /dev/null +++ b/pkg/accessreview/drivers/supabase_test.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/tally.go b/pkg/accessreview/drivers/tally.go new file mode 100644 index 000000000..8434ae9ac --- /dev/null +++ b/pkg/accessreview/drivers/tally.go @@ -0,0 +1,186 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/tally_test.go b/pkg/accessreview/drivers/tally_test.go new file mode 100644 index 000000000..e1d296d42 --- /dev/null +++ b/pkg/accessreview/drivers/tally_test.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/testdata/brex.yaml b/pkg/accessreview/drivers/testdata/brex.yaml new file mode 100644 index 000000000..ba06e5e1b --- /dev/null +++ b/pkg/accessreview/drivers/testdata/brex.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/cloudflare.yaml b/pkg/accessreview/drivers/testdata/cloudflare.yaml new file mode 100644 index 000000000..309945814 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/cloudflare.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/github.yaml b/pkg/accessreview/drivers/testdata/github.yaml new file mode 100644 index 000000000..8830d3715 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/github.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/google_workspace.yaml b/pkg/accessreview/drivers/testdata/google_workspace.yaml new file mode 100644 index 000000000..5da656ff6 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/google_workspace.yaml @@ -0,0 +1,59 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: admin.googleapis.com + form: + alt: + - json + customer: + - my_customer + maxResults: + - "500" + prettyPrint: + - "false" + projection: + - full + headers: + User-Agent: + - google-api-go-client/0.5 + X-Goog-Api-Client: + - gl-go/1.26.1 gdcl/0.269.0 + url: https://admin.googleapis.com/admin/directory/v1/users?alt=json&customer=my_customer&maxResults=500&prettyPrint=false&projection=full + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"kind":"admin#directory#users","etag":"\"1jGAF1FWZlpfhWcmHrlfmqb5ce_W0dm0ajQixlgUSrw/WNFnbiExslIFfweBCCvZF7X9P54\"","users":[{"kind":"admin#directory#user","id":"100000000000000000001","etag":"\"1jGAF1FWZlpfhWcmHrlfmqb5ce_W0dm0ajQixlgUSrw/DxQUydt7QDpnnNeKSgPDFsixDyc\"","primaryEmail":"admin@example.com","name":{"givenName":"Admin","familyName":"Acme","fullName":"Admin Acme"},"isAdmin":false,"isDelegatedAdmin":false,"lastLoginTime":"2026-03-01T21:05:54.000Z","creationTime":"2025-08-12T14:19:33.000Z","agreedToTerms":true,"suspended":false,"archived":false,"changePasswordAtNextLogin":false,"ipWhitelisted":false,"emails":[{"address":"admin@example.com","primary":true}],"languages":[{"languageCode":"fr","preference":"preferred"}],"customerId":"C00000000","orgUnitPath":"/","isMailboxSetup":true,"isEnrolledIn2Sv":false,"isEnforcedIn2Sv":false,"includeInGlobalAddressList":true,"isGuestUser":false},{"kind":"admin#directory#user","id":"100000000000000000002","etag":"\"1jGAF1FWZlpfhWcmHrlfmqb5ce_W0dm0ajQixlgUSrw/KMljSOxw0Onv2s8INJKG2EuQ2vk\"","primaryEmail":"jane@example.com","name":{"givenName":"Jane","familyName":"Doe","fullName":"Jane Doe"},"isAdmin":true,"isDelegatedAdmin":false,"lastLoginTime":"2026-03-24T16:58:48.000Z","creationTime":"2024-06-27T15:34:22.000Z","agreedToTerms":true,"suspended":false,"archived":false,"changePasswordAtNextLogin":false,"ipWhitelisted":false,"emails":[{"address":"jane.doe@mail.com","type":"work"},{"address":"jane@example.com","primary":true},{"address":"jane@alias.example.com"},{"address":"jane@alias.example.com.test-google-a.com"}],"languages":[{"languageCode":"fr","preference":"preferred"}],"aliases":["jane@alias.example.com"],"nonEditableAliases":["jane@alias.example.com.test-google-a.com"],"customerId":"C00000000","orgUnitPath":"/","isMailboxSetup":true,"isEnrolledIn2Sv":false,"isEnforcedIn2Sv":false,"includeInGlobalAddressList":true},{"kind":"admin#directory#user","id":"100000000000000000003","etag":"\"1jGAF1FWZlpfhWcmHrlfmqb5ce_W0dm0ajQixlgUSrw/q0vjbrybeHcyVw_tohnBmp7QR9o\"","primaryEmail":"john@example.com","name":{"givenName":"John","familyName":"Smith","fullName":"John Smith"},"isAdmin":true,"isDelegatedAdmin":false,"lastLoginTime":"2026-03-25T12:59:35.000Z","creationTime":"2024-06-21T08:54:20.000Z","agreedToTerms":true,"suspended":false,"archived":false,"changePasswordAtNextLogin":false,"ipWhitelisted":false,"emails":[{"address":"john.smith@mail.com","type":"home"},{"address":"john@example.com","primary":true},{"address":"john@alias.example.com"},{"address":"john@alias.example.com.test-google-a.com"}],"languages":[{"languageCode":"fr","preference":"preferred"}],"aliases":["john@alias.example.com"],"nonEditableAliases":["john@alias.example.com.test-google-a.com"],"customerId":"C00000000","orgUnitPath":"/","isMailboxSetup":true,"isEnrolledIn2Sv":false,"isEnforcedIn2Sv":false,"includeInGlobalAddressList":true},{"kind":"admin#directory#user","id":"100000000000000000004","etag":"\"1jGAF1FWZlpfhWcmHrlfmqb5ce_W0dm0ajQixlgUSrw/ds_13bLY04Q9Og1eNpsd0YjVxaY\"","primaryEmail":"alice@example.com","name":{"givenName":"Alice","familyName":"Martin","fullName":"Alice Martin"},"isAdmin":false,"isDelegatedAdmin":false,"lastLoginTime":"2026-03-23T00:49:43.000Z","creationTime":"2026-02-27T13:04:06.000Z","agreedToTerms":true,"suspended":false,"archived":false,"changePasswordAtNextLogin":false,"ipWhitelisted":false,"emails":[{"address":"alice.martin@mail.com","type":"work"},{"address":"alice@example.com","primary":true}],"languages":[{"languageCode":"fr","preference":"preferred"}],"customerId":"C00000000","orgUnitPath":"/","isMailboxSetup":true,"isEnrolledIn2Sv":false,"isEnforcedIn2Sv":false,"includeInGlobalAddressList":true,"isGuestUser":false},{"kind":"admin#directory#user","id":"100000000000000000005","etag":"\"1jGAF1FWZlpfhWcmHrlfmqb5ce_W0dm0ajQixlgUSrw/1iEamJfIqzBoK68_qGSuZBuENO4\"","primaryEmail":"bob@example.com","name":{"givenName":"Bob","familyName":"Wilson","fullName":"Bob Wilson"},"isAdmin":false,"isDelegatedAdmin":false,"lastLoginTime":"2025-10-30T13:22:29.000Z","creationTime":"2025-09-01T13:31:09.000Z","agreedToTerms":true,"suspended":false,"archived":true,"changePasswordAtNextLogin":false,"ipWhitelisted":false,"emails":[{"address":"bob.wilson@mail.com","type":"work"},{"address":"bob@example.com","primary":true}],"languages":[{"languageCode":"fr","preference":"preferred"}],"customerId":"C00000000","orgUnitPath":"/","isMailboxSetup":true,"isEnrolledIn2Sv":false,"isEnforcedIn2Sv":false,"includeInGlobalAddressList":true,"isGuestUser":false},{"kind":"admin#directory#user","id":"100000000000000000006","etag":"\"1jGAF1FWZlpfhWcmHrlfmqb5ce_W0dm0ajQixlgUSrw/B3juSMM35EG9w667pEIJNKSZpuc\"","primaryEmail":"carol@example.com","name":{"givenName":"Carol","familyName":"Davis","fullName":"Carol Davis"},"isAdmin":false,"isDelegatedAdmin":false,"lastLoginTime":"2026-03-20T13:20:46.000Z","creationTime":"2024-12-30T22:47:49.000Z","agreedToTerms":true,"suspended":false,"archived":false,"changePasswordAtNextLogin":false,"ipWhitelisted":false,"emails":[{"address":"carol.davis@mail.com","type":"work"},{"address":"carol@example.com","primary":true}],"languages":[{"languageCode":"fr","preference":"preferred"}],"customerId":"C00000000","orgUnitPath":"/","isMailboxSetup":true,"isEnrolledIn2Sv":false,"isEnforcedIn2Sv":false,"includeInGlobalAddressList":true,"recoveryEmail":"carol.davis@mail.com","recoveryPhone":"+10000000000"},{"kind":"admin#directory#user","id":"100000000000000000007","etag":"\"1jGAF1FWZlpfhWcmHrlfmqb5ce_W0dm0ajQixlgUSrw/Ks9OEK3oCK5B8YiH3q-5OMRaUe4\"","primaryEmail":"toolsadmin@example.com","name":{"givenName":"Team","familyName":"Admin","fullName":"Team Admin"},"isAdmin":true,"isDelegatedAdmin":false,"lastLoginTime":"2025-03-10T10:10:22.000Z","creationTime":"2024-06-21T08:31:08.000Z","agreedToTerms":true,"suspended":false,"archived":false,"changePasswordAtNextLogin":false,"ipWhitelisted":false,"emails":[{"address":"toolsadmin@example.com","primary":true},{"address":"toolsadmin@alias.example.com"},{"address":"toolsadmin@alias.example.com.test-google-a.com"}],"languages":[{"languageCode":"fr","preference":"preferred"}],"aliases":["toolsadmin@alias.example.com"],"nonEditableAliases":["toolsadmin@alias.example.com.test-google-a.com"],"customerId":"C00000000","orgUnitPath":"/","isMailboxSetup":true,"isEnrolledIn2Sv":false,"isEnforcedIn2Sv":false,"includeInGlobalAddressList":true,"recoveryEmail":"admin@example.com"}]}' + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Type: + - application/json; charset=UTF-8 + Date: + - Thu, 26 Mar 2026 13:14:54 GMT + Etag: + - '"1jGAF1FWZlpfhWcmHrlfmqb5ce_W0dm0ajQixlgUSrw/WNFnbiExslIFfweBCCvZF7X9P54"' + Server: + - ESF + Vary: + - Origin + - X-Origin + - Referer + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 541.406625ms diff --git a/pkg/accessreview/drivers/testdata/hubspot.yaml b/pkg/accessreview/drivers/testdata/hubspot.yaml new file mode 100644 index 000000000..4f8168b30 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/hubspot.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/intercom.yaml b/pkg/accessreview/drivers/testdata/intercom.yaml new file mode 100644 index 000000000..1806a816e --- /dev/null +++ b/pkg/accessreview/drivers/testdata/intercom.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/linear.yaml b/pkg/accessreview/drivers/testdata/linear.yaml new file mode 100644 index 000000000..15060701a --- /dev/null +++ b/pkg/accessreview/drivers/testdata/linear.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/notion.yaml b/pkg/accessreview/drivers/testdata/notion.yaml new file mode 100644 index 000000000..f9e39cee4 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/notion.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/openai.yaml b/pkg/accessreview/drivers/testdata/openai.yaml new file mode 100644 index 000000000..d7aba5a20 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/openai.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/resend.yaml b/pkg/accessreview/drivers/testdata/resend.yaml new file mode 100644 index 000000000..8a0ddb3b5 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/resend.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/sentry.yaml b/pkg/accessreview/drivers/testdata/sentry.yaml new file mode 100644 index 000000000..3e0e6e9eb --- /dev/null +++ b/pkg/accessreview/drivers/testdata/sentry.yaml @@ -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: + - ; rel="previous"; results="false"; cursor="100:-1:1", ; 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 diff --git a/pkg/accessreview/drivers/testdata/slack.yaml b/pkg/accessreview/drivers/testdata/slack.yaml new file mode 100644 index 000000000..cd8f6b004 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/slack.yaml @@ -0,0 +1,84 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: slack.com + form: + limit: + - "200" + url: https://slack.com/api/users.list?limit=200 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"ok":true,"members":[{"id":"USLACKBOT","name":"slackbot","is_bot":false,"updated":0,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"757575","is_email_confirmed":false,"real_name":"Slackbot","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Slackbot","display_name":"Slackbot","avatar_hash":"sv41d8cd98f0","real_name_normalized":"Slackbot","display_name_normalized":"Slackbot","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","first_name":"slackbot","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","fields":{},"status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"always_active":true}},{"id":"U00AAAAAA01","name":"toolsadmin","is_bot":false,"updated":1757334170,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":true,"profile":{"real_name":"Tools Admin","display_name":"","avatar_hash":"","real_name_normalized":"Tools Admin","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","first_name":"Tools","last_name":"Admin","team":"T00AAAAAAA","email":"toolsadmin@example.com","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0}},{"id":"U00AAAAAA02","name":"alice","is_bot":false,"updated":1773673016,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"4bbe2e","is_email_confirmed":true,"real_name":"Alice Martin","tz":"Europe/Brussels","tz_label":"Central European Time","tz_offset":3600,"is_admin":true,"is_owner":true,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"has_2fa":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Alice Martin","display_name":"","avatar_hash":"","real_name_normalized":"Alice Martin","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Alice","last_name":"Martin","team":"T00AAAAAAA","email":"alice@example.com","title":"CEO of Acme Corp","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"huddle_state":"default_unset","huddle_state_expiration_ts":0}},{"id":"U00AAAAAA03","name":"bob","is_bot":false,"updated":1774513102,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"e7392d","is_email_confirmed":true,"real_name":"Bob Smith","tz":"Europe/Brussels","tz_label":"Central European Time","tz_offset":3600,"is_admin":true,"is_owner":true,"is_primary_owner":true,"is_restricted":false,"is_ultra_restricted":false,"has_2fa":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Bob Smith","display_name":"Bob","avatar_hash":"","real_name_normalized":"Bob Smith","display_name_normalized":"Bob","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Bob","last_name":"Smith","team":"T00AAAAAAA","email":"bob@example.com","title":"CTO @ Acme Corp","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"huddle_state":"default_unset","huddle_state_expiration_ts":0}},{"id":"U00BBBBBB01","name":"linear","is_bot":true,"updated":1719840340,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"3c989f","is_email_confirmed":false,"real_name":"Linear","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Linear","display_name":"","avatar_hash":"","real_name_normalized":"Linear","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Linear","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB02","api_app_id":"A00AAAAAA01","always_active":true}},{"id":"U00BBBBBB02","name":"github","is_bot":true,"updated":1719840978,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"674b1b","is_email_confirmed":false,"real_name":"GitHub","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"GitHub","display_name":"","avatar_hash":"","real_name_normalized":"GitHub","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"GitHub","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB01","api_app_id":"A00AAAAAA02","always_active":false}},{"id":"U00BBBBBB03","name":"tldv","is_bot":true,"updated":1721057296,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"e96699","is_email_confirmed":false,"real_name":"tldv","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"tldv","display_name":"","avatar_hash":"","real_name_normalized":"tldv","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"tldv","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB03","api_app_id":"A00AAAAAA03","always_active":false}},{"id":"U00AAAAAA04","name":"charlie","is_bot":false,"updated":1741601527,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":true,"profile":{"real_name":"Charlie Brown","display_name":"Charlie Brown","avatar_hash":"","real_name_normalized":"Charlie Brown","display_name_normalized":"Charlie Brown","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Charlie","last_name":"Brown","team":"T00AAAAAAA","email":"charlie@example.com","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"huddle_state":"default_unset","huddle_state_expiration_ts":0}},{"id":"U00BBBBBB04","name":"airtable","is_bot":true,"updated":1733434893,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"bd9336","is_email_confirmed":false,"real_name":"Airtable","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Airtable","display_name":"","avatar_hash":"","real_name_normalized":"Airtable","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Airtable","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB05","api_app_id":"A00AAAAAA04","always_active":false}},{"id":"U00BBBBBB05","name":"dagster_cloud","is_bot":true,"updated":1733298688,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"d55aef","is_email_confirmed":false,"real_name":"Dagster Cloud","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Dagster Cloud","display_name":"","avatar_hash":"","real_name_normalized":"Dagster Cloud","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Dagster","last_name":"Cloud","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB04","api_app_id":"A00AAAAAA05","always_active":true}},{"id":"U00AAAAAA05","name":"dana","is_bot":false,"updated":1751905973,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":true,"profile":{"real_name":"Dana","display_name":"Dana","avatar_hash":"","real_name_normalized":"Dana","display_name_normalized":"Dana","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Dana","last_name":"","team":"T00AAAAAAA","email":"dana@example.com","title":"Frontend Engineer","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"huddle_state":"default_unset","huddle_state_expiration_ts":0}},{"id":"U00BBBBBB06","name":"notion","is_bot":true,"updated":1734104595,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"902d59","is_email_confirmed":false,"real_name":"Notion","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Notion","display_name":"","avatar_hash":"","real_name_normalized":"Notion","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Notion","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB07","api_app_id":"A00AAAAAA06","always_active":false}},{"id":"U00BBBBBB07","name":"render","is_bot":true,"updated":1754497008,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":true,"profile":{"real_name":"render","display_name":"","avatar_hash":"","real_name_normalized":"render","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"render","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB06","api_app_id":"A00AAAAAA07","always_active":true}},{"id":"U00BBBBBB08","name":"sentry","is_bot":true,"updated":1735207126,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"4ec0d6","is_email_confirmed":false,"real_name":"Sentry","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Sentry","display_name":"","avatar_hash":"","real_name_normalized":"Sentry","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Sentry","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB08","api_app_id":"A00AAAAAA08","always_active":true}},{"id":"U00BBBBBB09","name":"lindy","is_bot":true,"updated":1769807705,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"bd9336","is_email_confirmed":false,"real_name":"Lindy","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Lindy","display_name":"","avatar_hash":"","real_name_normalized":"Lindy","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Lindy","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB09","api_app_id":"A00AAAAAA09","always_active":true}},{"id":"U00AAAAAA06","name":"eve","is_bot":false,"updated":1773236906,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"b14cbc","is_email_confirmed":true,"real_name":"Eve Johnson","tz":"Europe/Brussels","tz_label":"Central European Time","tz_offset":3600,"is_admin":true,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"has_2fa":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Eve Johnson","display_name":"Eve Johnson","avatar_hash":"","real_name_normalized":"Eve Johnson","display_name_normalized":"Eve Johnson","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Eve","last_name":"Johnson","team":"T00AAAAAAA","email":"eve@example.com","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"huddle_state":"default_unset","huddle_state_expiration_ts":0}},{"id":"U00BBBBBB10","name":"wf_bot_a08aa9u3a5p","is_bot":true,"updated":1763368323,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":true,"is_workflow_bot":true,"profile":{"real_name":"Workflow Bot 1","display_name":"","avatar_hash":"","real_name_normalized":"Workflow Bot 1","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Workflow Bot 1","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB10","api_app_id":"A00AAAAAA10","always_active":true}},{"id":"U00BBBBBB11","name":"wf_bot_a08aw1l4tmj","is_bot":true,"updated":1738226462,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":true,"is_workflow_bot":true,"profile":{"real_name":"Workflow Bot 2","display_name":"","avatar_hash":"","real_name_normalized":"Workflow Bot 2","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Workflow Bot","last_name":"2","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB11","api_app_id":"A00AAAAAA11","always_active":true}},{"id":"U00BBBBBB12","name":"linear_asks","is_bot":true,"updated":1739711625,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"4cc091","is_email_confirmed":false,"real_name":"Linear Asks","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Linear Asks","display_name":"","avatar_hash":"","real_name_normalized":"Linear Asks","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Linear","last_name":"Asks","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB12","api_app_id":"A00AAAAAA12","always_active":true}},{"id":"U00BBBBBB13","name":"incident","is_bot":true,"updated":1739718459,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"9b3b45","is_email_confirmed":false,"real_name":"incident","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"incident","display_name":"","avatar_hash":"","real_name_normalized":"incident","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"incident","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB13","api_app_id":"A00AAAAAA13","always_active":true}},{"id":"U00BBBBBB14","name":"intercom","is_bot":true,"updated":1751549226,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"db3150","is_email_confirmed":false,"real_name":"Intercom Notifications","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Intercom Notifications","display_name":"","avatar_hash":"","real_name_normalized":"Intercom Notifications","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Intercom","last_name":"Notifications","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB14","api_app_id":"A00AAAAAA14","always_active":true}},{"id":"U00BBBBBB15","name":"posthog","is_bot":true,"updated":1774120313,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"73769d","is_email_confirmed":false,"real_name":"PostHog","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"PostHog","display_name":"","avatar_hash":"","real_name_normalized":"PostHog","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"PostHog","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB15","api_app_id":"A00AAAAAA15","always_active":true}},{"id":"U00AAAAAA07","name":"frank","is_bot":false,"updated":1749017876,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":true,"profile":{"real_name":"Frank Wilson","display_name":"Frank Wilson","avatar_hash":"","real_name_normalized":"Frank Wilson","display_name_normalized":"Frank Wilson","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Frank","last_name":"Wilson","team":"T00AAAAAAA","email":"frank@example.com","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0}},{"id":"U00BBBBBB16","name":"n8ncloud","is_bot":true,"updated":1762871577,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"a72f79","is_email_confirmed":false,"real_name":"n8n.cloud","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"n8n.cloud","display_name":"","avatar_hash":"","real_name_normalized":"n8n.cloud","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"n8n.cloud","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB16","api_app_id":"A00AAAAAA16","always_active":false}},{"id":"U00BBBBBB17","name":"wf_bot_a08pu43bvex","is_bot":true,"updated":1752740905,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":true,"is_workflow_bot":true,"profile":{"real_name":"Workflow Bot 3","display_name":"","avatar_hash":"","real_name_normalized":"Workflow Bot 3","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Workflow Bot","last_name":"3","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB17","api_app_id":"A00AAAAAA17","always_active":true}},{"id":"U00BBBBBB18","name":"wf_bot_a09063b9fan","is_bot":true,"updated":1749127626,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"53b759","is_email_confirmed":false,"real_name":"Workflow Bot 4","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"is_workflow_bot":true,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Workflow Bot 4","display_name":"","avatar_hash":"","real_name_normalized":"Workflow Bot 4","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Workflow Bot","last_name":"4","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB18","api_app_id":"A00AAAAAA18","always_active":true}},{"id":"U00BBBBBB19","name":"cursor","is_bot":true,"updated":1770843775,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"827327","is_email_confirmed":false,"real_name":"Cursor","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Cursor","display_name":"","avatar_hash":"","real_name_normalized":"Cursor","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Cursor","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB19","api_app_id":"A00AAAAAA19","always_active":true}},{"id":"U00BBBBBB20","name":"wf_bot_a0967urvc7l","is_bot":true,"updated":1752739684,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"8d4b84","is_email_confirmed":false,"real_name":"Workflow Bot 5","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"is_workflow_bot":true,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Workflow Bot 5","display_name":"","avatar_hash":"","real_name_normalized":"Workflow Bot 5","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Workflow Bot","last_name":"5","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB20","api_app_id":"A00AAAAAA20","always_active":true}},{"id":"U00BBBBBB21","name":"wf_bot_a0964gds5fg","is_bot":true,"updated":1752741289,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"e475df","is_email_confirmed":false,"real_name":"Workflow Bot 6","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"is_workflow_bot":true,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Workflow Bot 6","display_name":"","avatar_hash":"","real_name_normalized":"Workflow Bot 6","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Workflow Bot","last_name":"6","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB21","api_app_id":"A00AAAAAA21","always_active":true}},{"id":"U00BBBBBB22","name":"wf_bot_a095tep2zrv","is_bot":true,"updated":1752741390,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":true,"is_workflow_bot":true,"profile":{"real_name":"Workflow Bot 7","display_name":"","avatar_hash":"","real_name_normalized":"Workflow Bot 7","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Loader","last_name":"7","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB22","api_app_id":"A00AAAAAA22","always_active":true}},{"id":"U00BBBBBB23","name":"wf_bot_a096847stjn","is_bot":true,"updated":1752741651,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"8469bc","is_email_confirmed":false,"real_name":"Workflow Bot 8","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"is_workflow_bot":true,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Workflow Bot 8","display_name":"","avatar_hash":"","real_name_normalized":"Workflow Bot 8","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Workflow Bot","last_name":"8","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB23","api_app_id":"A00AAAAAA23","always_active":true}},{"id":"U00BBBBBB24","name":"wf_bot_a0962e18h0b","is_bot":true,"updated":1752741331,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":true,"is_workflow_bot":true,"profile":{"real_name":"Workflow Bot 9","display_name":"","avatar_hash":"","real_name_normalized":"Workflow Bot 9","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Loader","last_name":"9","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB24","api_app_id":"A00AAAAAA24","always_active":true}},{"id":"U00AAAAAA08","name":"deactivateduser","is_bot":false,"updated":1757334209,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":true,"is_forgotten":true,"profile":{"real_name":"Deactivated User","display_name":"deactivateduser","avatar_hash":"","real_name_normalized":"Deactivated User","display_name_normalized":"deactivateduser","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","first_name":"Deactivated","last_name":"User","team":"T00AAAAAAA","email":"deactivateduser@example.com","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"huddle_state":"default_unset"}},{"id":"U00BBBBBB25","name":"typeform","is_bot":true,"updated":1753436556,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"684b6c","is_email_confirmed":false,"real_name":"Typeform","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Typeform","display_name":"","avatar_hash":"","real_name_normalized":"Typeform","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Typeform","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB25","api_app_id":"A00AAAAAA25","always_active":false}},{"id":"U00AAAAAA09","name":"grace","is_bot":false,"updated":1762935577,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":true,"profile":{"real_name":"Grace","display_name":"Grace","avatar_hash":"","real_name_normalized":"Grace","display_name_normalized":"Grace","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Grace","last_name":"","team":"T00AAAAAAA","email":"grace@example.com","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"huddle_state":"default_unset","huddle_state_expiration_ts":0}},{"id":"U00BBBBBB26","name":"zapier","is_bot":true,"updated":1763764347,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"e06b56","is_email_confirmed":false,"real_name":"Zapier","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Zapier","display_name":"","avatar_hash":"","real_name_normalized":"Zapier","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Zapier","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB26","api_app_id":"A00AAAAAA26","always_active":true}},{"id":"U00BBBBBB27","name":"acmebot","is_bot":true,"updated":1769184109,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"d55aef","is_email_confirmed":false,"real_name":"Acme-bot","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Acme-bot","display_name":"","avatar_hash":"","real_name_normalized":"Acme-bot","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Acme-bot","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB27","api_app_id":"A00AAAAAA27","always_active":false}},{"id":"U00BBBBBB28","name":"docusign","is_bot":true,"updated":1766152932,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"99a949","is_email_confirmed":false,"real_name":"Docusign","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Docusign","display_name":"","avatar_hash":"","real_name_normalized":"Docusign","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Docusign","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB28","api_app_id":"A00AAAAAA28","always_active":true}},{"id":"U00BBBBBB29","name":"mat","is_bot":true,"updated":1769183789,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"c386df","is_email_confirmed":false,"real_name":"Mat","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Mat","display_name":"","avatar_hash":"","real_name_normalized":"Mat","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Mat","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB29","api_app_id":"A00AAAAAA29","always_active":false}},{"id":"U00AAAAAA10","name":"hank","is_bot":false,"updated":1772610820,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"385a86","is_email_confirmed":true,"real_name":"Hank","tz":"Europe/Brussels","tz_label":"Central European Time","tz_offset":3600,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"has_2fa":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Hank","display_name":"","avatar_hash":"","real_name_normalized":"Hank","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","first_name":"Hank","last_name":"","team":"T00AAAAAAA","email":"hank@example.com","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0}},{"id":"U00BBBBBB30","name":"acme-app","is_bot":true,"updated":1773333121,"is_app_user":false,"team_id":"T00AAAAAAA","deleted":false,"color":"e23f99","is_email_confirmed":false,"real_name":"Acme App","tz":"America/Los_Angeles","tz_label":"Pacific Daylight Time","tz_offset":-25200,"is_admin":false,"is_owner":false,"is_primary_owner":false,"is_restricted":false,"is_ultra_restricted":false,"who_can_share_contact_card":"EVERYONE","profile":{"real_name":"Acme App","display_name":"","avatar_hash":"","real_name_normalized":"Acme App","display_name_normalized":"","image_24":"","image_32":"","image_48":"","image_72":"","image_192":"","image_512":"","image_1024":"","image_original":"","is_custom_image":true,"first_name":"Acme App","last_name":"","team":"T00AAAAAAA","title":"","phone":"","skype":"","status_text":"","status_text_canonical":"","status_emoji":"","status_emoji_display_info":[],"status_expiration":0,"bot_id":"B00BBBBBB30","api_app_id":"A00AAAAAA30","always_active":false}}],"cache_ts":1774532132,"response_metadata":{"next_cursor":""}}' + headers: + Access-Control-Allow-Headers: + - slack-route, x-slack-version-ts, x-b3-traceid, x-b3-spanid, x-b3-parentspanid, x-b3-sampled, x-b3-flags + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - x-slack-req-id, retry-after + Alt-Svc: + - h3=":443"; ma=2592000, h3-29=":443"; ma=2592000, quic=":443"; ma=2592000 + Cache-Control: + - private, no-cache, no-store, must-revalidate + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 26 Mar 2026 13:35:32 GMT + Expires: + - Sat, 26 Jul 1997 05:00:00 GMT + Pragma: + - no-cache + Referrer-Policy: + - no-referrer + Server: + - Apache + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Timing-Allow-Origin: + - '*' + Vary: + - Accept-Encoding + Via: + - 1.1 slack-prod.tinyspeck.com, envoy-www-iad-dydpfbpg,envoy-edge-lhr-nhkhzroe + X-Accepted-Oauth-Scopes: + - users:read + X-Backend: + - api_normal + X-Content-Type-Options: + - nosniff + X-Edge-Backend: + - envoy-www + X-Envoy-Attempt-Count: + - "1" + X-Envoy-Upstream-Service-Time: + - "102" + X-Oauth-Scopes: + - identify,users:read,users:read.email + X-Server: + - slack-www-hhvm-api-iad-jetqqxjh9r6a + X-Slack-Backend: + - r + X-Slack-Edge-Shared-Secret-Outcome: + - no-match + X-Slack-Req-Id: + - c3fd959840ff44162588069883b7e337 + X-Slack-Shared-Secret-Outcome: + - no-match + X-Slack-Unique-Id: + - acU2JP6TQUxa6EL5tZ4C9AAAEB4 + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 179.724334ms diff --git a/pkg/accessreview/drivers/testdata/supabase.yaml b/pkg/accessreview/drivers/testdata/supabase.yaml new file mode 100644 index 000000000..f8f2d8901 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/supabase.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/tally.yaml b/pkg/accessreview/drivers/testdata/tally.yaml new file mode 100644 index 000000000..7c3814bb3 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/tally.yaml @@ -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 diff --git a/pkg/accessreview/drivers/vcr_test.go b/pkg/accessreview/drivers/vcr_test.go new file mode 100644 index 000000000..a60c3c6b0 --- /dev/null +++ b/pkg/accessreview/drivers/vcr_test.go @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 " 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} +}