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