Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Use this integration if your organization accesses GitHub at `github.com`. If yo
| Repositories | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> |
| Teams | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> |
| Orgs | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> |
| GitHub Apps (NHI) | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | |
| Secrets - API keys | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | |

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**.
Expand Down Expand Up @@ -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

Expand Down
15 changes: 1 addition & 14 deletions pkg/connector/api_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
133 changes: 133 additions & 0 deletions pkg/connector/app.go
Original file line number Diff line number Diff line change
@@ -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")
Comment on lines +100 to +101

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: The previous version of this code gracefully degraded on 403/404 by logging a warning and skipping the org, which was important for GitHub App auth where the installation token may lack organization_administration:read. The CapabilityPermissions annotation on the resource type is used for metadata reporting only (SDK connectorbuilder reads it in GetCapabilities), not for runtime error handling — so a missing permission will now propagate as a PermissionDenied gRPC error and fail the entire sync. If the intent is to make this permission strictly required, this is fine, but it's a behavior change for App-auth users whose installation token doesn't include Administration read access.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make permission required. if the customer doesn't want to sync apps should disable the resource

}

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,
}
}
47 changes: 47 additions & 0 deletions pkg/connector/app_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 7 additions & 0 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
35 changes: 32 additions & 3 deletions pkg/connector/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, ":")
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 2 additions & 6 deletions pkg/connector/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -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
}
Expand Down
7 changes: 1 addition & 6 deletions pkg/connector/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading