From bda71bd1c7b56920aa32051ecc410794a184b95f Mon Sep 17 00:00:00 2001 From: "c1-dev-bot[bot]" <2740113+c1-dev-bot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:03:48 +0000 Subject: [PATCH] feat: add Cloudflare Organization support Add an optional --organization-id configuration parameter. When set, the connector syncs the Cloudflare organization as a group resource and creates membership grants linking users to the organization. This allows customers using Cloudflare's new Organizations feature to see which users belong to which organization, solving the problem of running separate connectors per account with no way to distinguish user-to-organization membership. Uses raw HTTP calls to the Cloudflare Organizations API (Public Beta) since the cloudflare-go v0 SDK does not expose these endpoints. --- pkg/config/conf.gen.go | 1 + pkg/config/config.go | 10 +- pkg/connector/connector.go | 13 +- pkg/connector/model.go | 7 +- pkg/connector/organization.go | 248 ++++++++++++++++++++++++++++++++ pkg/connector/resource_types.go | 7 + 6 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 pkg/connector/organization.go diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index 4585bbc3..5b98dcfd 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -9,6 +9,7 @@ type Cloudflare struct { AccountId string `mapstructure:"account-id"` EmailId string `mapstructure:"email-id"` BaseUrl string `mapstructure:"base-url"` + OrganizationId string `mapstructure:"organization-id"` } func (c *Cloudflare) findFieldByTag(tagValue string) (any, bool) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 82c69da0..2bbc3dae 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -37,12 +37,18 @@ var ( field.WithHidden(true), field.WithExportTarget(field.ExportTargetCLIOnly), ) + organizationIdField = field.StringField( + "organization-id", + field.WithDisplayName("Organization ID"), + field.WithDescription("The Cloudflare organization ID. When set, the connector syncs organization membership so you can see which users belong to this organization."), + ) configurationFields = []field.SchemaField{ apiKeyField, apiTokenField, accountIdField, emailIdField, baseUrlField, + organizationIdField, } ) @@ -57,14 +63,14 @@ var Config = field.NewConfiguration( Name: "api-token-group", DisplayName: "API Token", HelpText: "Use an API token for authentication.", - Fields: []field.SchemaField{accountIdField, apiTokenField}, + Fields: []field.SchemaField{accountIdField, apiTokenField, organizationIdField}, Default: true, }, { Name: "api-key-group", DisplayName: "Email + API key", HelpText: "Use an API key with email for authentication.", - Fields: []field.SchemaField{accountIdField, emailIdField, apiKeyField}, + Fields: []field.SchemaField{accountIdField, emailIdField, apiKeyField, organizationIdField}, }, }), ) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 661f50e7..1d43e683 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -52,9 +52,10 @@ func New(ctx context.Context, cc *cfg.Cloudflare, _ *cli.ConnectorOpts) (connect } return &Cloudflare{ - client: client, - accountId: accountId, - emailId: emailId, + client: client, + accountId: accountId, + emailId: emailId, + organizationId: cc.OrganizationId, }, nil, nil } @@ -129,10 +130,14 @@ func (c *Cloudflare) Asset(_ context.Context, _ *v2.AssetRef) (string, io.ReadCl } func (c *Cloudflare) ResourceSyncers(_ context.Context) []connectorbuilder.ResourceSyncerV2 { - return []connectorbuilder.ResourceSyncerV2{ + syncers := []connectorbuilder.ResourceSyncerV2{ userBuilder(c.client, c.accountId), invitationBuilder(c.client, c.accountId, c.emailId), roleBuilder(c.client, c.accountId, c.emailId), apiTokenBuilder(c.client, c.accountId, c.emailId), } + if c.organizationId != "" { + syncers = append(syncers, organizationBuilder(c.client, c.organizationId, c.emailId)) + } + return syncers } diff --git a/pkg/connector/model.go b/pkg/connector/model.go index 3710375b..7cb56eab 100644 --- a/pkg/connector/model.go +++ b/pkg/connector/model.go @@ -3,9 +3,10 @@ package connector import "github.com/cloudflare/cloudflare-go" type Cloudflare struct { - client *cloudflare.API - accountId string - emailId string + client *cloudflare.API + accountId string + emailId string + organizationId string } type Response struct { diff --git a/pkg/connector/organization.go b/pkg/connector/organization.go new file mode 100644 index 00000000..3dfc8b91 --- /dev/null +++ b/pkg/connector/organization.go @@ -0,0 +1,248 @@ +package connector + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/cloudflare/cloudflare-go" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + ent "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/conductorone/baton-sdk/pkg/types/grant" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" +) + +const ( + orgMemberEntitlement = "member" + orgMembersPerPage = 50 +) + +type organizationResourceType struct { + resourceType *v2.ResourceType + client *cloudflare.API + httpClient *uhttp.BaseHttpClient + organizationId string + emailId string +} + +func (o *organizationResourceType) ResourceType(_ context.Context) *v2.ResourceType { + return o.resourceType +} + +type cfOrganization struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type cfOrganizationResponse struct { + Result cfOrganization `json:"result"` + Success bool `json:"success"` + Errors []cloudflare.ResponseInfo `json:"errors"` +} + +type cfOrgMemberUser struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` +} + +type cfOrgMember struct { + ID string `json:"id"` + Status string `json:"status"` + User cfOrgMemberUser `json:"user"` +} + +type cfOrgMembersResponse struct { + Result []cfOrgMember `json:"result"` + ResultInfo cloudflare.ResultInfo `json:"result_info"` + Success bool `json:"success"` + Errors []cloudflare.ResponseInfo `json:"errors"` +} + +func (o *organizationResourceType) ensureHTTPClient(ctx context.Context) error { + if o.httpClient != nil { + return nil + } + httpClient, err := uhttp.NewClient(ctx, uhttp.WithLogger(true, ctxzap.Extract(ctx))) + if err != nil { + return fmt.Errorf("baton-cloudflare: failed to create http client: %w", err) + } + o.httpClient = uhttp.NewBaseHttpClient(httpClient) + return nil +} + +func (o *organizationResourceType) authOpts() []uhttp.RequestOption { + var opts []uhttp.RequestOption + if o.client.APIToken != "" { + opts = append(opts, uhttp.WithBearerToken(o.client.APIToken)) + } + if o.emailId != "" { + opts = append(opts, uhttp.WithHeader(XAuthEmailHeaderKey, o.emailId)) + } + if o.client.APIKey != "" { + opts = append(opts, uhttp.WithHeader(XAuthKeyHeaderKey, o.client.APIKey)) + } + return opts +} + +func (o *organizationResourceType) getOrganization(ctx context.Context) (*cfOrganization, error) { + if err := o.ensureHTTPClient(ctx); err != nil { + return nil, err + } + + endpointURL, err := url.JoinPath(o.client.BaseURL, "organizations", o.organizationId) + if err != nil { + return nil, fmt.Errorf("baton-cloudflare: failed to build organization endpoint url: %w", err) + } + uri, err := url.Parse(endpointURL) + if err != nil { + return nil, fmt.Errorf("baton-cloudflare: failed to parse organization endpoint url: %w", err) + } + + reqOpts := append([]uhttp.RequestOption{uhttp.WithAcceptJSONHeader()}, o.authOpts()...) + req, err := o.httpClient.NewRequest(ctx, http.MethodGet, uri, reqOpts...) + if err != nil { + return nil, fmt.Errorf("baton-cloudflare: failed to create organization request: %w", err) + } + + var result cfOrganizationResponse + resp, err := o.httpClient.Do(req, uhttp.WithJSONResponse(&result)) + if err != nil { + return nil, fmt.Errorf("baton-cloudflare: failed to get organization: %w", err) + } + defer resp.Body.Close() + + if !result.Success { + if len(result.Errors) > 0 { + return nil, fmt.Errorf("baton-cloudflare: get organization failed: %s (code %d)", result.Errors[0].Message, result.Errors[0].Code) + } + return nil, fmt.Errorf("baton-cloudflare: get organization failed: unknown error") + } + + return &result.Result, nil +} + +func (o *organizationResourceType) listOrganizationMembers(ctx context.Context, page, perPage int) (*cfOrgMembersResponse, error) { + if err := o.ensureHTTPClient(ctx); err != nil { + return nil, err + } + + endpointURL, err := url.JoinPath(o.client.BaseURL, "organizations", o.organizationId, "members") + if err != nil { + return nil, fmt.Errorf("baton-cloudflare: failed to build org members endpoint url: %w", err) + } + uri, err := url.Parse(endpointURL) + if err != nil { + return nil, fmt.Errorf("baton-cloudflare: failed to parse org members endpoint url: %w", err) + } + q := uri.Query() + q.Set("page", strconv.Itoa(page)) + q.Set("per_page", strconv.Itoa(perPage)) + uri.RawQuery = q.Encode() + + reqOpts := append([]uhttp.RequestOption{uhttp.WithAcceptJSONHeader()}, o.authOpts()...) + req, err := o.httpClient.NewRequest(ctx, http.MethodGet, uri, reqOpts...) + if err != nil { + return nil, fmt.Errorf("baton-cloudflare: failed to create org members request: %w", err) + } + + var result cfOrgMembersResponse + resp, err := o.httpClient.Do(req, uhttp.WithJSONResponse(&result)) + if err != nil { + return nil, fmt.Errorf("baton-cloudflare: failed to list organization members: %w", err) + } + defer resp.Body.Close() + + if !result.Success { + if len(result.Errors) > 0 { + return nil, fmt.Errorf("baton-cloudflare: list organization members failed: %s (code %d)", result.Errors[0].Message, result.Errors[0].Code) + } + return nil, fmt.Errorf("baton-cloudflare: list organization members failed: unknown error") + } + + return &result, nil +} + +func (o *organizationResourceType) List(ctx context.Context, _ *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { + org, err := o.getOrganization(ctx) + if err != nil { + return nil, nil, err + } + + profile := map[string]interface{}{ + "organization_id": org.ID, + "organization_name": org.Name, + } + + resource, err := rs.NewGroupResource( + org.Name, + resourceTypeOrganization, + org.ID, + []rs.GroupTraitOption{rs.WithGroupProfile(profile)}, + ) + if err != nil { + return nil, nil, fmt.Errorf("baton-cloudflare: failed to create organization resource: %w", err) + } + + return []*v2.Resource{resource}, &rs.SyncOpResults{}, nil +} + +func (o *organizationResourceType) Entitlements(_ context.Context, resource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { + rv := []*v2.Entitlement{ + ent.NewAssignmentEntitlement( + resource, + orgMemberEntitlement, + ent.WithGrantableTo(resourceTypeUser), + ent.WithDisplayName(fmt.Sprintf("%s Organization Member", resource.DisplayName)), + ent.WithDescription(fmt.Sprintf("Member of the %s Cloudflare organization", resource.DisplayName)), + ), + } + + return rv, &rs.SyncOpResults{}, nil +} + +func (o *organizationResourceType) Grants(ctx context.Context, resource *v2.Resource, opts rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { + page, err := convertPageToken(opts.PageToken.Token) + if err != nil { + return nil, nil, fmt.Errorf("baton-cloudflare: invalid page token error") + } + + resp, err := o.listOrganizationMembers(ctx, page, orgMembersPerPage) + if err != nil { + return nil, nil, err + } + + var rv []*v2.Grant + for _, member := range resp.Result { + if member.Status != "active" { + continue + } + if member.User.ID == "" { + continue + } + + userResourceId := &v2.ResourceId{ + ResourceType: resourceTypeUser.Id, + Resource: member.User.ID, + } + + rv = append(rv, grant.NewGrant(resource, orgMemberEntitlement, userResourceId)) + } + + nextPage := convertNextPageToken(resp.ResultInfo.Page, len(resp.Result)) + + return rv, &rs.SyncOpResults{NextPageToken: nextPage}, nil +} + +func organizationBuilder(client *cloudflare.API, organizationId, emailId string) *organizationResourceType { + return &organizationResourceType{ + resourceType: resourceTypeOrganization, + client: client, + organizationId: organizationId, + emailId: emailId, + } +} diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 0d313765..ac2e6049 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -60,4 +60,11 @@ var ( &v2.SkipEntitlementsAndGrants{}, ), } + resourceTypeOrganization = &v2.ResourceType{ + Id: "organization", + DisplayName: "Organization", + Traits: []v2.ResourceType_Trait{ + v2.ResourceType_TRAIT_GROUP, + }, + } )