diff --git a/baton_capabilities.json b/baton_capabilities.json
index c59b1a80..9202466f 100644
--- a/baton_capabilities.json
+++ b/baton_capabilities.json
@@ -30,6 +30,9 @@
{
"@type": "type.googleapis.com/c1.connector.v2.V1Identifier",
"id": "org"
+ },
+ {
+ "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlements"
}
]
},
@@ -50,6 +53,9 @@
{
"@type": "type.googleapis.com/c1.connector.v2.V1Identifier",
"id": "org_role"
+ },
+ {
+ "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlements"
}
]
},
@@ -67,6 +73,9 @@
{
"@type": "type.googleapis.com/c1.connector.v2.V1Identifier",
"id": "repository"
+ },
+ {
+ "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlements"
}
]
},
@@ -87,6 +96,9 @@
{
"@type": "type.googleapis.com/c1.connector.v2.V1Identifier",
"id": "team"
+ },
+ {
+ "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlements"
}
]
},
diff --git a/config_schema.json b/config_schema.json
index c5fe062d..51cf1358 100644
--- a/config_schema.json
+++ b/config_schema.json
@@ -177,6 +177,12 @@
"displayName": "Omit syncing archived repositories",
"description": "Whether to skip syncing archived repositories or not",
"boolField": {}
+ },
+ {
+ "name": "direct-collaborators-only",
+ "displayName": "Optimize sync for large organizations",
+ "description": "Reduces API calls by using grant expansion for team-based repo access and skipping per-team detail fetches. Recommended for large orgs.",
+ "boolField": {}
}
],
"displayName": "GitHub Enterprise",
@@ -192,7 +198,8 @@
"token",
"orgs",
"enterprises",
- "omit-archived-repositories"
+ "omit-archived-repositories",
+ "direct-collaborators-only"
],
"default": true
},
@@ -207,7 +214,8 @@
"org",
"enterprises",
"sync-secrets",
- "omit-archived-repositories"
+ "omit-archived-repositories",
+ "direct-collaborators-only"
]
}
]
diff --git a/docs/connector.mdx b/docs/connector.mdx
index f0c3b31d..4cf50106 100644
--- a/docs/connector.mdx
+++ b/docs/connector.mdx
@@ -237,6 +237,8 @@ If you're using a personal access token to set up the connector:
1. **Optional.** If you want to sync roles for only some enterprises, add the names of the enterprises in the **Enterprises to sync enterprise roles for** field.
1. **Optional.** If you do not want to include archived repos in syncs, click to enable **Omit archived repositories**.
+
+ 1. **Optional.** For large organizations, click to enable **Optimize sync for large organizations**. This reduces API calls by using grant expansion for team-based repo access and skipping per-team detail fetches.
If you're using a GitHub app to set up the connector:
@@ -254,6 +256,8 @@ If you're using a GitHub app to set up the connector:
1. **Optional.** If you want to sync roles for only some enterprises, add the names of the enterprises in the **Enterprises to sync enterprise roles for** field.
1. **Optional.** If you do not want to include archived repos in syncs, click to enable **Omit archived repositories**.
+
+ 1. **Optional.** For large organizations, click to enable **Optimize sync for large organizations**. This reduces API calls by using grant expansion for team-based repo access and skipping per-team detail fetches.
Click **Save**.
@@ -346,6 +350,7 @@ baton-github-enterprise \
| :--- | :--- |
| `--enterprises` | Comma-separated list of enterprises to sync enterprise roles for. |
| `--omit-archived-repositories` | Exclude archived repositories from syncs. |
+| `--direct-collaborators-only` | Optimize sync for large organizations by reducing API calls (uses grant expansion for team-based repo access and skips per-team detail fetches). |
| `--provisioning` | Enable provisioning and deprovisioning. |
diff --git a/go.mod b/go.mod
index ffe2a26c..cb473e78 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.16
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 fb1f2599..bdbd11c0 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.16 h1:/f9S1umZc7utfDXuWuNLkweCzqToo93LMLvhoNW8ABw=
github.com/conductorone/baton-sdk v0.9.16/go.mod h1:treFEoFwbzu9hgPqpCRD+Sr+p71xkeH98Y4ofDGrjSg=
github.com/conductorone/dpop v0.2.6 h1:fakwai/Xm2b/fcDUwJN41WtcSI/2UhQOyRIVvnnrrNA=
diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go
index c32961ae..e20969e7 100644
--- a/pkg/config/conf.gen.go
+++ b/pkg/config/conf.gen.go
@@ -13,6 +13,7 @@ type Githubenterprise struct {
Enterprises []string `mapstructure:"enterprises"`
SyncSecrets bool `mapstructure:"sync-secrets"`
OmitArchivedRepositories bool `mapstructure:"omit-archived-repositories"`
+ DirectCollaboratorsOnly bool `mapstructure:"direct-collaborators-only"`
}
func (c *Githubenterprise) findFieldByTag(tagValue string) (any, bool) {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 0d9e6127..c0499124 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -67,6 +67,14 @@ var (
field.WithDescription("Organization of your github app"),
field.WithRequired(true),
)
+ 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.",
+ ),
+ )
)
//go:generate go run ./gen
@@ -81,6 +89,7 @@ var Config = field.NewConfiguration(
EnterprisesField,
syncSecrets,
omitArchivedRepositories,
+ directCollaboratorsOnly,
},
field.WithConnectorDisplayName("GitHub Enterprise"),
field.WithHelpUrl("/docs/baton/github-enterprise"),
@@ -90,14 +99,14 @@ var Config = field.NewConfiguration(
Name: GithubPersonalAccessTokenGroup,
DisplayName: "Personal access token",
HelpText: "Use a personal access token for authentication.",
- Fields: []field.SchemaField{instanceUrlField, accessTokenField, orgsField, EnterprisesField, omitArchivedRepositories},
+ Fields: []field.SchemaField{instanceUrlField, accessTokenField, orgsField, EnterprisesField, omitArchivedRepositories, directCollaboratorsOnly},
Default: true,
},
{
Name: GithubAppGroup,
DisplayName: "GitHub app",
HelpText: "Use a github app for authentication",
- Fields: []field.SchemaField{instanceUrlField, appIDField, appPrivateKeyPath, orgField, EnterprisesField, syncSecrets, omitArchivedRepositories},
+ Fields: []field.SchemaField{instanceUrlField, appIDField, appPrivateKeyPath, orgField, EnterprisesField, syncSecrets, omitArchivedRepositories, directCollaboratorsOnly},
Default: false,
},
}),
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
index ca0aa255..ebb24591 100644
--- a/pkg/connector/connector.go
+++ b/pkg/connector/connector.go
@@ -21,5 +21,6 @@ func NewLambdaConnector(ctx context.Context, ghc *cfg.Githubenterprise, cliOpts
AppId: ghc.AppId,
AppPrivatekeyPath: ghc.AppPrivatekeyPath,
Org: ghc.Org,
+ DirectCollaboratorsOnly: ghc.DirectCollaboratorsOnly,
}, cliOpts)
}
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 ed410529..b0f3e684 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