diff --git a/baton_capabilities.json b/baton_capabilities.json index bdfc0a8..22db2e8 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -19,6 +19,42 @@ ], "permissions": {} }, + { + "resourceType": { + "id": "app", + "displayName": "GitHub App", + "traits": [ + "TRAIT_APP" + ], + "annotations": [ + { + "@type": "type.googleapis.com/c1.connector.v2.V1Identifier", + "id": "app" + }, + { + "@type": "type.googleapis.com/c1.connector.v2.CapabilityPermissions", + "permissions": [ + { + "permission": "organization_administration:read" + } + ] + }, + { + "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlementsAndGrants" + } + ] + }, + "capabilities": [ + "CAPABILITY_SYNC" + ], + "permissions": { + "permissions": [ + { + "permission": "organization_administration:read" + } + ] + } + }, { "resourceType": { "id": "enterprise_role", @@ -52,6 +88,9 @@ { "@type": "type.googleapis.com/c1.connector.v2.V1Identifier", "id": "invitation" + }, + { + "@type": "type.googleapis.com/c1.connector.v2.SkipSyncAnomalyDetection" } ] }, @@ -60,7 +99,8 @@ "CAPABILITY_ACCOUNT_PROVISIONING", "CAPABILITY_RESOURCE_DELETE" ], - "permissions": {} + "permissions": {}, + "skipSyncAnomalyDetection": true }, { "resourceType": { diff --git a/docs/connector.mdx b/docs/connector.mdx index e636add..cf14727 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -24,6 +24,7 @@ If you access GitHub at `github.com`, go to the [GitHub](/baton/github) integrat | Enterprise roles | | | | Licenses | | | | Secrets - API keys | | | +| GitHub App | | | The GitHub Enterprise connector supports [automatic account provisioning and deprovisioning](/product/admin/account-provisioning). New accounts will send an invitation to the account owner; if an invitation is pending, the account status will be shown as **Unspecified**. diff --git a/go.mod b/go.mod index b139230..61b0773 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.3.7 + github.com/conductorone/baton-github v0.3.10 github.com/conductorone/baton-sdk v0.13.0 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 e48d7fb..23397f8 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,8 @@ github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b h1:VXvSNzmr8hMj8 github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= -github.com/conductorone/baton-github v0.3.7 h1:wK65opd70uLjaSeuUfl5ZkK4h5nB4kZUQBWBK2fER7E= -github.com/conductorone/baton-github v0.3.7/go.mod h1:nnCvY6EzW768V5qbO95yEuLlMa97J2aWZ5qyirwYW/A= +github.com/conductorone/baton-github v0.3.10 h1:R/eEwIiuyU2jJ3no6vFEb/rFv/dypdVGhfjEffwEgGU= +github.com/conductorone/baton-github v0.3.10/go.mod h1:uMdYU1bf9pJMYysXUwVrNzhRD4XtlJSo7CxEPTd1uqI= github.com/conductorone/baton-sdk v0.13.0 h1:Z73ZX2zhEhIe47e0tPqvGsTQJkLauv/XAqOYs0t2qvE= github.com/conductorone/baton-sdk v0.13.0/go.mod h1:hNdQlCu0prYFQj8ysF/J+0zjUxdqjPPVBlZJVwMIb6o= github.com/conductorone/dpop v0.2.6 h1:fakwai/Xm2b/fcDUwJN41WtcSI/2UhQOyRIVvnnrrNA= diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index c7bf25b..eaa505d 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -52,5 +52,6 @@ func (d *defaultCapabilitiesBuilder) ResourceSyncers(_ context.Context) []connec connector.APITokenBuilder(nil, nil), connector.EnterpriseRoleBuilder(nil, nil, nil, nil), connector.LicenseBuilder(nil, nil), + connector.AppBuilder(nil, nil), } } diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/api_token.go b/vendor/github.com/conductorone/baton-github/pkg/connector/api_token.go index 6525860..5f0852e 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/api_token.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/api_token.go @@ -5,7 +5,6 @@ import ( "strconv" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" - "github.com/conductorone/baton-sdk/pkg/annotations" resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/google/go-github/v69/github" ) @@ -71,7 +70,6 @@ func (o *apiTokenResourceType) List( parentID *v2.ResourceId, opts resourceSdk.SyncOpAttrs, ) ([]*v2.Resource, *resourceSdk.SyncOpResults, error) { - var annotations annotations.Annotations if parentID == nil { return nil, &resourceSdk.SyncOpResults{}, nil } @@ -96,18 +94,7 @@ func (o *apiTokenResourceType) List( return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to list fine-grained personal access tokens") } - restApiRateLimit, err := extractRateLimitData(resp) - if err != nil { - return nil, nil, err - } - annotations.WithRateLimiting(restApiRateLimit) - - nextPage, _, err := parseResp(resp) - if err != nil { - return nil, nil, err - } - - pageToken, err := bag.NextToken(nextPage) + pageToken, annotations, err := nextPageToken(bag, resp) if err != nil { return nil, nil, err } diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/app.go b/vendor/github.com/conductorone/baton-github/pkg/connector/app.go new file mode 100644 index 0000000..cafe9ed --- /dev/null +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/app.go @@ -0,0 +1,133 @@ +package connector + +import ( + "context" + "fmt" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/google/go-github/v69/github" +) + +// appResource builds a GitHub App resource from one organization installation. +// The org installations endpoint is the only org-wide-enumerable window onto +// GitHub Apps: each installation row carries the app's identity (app_id, +// app_slug) plus the installed scope. The resource is keyed by installation ID +// (unique and stable per org) and carries TRAIT_APP + an APP_REGISTRATION NHI +// annotation so c1 classifies it as a non-human identity. +func appResource(ctx context.Context, installation *github.Installation, parentResourceID *v2.ResourceId) (*v2.Resource, error) { + appSlug := installation.GetAppSlug() + displayName := appSlug + if displayName == "" { + displayName = fmt.Sprintf("app-%d", installation.GetAppID()) + } + + profile := map[string]interface{}{ + "app_id": installation.GetAppID(), + "app_slug": appSlug, + "installation_id": installation.GetID(), + } + if account := installation.GetAccount(); account != nil { + profile["account_login"] = account.GetLogin() + } + if installation.TargetType != nil { + profile["target_type"] = installation.GetTargetType() + } + if installation.RepositorySelection != nil { + profile["repository_selection"] = installation.GetRepositorySelection() + } + + opts := []resourceSdk.ResourceOption{ + resourceSdk.WithParentResourceID(parentResourceID), + resourceSdk.WithAppTrait(resourceSdk.WithAppProfile(profile)), + resourceSdk.WithNHIType(v2.NonHumanIdentityTrait_NHI_TYPE_APP_REGISTRATION, "github.app"), + } + if installation.HTMLURL != nil { + opts = append(opts, resourceSdk.WithAnnotation(&v2.ExternalLink{Url: installation.GetHTMLURL()})) + } + + return resourceSdk.NewResource( + displayName, + resourceTypeApp, + installation.GetID(), + opts..., + ) +} + +type appResourceType struct { + resourceType *v2.ResourceType + client *github.Client + orgCache *orgNameCache +} + +func (o *appResourceType) ResourceType(_ context.Context) *v2.ResourceType { + return o.resourceType +} + +func (o *appResourceType) Entitlements(ctx context.Context, resource *v2.Resource, opts resourceSdk.SyncOpAttrs) ([]*v2.Entitlement, *resourceSdk.SyncOpResults, error) { + // GitHub Apps are synced read-only as NHI app registrations; no entitlements. + return nil, &resourceSdk.SyncOpResults{}, nil +} + +func (o *appResourceType) Grants(ctx context.Context, resource *v2.Resource, opts resourceSdk.SyncOpAttrs) ([]*v2.Grant, *resourceSdk.SyncOpResults, error) { + // GitHub Apps are synced read-only as NHI app registrations; no grants. + return nil, &resourceSdk.SyncOpResults{}, nil +} + +func (o *appResourceType) List( + ctx context.Context, + parentID *v2.ResourceId, + opts resourceSdk.SyncOpAttrs, +) ([]*v2.Resource, *resourceSdk.SyncOpResults, error) { + if parentID == nil { + return nil, &resourceSdk.SyncOpResults{}, nil + } + + bag, page, err := parsePageToken(opts.PageToken.Token, &v2.ResourceId{ResourceType: resourceTypeApp.Id}) + if err != nil { + return nil, nil, err + } + + orgName, err := o.orgCache.GetOrgName(ctx, opts.Session, parentID) + if err != nil { + return nil, nil, err + } + + installations, resp, err := o.client.Organizations.ListInstallations(ctx, orgName, &github.ListOptions{ + Page: page, + PerPage: opts.PageToken.Size, + }) + if err != nil { + return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to list organization app installations") + } + + pageToken, annos, err := nextPageToken(bag, resp) + if err != nil { + return nil, nil, err + } + + var rv []*v2.Resource + for _, installation := range installations.Installations { + resource, err := appResource(ctx, installation, parentID) + if err != nil { + return nil, &resourceSdk.SyncOpResults{ + NextPageToken: pageToken, + Annotations: annos, + }, err + } + rv = append(rv, resource) + } + + return rv, &resourceSdk.SyncOpResults{ + NextPageToken: pageToken, + Annotations: annos, + }, nil +} + +func AppBuilder(client *github.Client, orgCache *orgNameCache) *appResourceType { + return &appResourceType{ + resourceType: resourceTypeApp, + client: client, + orgCache: orgCache, + } +} 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 5d1590a..2cdb5a6 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/connector.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/connector.go @@ -73,7 +73,9 @@ var ( Traits: []v2.ResourceType_Trait{ v2.ResourceType_TRAIT_USER, }, - Annotations: v1AnnotationsForResourceType("invitation"), + // Invitations disappear once accepted, so their count can legitimately + // drop between syncs. Skip sync anomaly detection for this type only. + Annotations: append(v1AnnotationsForResourceType("invitation"), annotations.New(&v2.SkipSyncAnomalyDetection{})...), } resourceTypeApiToken = &v2.ResourceType{ Id: "api-key", @@ -105,6 +107,12 @@ var ( &v2.OptInRequired{}, ), } + resourceTypeApp = &v2.ResourceType{ + Id: "app", + DisplayName: "GitHub App", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_APP}, + Annotations: skipEntitlementsAndGrantsAnnotations("app", "organization_administration:read"), + } ) type GitHub struct { @@ -133,6 +141,7 @@ func (gh *GitHub) ResourceSyncers(ctx context.Context) []connectorbuilder.Resour orgCache: gh.orgCache, orgs: gh.orgs, }), + AppBuilder(gh.client, gh.orgCache), } if gh.syncSecrets { 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 ef37f6f..ece8476 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/helpers.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/helpers.go @@ -76,22 +76,37 @@ func newOrgNameCache(c *github.Client) *orgNameCache { } } -func v1AnnotationsForResourceType(resourceTypeID string) annotations.Annotations { +func v1AnnotationsForResourceType(resourceTypeID string, permissions ...string) annotations.Annotations { annos := annotations.Annotations{} annos.Update(&v2.V1Identifier{ Id: resourceTypeID, }) + if len(permissions) > 0 { + caps := make([]*v2.CapabilityPermission, 0, len(permissions)) + for _, p := range permissions { + caps = append(caps, &v2.CapabilityPermission{Permission: p}) + } + annos.Update(&v2.CapabilityPermissions{Permissions: caps}) + } + return annos } -func skipEntitlementsAnnotations(resourceTypeID string) annotations.Annotations { - annos := v1AnnotationsForResourceType(resourceTypeID) +func skipEntitlementsAnnotations(resourceTypeID string, permissions ...string) annotations.Annotations { + annos := v1AnnotationsForResourceType(resourceTypeID, permissions...) annos.Update(&v2.SkipEntitlements{}) return annos } +func skipEntitlementsAndGrantsAnnotations(resourceTypeID string, permissions ...string) annotations.Annotations { + annos := v1AnnotationsForResourceType(resourceTypeID, permissions...) + annos.Update(&v2.SkipEntitlementsAndGrants{}) + + 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, ":") @@ -129,6 +144,20 @@ func convertPageToken(token string) (int, error) { return strconv.Atoi(token) } +// nextPageToken combines parseResp and bag.NextToken into a single call, +// returning the serialized page token and rate-limit annotations together. +func nextPageToken(bag *pagination.Bag, resp *github.Response) (string, annotations.Annotations, error) { + nextPage, annos, err := parseResp(resp) + if err != nil { + return "", nil, err + } + pageToken, err := bag.NextToken(nextPage) + if err != nil { + return "", nil, err + } + return pageToken, annos, nil +} + // fmtGitHubPageToken return a formatted string for a github page token. func fmtGitHubPageToken(pageToken int) string { if pageToken == 0 { 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 5bf3bc4..4d17748 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/org.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/org.go @@ -55,6 +55,7 @@ func organizationResource( &v2.ChildResourceType{ResourceTypeId: resourceTypeRepository.Id}, &v2.ChildResourceType{ResourceTypeId: resourceTypeOrgRole.Id}, &v2.ChildResourceType{ResourceTypeId: resourceTypeInvitation.Id}, + &v2.ChildResourceType{ResourceTypeId: resourceTypeApp.Id}, } if syncSecrets { annotations = append(annotations, &v2.ChildResourceType{ResourceTypeId: resourceTypeApiToken.Id}) @@ -108,12 +109,7 @@ func (o *orgResourceType) List( return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to fetch organizations") } - nextPage, reqAnnos, err := parseResp(resp) - if err != nil { - return nil, nil, err - } - - pageToken, err := bag.NextToken(nextPage) + pageToken, reqAnnos, err := nextPageToken(bag, resp) if err != nil { return nil, nil, err } 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 f9eb3b1..fe74983 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/repository.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/repository.go @@ -97,12 +97,7 @@ func (o *repositoryResourceType) List(ctx context.Context, parentID *v2.Resource return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to list repositories") } - nextPage, reqAnnos, err := parseResp(resp) - if err != nil { - return nil, nil, err - } - - pageToken, err := bag.NextToken(nextPage) + pageToken, reqAnnos, err := nextPageToken(bag, resp) if err != nil { return nil, nil, err } @@ -131,11 +126,19 @@ func (o *repositoryResourceType) Entitlements(_ context.Context, resource *v2.Re 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(nil, level, + for i, level := range repoAccessLevels { + 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), + entitlement.WithAnnotation(&v2.EntitlementExclusionGroup{ + ExclusionGroupId: "repository", + Order: uint32(i), + IsDefault: false, + ScopeToResource: true, + }), )) } 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 6cd7573..8e1579b 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/team.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/team.go @@ -102,7 +102,7 @@ func (o *teamResourceType) List(ctx context.Context, parentID *v2.ResourceId, op return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to list teams") } - nextPage, reqAnnos, err := parseResp(resp) + pageToken, reqAnnos, err := nextPageToken(bag, resp) if err != nil { return nil, nil, err } @@ -125,11 +125,6 @@ func (o *teamResourceType) List(ctx context.Context, parentID *v2.ResourceId, op rv = append(rv, tr) } - pageToken, err := bag.NextToken(nextPage) - if err != nil { - return nil, nil, err - } - return rv, &rType.SyncOpResults{ NextPageToken: pageToken, Annotations: reqAnnos, diff --git a/vendor/modules.txt b/vendor/modules.txt index 165b40f..595a027 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -266,7 +266,7 @@ github.com/cockroachdb/swiss # github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 ## explicit; go 1.19 github.com/cockroachdb/tokenbucket -# github.com/conductorone/baton-github v0.3.7 +# github.com/conductorone/baton-github v0.3.10 ## explicit; go 1.25.2 github.com/conductorone/baton-github/pkg/config github.com/conductorone/baton-github/pkg/connector