From 154b4dda2ac420690b50962be446cb672840a73c Mon Sep 17 00:00:00 2001 From: John Allers Date: Thu, 21 May 2026 17:53:48 -0400 Subject: [PATCH] chore: update baton-github to v0.3.3 --- go.mod | 2 +- go.sum | 4 +- .../baton-github/pkg/config/conf.gen.go | 5 +- .../baton-github/pkg/config/config.go | 19 +- .../baton-github/pkg/connector/connector.go | 76 ++-- .../pkg/connector/enterprise_role.go | 16 +- .../pkg/connector/graphql_transport.go | 57 +++ .../baton-github/pkg/connector/helpers.go | 7 + .../baton-github/pkg/connector/invitation.go | 362 ++++++++++++++++-- .../baton-github/pkg/connector/org.go | 25 +- .../baton-github/pkg/connector/org_role.go | 29 +- .../baton-github/pkg/connector/repository.go | 121 +++++- .../baton-github/pkg/connector/team.go | 50 ++- vendor/modules.txt | 2 +- 14 files changed, 645 insertions(+), 130 deletions(-) create mode 100644 vendor/github.com/conductorone/baton-github/pkg/connector/graphql_transport.go diff --git a/go.mod b/go.mod index f48fe17b..d8b30ef0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/conductorone/baton-github-enterprise go 1.25.2 require ( - github.com/conductorone/baton-github v0.2.15 + github.com/conductorone/baton-github v0.3.3 github.com/conductorone/baton-sdk v0.9.15 github.com/ennyjfrick/ruleguard-logfatal v0.0.2 github.com/quasilyte/go-ruleguard/dsl v0.3.23 diff --git a/go.sum b/go.sum index 3832c694..de88bea2 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/conductorone/baton-github v0.2.15 h1:8TXW9mNa9+wPB5n1ghTXg+4baUEFo3ARlBoPxvyqBoQ= -github.com/conductorone/baton-github v0.2.15/go.mod h1:qyVHJTc78i0iJmoIrCZkTUFENndRnm+3AVvzahX1Vzk= +github.com/conductorone/baton-github v0.3.3 h1:W5BR64l/aTpyvjUcBHt9+aJK1/JmwaFGpOHtau1aHJU= +github.com/conductorone/baton-github v0.3.3/go.mod h1:50KZVcuXgAF+WlkeKQ4yaWr0N46okhIc7d54sYXU7js= github.com/conductorone/baton-sdk v0.9.15 h1:xChC/4JwN2Vmk8uTVv2owQV3ubh3w3G4gIOPug8pYQU= github.com/conductorone/baton-sdk v0.9.15/go.mod h1:treFEoFwbzu9hgPqpCRD+Sr+p71xkeH98Y4ofDGrjSg= github.com/conductorone/dpop v0.2.6 h1:fakwai/Xm2b/fcDUwJN41WtcSI/2UhQOyRIVvnnrrNA= diff --git a/vendor/github.com/conductorone/baton-github/pkg/config/conf.gen.go b/vendor/github.com/conductorone/baton-github/pkg/config/conf.gen.go index 4a963e2f..5ed99148 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/config/conf.gen.go +++ b/vendor/github.com/conductorone/baton-github/pkg/config/conf.gen.go @@ -8,11 +8,12 @@ type Github struct { Orgs []string `mapstructure:"orgs"` Enterprises []string `mapstructure:"enterprises"` InstanceUrl string `mapstructure:"instance-url"` - SyncSecrets bool `mapstructure:"sync-secrets"` - OmitArchivedRepositories bool `mapstructure:"omit-archived-repositories"` AppId string `mapstructure:"app-id"` AppPrivatekeyPath []byte `mapstructure:"app-privatekey-path"` Org string `mapstructure:"org"` + SyncSecrets bool `mapstructure:"sync-secrets"` + OmitArchivedRepositories bool `mapstructure:"omit-archived-repositories"` + DirectCollaboratorsOnly bool `mapstructure:"direct-collaborators-only"` } func (c *Github) findFieldByTag(tagValue string) (any, bool) { diff --git a/vendor/github.com/conductorone/baton-github/pkg/config/config.go b/vendor/github.com/conductorone/baton-github/pkg/config/config.go index 66621f24..2b9c5e63 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/config/config.go +++ b/vendor/github.com/conductorone/baton-github/pkg/config/config.go @@ -59,6 +59,20 @@ var ( field.WithDisplayName("Omit syncing archived repositories"), field.WithDescription("Whether to skip syncing archived repositories or not"), ) + // directCollaboratorsOnly enables performance optimizations for large orgs: + // 1. Repo grants: ListCollaborators uses "direct" affiliation instead of "all", + // so team-based users are discovered via grant expansion instead of pagination. + // 2. Org→repo expansion: adds expandable grants from org admin/member entitlements + // to repo permissions based on the org's default_repository_permission setting. + // 3. Team sync: skips per-team GetTeamByID calls (members_count/repos_count become zero). + directCollaboratorsOnly = field.BoolField( + "direct-collaborators-only", + field.WithDisplayName("Optimize sync for large organizations"), + field.WithDescription( + "Reduces API calls by using grant expansion for team-based repo access "+ + "and skipping per-team detail fetches. Recommended for large orgs.", + ), + ) orgField = field.StringField( "org", field.WithDisplayName("Github App Organization"), @@ -79,6 +93,7 @@ var Config = field.NewConfiguration( orgField, syncSecrets, omitArchivedRepositories, + directCollaboratorsOnly, }, field.WithConnectorDisplayName("GitHub v2"), field.WithHelpUrl("/docs/baton/github-v2"), @@ -88,14 +103,14 @@ var Config = field.NewConfiguration( Name: GithubPersonalAccessTokenGroup, DisplayName: "Personal access token", HelpText: "Use a personal access token for authentication.", - Fields: []field.SchemaField{accessTokenField, orgsField, omitArchivedRepositories}, + Fields: []field.SchemaField{accessTokenField, orgsField, omitArchivedRepositories, directCollaboratorsOnly}, Default: true, }, { Name: GithubAppGroup, DisplayName: "GitHub app", HelpText: "Use a github app for authentication", - Fields: []field.SchemaField{appIDField, appPrivateKeyPath, orgField, syncSecrets, omitArchivedRepositories}, + Fields: []field.SchemaField{appIDField, appPrivateKeyPath, orgField, syncSecrets, omitArchivedRepositories, directCollaboratorsOnly}, Default: false, }, }), diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/connector.go b/vendor/github.com/conductorone/baton-github/pkg/connector/connector.go index 9442536b..ccc1903b 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/connector.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/connector.go @@ -44,18 +44,18 @@ var ( resourceTypeOrg = &v2.ResourceType{ Id: "org", DisplayName: "Org", - Annotations: v1AnnotationsForResourceType("org"), + Annotations: skipEntitlementsAnnotations("org"), } resourceTypeTeam = &v2.ResourceType{ Id: "team", DisplayName: "Team", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, - Annotations: v1AnnotationsForResourceType("team"), + Annotations: skipEntitlementsAnnotations("team"), } resourceTypeRepository = &v2.ResourceType{ Id: "repository", DisplayName: "Repository", - Annotations: v1AnnotationsForResourceType("repository"), + Annotations: skipEntitlementsAnnotations("repository"), } resourceTypeUser = &v2.ResourceType{ Id: "user", @@ -68,6 +68,8 @@ var ( resourceTypeInvitation = &v2.ResourceType{ Id: "invitation", DisplayName: "Invitation", + // Invitations emit TRAIT_USER with UserTrait_Status_STATUS_UNSPECIFIED. + // Accepted members from user.go emit STATUS_ENABLED. Traits: []v2.ResourceType_Trait{ v2.ResourceType_TRAIT_USER, }, @@ -83,35 +85,36 @@ var ( Id: "org_role", DisplayName: "Organization Role", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE}, - Annotations: v1AnnotationsForResourceType("org_role"), + Annotations: skipEntitlementsAnnotations("org_role"), } resourceTypeEnterpriseRole = &v2.ResourceType{ Id: "enterprise_role", DisplayName: "Enterprise Role", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE}, - Annotations: v1AnnotationsForResourceType("enterprise_role"), + Annotations: skipEntitlementsAnnotations("enterprise_role"), } ) type GitHub struct { - orgs []string - client *github.Client - appClient *github.Client - customClient *customclient.Client - instanceURL string - graphqlClient *githubv4.Client - orgCache *orgNameCache - syncSecrets bool - omitArchivedRepositories bool - enterprises []string + orgs []string + client *github.Client + appClient *github.Client + customClient *customclient.Client + instanceURL string + graphqlClient *githubv4.Client + orgCache *orgNameCache + syncSecrets bool + omitArchivedRepositories bool + directCollaboratorsOnly bool + enterprises []string } func (gh *GitHub) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncerV2 { resourceSyncers := []connectorbuilder.ResourceSyncerV2{ orgBuilder(gh.client, gh.appClient, gh.orgCache, gh.orgs, gh.syncSecrets), - teamBuilder(gh.client, gh.orgCache), + teamBuilder(gh.client, gh.orgCache, gh.directCollaboratorsOnly), userBuilder(gh.client, gh.graphqlClient, gh.orgCache, gh.orgs, gh.customClient, gh.enterprises), - repositoryBuilder(gh.client, gh.orgCache, gh.omitArchivedRepositories), + repositoryBuilder(gh.client, gh.orgCache, gh.omitArchivedRepositories, gh.directCollaboratorsOnly), orgRoleBuilder(gh.client, gh.orgCache), invitationBuilder(invitationBuilderParams{ client: gh.client, @@ -156,6 +159,16 @@ func (gh *GitHub) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { Placeholder: "organization name", Order: 2, }, + "github_username": { + DisplayName: "GitHub username", + Required: false, + Description: "The user's GitHub username (optional, used to look up the user if email is private).", + Field: &v2.ConnectorAccountCreationSchema_Field_StringField{ + StringField: &v2.ConnectorAccountCreationSchema_StringField{}, + }, + Placeholder: "octocat", + Order: 3, + }, }, }, }, nil @@ -308,6 +321,7 @@ func newWithGithubPAT(ctx context.Context, ghc *cfg.Github) (*GitHub, error) { orgCache: newOrgNameCache(ghClient), syncSecrets: ghc.SyncSecrets, omitArchivedRepositories: ghc.OmitArchivedRepositories, + directCollaboratorsOnly: ghc.DirectCollaboratorsOnly, }, nil } @@ -388,32 +402,36 @@ func newWithGithubApp(ctx context.Context, ghc *cfg.Github) (*GitHub, error) { orgCache: newOrgNameCache(ghClient), syncSecrets: ghc.SyncSecrets, omitArchivedRepositories: ghc.OmitArchivedRepositories, + directCollaboratorsOnly: ghc.DirectCollaboratorsOnly, } return gh, nil } func newGitHubGraphqlClient(ctx context.Context, instanceURL string, ts oauth2.TokenSource) (*githubv4.Client, error) { + instanceURL = strings.TrimSuffix(instanceURL, "/") + + var enterpriseGqlURL string + if instanceURL != "" && instanceURL != githubDotCom { + parsed, err := url.Parse(instanceURL) + if err != nil { + return nil, err + } + parsed.Path = "/api/graphql" + enterpriseGqlURL = parsed.String() + } + httpClient, err := uhttp.NewClient(ctx, uhttp.WithLogger(true, ctxzap.Extract(ctx))) if err != nil { return nil, err } + httpClient.Transport = &statusClassifyingTransport{base: httpClient.Transport} ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) - tc := oauth2.NewClient(ctx, ts) - instanceURL = strings.TrimSuffix(instanceURL, "/") - if instanceURL != "" && instanceURL != githubDotCom { - gqlURL, err := url.Parse(instanceURL) - if err != nil { - return nil, err - } - - gqlURL.Path = "/api/graphql" - - return githubv4.NewEnterpriseClient(gqlURL.String(), tc), nil + if enterpriseGqlURL != "" { + return githubv4.NewEnterpriseClient(enterpriseGqlURL, tc), nil } - return githubv4.NewClient(tc), nil } diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/enterprise_role.go b/vendor/github.com/conductorone/baton-github/pkg/connector/enterprise_role.go index f80be99d..b8474c22 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/enterprise_role.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/enterprise_role.go @@ -126,14 +126,18 @@ func (o *enterpriseRoleResourceType) Entitlements( _ context.Context, resource *v2.Resource, _ resourceSdk.SyncOpAttrs, +) ([]*v2.Entitlement, *resourceSdk.SyncOpResults, error) { + return nil, nil, nil +} + +func (o *enterpriseRoleResourceType) StaticEntitlements( + _ context.Context, + _ resourceSdk.SyncOpAttrs, ) ([]*v2.Entitlement, *resourceSdk.SyncOpResults, error) { rv := []*v2.Entitlement{} - rv = append(rv, entitlement.NewAssignmentEntitlement(resource, "assigned", - entitlement.WithDisplayName(resource.DisplayName), - entitlement.WithDescription(fmt.Sprintf("Assignment to %s enterprise role in GitHub", resource.DisplayName)), - entitlement.WithAnnotation(&v2.V1Identifier{ - Id: resource.Id.Resource, - }), + rv = append(rv, entitlement.NewAssignmentEntitlement(nil, "assigned", + entitlement.WithDisplayName("Role Assigned"), + entitlement.WithDescription("Assignment to enterprise role in GitHub"), entitlement.WithGrantableTo(resourceTypeUser), )) diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/graphql_transport.go b/vendor/github.com/conductorone/baton-github/pkg/connector/graphql_transport.go new file mode 100644 index 00000000..d18fbf0c --- /dev/null +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/graphql_transport.go @@ -0,0 +1,57 @@ +package connector + +import ( + "fmt" + "io" + "net/http" + + "github.com/conductorone/baton-sdk/pkg/ratelimit" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "google.golang.org/grpc/status" +) + +// statusClassifyingTransport converts non-2xx HTTP responses into +// gRPC-classified errors using the SDK's canonical status-to-code mapping +// (uhttp.GrpcCodeFromHTTPStatus). This matches how uhttp.BaseHttpClient.Do +// classifies its own responses. +// +// It exists because shurcooL/graphql surfaces non-200 responses as opaque +// fmt.Errorf strings, which otherwise propagate as codes.Unknown and abort +// the sync on a single transient blip — even when the underlying status +// (429, 5xx, 401, 403, 404, ...) carries enough information for the SDK +// retry layer to do the right thing. The REST path doesn't need this because +// go-github exposes structured response/error types that wrapGitHubError +// already classifies at the call site. +type statusClassifyingTransport struct { + base http.RoundTripper +} + +func (t *statusClassifyingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.base.RoundTrip(req) + if err != nil || resp == nil { + return resp, err + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp, nil + } + rlDesc, _ := ratelimit.ExtractRateLimitData(resp.StatusCode, &resp.Header) + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + _ = resp.Body.Close() + msg := fmt.Sprintf("%s %s: HTTP %d", req.Method, req.URL.Path, resp.StatusCode) + if s := resp.Header.Get("Server"); s != "" { + msg += " server=" + s + } + if rid := resp.Header.Get("X-GitHub-Request-Id"); rid != "" { + msg += " request-id=" + rid + } + if len(body) > 0 { + msg += ": " + string(body) + } + st := status.New(uhttp.GrpcCodeFromHTTPStatus(resp.StatusCode), msg) + if rlDesc != nil { + if withDetails, err := st.WithDetails(rlDesc); err == nil { + st = withDetails + } + } + return nil, st.Err() +} diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/helpers.go b/vendor/github.com/conductorone/baton-github/pkg/connector/helpers.go index 2659668b..ef37f6f5 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/helpers.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/helpers.go @@ -85,6 +85,13 @@ func v1AnnotationsForResourceType(resourceTypeID string) annotations.Annotations return annos } +func skipEntitlementsAnnotations(resourceTypeID string) annotations.Annotations { + annos := v1AnnotationsForResourceType(resourceTypeID) + annos.Update(&v2.SkipEntitlements{}) + + return annos +} + // parseResourceToGitHub returns the upstream API ID by looking at the last 'part' of the resource ID. func parseResourceToGitHub(id *v2.ResourceId) (int64, error) { idParts := strings.Split(id.Resource, ":") diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/invitation.go b/vendor/github.com/conductorone/baton-github/pkg/connector/invitation.go index c74f6d38..95212ad8 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/invitation.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/invitation.go @@ -2,32 +2,69 @@ package connector import ( "context" + "errors" "fmt" + "net/http" "strconv" + "strings" + "time" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/conductorone/baton-sdk/pkg/pagination" resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/google/go-github/v69/github" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" ) -func invitationToUserResource(invitation *github.Invitation) (*v2.Resource, error) { +const ( + // Profile keys for invitation status metadata. + invitationProfileKeyStatus = "invitation_status" + invitationProfileKeyExpiresAt = "invitation_expires_at" + + // Values exposed via invitation_status. + invitationStatusPendingAcceptance = "invitation_pending_acceptance" + invitationStatusExpired = "invitation_expired" + + // Pagination bag states used to drive the two upstream listing endpoints. + invitationStatePending = "invitation:pending" + invitationStateFailed = "invitation:failed" + + // GitHub's failed_reason is a free-form sentence, not an enum, so we + // substring-match (case-insensitive) for this token to detect + // expiration. Observed format on api.github.com today: + // "Invitation expired. User did not accept this invite for 7 days" + githubInvitationFailedReasonExpired = "expired" + + // Organization invitations expire 7 days after creation. + // https://github.blog/changelog/2020-02-05-self-expiring-repository-and-organization-invitations/ + invitationLifetime = 7 * 24 * time.Hour +) + +func invitationToUserResource(invitation *github.Invitation, status string) (*v2.Resource, error) { login := invitation.GetLogin() if login == "" { login = invitation.GetEmail() } + profile := map[string]interface{}{ + "login": login, + "inviter": invitation.GetInviter().GetLogin(), + invitationProfileKeyStatus: status, + } + if expiresAt, ok := invitationExpiresAt(invitation, status); ok { + profile[invitationProfileKeyExpiresAt] = expiresAt.UTC().Format(time.RFC3339) + } + ret, err := resourceSdk.NewUserResource( login, resourceTypeInvitation, invitation.GetID(), []resourceSdk.UserTraitOption{ resourceSdk.WithEmail(invitation.GetEmail(), true), - resourceSdk.WithUserProfile(map[string]interface{}{ - "login": login, - "inviter": invitation.GetInviter().GetLogin(), - }), + resourceSdk.WithUserProfile(profile), resourceSdk.WithStatus(v2.UserTrait_Status_STATUS_UNSPECIFIED), resourceSdk.WithUserLogin(login), }, @@ -38,6 +75,22 @@ func invitationToUserResource(invitation *github.Invitation) (*v2.Resource, erro return ret, nil } +// invitationExpiresAt returns the moment the invitation expired (for already +// expired invitations) or will expire (for pending invitations). GitHub does +// not surface an expires_at field on the org invitation payload, so for +// pending invitations we derive it from created_at + 7 days. +func invitationExpiresAt(invitation *github.Invitation, status string) (time.Time, bool) { + if status == invitationStatusExpired { + if t := invitation.GetFailedAt(); !t.IsZero() { + return t.Time, true + } + } + if t := invitation.GetCreatedAt(); !t.IsZero() { + return t.Add(invitationLifetime), true + } + return time.Time{}, false +} + type invitationResourceType struct { client *github.Client orgCache *orgNameCache @@ -49,7 +102,6 @@ func (i *invitationResourceType) ResourceType(_ context.Context) *v2.ResourceTyp } func (i *invitationResourceType) List(ctx context.Context, parentID *v2.ResourceId, opts resourceSdk.SyncOpAttrs) ([]*v2.Resource, *resourceSdk.SyncOpResults, error) { - var annotations annotations.Annotations if parentID == nil { return nil, &resourceSdk.SyncOpResults{}, nil } @@ -63,44 +115,113 @@ func (i *invitationResourceType) List(ctx context.Context, parentID *v2.Resource if err != nil { return nil, nil, err } - invitations, resp, err := i.client.Organizations.ListPendingOrgInvitations(ctx, orgName, &github.ListOptions{ + + listOpts := &github.ListOptions{ Page: page, PerPage: opts.PageToken.Size, - }) - if err != nil { - if isNotFoundError(resp) { - return nil, &resourceSdk.SyncOpResults{}, nil - } - return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to list pending org invitations") } - restApiRateLimit, err := extractRateLimitData(resp) - if err != nil { - return nil, nil, err - } + var ( + invitationResources []*v2.Resource + respAnnos annotations.Annotations + ) - nextPage, _, err := parseResp(resp) - if err != nil { - return nil, nil, err - } + switch bag.ResourceTypeID() { + case resourceTypeInvitation.Id: + // First call: fan out into the two listing states. Pending is pushed + // last so it is processed first; failed/expired runs after pending + // fully drains. + bag.Pop() + bag.Push(pagination.PageState{ResourceTypeID: invitationStateFailed}) + bag.Push(pagination.PageState{ResourceTypeID: invitationStatePending}) + + case invitationStatePending: + invitations, resp, err := i.client.Organizations.ListPendingOrgInvitations(ctx, orgName, listOpts) + if err != nil { + if isNotFoundError(resp) { + if err := bag.Next(""); err != nil { + return nil, nil, err + } + break + } + return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to list pending org invitations") + } - pageToken, err := bag.NextToken(nextPage) - if err != nil { - return nil, nil, err - } + nextPage, annos, err := parseResp(resp) + if err != nil { + return nil, nil, err + } + respAnnos = annos + + if err := bag.Next(nextPage); err != nil { + return nil, nil, err + } + + invitationResources = make([]*v2.Resource, 0, len(invitations)) + for _, invitation := range invitations { + ir, err := invitationToUserResource(invitation, invitationStatusPendingAcceptance) + if err != nil { + return nil, nil, err + } + invitationResources = append(invitationResources, ir) + } - invitationResources := make([]*v2.Resource, 0, len(invitations)) - for _, invitation := range invitations { - ir, err := invitationToUserResource(invitation) + case invitationStateFailed: + invitations, resp, err := i.client.Organizations.ListFailedOrgInvitations(ctx, orgName, listOpts) + if err != nil { + if isNotFoundError(resp) { + if err := bag.Next(""); err != nil { + return nil, nil, err + } + break + } + return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to list failed org invitations") + } + + nextPage, annos, err := parseResp(resp) if err != nil { return nil, nil, err } - invitationResources = append(invitationResources, ir) + respAnnos = annos + + if err := bag.Next(nextPage); err != nil { + return nil, nil, err + } + + l := ctxzap.Extract(ctx) + invitationResources = make([]*v2.Resource, 0, len(invitations)) + for _, invitation := range invitations { + // The failed_invitations endpoint includes failures other than + // expirations (e.g. user_was_inactive, unexpected_failure). Only + // surface invitations that explicitly expired. + failedReason := invitation.GetFailedReason() + if !strings.Contains(strings.ToLower(failedReason), githubInvitationFailedReasonExpired) { + l.Debug("skipping non-expired failed invitation", + zap.Int64("invitation_id", invitation.GetID()), + zap.String("failed_reason", failedReason), + ) + continue + } + + ir, err := invitationToUserResource(invitation, invitationStatusExpired) + if err != nil { + return nil, nil, err + } + invitationResources = append(invitationResources, ir) + } + + default: + return nil, nil, fmt.Errorf("github-connector: unexpected invitation page state %q", bag.ResourceTypeID()) } - annotations.WithRateLimiting(restApiRateLimit) + + pageToken, err := bag.Marshal() + if err != nil { + return nil, nil, err + } + return invitationResources, &resourceSdk.SyncOpResults{ NextPageToken: pageToken, - Annotations: annotations, + Annotations: respAnnos, }, nil } @@ -131,6 +252,8 @@ func (i *invitationResourceType) CreateAccount( annotations.Annotations, error, ) { + l := ctxzap.Extract(ctx) + params, err := getCreateUserParams(accountInfo) if err != nil { return nil, nil, nil, fmt.Errorf("github-connectorv2: failed to get CreateUserParams: %w", err) @@ -140,6 +263,43 @@ func (i *invitationResourceType) CreateAccount( Email: params.email, }) if err != nil { + if isAlreadyOrgMemberError(err, resp) { + memberResource, lookupErr := i.lookupUser(ctx, params.login, *params.email) + if lookupErr != nil { + l.Warn("failed to look up existing org member, returning AlreadyExistsResult without resource", zap.Error(lookupErr)) + return &v2.CreateAccountResponse_AlreadyExistsResult{}, nil, nil, nil + } + return &v2.CreateAccountResponse_AlreadyExistsResult{Resource: memberResource}, nil, nil, nil + } + if isAlreadyInvitedError(err, resp) { + invitationResource, lookupErr := i.lookupPendingInvitation(ctx, params.org, params.login, *params.email) + if lookupErr != nil { + l.Warn("failed to look up existing invitation", zap.Error(lookupErr)) + } else if invitationResource == nil { + l.Warn("pending invitation not found despite 'already invited' response from GitHub", + zap.String("org", params.org), zap.String("email", *params.email)) + } + return &v2.CreateAccountResponse_ActionRequiredResult{ + Resource: invitationResource, + Message: "GitHub org invite already pending. User must accept the existing invitation.", + }, nil, nil, nil + } + if isEMUOrgError(err, resp) { + return nil, nil, nil, fmt.Errorf("github-connector: organization %s uses Enterprise Managed Users (EMU); accounts are provisioned by the IdP, not via org invitations", params.org) + } + + // Check for expired/failed invitations as diagnostic context for unexpected failures. + failedInv, failedErr := i.lookupFailedInvitation(ctx, params.org, params.login, *params.email) + if failedErr != nil { + l.Warn("failed to check for expired invitations", zap.Error(failedErr)) + } + if failedInv != nil { + l.Warn("previous invitation expired or failed", + zap.String("failed_reason", failedInv.GetFailedReason()), + zap.Time("failed_at", failedInv.GetFailedAt().Time), + ) + } + return nil, nil, nil, wrapGitHubError(err, resp, "github-connector: failed to create org invitation") } @@ -151,12 +311,13 @@ func (i *invitationResourceType) CreateAccount( var annotations annotations.Annotations annotations.WithRateLimiting(restApiRateLimit) - r, err := invitationToUserResource(invitation) + r, err := invitationToUserResource(invitation, invitationStatusPendingAcceptance) if err != nil { return nil, nil, nil, fmt.Errorf("github-connectorv2: cannot create user resource: %w", err) } - return &v2.CreateAccountResponse_SuccessResult{ + return &v2.CreateAccountResponse_ActionRequiredResult{ Resource: r, + Message: "GitHub org invite sent. User must accept the invitation before team membership can be granted.", }, nil, annotations, nil } @@ -184,6 +345,12 @@ func (i *invitationResourceType) Delete(ctx context.Context, resourceId *v2.Reso resp, err = i.client.Organizations.CancelInvite(ctx, org, invitationID) if err == nil { isRemoved = true + continue + } + if isNotFoundError(resp) { + // Invitation is already gone (expired or previously cancelled). + // Desired state is achieved, so treat as success. + isRemoved = true } } @@ -204,6 +371,7 @@ func (i *invitationResourceType) Delete(ctx context.Context, resourceId *v2.Reso type createUserParams struct { org string email *string + login string // optional GitHub username } func getCreateUserParams(accountInfo *v2.AccountInfo) (*createUserParams, error) { @@ -218,12 +386,140 @@ func getCreateUserParams(accountInfo *v2.AccountInfo) (*createUserParams, error) return nil, fmt.Errorf("email is required") } + login, _ := pMap["github_username"].(string) + return &createUserParams{ org: org, email: &e, + login: login, }, nil } +// lookupUser resolves a GitHub user resource. Tries login via Users.Get first +// (works regardless of email privacy), then falls back to email search. +func (i *invitationResourceType) lookupUser(ctx context.Context, login, email string) (*v2.Resource, error) { + l := ctxzap.Extract(ctx) + if login != "" { + ghUser, _, err := i.client.Users.Get(ctx, login) + if err == nil { + userEmail := ghUser.GetEmail() + if userEmail == "" { + userEmail = email + } + return userResource(ctx, ghUser, userEmail, nil) + } + l.Debug("user lookup by login failed, falling back to email search", + zap.String("login", login), zap.Error(err)) + } + + result, _, err := i.client.Search.Users(ctx, fmt.Sprintf(`"%s" in:email`, email), nil) + if err != nil { + return nil, fmt.Errorf("github-connector: failed to search users by email: %w", err) + } + if len(result.Users) == 0 { + return nil, fmt.Errorf("github-connector: no user found with login %q or email %q", login, email) + } + return userResource(ctx, result.Users[0], email, nil) +} + +// maxLookupPages limits pagination in invitation lookups to avoid excessive +// API calls and rate limit consumption for orgs with long invitation histories. +const maxLookupPages = 5 + +// lookupPendingInvitation searches pending org invitations matching by login or email. +// Returns (nil, nil) if no matching invitation is found. +func (i *invitationResourceType) lookupPendingInvitation(ctx context.Context, org, login, email string) (*v2.Resource, error) { + opts := &github.ListOptions{PerPage: 100} + for page := 0; page < maxLookupPages; page++ { + invitations, resp, err := i.client.Organizations.ListPendingOrgInvitations(ctx, org, opts) + if err != nil { + return nil, fmt.Errorf("github-connector: failed to list pending invitations: %w", err) + } + for _, inv := range invitations { + if invitationMatches(inv, login, email) { + return invitationToUserResource(inv, invitationStatusPendingAcceptance) + } + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return nil, nil +} + +// lookupFailedInvitation searches failed/expired org invitations matching by login or email. +// Returns (nil, nil) if no matching invitation is found. +func (i *invitationResourceType) lookupFailedInvitation(ctx context.Context, org, login, email string) (*github.Invitation, error) { + opts := &github.ListOptions{PerPage: 100} + for page := 0; page < maxLookupPages; page++ { + invitations, resp, err := i.client.Organizations.ListFailedOrgInvitations(ctx, org, opts) + if err != nil { + return nil, fmt.Errorf("github-connector: failed to list failed invitations: %w", err) + } + for _, inv := range invitations { + if invitationMatches(inv, login, email) { + return inv, nil + } + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return nil, nil +} + +// invitationMatches returns true if the invitation matches the given login or email. +func invitationMatches(inv *github.Invitation, login, email string) bool { + if login != "" && strings.EqualFold(inv.GetLogin(), login) { + return true + } + if email != "" && strings.EqualFold(inv.GetEmail(), email) { + return true + } + return false +} + +func isAlreadyOrgMemberError(err error, resp *github.Response) bool { + return isGitHubValidationError(err, resp, "already a member", "already a part of") +} + +func isAlreadyInvitedError(err error, resp *github.Response) bool { + return isGitHubValidationError(err, resp, "already invited", "already been invited") +} + +func isEMUOrgError(err error, resp *github.Response) bool { + return isGitHubValidationError(err, resp, "managed by an enterprise", "enterprise managed") +} + +// isGitHubValidationError returns true if the GitHub API response is a 422 +// and the error message contains any of the given substrings (case-insensitive). +func isGitHubValidationError(err error, resp *github.Response, substrings ...string) bool { + if resp == nil || resp.StatusCode != http.StatusUnprocessableEntity { + return false + } + var ghErr *github.ErrorResponse + if !errors.As(err, &ghErr) { + return false + } + for _, sub := range substrings { + if containsLower(ghErr.Message, sub) { + return true + } + for _, e := range ghErr.Errors { + if containsLower(e.Message, sub) { + return true + } + } + } + return false +} + +func containsLower(s, substr string) bool { + return strings.Contains(strings.ToLower(s), substr) +} + type invitationBuilderParams struct { client *github.Client orgCache *orgNameCache diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/org.go b/vendor/github.com/conductorone/baton-github/pkg/connector/org.go index 0f4c79bc..96739f77 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/org.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/org.go @@ -158,22 +158,23 @@ func (o *orgResourceType) Entitlements( _ context.Context, resource *v2.Resource, _ resourceSdk.SyncOpAttrs, +) ([]*v2.Entitlement, *resourceSdk.SyncOpResults, error) { + return nil, nil, nil +} + +func (o *orgResourceType) StaticEntitlements( + _ context.Context, + _ resourceSdk.SyncOpAttrs, ) ([]*v2.Entitlement, *resourceSdk.SyncOpResults, error) { rv := make([]*v2.Entitlement, 0, len(orgAccessLevels)) - rv = append(rv, entitlement.NewAssignmentEntitlement(resource, orgRoleMember, - entitlement.WithDisplayName(fmt.Sprintf("%s Org %s", resource.DisplayName, titleCase(orgRoleMember))), - entitlement.WithDescription(fmt.Sprintf("Access to %s org in GitHub", resource.DisplayName)), - entitlement.WithAnnotation(&v2.V1Identifier{ - Id: fmt.Sprintf("org:%s:role:%s", resource.Id.Resource, orgRoleMember), - }), + rv = append(rv, entitlement.NewAssignmentEntitlement(nil, orgRoleMember, + entitlement.WithDisplayName("Org Member"), + entitlement.WithDescription("Access to org in GitHub as member"), entitlement.WithGrantableTo(resourceTypeUser), )) - rv = append(rv, entitlement.NewPermissionEntitlement(resource, orgRoleAdmin, - entitlement.WithDisplayName(fmt.Sprintf("%s Org %s", resource.DisplayName, titleCase(orgRoleAdmin))), - entitlement.WithDescription(fmt.Sprintf("Access to %s org in GitHub", resource.DisplayName)), - entitlement.WithAnnotation(&v2.V1Identifier{ - Id: fmt.Sprintf("org:%s:role:%s", resource.Id.Resource, orgRoleAdmin), - }), + rv = append(rv, entitlement.NewPermissionEntitlement(nil, orgRoleAdmin, + entitlement.WithDisplayName("Org Admin"), + entitlement.WithDescription("Access to org in GitHub as admin"), entitlement.WithGrantableTo(resourceTypeUser), )) diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/org_role.go b/vendor/github.com/conductorone/baton-github/pkg/connector/org_role.go index 99fcf031..efc21138 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/org_role.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/org_role.go @@ -116,14 +116,18 @@ func (o *orgRoleResourceType) Entitlements( _ context.Context, resource *v2.Resource, _ resourceSdk.SyncOpAttrs, +) ([]*v2.Entitlement, *resourceSdk.SyncOpResults, error) { + return nil, nil, nil +} + +func (o *orgRoleResourceType) StaticEntitlements( + _ context.Context, + _ resourceSdk.SyncOpAttrs, ) ([]*v2.Entitlement, *resourceSdk.SyncOpResults, error) { rv := make([]*v2.Entitlement, 0, 1) - rv = append(rv, entitlement.NewAssignmentEntitlement(resource, "assigned", - entitlement.WithDisplayName(resource.DisplayName), - entitlement.WithDescription(fmt.Sprintf("Assignment to %s role in GitHub", resource.DisplayName)), - entitlement.WithAnnotation(&v2.V1Identifier{ - Id: fmt.Sprintf("org_role:%s", resource.Id.Resource), - }), + rv = append(rv, entitlement.NewAssignmentEntitlement(nil, "assigned", + entitlement.WithDisplayName("Role Assigned"), + entitlement.WithDescription("Assignment to role in GitHub"), entitlement.WithGrantableTo(resourceTypeUser), )) @@ -219,6 +223,11 @@ func (o *orgRoleResourceType) Grants( rv = append(rv, grant) } case resourceTypeTeam.Id: + orgID, err := parseResourceToGitHub(resource.ParentResourceId) + if err != nil { + return nil, nil, err + } + listOpts := &github.ListOptions{ Page: page, PerPage: maxPageSize, @@ -256,21 +265,21 @@ func (o *orgRoleResourceType) Grants( // Create expandable grants for teams. To show inherited roles, we need to show the teams that have the role. for _, team := range teams { - teamResource, err := teamResource(team, resource.ParentResourceId) + tr, err := teamResource(team, orgID, resource.ParentResourceId) if err != nil { return nil, nil, err } rv = append(rv, grant.NewGrant( resource, "assigned", - teamResource.Id, + tr.Id, grant.WithAnnotation(&v2.V1Identifier{ Id: fmt.Sprintf("org-role-grant:%s:%d:%s", resource.Id.Resource, team.GetID(), "assigned"), }, &v2.GrantExpandable{ EntitlementIds: []string{ - entitlement.NewEntitlementID(teamResource, teamRoleMaintainer), - entitlement.NewEntitlementID(teamResource, teamRoleMember), + entitlement.NewEntitlementID(tr, teamRoleMaintainer), + entitlement.NewEntitlementID(tr, teamRoleMember), }, Shallow: true, }, diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/repository.go b/vendor/github.com/conductorone/baton-github/pkg/connector/repository.go index 571bb358..0a712416 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/repository.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/repository.go @@ -10,9 +10,11 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/pagination" + "github.com/conductorone/baton-sdk/pkg/session" "github.com/conductorone/baton-sdk/pkg/types/entitlement" "github.com/conductorone/baton-sdk/pkg/types/grant" resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/conductorone/baton-sdk/pkg/types/sessions" "github.com/conductorone/baton-sdk/pkg/uhttp" "github.com/google/go-github/v69/github" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" @@ -61,6 +63,7 @@ type repositoryResourceType struct { client *github.Client orgCache *orgNameCache omitArchivedRepositories bool + directCollaboratorsOnly bool } func (o *repositoryResourceType) ResourceType(_ context.Context) *v2.ResourceType { @@ -123,14 +126,15 @@ func (o *repositoryResourceType) List(ctx context.Context, parentID *v2.Resource } func (o *repositoryResourceType) Entitlements(_ context.Context, resource *v2.Resource, _ resourceSdk.SyncOpAttrs) ([]*v2.Entitlement, *resourceSdk.SyncOpResults, error) { + return nil, nil, nil +} + +func (o *repositoryResourceType) StaticEntitlements(_ context.Context, _ resourceSdk.SyncOpAttrs) ([]*v2.Entitlement, *resourceSdk.SyncOpResults, error) { rv := make([]*v2.Entitlement, 0, len(repoAccessLevels)) for _, level := range repoAccessLevels { - rv = append(rv, entitlement.NewPermissionEntitlement(resource, level, - entitlement.WithDisplayName(fmt.Sprintf("%s Repo %s", resource.DisplayName, titleCase(level))), - entitlement.WithDescription(fmt.Sprintf("Access to %s repository in GitHub", resource.DisplayName)), - entitlement.WithAnnotation(&v2.V1Identifier{ - Id: fmt.Sprintf("repo:%s:role:%s", resource.Id.Resource, level), - }), + rv = append(rv, entitlement.NewPermissionEntitlement(nil, level, + entitlement.WithDisplayName(fmt.Sprintf("Repo %s", titleCase(level))), + entitlement.WithDescription(fmt.Sprintf("Access to repository in GitHub as %s", level)), entitlement.WithGrantableTo(resourceTypeUser, resourceTypeTeam), )) } @@ -167,9 +171,50 @@ func (o *repositoryResourceType) Grants( ResourceTypeID: resourceTypeTeam.Id, }) + // When direct-collaborators-only is enabled, org members whose only repo access + // is via the org base permission won't appear in ListCollaborators. Add expandable + // grants so the SDK resolves org membership into repo access. + if o.directCollaboratorsOnly { + basePerm, err := o.getOrgBasePermission(ctx, opts.Session, orgName, resource.ParentResourceId) + if err != nil { + l.Debug("failed to fetch org base permission, skipping org expansion", zap.Error(err)) + } else { + orgResID := resource.ParentResourceId.Resource + // Org admins always have admin on all repos + adminEntitlementID := fmt.Sprintf("%s:%s:%s", resourceTypeOrg.Id, orgResID, orgRoleAdmin) + for _, perm := range repoAccessLevels { + rv = append(rv, grant.NewGrant(resource, perm, resource.ParentResourceId, + grant.WithAnnotation(&v2.GrantExpandable{ + EntitlementIds: []string{adminEntitlementID}, + Shallow: true, + ResourceTypeIds: []string{resourceTypeUser.Id}, + }), + )) + } + // Org members get access based on the org's default repo permission + memberPerms := orgBasePermissionToRepoPermissions(basePerm) + if len(memberPerms) > 0 { + memberEntitlementID := fmt.Sprintf("%s:%s:%s", resourceTypeOrg.Id, orgResID, orgRoleMember) + for _, perm := range memberPerms { + rv = append(rv, grant.NewGrant(resource, perm, resource.ParentResourceId, + grant.WithAnnotation(&v2.GrantExpandable{ + EntitlementIds: []string{memberEntitlementID}, + Shallow: true, + ResourceTypeIds: []string{resourceTypeUser.Id}, + }), + )) + } + } + } + } + case resourceTypeUser.Id: + affiliation := "all" + if o.directCollaboratorsOnly { + affiliation = "direct" + } listOpts := &github.ListCollaboratorsOptions{ - Affiliation: "all", + Affiliation: affiliation, ListOptions: github.ListOptions{ Page: page, PerPage: maxPageSize, @@ -226,6 +271,11 @@ func (o *repositoryResourceType) Grants( } case resourceTypeTeam.Id: + orgID, err := parseResourceToGitHub(resource.ParentResourceId) + if err != nil { + return nil, nil, err + } + listOpts := &github.ListOptions{ Page: page, PerPage: maxPageSize, @@ -269,7 +319,7 @@ func (o *repositoryResourceType) Grants( continue } - tr, err := teamResource(team, resource.ParentResourceId) + tr, err := teamResource(team, orgID, resource.ParentResourceId) if err != nil { return nil, nil, err } @@ -348,7 +398,7 @@ func (o *repositoryResourceType) Grant(ctx context.Context, principal *v2.Resour return nil, wrapGitHubError(er, resp, "github-connector: failed to add user to repository") } case resourceTypeTeam.Id: - team, resp, err := o.client.Teams.GetTeamByID(ctx, org.GetID(), principalID) //nolint:staticcheck // TODO: migrate to GetTeamBySlug + team, resp, err := o.client.Teams.GetTeamByID(ctx, org.GetID(), principalID) //nolint:staticcheck,nolintlint // TODO: migrate to GetTeamBySlug if err != nil { return nil, wrapGitHubError(err, resp, "github-connector: failed to get team") } @@ -406,7 +456,7 @@ func (o *repositoryResourceType) Revoke(ctx context.Context, grant *v2.Grant) (a return nil, wrapGitHubError(er, resp, "github-connector: failed to remove user from repository") } case resourceTypeTeam.Id: - team, resp, err := o.client.Teams.GetTeamByID(ctx, org.GetID(), principalID) //nolint:staticcheck // TODO: migrate to GetTeamBySlug + team, resp, err := o.client.Teams.GetTeamByID(ctx, org.GetID(), principalID) //nolint:staticcheck,nolintlint // TODO: migrate to GetTeamBySlug if err != nil { return nil, wrapGitHubError(err, resp, "github-connector: failed to get team") } @@ -427,12 +477,61 @@ func (o *repositoryResourceType) Revoke(ctx context.Context, grant *v2.Grant) (a return nil, nil } -func repositoryBuilder(client *github.Client, orgCache *orgNameCache, omitArchivedRepositories bool) *repositoryResourceType { +// orgBasePermissionSessionKey returns the session key for caching the org's default repo permission. +func orgBasePermissionSessionKey(orgID string) string { + return "org_base_perm:" + orgID +} + +// getOrgBasePermission fetches the org's default_repository_permission, caching in the session. +// Returns "read", "write", "admin", or "none". +func (o *repositoryResourceType) getOrgBasePermission(ctx context.Context, ss sessions.SessionStore, orgName string, orgResourceID *v2.ResourceId) (string, error) { + key := orgBasePermissionSessionKey(orgResourceID.Resource) + cached, found, err := session.GetJSON[string](ctx, ss, key) + if err != nil { + return "", fmt.Errorf("baton-github: error reading org base permission from session: %w", err) + } + if found { + return cached, nil + } + + org, resp, err := o.client.Organizations.Get(ctx, orgName) + if err != nil { + return "", wrapGitHubError(err, resp, "baton-github: failed to get organization") + } + + perm := org.GetDefaultRepoPermission() + if perm == "" { + perm = "read" // GitHub default + } + + if err := session.SetJSON(ctx, ss, key, perm); err != nil { + return "", fmt.Errorf("baton-github: error caching org base permission: %w", err) + } + return perm, nil +} + +// orgBasePermissionToRepoPermissions maps the org's default_repository_permission to +// the cumulative repo permission levels it grants. +func orgBasePermissionToRepoPermissions(basePerm string) []string { + switch basePerm { + case "admin": + return []string{repoPermissionPull, repoPermissionTriage, repoPermissionPush, repoPermissionMaintain, repoPermissionAdmin} + case "write": + return []string{repoPermissionPull, repoPermissionTriage, repoPermissionPush} + case "read": + return []string{repoPermissionPull} + default: + return nil + } +} + +func repositoryBuilder(client *github.Client, orgCache *orgNameCache, omitArchivedRepositories bool, directCollaboratorsOnly bool) *repositoryResourceType { return &repositoryResourceType{ resourceType: resourceTypeRepository, client: client, orgCache: orgCache, omitArchivedRepositories: omitArchivedRepositories, + directCollaboratorsOnly: directCollaboratorsOnly, } } diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/team.go b/vendor/github.com/conductorone/baton-github/pkg/connector/team.go index e6eff4c7..d4399f95 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/team.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/team.go @@ -30,12 +30,15 @@ var teamAccessLevels = []string{ } // teamResource creates a new connector resource for a GitHub Team. It is possible that the team has a parent resource. -func teamResource(team *github.Team, parentResourceID *v2.ResourceId) (*v2.Resource, error) { +// orgID must be passed explicitly since ListTeams() doesn't return the full organization object. +func teamResource(team *github.Team, orgID int64, parentResourceID *v2.ResourceId) (*v2.Resource, error) { profile := map[string]interface{}{ + // Note: members_count and repos_count are only populated when fetching + // individual teams. We skip the per-team API call to avoid N+1 requests. "members_count": team.GetMembersCount(), "repos_count": team.GetReposCount(), // Store the org ID in the profile so that we can reference it when calculating grants - "orgID": team.GetOrganization().GetID(), + "orgID": orgID, } ret, err := rType.NewGroupResource( @@ -57,9 +60,10 @@ func teamResource(team *github.Team, parentResourceID *v2.ResourceId) (*v2.Resou } type teamResourceType struct { - resourceType *v2.ResourceType - client *github.Client - orgCache *orgNameCache + resourceType *v2.ResourceType + client *github.Client + orgCache *orgNameCache + directCollaboratorsOnly bool } func (o *teamResourceType) ResourceType(_ context.Context) *v2.ResourceType { @@ -104,12 +108,16 @@ func (o *teamResourceType) List(ctx context.Context, parentID *v2.ResourceId, op } for _, team := range teams { - fullTeam, resp, err := o.client.Teams.GetTeamByID(ctx, orgID, team.GetID()) //nolint:staticcheck // TODO: migrate to GetTeamBySlug - if err != nil { - return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to get team details") + teamData := team + if !o.directCollaboratorsOnly { + fullTeam, resp, err := o.client.Teams.GetTeamByID(ctx, orgID, team.GetID()) //nolint:staticcheck,nolintlint // TODO: migrate to GetTeamBySlug + if err != nil { + return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to get team details") + } + teamData = fullTeam } - tr, err := teamResource(fullTeam, &v2.ResourceId{ResourceType: resourceTypeOrg.Id, Resource: fmt.Sprintf("%d", orgID)}) + tr, err := teamResource(teamData, orgID, &v2.ResourceId{ResourceType: resourceTypeOrg.Id, Resource: fmt.Sprintf("%d", orgID)}) if err != nil { return nil, nil, err } @@ -129,20 +137,19 @@ func (o *teamResourceType) List(ctx context.Context, parentID *v2.ResourceId, op } func (o *teamResourceType) Entitlements(_ context.Context, resource *v2.Resource, _ rType.SyncOpAttrs) ([]*v2.Entitlement, *rType.SyncOpResults, error) { + return nil, nil, nil +} + +func (o *teamResourceType) StaticEntitlements(_ context.Context, _ rType.SyncOpAttrs) ([]*v2.Entitlement, *rType.SyncOpResults, error) { rv := make([]*v2.Entitlement, 0, len(teamAccessLevels)) for _, level := range teamAccessLevels { rv = append( rv, entitlement.NewPermissionEntitlement( - resource, + nil, level, - entitlement.WithAnnotation( - &v2.V1Identifier{ - Id: fmt.Sprintf("team:%s:role:%s", resource.Id.Resource, level), - }, - ), - entitlement.WithDisplayName(fmt.Sprintf("%s Team %s", resource.DisplayName, titleCase(level))), - entitlement.WithDescription(fmt.Sprintf("Access to %s team in GitHub", resource.DisplayName)), + entitlement.WithDisplayName(fmt.Sprintf("Team %s", titleCase(level))), + entitlement.WithDescription(fmt.Sprintf("Access to team in GitHub as %s", level)), entitlement.WithGrantableTo(resourceTypeUser), ), ) @@ -368,10 +375,11 @@ func (o *teamResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annotat return nil, nil } -func teamBuilder(client *github.Client, orgCache *orgNameCache) *teamResourceType { +func teamBuilder(client *github.Client, orgCache *orgNameCache, directCollaboratorsOnly bool) *teamResourceType { return &teamResourceType{ - resourceType: resourceTypeTeam, - client: client, - orgCache: orgCache, + resourceType: resourceTypeTeam, + client: client, + orgCache: orgCache, + directCollaboratorsOnly: directCollaboratorsOnly, } } diff --git a/vendor/modules.txt b/vendor/modules.txt index ab9d61ff..7aef2847 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -165,7 +165,7 @@ github.com/cenkalti/backoff/v5 # github.com/cespare/xxhash/v2 v2.3.0 ## explicit; go 1.11 github.com/cespare/xxhash/v2 -# github.com/conductorone/baton-github v0.2.15 +# github.com/conductorone/baton-github v0.3.3 ## explicit; go 1.25.2 github.com/conductorone/baton-github/pkg/config github.com/conductorone/baton-github/pkg/connector