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, + }, + } )