diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 38f843ad..adf7df04 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -5,7 +5,7 @@ jobs:
runs-on: ubuntu-latest
env:
BATON_TOKEN: ${{ secrets.BATON_TOKEN }}
- BATON_ORGS: ConductorOne
+ BATON_ORGS: ${{ vars.BATON_ORGS }}
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -16,9 +16,9 @@ jobs:
- name: Build baton-github
run: go build ./cmd/baton-github
- name: Grant/revoke
- uses: ConductorOne/github-workflows/actions/sync-test@v2
+ uses: ConductorOne/github-workflows/actions/sync-test@v4
with:
connector: ./baton-github
- baton-entitlement: 'repository:642588514:admin'
- baton-principal: '166871869'
- baton-principal-type: 'user'
+ baton-entitlement: ${{ vars.BATON_ENTITLEMENT_REPOSITORY }}
+ baton-principal: ${{ vars.BATON_PRINCIPAL }}
+ baton-principal-type: ${{ vars.BATON_PRINCIPAL_TYPE }}
diff --git a/README.md b/README.md
index 8db4c3e7..0c6cc073 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,7 @@ baton resources
- Users
- Teams
- Repositories
+- GitHub Apps (installed in organizations, synced as non-human identities)
By default, `baton-github` will sync information from any organizations that the provided credential has Administrator permissions on. You can specify exactly which organizations you would like to sync using the `--orgs` flag.
@@ -105,7 +106,7 @@ Use "baton-github [command] --help" for more information about a command.
To use this Baton connector, you need to create a GitHub organization access token with the following permissions:
Org:
-- Administration: Read-only (required to detect SAML/SSO configuration)
+- Administration: Read-only (required to detect SAML/SSO configuration and to sync installed GitHub Apps)
- Member Read and Write
Repo:
diff --git a/docs/connector.mdx b/docs/connector.mdx
index ddcf66ca..2286203a 100644
--- a/docs/connector.mdx
+++ b/docs/connector.mdx
@@ -20,6 +20,7 @@ Use this integration if your organization accesses GitHub at `github.com`. If yo
| Repositories | | |
| Teams | | |
| Orgs | | |
+| GitHub Apps (NHI) | | |
| Secrets - API keys | | |
The GitHub 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**.
@@ -123,6 +124,7 @@ In the **Permissions** section of the page, give the token the following permiss
- Organization permissions:
+ - **Administration**: Read-only access (required to sync installed GitHub Apps)
- **Members**: Read and write access
- **Custom organization roles**: Read and write access
diff --git a/pkg/connector/api_token.go b/pkg/connector/api_token.go
index a1e5b46a..b16b84bb 100644
--- a/pkg/connector/api_token.go
+++ b/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"
)
@@ -69,7 +68,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
}
@@ -94,18 +92,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/pkg/connector/app.go b/pkg/connector/app.go
new file mode 100644
index 00000000..cafe9ed9
--- /dev/null
+++ b/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/pkg/connector/app_test.go b/pkg/connector/app_test.go
new file mode 100644
index 00000000..eb5e4e4c
--- /dev/null
+++ b/pkg/connector/app_test.go
@@ -0,0 +1,47 @@
+package connector
+
+import (
+ "context"
+ "testing"
+
+ 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"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAppResource(t *testing.T) {
+ ctx := context.Background()
+
+ installation := &github.Installation{
+ ID: github.Ptr(int64(42)),
+ AppID: github.Ptr(int64(7)),
+ AppSlug: github.Ptr("octo-app"),
+ TargetType: github.Ptr("Organization"),
+ RepositorySelection: github.Ptr("all"),
+ HTMLURL: github.Ptr("https://github.com/organizations/acme/settings/installations/42"),
+ Account: &github.User{Login: github.Ptr("acme")},
+ }
+ parent := &v2.ResourceId{ResourceType: resourceTypeOrg.Id, Resource: "1"}
+
+ resource, err := appResource(ctx, installation, parent)
+ require.NoError(t, err)
+ require.Equal(t, "octo-app", resource.DisplayName)
+ require.Equal(t, "42", resource.Id.Resource)
+ require.Equal(t, resourceTypeApp.Id, resource.Id.ResourceType)
+ require.Equal(t, parent.Resource, resource.ParentResourceId.Resource)
+
+ nhi, err := resourceSdk.GetNonHumanIdentityTrait(resource)
+ require.NoError(t, err)
+ require.Equal(t, v2.NonHumanIdentityTrait_NHI_TYPE_APP_REGISTRATION, nhi.GetNhiType())
+ require.Equal(t, "github.app", nhi.GetNhiDetail())
+
+ app, err := resourceSdk.GetAppTrait(resource)
+ require.NoError(t, err)
+ appID, ok := resourceSdk.GetProfileInt64Value(app.GetProfile(), "app_id")
+ require.True(t, ok)
+ require.Equal(t, int64(7), appID)
+ login, ok := resourceSdk.GetProfileStringValue(app.GetProfile(), "account_login")
+ require.True(t, ok)
+ require.Equal(t, "acme", login)
+}
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
index 7705b46c..1987212f 100644
--- a/pkg/connector/connector.go
+++ b/pkg/connector/connector.go
@@ -99,6 +99,12 @@ var (
Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_LICENSE_PROFILE},
Annotations: skipEntitlementsAnnotations("license"),
}
+ 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 {
@@ -127,6 +133,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/pkg/connector/helpers.go b/pkg/connector/helpers.go
index ef37f6f5..ece84762 100644
--- a/pkg/connector/helpers.go
+++ b/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/pkg/connector/org.go b/pkg/connector/org.go
index 5bf3bc43..4d17748d 100644
--- a/pkg/connector/org.go
+++ b/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/pkg/connector/repository.go b/pkg/connector/repository.go
index f9eb3b10..d5a6e961 100644
--- a/pkg/connector/repository.go
+++ b/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
}
diff --git a/pkg/connector/team.go b/pkg/connector/team.go
index 6cd75739..8e1579ba 100644
--- a/pkg/connector/team.go
+++ b/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,