Files
probo/pkg/accessreview/drivers/cloudflare.go
Aurélien Sibiril 2320e1e0be Add access source drivers
Add Driver interface and implementations for Google
Workspace, Linear, Slack, 1Password, HubSpot, DocuSign,
Notion, Brex, Tally, Cloudflare, CSV, Probo memberships,
Sentry, OpenAI, Supabase, GitHub, Intercom, and Resend.
Include name resolvers, VCR test infrastructure with
cassettes, and RFC 5988 link header parser.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-04-02 14:37:29 +02:00

242 lines
6.6 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
// CloudflareDriver fetches account members from the Cloudflare API.
type CloudflareDriver struct {
httpClient *http.Client
}
var _ Driver = (*CloudflareDriver)(nil)
type cloudflareAccount struct {
ID string `json:"id"`
Name string `json:"name"`
}
type cloudflareListAccountsResponse struct {
Result []cloudflareAccount `json:"result"`
ResultInfo cloudflareResultInfo `json:"result_info"`
}
type cloudflareResultInfo struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
Count int `json:"count"`
TotalCount int `json:"total_count"`
}
type cloudflareListMembersResponse struct {
Result []struct {
ID string `json:"id"`
Status string `json:"status"`
User struct {
ID string `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
TwoFactorEnabled bool `json:"two_factor_authentication_enabled"`
} `json:"user"`
Roles []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"roles"`
} `json:"result"`
ResultInfo cloudflareResultInfo `json:"result_info"`
}
func NewCloudflareDriver(httpClient *http.Client) *CloudflareDriver {
return &CloudflareDriver{
httpClient: httpClient,
}
}
func (d *CloudflareDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
accounts, err := d.queryAllAccounts(ctx)
if err != nil {
return nil, err
}
var records []AccountRecord
for _, account := range accounts {
members, err := d.queryAllMembers(ctx, account.ID)
if err != nil {
return nil, fmt.Errorf("cannot fetch members for cloudflare account %s: %w", account.ID, err)
}
records = append(records, members...)
}
return records, nil
}
func (d *CloudflareDriver) queryAllAccounts(ctx context.Context) ([]cloudflareAccount, error) {
var accounts []cloudflareAccount
for page := range maxPaginationPages {
resp, err := d.queryAccounts(ctx, page+1)
if err != nil {
return nil, err
}
accounts = append(accounts, resp.Result...)
if page+1 >= resp.ResultInfo.TotalPages {
return accounts, nil
}
}
return nil, fmt.Errorf("cannot list all cloudflare accounts: %w", ErrPaginationLimitReached)
}
func (d *CloudflareDriver) queryAccounts(ctx context.Context, page int) (*cloudflareListAccountsResponse, error) {
url := fmt.Sprintf(
"https://api.cloudflare.com/client/v4/accounts?page=%d&per_page=50",
page,
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("cannot create cloudflare accounts request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute cloudflare accounts request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch cloudflare accounts: unexpected status %d", httpResp.StatusCode)
}
var resp cloudflareListAccountsResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode cloudflare accounts response: %w", err)
}
return &resp, nil
}
func (d *CloudflareDriver) queryAllMembers(ctx context.Context, accountID string) ([]AccountRecord, error) {
var records []AccountRecord
for page := range maxPaginationPages {
resp, err := d.queryMembers(ctx, accountID, page+1)
if err != nil {
return nil, err
}
for _, m := range resp.Result {
roles := make([]string, 0, len(m.Roles))
for _, r := range m.Roles {
roles = append(roles, r.Name)
}
role := "Member"
if len(roles) > 0 {
role = strings.Join(roles, ", ")
}
isAdmin := false
for _, r := range m.Roles {
if r.Name == "Super Administrator - All Privileges" || r.Name == "Administrator" {
isAdmin = true
break
}
}
mfaStatus := coredata.MFAStatusUnknown
if m.User.TwoFactorEnabled {
mfaStatus = coredata.MFAStatusEnabled
}
record := AccountRecord{
Email: m.User.Email,
FullName: m.User.FirstName + " " + m.User.LastName,
Role: role,
Active: m.Status == "accepted",
IsAdmin: isAdmin,
ExternalID: m.ID,
MFAStatus: mfaStatus,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if record.Email != "" {
records = append(records, record)
}
}
if page+1 >= resp.ResultInfo.TotalPages {
return records, nil
}
}
return nil, fmt.Errorf("cannot list all cloudflare members: %w", ErrPaginationLimitReached)
}
func (d *CloudflareDriver) queryMembers(ctx context.Context, accountID string, page int) (*cloudflareListMembersResponse, error) {
url := fmt.Sprintf(
"https://api.cloudflare.com/client/v4/accounts/%s/members?page=%d&per_page=50",
accountID,
page,
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("cannot create cloudflare members request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute cloudflare members request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch cloudflare members: unexpected status %d", httpResp.StatusCode)
}
var resp cloudflareListMembersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode cloudflare members response: %w", err)
}
return &resp, nil
}