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,