diff --git a/README.md b/README.md index 6ec3d630..77016607 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ baton resources - Users - Teams - Repositories +- Enterprise roles, when `--enterprises` is set. With a GitHub App, the built-in Enterprise Owner role can also be granted and revoked. By default, `baton-github-enterprise` 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. @@ -109,4 +110,8 @@ Org: Repo: - Administrator: Read and Write - - This permission implies Metadata: Read \ No newline at end of file + - This permission implies Metadata: Read + +Enterprise (GitHub App with `--enterprises` only): +- Enterprise people: Read and Write + - The app must be installed on the enterprise account as well as on the organization. This is what syncs and provisions the built-in Enterprise Owner role. diff --git a/baton_capabilities.json b/baton_capabilities.json index 22db2e8d..ca6ce0e2 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -73,7 +73,8 @@ ] }, "capabilities": [ - "CAPABILITY_SYNC" + "CAPABILITY_SYNC", + "CAPABILITY_PROVISION" ], "permissions": {} }, diff --git a/config_schema.json b/config_schema.json index 2acf3e5e..d4884d4f 100644 --- a/config_schema.json +++ b/config_schema.json @@ -163,7 +163,7 @@ { "name": "enterprises", "displayName": "Enterprises", - "description": "Sync enterprise roles, must be an admin of the enterprise.", + "description": "Sync enterprise roles, must be an admin of the enterprise. A personal access token syncs every role. A GitHub App syncs and provisions the built-in Owner role instead, and needs to be installed on the enterprise account as well as on the organization, with the \"Enterprise people: read and write\" permission.", "stringSliceField": {} }, { diff --git a/docs/connector.mdx b/docs/connector.mdx index 3db1cdef..cc8947dc 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -21,7 +21,7 @@ If you access GitHub at `github.com`, go to the [GitHub](/baton/github) integrat | Teams | | | | Orgs\* | | | | Org roles\*\* | | | -| Enterprise roles | | | +| Enterprise roles\*\*\*\* | | | | Licenses | | | | Secrets - API keys | | | | GitHub App | | | @@ -35,12 +35,26 @@ The GitHub Enterprise connector supports [automatic account provisioning and dep \*\*\* Only synced when **Sync user last activity** is enabled. See [Sync member last activity](#sync-member-last-activity) below. +\*\*\*\* Only synced when **Enterprises** is set. Only the built-in **Enterprise Owner** role can be provisioned, and only with GitHub App authentication. See [Enterprise Owner provisioning](#enterprise-owner-provisioning) below. + [This connector can sync secrets](/product/admin/inventory) and display them on the **Inventory** page. -Syncing the **Licenses** resource type only works with a personal access token (classic). GitHub App installation tokens are not supported for license sync. +Syncing the **Licenses** resource type only works with a personal access token (classic). GitHub App installation tokens are not supported for license sync. If you use a GitHub App and set **Enterprises**, leave the **Licenses** resource type disabled. +### Enterprise Owner provisioning + +C1 can grant and revoke the built-in **Enterprise Owner** role when the connector uses a GitHub App that is installed on both the enterprise account and the organization, with the **Enterprise people** permission set to read and write. A personal access token syncs enterprise roles but cannot provision them. + +Granting Owner sends the user an enterprise administrator invitation. The grant appears in C1 right away, but the user holds the role only after accepting the invitation in GitHub, and C1 shows a pending invitation and an accepted Owner as the same grant. For time-bound access, the clock starts when the invitation is sent. If the window closes before the user accepts, the invitation is cancelled and they never hold the role. GitHub expires pending invitations after seven days. + +A user who already administers the enterprise, such as a billing manager, cannot be invited, and the request fails with GitHub's reason. Manage Owner for those users in GitHub. + +Revoking Owner keeps the user as a member of the enterprise and cancels an invitation that has not been accepted. + +Enterprise roles are a GitHub Enterprise Cloud feature. Setting **Enterprises** on GitHub Enterprise Server fails the sync, because that host has no Owner invitation API. On Enterprise Cloud, if the app is not installed on the enterprise account, or the organization does not belong to that enterprise, that enterprise is left out of the sync. C1 then drops its Owner grants until the installation is restored. + ## Gather GitHub Enterprise credentials Configuring the connector requires you to pass in credentials generated in GitHub Enterprise. Gather these credentials before you move on. To set up the GitHub Enterprise connector, you can choose to create either a personal access token (classic) or a fine-grained access token. @@ -103,11 +117,11 @@ Follow these instructions to integrate your GitHub instance by using a [GitHub a This process creates a GitHub app that is only available to your GitHub organization, then generates an installation token for that app, which can be used to integrate the GitHub organization with C1. This creates the equivalent of a personal access token, but does not tie the token to a specific identity. -**Integrating multiple GitHub organizations requires separate GitHub Apps and connectors.** GitHub Enterprise APIs do not support GitHub App tokens and require classic PATs with the [`admin:enterprise`](https://docs.github.com/en/rest/enterprise-teams/enterprise-teams?apiVersion=2022-11-28) scope. +**Integrating multiple GitHub organizations requires separate GitHub Apps and connectors.** To sync and provision the built-in Enterprise Owner role, the same app must also be installed on the enterprise account. License sync still requires a classic PAT with the [`admin:enterprise`](https://docs.github.com/en/rest/enterprise-teams/enterprise-teams?apiVersion=2022-11-28) scope. -A user with the **Org Owner** permission in the GitHub organization to be integrated with C1 must perform this task. +A user with the **Org Owner** permission in the GitHub organization to be integrated with C1 must perform this task. Installing the app on the enterprise account requires an **Enterprise Owner**. @@ -154,6 +168,7 @@ In the **Permissions** section of the page, give the app the following permissio - Enterprise permissions: + - **Enterprise people**: Read and write access (required to sync and provision the built-in Enterprise Owner role) - **Custom enterprise roles**: Read-only access - **Enterprise custom properties for organizations**: Read-only access @@ -189,6 +204,9 @@ Select the repositories the app can act on. Click **Install**. + +To sync and provision the built-in Enterprise Owner role, install the app on the enterprise account as well. Open the app's installation page again and select your enterprise. The enterprise is listed as an installation target when you are an Enterprise Owner and the app requests enterprise permissions. See [Installing a GitHub App on your enterprise](https://docs.github.com/enterprise-cloud@latest/apps/using-github-apps/installing-a-github-app-on-your-enterprise). The enterprise installation does not grant organization or repository access, so both installations are needed. + **Done.** Next, move on to the connector configuration instructions. @@ -241,7 +259,7 @@ If you're using a personal access token to set up the connector: 1. **Optional.** If you want to sync only specific organizations, enter the organizations' names in the **Organizations** field. If you do not specify specific organizations, C1 will sync all organizations. - 1. **Optional.** If you want to sync roles for only some enterprises, add the names of the enterprises in the **Enterprises to sync enterprise roles for** field. + 1. **Optional.** If you want to sync roles for only some enterprises, add the names of the enterprises in the **Enterprises** field. A personal access token syncs those roles and cannot provision them. 1. **Optional.** If you do not want to include archived repos in syncs, click to enable **Omit archived repositories**. @@ -262,7 +280,7 @@ If you're using a GitHub app to set up the connector: 1. In the **Organization** field, enter the name of the GitHub organization associated with the GitHub app. **You must enter a single organization name in this field or the connector configuration will fail.** - 1. **Optional.** If you want to sync roles for only some enterprises, add the names of the enterprises in the **Enterprises to sync enterprise roles for** field. + 1. **Optional.** To sync and provision the built-in Enterprise Owner role, enter the enterprise that owns the organization in the **Enterprises** field. The app must be installed on that enterprise account. See [Enterprise Owner provisioning](#enterprise-owner-provisioning). 1. **Optional.** If you do not want to include archived repos in syncs, click to enable **Omit archived repositories**. diff --git a/docs/docs-info.md b/docs/docs-info.md new file mode 100644 index 00000000..e3acd36e --- /dev/null +++ b/docs/docs-info.md @@ -0,0 +1,216 @@ +# GitHub Enterprise Connector Setup Guide + +This connector is a wrapper. `NewLambdaConnector` copies its config onto `baton-github` and returns that connector. Resource sync, grants, and provisioning are implemented there. This file records what is different because the instance is a custom domain and because this repo publishes the enterprise types. + +Behavior of each resource is in [baton-github `docs/docs-info.md`](https://github.com/ConductorOne/baton-github/blob/main/docs/docs-info.md). Do not fork that description here. + +--- + +## Requirements + +- A GitHub instance at a custom domain: GitHub Enterprise Cloud with data residency (`*.ghe.com`) or GitHub Enterprise Server +- Either a **personal access token (classic)** or a **GitHub App** +- For the built-in Enterprise Owner role: a GitHub App installed on **both** the enterprise account and the organization, with **Enterprise people** read and write. That role is GitHub Enterprise Cloud only + +--- + +## Connector capabilities + +1. **What resources does the connector sync?** + The same resources as `baton-github`: + - Organizations + - Users + - Invitations + - Teams + - Repositories + - Organization roles + - Enterprise roles, only when `--enterprises` is set + - Licenses, only when `--enterprises` is set + - GitHub Apps installed on the organization + - API keys, only when `--sync-secrets` is set + - A usage app and an audit-log usage feed, only when `--sync-last-activity` is set + +2. **Can the connector provision any resources? If so, which ones?** + The same operations as `baton-github`, including Grant and Revoke of the built-in Enterprise **Owner** role. A personal access token advertises that capability and the request fails naming the GitHub App. See [Enterprise Owner provisioning](#enterprise-owner-provisioning). + +3. **Does the connector emit any event feeds?** + Yes, when `--sync-last-activity` is set. The flag is a normal config field on this connector. On `baton-github` it is hidden, because audit-log access is an enterprise concern and this connector is the one that sets it. + +4. **Does the connector support grant expansion?** + Yes, the same three expansions as `baton-github`: baseline repository permission through the organization, an organization role assigned to a team, and an enterprise license through the role's `assigned` entitlement. + +--- + +## Connector credentials + +1. **What credentials or information are needed to set up the connector?** + + **Personal access token (classic)** + + **Args**: + `--token` — the GitHub personal access token + `--instance-url` — required, the instance URL + `--orgs` — optional, limits syncing to specific organizations + + **GitHub App** + + **Args**: + `--app-id` — the GitHub App ID + `--app-privatekey-path` — path to the App's private key `.pem` + `--org` — required, the single organization the App is installed on + `--instance-url` — required, the instance URL + + Common to both: + `--enterprises` — enterprises to sync enterprise roles and licenses for. A personal access token syncs every role. A GitHub App syncs and provisions the built-in Owner role, and the App must be installed on the enterprise account as well as the organization + `--sync-secrets` — GitHub App only. Sync fine-grained personal access tokens as API keys + `--sync-last-activity` — emit the audit-log usage event feed + `--omit-archived-repositories` — skip archived repositories + `--direct-collaborators-only` — reduce API calls on large organizations + +2. **How does a user create or look up that credential?** + The customer steps are in `docs/connector.mdx`. The enterprise installation is a second install of the same App: an enterprise owner opens the App's installation page and selects the enterprise. That installation does not grant organization or repository access, so both are required. See [Installing a GitHub App on your enterprise](https://docs.github.com/en/enterprise-cloud@latest/apps/using-github-apps/installing-a-github-app-on-your-enterprise). + +--- + +## Resource Details + +Each type below is built by `baton-github`. The IDs match `baton_capabilities.json` in this repo. + +### Organizations + +- **Resource type ID**: `org` +- **Provisioning**: Grant and Revoke of organization membership + +### Users + +- **Resource type ID**: `user` +- **Provisioning**: `Delete` removes the user from the organization + +### Invitations + +- **Resource type ID**: `invitation` +- **Provisioning**: `CreateAccount` sends an organization invitation; `Delete` cancels it + +### Teams + +- **Resource type ID**: `team` +- **Provisioning**: Grant and Revoke of team membership + +### Repositories + +- **Resource type ID**: `repository` +- **Provisioning**: Grant and Revoke of repository access + +### Organization roles + +- **Resource type ID**: `org_role` +- **Provisioning**: Grant and Revoke of the role assignment + +### Enterprise roles + +- **Resource type ID**: `enterprise_role` +- **Description**: Synced only when `--enterprises` is set. A personal access token reads every role from consumed-licenses. A GitHub App reads the built-in Owner role, plus pending Owner invitations, and can grant and revoke that role +- **Entitlements**: `assigned` +- **Provisioning**: Advertised in this connector's published capabilities. Only Owner provisioning succeeds, and only with a GitHub App. See [Enterprise Owner provisioning](#enterprise-owner-provisioning) + +### Licenses + +- **Resource type ID**: `license` +- **Description**: Enterprise seat consumption, only when `--enterprises` is set +- **Limitation**: Personal access token only. A GitHub App gets 403 from consumed-licenses. The type is opt-in. On a GitHub App deployment that sets `--enterprises`, leave it disabled or the sync fails + +### GitHub Apps + +- **Resource type ID**: `app` +- **Provisioning**: None + +### API keys + +- **Resource type ID**: `api-key` +- **Provisioning**: None. Synced only when `--sync-secrets` is set + +--- + +## Enterprise Owner provisioning + +The mutations and the read path are `baton-github`'s. What this repo adds is who publishes the capability, and what a custom domain does to it. + +`pkg/connector/connector.go` registers `EnterpriseRoleProvisioningBuilder` in `defaultCapabilitiesBuilder`. `baton-github`'s `DefaultCapabilitiesBuilder` omits `enterprise_role` and `license`, because that catalog describes a `github.com` account without an enterprise. Delegating to it would drop both types from this connector the moment the dependency is bumped. The provisioning builder is the one the running connector registers when `--enterprises` is set. The read-only `EnterpriseRoleBuilder` does not implement Grant and Revoke, so registering it here would publish sync only. + +There is no separate flag. `--enterprises` turns the role on. Both credentials get the provisioning builder. A personal access token fails Grant and Revoke with an error that names the GitHub App. + +Grant invites the user (`inviteEnterpriseAdmin`). GitHub assigns Owner when they accept, and the grant ID does not change. A pending invitation and an accepted Owner are the same grant. If the user already holds an administrator role, such as billing manager, GitHub rejects the invite and the connector returns `FailedPrecondition` instead of promoting them. The prior role cannot be read back, so Revoke could not restore it. + +Revoke demotes an accepted Owner to `UNAFFILIATED`, which keeps enterprise membership, and cancels a pending invitation. `NOT_FOUND` on either mutation is success. + +### GitHub Enterprise Server is not Cloud + +`isEnterpriseCloud` treats `github.com`, `ghe.com`, and `*.ghe.com` as Enterprise Cloud. Any other host is GitHub Enterprise Server. With `--enterprises` set, a Server host fails the sync before the enterprise API is called. The error says the capability is Cloud-only and to remove `--enterprises`. + +Server has `addEnterpriseAdmin` and `removeEnterpriseAdmin`. It does not have `inviteEnterpriseAdmin`, `updateEnterpriseAdministratorRole`, or `cancelEnterpriseAdminInvitation`. That path is not implemented. + +`GET /enterprises/{enterprise}/consumed-licenses` is also absent on Server, for either credential. The license type still reaches that call on the token path and gets a 404. The host check above covers the enterprise role, not this license call. + +### A setup problem skips the role; anything else fails the sync + +On Cloud, a setup problem (the App is not installed on the enterprise, or the organization does not belong to it) completes the sync and leaves that enterprise out. Several configured slugs are skipped one by one, and an enterprise whose client built is kept. An organization belongs to one enterprise, so under App auth at most one slug succeeds. The skipped slug is tried again on the next sync. + +A rate limit, a 5xx, or a cancelled context fails the sync. Grant and Revoke still return the error. + +The trade-off: a deployment that was syncing Owner and then loses the enterprise installation completes one sync with that enterprise missing. C1 drops the Owner role and its grants until the installation is restored, and a time-bound grant that expires in that window has nothing left to revoke. Failing the sync instead would turn an App deployment that never installed on the enterprise into a red sync. Server is not part of this skip. It fails closed. + +### Pending invitations + +C1 has no pending state, so an invitation and an accepted Owner look the same. Time-bound access starts when the invitation is sent, not when it is accepted. GitHub expires a pending invitation after seven days. The sync can only see invitations for users who are already enterprise members. Grant rejects a principal who is not a member. + +### Published capabilities + +`baton_capabilities.json` is what C1 stamps when the connector is created. This repo's file includes `enterprise_role` with `CAPABILITY_SYNC` and `CAPABILITY_PROVISION`, and `license` with `CAPABILITY_SYNC`. A customer still has to select those types when selective sync is on. The type is off until selected, not unreachable. + +Regenerate after a resource-type or config-field change with `./baton-github-enterprise capabilities` and `./baton-github-enterprise config`, without credentials. On a push to `main`, `.github/workflows/generate-baton-metadata.yaml` regenerates both and commits them. Pull requests only check that the committed files match the binary. + +--- + +## Authentication + +Same two methods as `baton-github`. A GitHub App that provisions Owner holds two installation tokens: one for the organization, which reads `Organization.enterpriseOwners`, and one for the enterprise account, which runs the administrator mutations. + +--- + +## API Endpoints Used + +Owned by `baton-github`. The calls this connector's enterprise path depends on: + +**REST** + +- `GET /enterprises/{enterprise}/installation` — the App's installation on the enterprise. A 404 is treated as a setup problem +- `GET /enterprises/{enterprise}/consumed-licenses` — enterprise roles on the token path, and licenses. PAT only. Absent on GitHub Enterprise Server + +**GraphQL** + +- `organization(login:) { enterpriseOwners }` — Owner role grants, organization installation token +- `enterpriseAdministratorInvitation` — a pending Owner invitation for one login +- `inviteEnterpriseAdmin`, `updateEnterpriseAdministratorRole`, `cancelEnterpriseAdminInvitation` — Owner Grant and Revoke. Cloud only + +--- + +## Pagination + +Same as `baton-github`. Consumed-licenses is 1-indexed. GraphQL connections use cursor pagination, 100 per page. + +--- + +## Rate Limits + +Same as `baton-github`. The connector returns GitHub's rate limit headers to the SDK. A rate limit while building an enterprise client fails the sync. It is not a setup problem. + +--- + +## API Documentation + +- **REST**: https://docs.github.com/en/rest +- **GraphQL**: https://docs.github.com/en/graphql +- **Installing a GitHub App on your enterprise**: https://docs.github.com/en/enterprise-cloud@latest/apps/using-github-apps/installing-a-github-app-on-your-enterprise +- **Inviting people to manage your enterprise**: https://docs.github.com/en/enterprise-cloud@latest/admin/managing-accounts-and-repositories/managing-users-in-your-enterprise/inviting-people-to-manage-your-enterprise +- **Enterprise licensing**: https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/license +- **baton-github internal notes**: https://github.com/ConductorOne/baton-github/blob/main/docs/docs-info.md diff --git a/go.mod b/go.mod index f2896fe6..a78fb2f0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/conductorone/baton-github-enterprise go 1.27.1 require ( - github.com/conductorone/baton-github v0.4.7 + github.com/conductorone/baton-github v0.4.8 github.com/conductorone/baton-sdk v0.36.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 7532bfdb..6b63f865 100644 --- a/go.sum +++ b/go.sum @@ -84,8 +84,8 @@ github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258 h1:IJ+uNItEm0qx9 github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258/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.4.7 h1:a/YRA4Iy4ey9wk2Azrg0v+E/AWmltnvPzVkPGJIQP6c= -github.com/conductorone/baton-github v0.4.7/go.mod h1:rooHPNjHMlUi/5WTG7obpFwwLEqJQr9CCyLzgF2IGus= +github.com/conductorone/baton-github v0.4.8 h1:MnHJ52afdVDJGrfv5nINu7An2SoLjgMtDvA/ILLqLRo= +github.com/conductorone/baton-github v0.4.8/go.mod h1:0J0AmiX5H3pW+jH807dSVAH2xcyXxv9Vlc2yE2l+rJg= github.com/conductorone/baton-sdk v0.36.0 h1:bysRT+cYuZ4m/4FIsz3lbCchKzgdmwfOD4zG8xVeGsY= github.com/conductorone/baton-sdk v0.36.0/go.mod h1:EJcj98rPL8NYoD5UTHeHNrJl7LuCevvNWQOzGazJPDU= github.com/conductorone/dpop v0.3.0 h1:j5fZk0VqepGKYo+/NDikCOMsZcgs4HO4i0k56wRel5g= diff --git a/pkg/config/config.go b/pkg/config/config.go index 9bcddc1d..834aa813 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -26,7 +26,10 @@ var ( EnterprisesField = field.StringSliceField( "enterprises", field.WithDisplayName("Enterprises"), - field.WithDescription("Sync enterprise roles, must be an admin of the enterprise."), + field.WithDescription("Sync enterprise roles, must be an admin of the enterprise. "+ + "A personal access token syncs every role. A GitHub App syncs and provisions the built-in Owner "+ + "role instead, and needs to be installed on the enterprise account as well as on the organization, "+ + "with the \"Enterprise people: read and write\" permission."), ) instanceUrlField = field.StringField( "instance-url", diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 30253ba4..7624c90e 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -42,6 +42,8 @@ func (d *defaultCapabilitiesBuilder) Validate(_ context.Context) (annotations.An return nil, nil } +// Includes enterprise role and license. baton-github omits both from its +// published capabilities. The enterprise role is provisionable. func (d *defaultCapabilitiesBuilder) ResourceSyncers(_ context.Context) []connectorbuilder.ResourceSyncerV2 { return []connectorbuilder.ResourceSyncerV2{ connector.OrgBuilder(nil, nil, nil, nil, false), @@ -51,7 +53,7 @@ func (d *defaultCapabilitiesBuilder) ResourceSyncers(_ context.Context) []connec connector.OrgRoleBuilder(nil, nil), connector.InvitationBuilder(connector.InvitationBuilderParams{}), connector.APITokenBuilder(nil, nil), - connector.EnterpriseRoleBuilder(nil, nil, nil, nil), + connector.EnterpriseRoleProvisioningBuilder(nil, nil, nil, nil), connector.LicenseBuilder(nil, nil), connector.AppBuilder(nil, nil), } diff --git a/vendor/github.com/conductorone/baton-github/pkg/config/config.go b/vendor/github.com/conductorone/baton-github/pkg/config/config.go index 60856c56..ecfb0d51 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/config/config.go +++ b/vendor/github.com/conductorone/baton-github/pkg/config/config.go @@ -26,7 +26,10 @@ var ( EnterprisesField = field.StringSliceField( "enterprises", field.WithDisplayName("Enterprises"), - field.WithDescription("Sync enterprise roles, must be an admin of the enterprise."), + field.WithDescription("Sync enterprise roles, must be an admin of the enterprise. "+ + "A personal access token syncs every role. A GitHub App syncs and provisions the built-in Owner "+ + "role instead, and needs to be installed on the enterprise account as well as on the organization, "+ + "with the \"Enterprise people: read and write\" permission."), ) instanceUrlField = field.StringField( "instance-url", 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 b1bd1017..22e16bfb 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/connector.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/connector.go @@ -30,7 +30,7 @@ import ( "google.golang.org/grpc/status" ) -const githubDotCom = "https://github.com" +const githubDotCom = customclient.GitHubDotCom // JWT token expires in 10 minutes, so we set it to 9 minutes to leave some buffer. const jwtExpiryTime = 9 * time.Minute @@ -127,6 +127,7 @@ type GitHub struct { omitArchivedRepositories bool directCollaboratorsOnly bool enterprises []string + newEnterpriseRoleClients enterpriseClientProvider syncLastActivity bool } @@ -156,7 +157,10 @@ func (gh *GitHub) ResourceSyncers(ctx context.Context) []connectorbuilder.Resour if len(gh.enterprises) > 0 { resourceSyncers = append(resourceSyncers, - EnterpriseRoleBuilder(gh.client, gh.appClient, gh.customClient, gh.enterprises), + EnterpriseRoleProvisioningBuilder( + gh.client, gh.customClient, gh.enterprises, + gh.newEnterpriseRoleClients, + ), LicenseBuilder(gh.customClient, gh.enterprises), ) } @@ -288,9 +292,9 @@ func (gh *GitHub) validateAppCredentials(ctx context.Context) (annotations.Annot l := ctxzap.Extract(ctx) _, _, err := gh.customClient.ListEnterpriseConsumedLicenses(ctx, gh.enterprises[0], 1) if err != nil { - l.Debug("baton-github: enterprise features (--enterprises) require a Personal Access Token. "+ - "GitHub App authentication cannot access the consumed-licenses API. "+ - "Either switch to PAT auth or remove the --enterprises flag.", + l.Debug("baton-github: enterprise license data requires a Personal Access Token. "+ + "GitHub App authentication cannot access the consumed-licenses API, "+ + "so the license resource type cannot sync.", zap.Error(err)) } } @@ -381,6 +385,7 @@ func appPrivateKeyPEM(ghc *cfg.Github) (string, error) { } func newWithGithubApp(ctx context.Context, ghc *cfg.Github) (*GitHub, error) { + enterprises := distinctEnterprises(ghc.Enterprises) privateKey, err := appPrivateKeyPEM(ghc) if err != nil { return nil, err @@ -457,13 +462,23 @@ func newWithGithubApp(ctx context.Context, ghc *cfg.Github) (*GitHub, error) { return nil, err } + // Built lazily so construction and Validate don't depend on the enterprise + // installation. The memoized clients refresh their token with connectorCtx, + // not the ctx of the RPC that first built them, which is cancelled when it returns. + connectorCtx := ctx + newEnterpriseRoleClientsFn := func(ctx context.Context, enterprises []string) (map[string]*customclient.EnterpriseAdminClient, error) { + return newEnterpriseRoleClients( + ctx, connectorCtx, ghc.InstanceUrl, appClient, jwtts, enterprises, appHTTPClient, ghc.Org) + } + gh := &GitHub{ client: ghClient, appClient: appClient, customClient: customclient.New(ghClient), instanceURL: ghc.InstanceUrl, orgs: []string{ghc.Org}, - enterprises: ghc.Enterprises, + enterprises: enterprises, + newEnterpriseRoleClients: newEnterpriseRoleClientsFn, graphqlClient: graphqlClient, orgCache: newOrgNameCache(ghClient), syncSecrets: ghc.SyncSecrets, @@ -474,32 +489,227 @@ func newWithGithubApp(ctx context.Context, ghc *cfg.Github) (*GitHub, error) { return gh, nil } -func newGitHubGraphqlClient(ctx context.Context, instanceURL string, ts oauth2.TokenSource) (*githubv4.Client, error) { - instanceURL = strings.TrimSuffix(instanceURL, "/") +// newEnterpriseRoleClients builds one client per configured enterprise using +// that enterprise's own installation token, since enterprise mutations reject +// the org token. +// +// It returns the clients that built. A setup error skips that enterprise; the +// caller skips the whole type only when none built, and fails the sync for +// any other error, because a sync that completes without owners for a +// transient reason makes C1 delete the Owner role and every grant on it. +// See enterpriseSetupError. +// +// ctx scopes the discovery requests; connectorCtx is kept by the memoized +// clients for refreshing the installation token. +func newEnterpriseRoleClients( + ctx context.Context, + connectorCtx context.Context, + instanceURL string, + appClient *github.Client, + jwtTokenSource oauth2.TokenSource, + enterprises []string, + orgHTTPClient *http.Client, + org string, +) (map[string]*customclient.EnterpriseAdminClient, error) { + enterprises = distinctEnterprises(enterprises) + if len(enterprises) == 0 { + return nil, nil + } + // GitHub Enterprise Server has no enterprise administrator API: the + // invitation mutations and the enterprise installation endpoint are both + // absent from its REST and GraphQL references. Checking the host first + // keeps the operator from being told to install the app on an enterprise + // account their instance does not have, which is what the 404 below would + // otherwise read as. Not an enterpriseSetupError, unlike the two checks + // after it: a Server instance was already failing its sync before this API + // was used, since the consumed-licenses fallback answers 404 there and + // fillCache returns every consumed-licenses error, so skipping would hide + // a configuration that cannot work rather than preserve an outcome it + // used to have. + if !isEnterpriseCloud(instanceURL) { + return nil, status.Errorf(codes.FailedPrecondition, + "baton-github: the built-in Owner role is a GitHub Enterprise Cloud capability and %s does not serve it; "+ + "remove --enterprises on this instance", instanceURL) + } + + // NewBaseHttpClient returns nil when its cache setup fails. + installationClient := customclient.New(appClient) + if installationClient.BaseHttpClient == nil { + return nil, fmt.Errorf("baton-github: error building the enterprise installation client") + } + + return collectEnterpriseRoleClients(ctx, enterprises, func(enterprise string) (*customclient.EnterpriseAdminClient, error) { + return newEnterpriseRoleClient( + ctx, connectorCtx, instanceURL, appClient, installationClient, + jwtTokenSource, orgHTTPClient, enterprise, org, len(enterprises)) + }) +} - var enterpriseGqlURL string - if instanceURL != "" && instanceURL != githubDotCom { - parsed, err := url.Parse(instanceURL) - if err != nil { +// collectEnterpriseRoleClients keeps every enterprise whose client built. +// A setup error skips that one enterprise. Anything else fails the build: +// the owners are unknown rather than absent, and a sync that completed +// without them would make C1 delete the grants. When none build, the first +// setup error is returned so the caller can skip the type. +func collectEnterpriseRoleClients( + ctx context.Context, + enterprises []string, + build func(enterprise string) (*customclient.EnterpriseAdminClient, error), +) (map[string]*customclient.EnterpriseAdminClient, error) { + clients := make(map[string]*customclient.EnterpriseAdminClient, len(enterprises)) + var setupErr error + for _, enterprise := range enterprises { + client, err := build(enterprise) + switch { + case err == nil: + clients[enterprise] = client + case isEnterpriseSetupError(err): + if setupErr == nil { + setupErr = err + } + ctxzap.Extract(ctx).Warn("baton-github: skipping an enterprise the GitHub App is not set up to read", + zap.String("enterprise", enterprise), + zap.Error(err)) + default: return nil, err } - parsed.Path = "/api/graphql" - enterpriseGqlURL = parsed.String() + } + if len(clients) == 0 { + return nil, setupErr + } + + return clients, nil +} + +// newEnterpriseRoleClient builds the administration client of one enterprise +// with that enterprise's own installation token. +func newEnterpriseRoleClient( + ctx context.Context, + connectorCtx context.Context, + instanceURL string, + appClient *github.Client, + installationClient *customclient.Client, + jwtTokenSource oauth2.TokenSource, + orgHTTPClient *http.Client, + enterprise string, + org string, + configured int, +) (*customclient.EnterpriseAdminClient, error) { + installation, _, err := installationClient.GetEnterpriseInstallation(ctx, enterprise) + if err != nil { + if status.Code(err) == codes.NotFound { + return nil, enterpriseSetupError{status.Error(codes.FailedPrecondition, + enterpriseNotInstalledMessage(enterprise, org, configured))} + } + return nil, err + } + installationID := installation.ID + + token, err := getInstallationToken(ctx, appClient, installationID) + if err != nil { + return nil, err + } + + ts := newRefreshableTokenSource( + &oauth2.Token{ + AccessToken: token.GetToken(), + Expiry: token.GetExpiresAt().Time, + }, + &appTokenRefresher{ + ctx: connectorCtx, + instanceURL: instanceURL, + installationID: installationID, + jwtTokenSource: jwtTokenSource, + }, + ) + + httpClient, err := newGitHubAppHTTPClient(connectorCtx, ts) + if err != nil { + return nil, err + } + + client, err := customclient.NewEnterpriseAdminClient(instanceURL, httpClient, orgHTTPClient, org) + if err != nil { + return nil, err + } + if err := client.VerifyOrganization(ctx, enterprise); err != nil { + return nil, asEnterpriseSetupError(err) + } + if err := client.ResolveEnterpriseNodeID(ctx, enterprise); err != nil { + return nil, err + } + + return client, nil +} + +// enterpriseNotInstalledMessage names the fix for an enterprise the app cannot +// reach. Installing the app is the fix for one; with several configured it is +// not, because the organization the owners are read through belongs to exactly +// one enterprise, so the operator would install an app on an account that +// still cannot be served. +func enterpriseNotInstalledMessage(enterprise, org string, configured int) string { + msg := fmt.Sprintf("baton-github: GitHub App is not installed on enterprise %q; install it on the enterprise "+ + "account with the Enterprise people read and write permission", enterprise) + if configured > 1 { + msg += fmt.Sprintf("; with %d enterprises configured, installing it may not be enough, because the owners "+ + "are read through organization %q, which belongs to exactly one -- remove the slugs it does not belong to", + configured, org) + } + + return msg +} + +// isEnterpriseCloud reports whether the instance serves the enterprise +// administrator API. Enterprise Cloud is reached at github.com, or at a +// data-residency host under ghe.com, which GitHub owns -- anything else is a +// customer-hosted Enterprise Server instance. +func isEnterpriseCloud(instanceURL string) bool { + trimmed := strings.TrimSuffix(instanceURL, "/") + if trimmed == "" || trimmed == githubDotCom { + return true + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return false + } + host := strings.ToLower(parsed.Hostname()) + + return host == "github.com" || host == "ghe.com" || strings.HasSuffix(host, ".ghe.com") +} + +// distinctEnterprises dedupes slugs case-insensitively, as GitHub matches them. +// The clients are keyed by the slug as configured, so a repeat would otherwise +// build two entries for one enterprise and emit its Owner role twice. +func distinctEnterprises(enterprises []string) []string { + seen := make(map[string]struct{}, len(enterprises)) + distinct := make([]string, 0, len(enterprises)) + for _, enterprise := range enterprises { + key := strings.ToLower(enterprise) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + distinct = append(distinct, enterprise) + } + return distinct +} + +func newGitHubGraphqlClient(ctx context.Context, instanceURL string, ts oauth2.TokenSource) (*githubv4.Client, error) { + endpoint, err := customclient.EnterpriseGraphQLEndpoint(instanceURL) + if err != nil { + return nil, err } httpClient, err := uhttp.NewClient(ctx, uhttp.WithLogger(true, ctxzap.Extract(ctx))) if err != nil { return nil, err } - httpClient.Transport = &statusClassifyingTransport{base: httpClient.Transport} + httpClient.Transport = customclient.NewStatusClassifyingTransport(httpClient.Transport) ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) tc := oauth2.NewClient(ctx, ts) - if enterpriseGqlURL != "" { - return githubv4.NewEnterpriseClient(enterpriseGqlURL, tc), nil - } - return githubv4.NewClient(tc), nil + return githubv4.NewEnterpriseClient(endpoint.String(), tc), nil } // escapedLineBreaks unescapes LF-, CRLF-, and CR-escaped line breaks (`\r\n`, diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/default_capabilities.go b/vendor/github.com/conductorone/baton-github/pkg/connector/default_capabilities.go new file mode 100644 index 00000000..f0dbe55d --- /dev/null +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/default_capabilities.go @@ -0,0 +1,47 @@ +package connector + +import ( + "context" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" +) + +// DefaultCapabilitiesBuilder is used only by the `capabilities` command, which +// runs without config. The published metadata describes a GitHub account +// without an enterprise, including the types --sync-secrets and +// --sync-last-activity enable; --enterprises types are reported by the live +// connector only. +type DefaultCapabilitiesBuilder struct{} + +func (d *DefaultCapabilitiesBuilder) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { + return (&GitHub{}).Metadata(ctx) +} + +func (d *DefaultCapabilitiesBuilder) Validate(_ context.Context) (annotations.Annotations, error) { + return nil, nil +} + +// ResourceSyncers lists the syncers a GitHub account without an enterprise +// can register, including the ones --sync-secrets and --sync-last-activity +// gate. Their nil dependencies are never used. +func (d *DefaultCapabilitiesBuilder) ResourceSyncers(_ context.Context) []connectorbuilder.ResourceSyncerV2 { + return []connectorbuilder.ResourceSyncerV2{ + OrgBuilder(nil, nil, nil, nil, false), + TeamBuilder(nil, nil, false), + UserBuilder(nil, nil, nil, nil, nil, nil), + RepositoryBuilder(nil, nil, false, false), + OrgRoleBuilder(nil, nil), + InvitationBuilder(InvitationBuilderParams{}), + AppBuilder(nil, nil), + APITokenBuilder(nil, nil), + newUsageAppBuilder(), + } +} + +func (d *DefaultCapabilitiesBuilder) EventFeeds(_ context.Context) []connectorbuilder.EventFeed { + return []connectorbuilder.EventFeed{ + newUsageEventFeed(nil, nil), + } +} diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/enterprise_role.go b/vendor/github.com/conductorone/baton-github/pkg/connector/enterprise_role.go index a6e79aab..54d49556 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/enterprise_role.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/enterprise_role.go @@ -4,35 +4,169 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "sync" "github.com/conductorone/baton-github/pkg/customclient" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/conductorone/baton-sdk/pkg/pagination" "github.com/conductorone/baton-sdk/pkg/types/entitlement" "github.com/conductorone/baton-sdk/pkg/types/grant" resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/google/go-github/v69/github" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "github.com/shurcooL/githubv4" "go.uber.org/zap" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) +const ( + enterpriseRoleAssigned = "assigned" + enterpriseRoleOwner = "Owner" + + // Sync phases of the Owner grants. The invitations cannot be listed, so + // they are resolved from the members in a second pass over the same token. + enterpriseOwnersPhase = "enterprise-owners" + enterprisePendingPhase = "enterprise-pending-invitations" + + // UNAFFILIATED demotes an administrator while keeping their enterprise + // membership; removeEnterpriseAdmin would evict them from the enterprise. + enterpriseAdministratorRoleUnaffiliated githubv4.EnterpriseAdministratorRole = "UNAFFILIATED" +) + +// enterpriseClientProvider builds administration clients for the given enterprises. +type enterpriseClientProvider func(ctx context.Context, enterprises []string) (map[string]*customclient.EnterpriseAdminClient, error) + type enterpriseRoleResourceType struct { resourceType *v2.ResourceType client *github.Client - appClient *github.Client customClient *customclient.Client enterprises []string roleUsersCache map[string][]string mu *sync.Mutex + // newEnterpriseClients is nil under PAT auth. + newEnterpriseClients enterpriseClientProvider + // enterpriseClients holds the enterprises whose clients built. + enterpriseClients map[string]*customclient.EnterpriseAdminClient + // skippedEnterprises were setup errors. List and provisioning retry them; + // Grants pages do not, so a typo slug is not looked up on every page. + skippedEnterprises map[string]struct{} } func (o *enterpriseRoleResourceType) ResourceType(_ context.Context) *v2.ResourceType { return o.resourceType } +// clients returns the administration clients. Enterprises that built are kept. +// A setup error is remembered so Grants pages do not look it up again; List +// and provisioning pass retrySkipped and try it on the next sync. A hard +// failure is not remembered. +func (o *enterpriseRoleResourceType) clients( + ctx context.Context, + retrySkipped bool, +) (map[string]*customclient.EnterpriseAdminClient, error) { + if o.newEnterpriseClients == nil { + return nil, nil + } + + o.mu.Lock() + defer o.mu.Unlock() + + missing := o.enterprisesToBuild(retrySkipped) + if len(missing) == 0 { + return cloneEnterpriseClients(o.enterpriseClients), nil + } + + built, err := o.newEnterpriseClients(ctx, missing) + if err != nil { + // One skipped enterprise must not discard the clients that already built. + if isEnterpriseSetupError(err) && len(o.enterpriseClients) > 0 { + o.rememberSkipped(missing) + return cloneEnterpriseClients(o.enterpriseClients), nil + } + return nil, err + } + + if o.enterpriseClients == nil { + o.enterpriseClients = map[string]*customclient.EnterpriseAdminClient{} + } + if o.skippedEnterprises == nil { + o.skippedEnterprises = map[string]struct{}{} + } + for _, enterprise := range missing { + client, ok := built[enterprise] + if !ok { + o.skippedEnterprises[enterprise] = struct{}{} + continue + } + o.enterpriseClients[enterprise] = client + delete(o.skippedEnterprises, enterprise) + } + + return cloneEnterpriseClients(o.enterpriseClients), nil +} + +// enterprisesToBuild is every configured enterprise on the first call. +// After that it is the ones that have no client, and the skipped ones only +// when the caller asked to retry them. +func (o *enterpriseRoleResourceType) enterprisesToBuild(retrySkipped bool) []string { + if o.enterpriseClients == nil && o.skippedEnterprises == nil { + return distinctEnterprises(o.enterprises) + } + + var missing []string + for _, enterprise := range distinctEnterprises(o.enterprises) { + if _, ok := o.enterpriseClients[enterprise]; ok { + continue + } + if _, skipped := o.skippedEnterprises[enterprise]; skipped && !retrySkipped { + continue + } + missing = append(missing, enterprise) + } + return missing +} + +func (o *enterpriseRoleResourceType) rememberSkipped(enterprises []string) { + if o.skippedEnterprises == nil { + o.skippedEnterprises = map[string]struct{}{} + } + for _, enterprise := range enterprises { + o.skippedEnterprises[enterprise] = struct{}{} + } +} + +func cloneEnterpriseClients( + clients map[string]*customclient.EnterpriseAdminClient, +) map[string]*customclient.EnterpriseAdminClient { + if clients == nil { + return nil + } + cloned := make(map[string]*customclient.EnterpriseAdminClient, len(clients)) + for enterprise, client := range clients { + cloned[enterprise] = client + } + return cloned +} + +// noClientReason explains why no administration client exists for enterprise. +func (o *enterpriseRoleResourceType) noClientReason(enterprise string) string { + if o.newEnterpriseClients == nil { + return "a personal access token can sync enterprise roles but cannot provision them, " + + "which needs GitHub App authentication" + } + for _, configured := range o.enterprises { + if strings.EqualFold(configured, enterprise) { + return "the GitHub App is not set up to read it" + } + } + return "it is not one of the configured enterprises" +} + func (o *enterpriseRoleResourceType) cacheRole(roleId string, userLogin string) { o.mu.Lock() defer o.mu.Unlock() @@ -56,7 +190,6 @@ func (o *enterpriseRoleResourceType) getRoleUsersCache(ctx context.Context) (map } func (o *enterpriseRoleResourceType) fillCache(ctx context.Context) error { - l := ctxzap.Extract(ctx) for _, enterprise := range o.enterprises { // GitHub's consumed-licenses API is 1-indexed; page 0 is undocumented // and may return the same results as page 1, causing duplicates. @@ -65,14 +198,6 @@ func (o *enterpriseRoleResourceType) fillCache(ctx context.Context) error { for continuePagination { consumedLicenses, _, err := o.customClient.ListEnterpriseConsumedLicenses(ctx, enterprise, page) if err != nil { - if page == 1 && o.appClient != nil && isPermissionDenied(err) { - l.Debug("baton-github: enterprise features (--enterprises) require a Personal Access Token. "+ - "GitHub App authentication cannot access the consumed-licenses API. "+ - "Either switch to PAT auth or remove the --enterprises flag.", - zap.String("enterprise", enterprise), - zap.Error(err)) - return nil - } return fmt.Errorf("baton-github: error listing enterprise consumed licenses for %s: %w", enterprise, err) } @@ -97,6 +222,20 @@ func (o *enterpriseRoleResourceType) List( parentID *v2.ResourceId, opts resourceSdk.SyncOpAttrs, ) ([]*v2.Resource, *resourceSdk.SyncOpResults, error) { + enterpriseClients, err := o.clients(ctx, true) + if err != nil { + if isEnterpriseSetupError(err) { + warnEnterpriseRolesSkipped(ctx, err) + return nil, &resourceSdk.SyncOpResults{}, nil + } + return nil, nil, failClosedOnUnreadableEnterprise(err, strings.Join(o.enterprises, ", ")) + } + + // Under app auth only the Owner role is readable; consumed-licenses is PAT-only. + if len(enterpriseClients) > 0 { + return appList(o.enterprises, enterpriseClients) + } + var ret []*v2.Resource cache, err := o.getRoleUsersCache(ctx) if err != nil { @@ -135,7 +274,7 @@ func (o *enterpriseRoleResourceType) StaticEntitlements( _ resourceSdk.SyncOpAttrs, ) ([]*v2.Entitlement, *resourceSdk.SyncOpResults, error) { rv := []*v2.Entitlement{} - rv = append(rv, entitlement.NewAssignmentEntitlement(nil, "assigned", + rv = append(rv, entitlement.NewAssignmentEntitlement(nil, enterpriseRoleAssigned, entitlement.WithDisplayName("Role Assigned"), entitlement.WithDescription("Assignment to enterprise role in GitHub"), entitlement.WithGrantableTo(resourceTypeUser), @@ -149,6 +288,18 @@ func (o *enterpriseRoleResourceType) Grants( resource *v2.Resource, opts resourceSdk.SyncOpAttrs, ) ([]*v2.Grant, *resourceSdk.SyncOpResults, error) { + enterpriseClients, err := o.clients(ctx, false) + if err != nil { + if isEnterpriseSetupError(err) { + warnEnterpriseRolesSkipped(ctx, err) + return nil, &resourceSdk.SyncOpResults{}, nil + } + return nil, nil, failClosedOnUnreadableEnterprise(err, strings.Join(o.enterprises, ", ")) + } + if len(enterpriseClients) > 0 { + return o.appGrants(ctx, enterpriseClients, resource, opts) + } + cache, err := o.getRoleUsersCache(ctx) if err != nil { return nil, nil, fmt.Errorf("baton-github: error getting user roles cache: %w", err) @@ -168,7 +319,7 @@ func (o *enterpriseRoleResourceType) Grants( ret = append(ret, grant.NewGrant( resource, - "assigned", + enterpriseRoleAssigned, principalId, )) } @@ -176,22 +327,441 @@ func (o *enterpriseRoleResourceType) Grants( return ret, &resourceSdk.SyncOpResults{}, nil } -func EnterpriseRoleBuilder(client *github.Client, appClient *github.Client, customClient *customclient.Client, enterprises []string) *enterpriseRoleResourceType { +var _ connectorbuilder.ResourceProvisionerV2 = (*enterpriseRoleProvisioner)(nil) + +// enterpriseRoleProvisioner adds Grant and Revoke to the read-only syncer. Under +// PAT auth both fail, naming the credential they need. +type enterpriseRoleProvisioner struct { + *enterpriseRoleResourceType +} + +// EnterpriseRoleProvisioningBuilder returns the syncer with Grant and Revoke. +func EnterpriseRoleProvisioningBuilder( + client *github.Client, + customClient *customclient.Client, + enterprises []string, + newEnterpriseClients enterpriseClientProvider, +) *enterpriseRoleProvisioner { + return &enterpriseRoleProvisioner{ + enterpriseRoleResourceType: EnterpriseRoleBuilder( + client, customClient, enterprises, newEnterpriseClients), + } +} + +// EnterpriseRoleBuilder returns the read-only enterprise role syncer. +func EnterpriseRoleBuilder( + client *github.Client, + customClient *customclient.Client, + enterprises []string, + newEnterpriseClients enterpriseClientProvider, +) *enterpriseRoleResourceType { return &enterpriseRoleResourceType{ - resourceType: resourceTypeEnterpriseRole, - client: client, - appClient: appClient, - customClient: customClient, - enterprises: enterprises, - roleUsersCache: make(map[string][]string), - mu: &sync.Mutex{}, + resourceType: resourceTypeEnterpriseRole, + client: client, + customClient: customClient, + enterprises: enterprises, + roleUsersCache: make(map[string][]string), + mu: &sync.Mutex{}, + newEnterpriseClients: newEnterpriseClients, } } -func isPermissionDenied(err error) bool { - var grpcErr interface{ GRPCStatus() *status.Status } - if errors.As(err, &grpcErr) { - return grpcErr.GRPCStatus().Code() == codes.PermissionDenied +// appList emits only the built-in Owner role. +func appList( + enterprises []string, + enterpriseClients map[string]*customclient.EnterpriseAdminClient, +) ([]*v2.Resource, *resourceSdk.SyncOpResults, error) { + var ret []*v2.Resource + for _, enterprise := range enterprises { + if _, ok := enterpriseClients[enterprise]; !ok { + continue + } + + roleResource, err := resourceSdk.NewRoleResource( + enterpriseRoleOwner, + resourceTypeEnterpriseRole, + fmt.Sprintf("%s:%s", enterprise, enterpriseRoleOwner), + []resourceSdk.RoleTraitOption{}, + ) + if err != nil { + return nil, nil, fmt.Errorf("baton-github: error creating role resource for %s in enterprise %s: %w", + enterpriseRoleOwner, enterprise, err) + } + ret = append(ret, roleResource) } - return false + + return ret, &resourceSdk.SyncOpResults{}, nil +} + +// appGrants emits current owners and pending invitees on the same entitlement, +// since C1 has no pending grant state. Owners and invitations are two phases of +// one page token, because invitations are resolved from the enterprise members. +func (o *enterpriseRoleResourceType) appGrants( + ctx context.Context, + enterpriseClients map[string]*customclient.EnterpriseAdminClient, + resource *v2.Resource, + opts resourceSdk.SyncOpAttrs, +) ([]*v2.Grant, *resourceSdk.SyncOpResults, error) { + enterprise, ok := provisionableEnterpriseOwner(resource.Id.Resource) + if !ok { + return nil, &resourceSdk.SyncOpResults{}, nil + } + client, ok := enterpriseClients[enterprise] + if !ok { + return nil, &resourceSdk.SyncOpResults{}, nil + } + + bag := &pagination.Bag{} + if err := bag.Unmarshal(opts.PageToken.Token); err != nil { + return nil, nil, fmt.Errorf("baton-github: error parsing enterprise owner page token: %w", err) + } + if bag.Current() == nil { + // Reverse order: the owners are walked first. + bag.Push(pagination.PageState{ResourceTypeID: enterprisePendingPhase}) + bag.Push(pagination.PageState{ResourceTypeID: enterpriseOwnersPhase}) + } + + var after *githubv4.String + if cursor := bag.PageToken(); cursor != "" { + after = githubv4.NewString(githubv4.String(cursor)) + } + + var ( + ret []*v2.Grant + nextCursor string + annos annotations.Annotations + err error + ) + switch phase := bag.ResourceTypeID(); phase { + case enterpriseOwnersPhase: + ret, nextCursor, annos, err = o.ownerGrants(ctx, client, resource, after) + case enterprisePendingPhase: + ret, nextCursor, annos, err = o.pendingInvitationGrants(ctx, client, resource, enterprise, after) + default: + return nil, nil, fmt.Errorf("baton-github: unexpected enterprise owner sync phase %q", phase) + } + if err != nil { + return nil, &resourceSdk.SyncOpResults{Annotations: annos}, failClosedOnUnreadableEnterprise(err, enterprise) + } + + if err := bag.Next(nextCursor); err != nil { + return nil, &resourceSdk.SyncOpResults{Annotations: annos}, + fmt.Errorf("baton-github: error advancing the enterprise owner page token: %w", err) + } + pageToken, err := bag.Marshal() + if err != nil { + return nil, &resourceSdk.SyncOpResults{Annotations: annos}, + fmt.Errorf("baton-github: error building the enterprise owner page token: %w", err) + } + + return ret, &resourceSdk.SyncOpResults{Annotations: annos, NextPageToken: pageToken}, nil +} + +// enterpriseSetupError marks a client build that failed on how the app or +// --enterprises is configured, as opposed to GitHub being unreachable. Before +// the enterprise administrator API was used, such a deployment synced no +// enterprise roles without failing, so sync keeps doing that; Grant and Revoke +// still return the error, which names the fix. +type enterpriseSetupError struct{ error } + +func (e enterpriseSetupError) Unwrap() error { return e.error } + +func isEnterpriseSetupError(err error) bool { + var setupErr enterpriseSetupError + return errors.As(err, &setupErr) +} + +// asEnterpriseSetupError marks the organization mismatch as a configuration +// problem. It keys on the sentinel rather than on FailedPrecondition, because +// the client wraps its query failures with %w and status.Code unwraps through +// them: a GraphQL UNPROCESSABLE during the walk carries that same code, and +// skipping the sync on one would delete every Owner grant for a reason that +// has nothing to do with how the deployment is configured. +func asEnterpriseSetupError(err error) error { + if !errors.Is(err, customclient.ErrOrganizationNotInEnterprise) { + return err + } + + return enterpriseSetupError{err} +} + +func warnEnterpriseRolesSkipped(ctx context.Context, err error) { + ctxzap.Extract(ctx).Warn("baton-github: skipping enterprise roles, the GitHub App is not set up to read them", + zap.Error(err)) +} + +// failClosedOnUnreadableEnterprise turns NotFound into FailedPrecondition. The SDK +// downgrades NotFound to a warning and completes the sync, which would make C1 +// delete every Owner grant. +func failClosedOnUnreadableEnterprise(err error, enterprise string) error { + if status.Code(err) != codes.NotFound { + return err + } + + return status.Errorf(codes.FailedPrecondition, + "baton-github: enterprise %q or its configured organization could not be read, so no owners can be "+ + "listed; failing rather than reporting none, which would revoke every Owner grant: %v", + enterprise, err) +} + +// ownerGrants emits one page of the users who hold the role today. +func (o *enterpriseRoleResourceType) ownerGrants( + ctx context.Context, + client *customclient.EnterpriseAdminClient, + resource *v2.Resource, + after *githubv4.String, +) ([]*v2.Grant, string, annotations.Annotations, error) { + owners, nextCursor, annos, err := client.Owners(ctx, after) + if err != nil { + return nil, "", annos, err + } + + ret := make([]*v2.Grant, 0, len(owners)) + for _, owner := range owners { + principalId, err := enterpriseOwnerPrincipalID(owner) + if err != nil { + return nil, "", annos, err + } + ret = append(ret, grant.NewGrant(resource, enterpriseRoleAssigned, principalId)) + } + + return ret, nextCursor, annos, nil +} + +// pendingInvitationGrants emits one page of pending Owner invitees, resolved by +// looking up the enterprise members, since invitations can't be listed with an +// installation token. Invitations to non-members are not visible. +func (o *enterpriseRoleResourceType) pendingInvitationGrants( + ctx context.Context, + client *customclient.EnterpriseAdminClient, + resource *v2.Resource, + enterprise string, + after *githubv4.String, +) ([]*v2.Grant, string, annotations.Annotations, error) { + members, nextCursor, annos, err := client.Members(ctx, enterprise, after) + if err != nil { + return nil, "", annos, err + } + if len(members) == 0 { + return nil, nextCursor, annos, nil + } + + logins := make([]string, 0, len(members)) + for _, member := range members { + logins = append(logins, member.Login) + } + + invitations, invitationAnnos, err := client.PendingOwnerInvitations(ctx, enterprise, logins) + annos = freshestRateLimit(annos, invitationAnnos) + if err != nil { + return nil, "", annos, err + } + + ret := make([]*v2.Grant, 0, len(invitations)) + for _, member := range members { + if _, invited := invitations[member.Login]; !invited { + continue + } + principalId, err := enterpriseOwnerPrincipalID(member) + if err != nil { + return nil, "", annos, err + } + ret = append(ret, grant.NewGrant(resource, enterpriseRoleAssigned, principalId)) + } + + return ret, nextCursor, annos, nil +} + +// Grant gives a user the built-in Owner role by inviting them. Existing +// administrators are refused rather than promoted in place: their current role +// can't be read, so Revoke could not restore it. A pending invitation counts as +// granted, matching Grants(). +func (o *enterpriseRoleProvisioner) Grant( + ctx context.Context, + principal *v2.Resource, + ent *v2.Entitlement, +) ([]*v2.Grant, annotations.Annotations, error) { + enterprise, client, err := o.provisioningTarget(ctx, principal, ent) + if err != nil { + return nil, nil, err + } + login, err := o.userLogin(ctx, principal.Id.Resource) + if err != nil { + return nil, nil, err + } + + result := []*v2.Grant{grant.NewGrant(ent.GetResource(), ent.GetSlug(), principal.Id)} + annos := annotations.New() + state, stateAnnos, err := client.OwnerState(ctx, enterprise, login) + annos = freshestRateLimit(annos, stateAnnos) + if err != nil { + return nil, annos, err + } + if state.HoldsRole() { + annos.Append(&v2.GrantAlreadyExists{}) + return result, annos, nil + } + + // Grants() can only see invitations to enterprise members. + isMember, memberAnnos, err := client.IsMember(ctx, enterprise, login) + annos = freshestRateLimit(annos, memberAnnos) + if err != nil { + return nil, annos, err + } + if !isMember { + return nil, annos, status.Errorf(codes.InvalidArgument, + "baton-github: %s is not a member of enterprise %s; inviting them as Owner would create an "+ + "invitation this connector cannot read back, and the grant would disappear on the next sync", + login, enterprise) + } + + if inviteErr := client.InviteOwner(ctx, state.EnterpriseID, login); inviteErr != nil { + if status.Code(inviteErr) != codes.FailedPrecondition { + return nil, annos, inviteErr + } + ctxzap.Extract(ctx).Debug("baton-github: invitation rejected; refusing unsafe in-place promotion", + zap.String("login", login), + zap.Error(inviteErr), + ) + // The rejection can also be a seat limit or SSO restriction, so let GitHub's + // error give the reason. + return nil, annos, fmt.Errorf( + "baton-github: cannot grant enterprise Owner to %s, and promoting in place is not attempted "+ + "because a prior administrator role cannot be read back and revoke would discard it: %w", + login, inviteErr) + } + + state, stateAnnos, err = client.OwnerState(ctx, enterprise, login) + annos = freshestRateLimit(annos, stateAnnos) + if err != nil { + return nil, annos, err + } + if !state.HoldsRole() { + return nil, annos, status.Errorf(codes.Unavailable, + "baton-github: enterprise owner grant for %s is not visible in GitHub", login) + } + + return result, annos, nil +} + +// Revoke removes the Owner role and cancels any pending invitation. Demotion uses +// UNAFFILIATED, which keeps the user's enterprise membership. NOT_FOUND from +// either mutation means it's already done. +func (o *enterpriseRoleProvisioner) Revoke( + ctx context.Context, + grantObj *v2.Grant, +) (annotations.Annotations, error) { + enterprise, client, err := o.provisioningTarget(ctx, grantObj.GetPrincipal(), grantObj.GetEntitlement()) + if err != nil { + return nil, err + } + login, err := o.userLogin(ctx, grantObj.GetPrincipal().GetId().GetResource()) + if err != nil { + return nil, err + } + + annos := annotations.New() + state, stateAnnos, err := client.OwnerState(ctx, enterprise, login) + annos = freshestRateLimit(annos, stateAnnos) + if err != nil { + return annos, err + } + if !state.HoldsRole() { + annos.Append(&v2.GrantAlreadyRevoked{}) + return annos, nil + } + + if state.IsOwner { + if err := client.UpdateRole( + ctx, state.EnterpriseID, login, enterpriseAdministratorRoleUnaffiliated, + ); err != nil && status.Code(err) != codes.NotFound { + return annos, err + } + } + if state.PendingInvitationID != "" { + if err := client.CancelInvitation(ctx, state.PendingInvitationID); err != nil && status.Code(err) != codes.NotFound { + return annos, err + } + } + + state, stateAnnos, err = client.OwnerState(ctx, enterprise, login) + annos = freshestRateLimit(annos, stateAnnos) + if err != nil { + return annos, err + } + if state.HoldsRole() { + return annos, status.Errorf(codes.Unavailable, + "baton-github: enterprise owner revoke for %s is not visible in GitHub", login) + } + + return annos, nil +} + +func (o *enterpriseRoleProvisioner) provisioningTarget( + ctx context.Context, + principal *v2.Resource, + ent *v2.Entitlement, +) (string, *customclient.EnterpriseAdminClient, error) { + if principal.GetId().GetResourceType() != resourceTypeUser.Id { + return "", nil, status.Error(codes.InvalidArgument, + "baton-github: enterprise role can only be granted to a user") + } + enterprise, ok := provisionableEnterpriseOwner(ent.GetResource().GetId().GetResource()) + if !ok { + return "", nil, status.Error(codes.InvalidArgument, + "baton-github: only the built-in enterprise Owner role can be provisioned") + } + // The build error comes first: the generic reason below cannot name a cause. + enterpriseClients, err := o.clients(ctx, true) + if err != nil { + return "", nil, err + } + client, ok := enterpriseClients[enterprise] + if !ok { + return "", nil, status.Errorf(codes.FailedPrecondition, + "baton-github: cannot provision enterprise %s: %s", enterprise, o.noClientReason(enterprise)) + } + return enterprise, client, nil +} + +func (o *enterpriseRoleResourceType) userLogin(ctx context.Context, userID string) (string, error) { + id, err := strconv.ParseInt(userID, 10, 64) + if err != nil { + return "", status.Errorf(codes.InvalidArgument, "baton-github: invalid GitHub user ID %q", userID) + } + user, resp, err := o.client.Users.GetByID(ctx, id) + if err != nil { + return "", wrapGitHubError(err, resp, fmt.Sprintf("baton-github: failed to get user %d", id)) + } + if user.GetLogin() == "" { + return "", fmt.Errorf("baton-github: GitHub user %d has no login", id) + } + return user.GetLogin(), nil +} + +func enterpriseOwnerPrincipalID(owner customclient.EnterpriseUser) (*v2.ResourceId, error) { + if owner.DatabaseID <= 0 { + return nil, fmt.Errorf("baton-github: enterprise owner %q has no database ID", owner.Login) + } + principalId, err := resourceSdk.NewResourceID(resourceTypeUser, owner.DatabaseID) + if err != nil { + return nil, fmt.Errorf("baton-github: error creating resource ID for user %s: %w", owner.Login, err) + } + return principalId, nil +} + +// provisionableEnterpriseOwner returns the enterprise of a resource ID that names +// the Owner role. Shared by sync and provisioning. +func provisionableEnterpriseOwner(resourceID string) (string, bool) { + enterprise, role, ok := parseEnterpriseRoleID(resourceID) + if !ok || !strings.EqualFold(role, enterpriseRoleOwner) { + return "", false + } + + return enterprise, true +} + +func parseEnterpriseRoleID(resourceID string) (string, string, bool) { + enterprise, role, ok := strings.Cut(resourceID, ":") + return enterprise, role, ok && enterprise != "" && role != "" } diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/graphql_transport.go b/vendor/github.com/conductorone/baton-github/pkg/connector/graphql_transport.go deleted file mode 100644 index d18fbf0c..00000000 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/graphql_transport.go +++ /dev/null @@ -1,57 +0,0 @@ -package connector - -import ( - "fmt" - "io" - "net/http" - - "github.com/conductorone/baton-sdk/pkg/ratelimit" - "github.com/conductorone/baton-sdk/pkg/uhttp" - "google.golang.org/grpc/status" -) - -// statusClassifyingTransport converts non-2xx HTTP responses into -// gRPC-classified errors using the SDK's canonical status-to-code mapping -// (uhttp.GrpcCodeFromHTTPStatus). This matches how uhttp.BaseHttpClient.Do -// classifies its own responses. -// -// It exists because shurcooL/graphql surfaces non-200 responses as opaque -// fmt.Errorf strings, which otherwise propagate as codes.Unknown and abort -// the sync on a single transient blip — even when the underlying status -// (429, 5xx, 401, 403, 404, ...) carries enough information for the SDK -// retry layer to do the right thing. The REST path doesn't need this because -// go-github exposes structured response/error types that wrapGitHubError -// already classifies at the call site. -type statusClassifyingTransport struct { - base http.RoundTripper -} - -func (t *statusClassifyingTransport) RoundTrip(req *http.Request) (*http.Response, error) { - resp, err := t.base.RoundTrip(req) - if err != nil || resp == nil { - return resp, err - } - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return resp, nil - } - rlDesc, _ := ratelimit.ExtractRateLimitData(resp.StatusCode, &resp.Header) - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - _ = resp.Body.Close() - msg := fmt.Sprintf("%s %s: HTTP %d", req.Method, req.URL.Path, resp.StatusCode) - if s := resp.Header.Get("Server"); s != "" { - msg += " server=" + s - } - if rid := resp.Header.Get("X-GitHub-Request-Id"); rid != "" { - msg += " request-id=" + rid - } - if len(body) > 0 { - msg += ": " + string(body) - } - st := status.New(uhttp.GrpcCodeFromHTTPStatus(resp.StatusCode), msg) - if rlDesc != nil { - if withDetails, err := st.WithDetails(rlDesc); err == nil { - st = withDetails - } - } - return nil, st.Err() -} 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 ece84762..b4bc5f41 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/helpers.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/helpers.go @@ -324,6 +324,19 @@ func isAuthError(resp *github.Response) bool { return resp.StatusCode == http.StatusUnauthorized } +// freshestRateLimit replaces the rate limit in current with latest's, if any. +// Appending would not work: Annotations.Pick returns the first match. +func freshestRateLimit(current, latest annotations.Annotations) annotations.Annotations { + var rateLimit v2.RateLimitDescription + found, err := latest.Pick(&rateLimit) + if err != nil || !found { + return current + } + current.Update(&rateLimit) + + return current +} + func isPermissionError(resp *github.Response) bool { if resp == nil { return false diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/token_refresh.go b/vendor/github.com/conductorone/baton-github/pkg/connector/token_refresh.go index e34db985..4af900a6 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/token_refresh.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/token_refresh.go @@ -4,10 +4,10 @@ import ( "context" "io" "net/http" - "net/url" "strings" "sync" + "github.com/conductorone/baton-github/pkg/customclient" "github.com/conductorone/baton-sdk/pkg/uhttp" "github.com/google/go-github/v69/github" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" @@ -155,19 +155,13 @@ func newGitHubAppClients(instanceURL string, httpClient *http.Client) (*github.C gqlHTTPClient := &http.Client{ Timeout: httpClient.Timeout, - Transport: &statusClassifyingTransport{base: httpClient.Transport}, + Transport: customclient.NewStatusClassifyingTransport(httpClient.Transport), } - var gqlClient *githubv4.Client - if instanceURL != "" && instanceURL != githubDotCom { - gqlURL, err := url.Parse(instanceURL) - if err != nil { - return nil, nil, err - } - gqlURL.Path = "/api/graphql" - gqlClient = githubv4.NewEnterpriseClient(gqlURL.String(), gqlHTTPClient) - } else { - gqlClient = githubv4.NewClient(gqlHTTPClient) + endpoint, err := customclient.EnterpriseGraphQLEndpoint(instanceURL) + if err != nil { + return nil, nil, err } - return gc, gqlClient, nil + + return gc, githubv4.NewEnterpriseClient(endpoint.String(), gqlHTTPClient), nil } diff --git a/vendor/github.com/conductorone/baton-github/pkg/customclient/client.go b/vendor/github.com/conductorone/baton-github/pkg/customclient/client.go index 85485447..70ddc888 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/customclient/client.go +++ b/vendor/github.com/conductorone/baton-github/pkg/customclient/client.go @@ -4,35 +4,104 @@ import ( "context" "fmt" "net/http" + "net/url" + "strconv" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/uhttp" "github.com/google/go-github/v69/github" ) +// githubMaxPageSize is the largest per_page the GitHub REST API accepts. +const githubMaxPageSize = 100 + +// Endpoint paths, one element per path segment. +func enterpriseInstallationPath(enterprise string) []string { + return []string{"enterprises", enterprise, "installation"} +} + +func consumedLicensesPathParts(enterprise string) []string { + return []string{"enterprises", enterprise, "consumed-licenses"} +} + // used for endpoints not in the go-github library // example: https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/license?apiVersion=2022-11-28#list-enterprise-consumed-licenses type Client struct { *uhttp.BaseHttpClient + // baseURL is the go-github client's base, so endpoints follow --instance-url. + baseURL *url.URL } func New(client *github.Client) *Client { return &Client{ BaseHttpClient: uhttp.NewBaseHttpClient(client.Client()), + baseURL: client.BaseURL, + } +} + +// endpoint resolves path segments against the base URL, escaping each one +// because url.JoinPath treats its arguments as already escaped. +func (c *Client) endpoint(segments ...string) (string, error) { + if c.baseURL == nil { + return "", fmt.Errorf("github client has no base URL") + } + + escaped := make([]string, 0, len(segments)) + for _, segment := range segments { + escaped = append(escaped, url.PathEscape(segment)) + } + + return url.JoinPath(c.baseURL.String(), escaped...) +} + +// GetEnterpriseInstallation returns this app's installation on one enterprise. +// It needs the app JWT client, not an installation token. +// https://docs.github.com/en/rest/apps/apps#get-an-enterprise-installation-for-the-authenticated-app +func (c *Client) GetEnterpriseInstallation(ctx context.Context, enterprise string) (*AppInstallation, *v2.RateLimitDescription, error) { + endpoint, err := c.endpoint(enterpriseInstallationPath(enterprise)...) + if err != nil { + return nil, nil, fmt.Errorf("error building the enterprise installation URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, nil, fmt.Errorf("error creating request to get the enterprise installation: %w", err) } + + var target AppInstallation + var rateLimitData v2.RateLimitDescription + res, err := c.Do(req, + uhttp.WithJSONResponse(&target), + uhttp.WithRatelimitData(&rateLimitData), + ) + if err != nil { + if res != nil { + defer res.Body.Close() + logBody(ctx, res.Body) + } + return nil, &rateLimitData, fmt.Errorf("error getting the installation of enterprise %s: %w", enterprise, err) + } + + defer res.Body.Close() + + return &target, &rateLimitData, nil } // https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/license?apiVersion=2022-11-28#list-enterprise-consumed-licenses func (c *Client) ListEnterpriseConsumedLicenses(ctx context.Context, enterprise string, page int) (*EnterpriseConsumedLicense, *v2.RateLimitDescription, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://api.github.com/enterprises/%s/consumed-licenses", enterprise), nil) + endpoint, err := c.endpoint(consumedLicensesPathParts(enterprise)...) + if err != nil { + return nil, nil, fmt.Errorf("error building the consumed licenses URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, nil, fmt.Errorf("error creating request to list enterprise consumed licenses: %w", err) } q := req.URL.Query() - q.Add("page", fmt.Sprintf("%d", page)) - // GitHub REST API max per_page is 100, default is 30. - q.Add("per_page", "100") + q.Add("page", strconv.Itoa(page)) + q.Add("per_page", strconv.Itoa(githubMaxPageSize)) req.URL.RawQuery = q.Encode() var target EnterpriseConsumedLicense diff --git a/vendor/github.com/conductorone/baton-github/pkg/customclient/enterprise_admin.go b/vendor/github.com/conductorone/baton-github/pkg/customclient/enterprise_admin.go new file mode 100644 index 00000000..c68b419f --- /dev/null +++ b/vendor/github.com/conductorone/baton-github/pkg/customclient/enterprise_admin.go @@ -0,0 +1,593 @@ +package customclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "github.com/shurcooL/githubv4" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + // GraphQL variable names, shared by the query text and the variable map. + enterpriseSlugVariable = "slug" + enterpriseLoginVariable = "login" + enterpriseOrgVariable = "org" + enterpriseRoleVariable = "role" + enterpriseQueryVariable = "query" + enterpriseFirstVariable = "first" + enterpriseAfterVariable = "after" + + enterpriseGraphQLPath = "/api/graphql" + // GitHubDotCom is the default instance; anything else is self-hosted or a + // data-residency host. + GitHubDotCom = "https://github.com" + githubDotComGraphQL = "https://api.github.com/graphql" + enterpriseRateLimitField = "rateLimit" + + // GitHub caps every connection used here at 100 per page. + enterpriseOwnerPageSize = 100 + enterpriseOrganizationPageSize = 100 + enterpriseMemberPageSize = 100 + // A login search also matches display names, so it can return many accounts. + enterpriseMemberLookupPageSize = 100 + // One aliased invitation lookup per member of a page. + enterpriseInvitationBatchSize = enterpriseMemberPageSize + // Bounds in-call page walks so a mispaginating API cannot hang a provisioning task. + enterpriseMaxPages = 1000 +) + +// EnterpriseAdminClient reads and writes the Owner role of one +// enterprise. It needs both installation tokens: +// +// - Enterprise.ownerInfo is null for installation tokens, so owners are read +// from Organization.enterpriseOwners with the org token. +// - The invitation lookup and mutations need the enterprise token. +// +// Enterprise.members(role: OWNER) is not used: it returns owners of organizations +// in the enterprise, not owners of the enterprise account. +type EnterpriseAdminClient struct { + enterpriseClient *githubv4.Client + orgClient *githubv4.Client + org string + enterpriseNodeID string + endpoint *url.URL + // batchClient skips enterpriseGraphQLTransport because the aliased invitation + // lookup always contains NOT_FOUND entries. + batchClient *uhttp.BaseHttpClient +} + +// NewEnterpriseAdminClient returns the client for one enterprise, with a +// GraphQL client per installation token. +func NewEnterpriseAdminClient( + instanceURL string, + enterpriseHTTPClient *http.Client, + orgHTTPClient *http.Client, + org string, +) (*EnterpriseAdminClient, error) { + endpoint, err := EnterpriseGraphQLEndpoint(instanceURL) + if err != nil { + return nil, err + } + + // NewBaseHttpClient returns nil when its cache setup fails. + batchClient := uhttp.NewBaseHttpClient(enterpriseHTTPClient) + if batchClient == nil { + return nil, fmt.Errorf("baton-github: error building the enterprise GraphQL batch client") + } + + return &EnterpriseAdminClient{ + enterpriseClient: newEnterpriseGraphQLClient(endpoint.String(), enterpriseHTTPClient), + orgClient: newEnterpriseGraphQLClient(endpoint.String(), orgHTTPClient), + org: org, + endpoint: endpoint, + batchClient: batchClient, + }, nil +} + +// newEnterpriseGraphQLClient returns a GraphQL client that also classifies the +// errors GitHub returns inside an HTTP 200, such as rate limits. +func newEnterpriseGraphQLClient(endpoint string, httpClient *http.Client) *githubv4.Client { + base := httpClient.Transport + if base == nil { + base = http.DefaultTransport + } + + return githubv4.NewEnterpriseClient(endpoint, &http.Client{ + Timeout: httpClient.Timeout, + Transport: &enterpriseGraphQLTransport{ + base: &statusClassifyingTransport{base: base}, + }, + }) +} + +// EnterpriseGraphQLEndpoint returns the instance's GraphQL URL: api.github.com +// for GitHub.com, /api/graphql on a self-hosted host. +func EnterpriseGraphQLEndpoint(instanceURL string) (*url.URL, error) { + instanceURL = strings.TrimSuffix(instanceURL, "/") + if instanceURL == "" || instanceURL == GitHubDotCom { + return url.Parse(githubDotComGraphQL) + } + + gqlURL, err := url.Parse(instanceURL) + if err != nil { + return nil, err + } + gqlURL.Path = enterpriseGraphQLPath + + return gqlURL, nil +} + +// Owners returns one page of the enterprise account's Owners. +func (c *EnterpriseAdminClient) Owners( + ctx context.Context, + after *githubv4.String, +) ([]EnterpriseUser, string, annotations.Annotations, error) { + var query enterpriseOwnersQuery + err := c.orgClient.Query(ctx, &query, map[string]any{ + enterpriseOrgVariable: githubv4.String(c.org), + enterpriseFirstVariable: githubv4.Int(enterpriseOwnerPageSize), + enterpriseAfterVariable: after, + }) + if err != nil { + return nil, "", nil, fmt.Errorf("baton-github: error listing enterprise owners of org %s: %w", c.org, err) + } + + owners := make([]EnterpriseUser, 0, len(query.Organization.EnterpriseOwners.Nodes)) + for _, node := range query.Organization.EnterpriseOwners.Nodes { + owners = append(owners, EnterpriseUser{ + DatabaseID: int64(node.DatabaseID), + Login: string(node.Login), + }) + } + + nextCursor := "" + if query.Organization.EnterpriseOwners.PageInfo.HasNextPage { + nextCursor = string(query.Organization.EnterpriseOwners.PageInfo.EndCursor) + } + + return owners, nextCursor, query.RateLimit.annotations(), nil +} + +// ResolveEnterpriseNodeID stores the node ID enterprise mutations take. It also +// checks that the enterprise is visible to this installation. +func (c *EnterpriseAdminClient) ResolveEnterpriseNodeID(ctx context.Context, enterprise string) error { + var query struct { + Enterprise struct { + ID githubv4.String + } `graphql:"enterprise(slug: $slug)"` + } + err := c.enterpriseClient.Query(ctx, &query, map[string]any{ + enterpriseSlugVariable: githubv4.String(enterprise), + }) + if err != nil { + return fmt.Errorf("baton-github: error getting enterprise %s: %w", enterprise, err) + } + if query.Enterprise.ID == "" { + return fmt.Errorf("baton-github: enterprise %s is not visible to the GitHub App", enterprise) + } + c.enterpriseNodeID = string(query.Enterprise.ID) + + return nil +} + +// ErrOrganizationNotInEnterprise is the one configuration answer +// VerifyOrganization gives. Callers key on it rather than on the gRPC code, +// because a query failure inside the walk carries a code too -- a GraphQL +// UNPROCESSABLE is classified FailedPrecondition, and status.Code unwraps +// through %w, so the code alone cannot tell the two apart. +var ErrOrganizationNotInEnterprise = errors.New("organization does not belong to the enterprise") + +// VerifyOrganization checks that the organization owners are read through +// belongs to this enterprise. organizations(query:) is a substring search, so +// every page is read before concluding it is not there. +func (c *EnterpriseAdminClient) VerifyOrganization(ctx context.Context, enterprise string) error { + var after *githubv4.String + walked := false + for page := 0; page < enterpriseMaxPages; page++ { + var query enterpriseOrganizationsQuery + err := c.enterpriseClient.Query(ctx, &query, map[string]any{ + enterpriseSlugVariable: githubv4.String(enterprise), + enterpriseFirstVariable: githubv4.Int(enterpriseOrganizationPageSize), + enterpriseAfterVariable: after, + enterpriseQueryVariable: githubv4.String(c.org), + }) + if err != nil { + return fmt.Errorf("baton-github: error listing organizations of enterprise %s: %w", enterprise, err) + } + + for _, node := range query.Enterprise.Organizations.Nodes { + if strings.EqualFold(string(node.Login), c.org) { + return nil + } + } + + if !query.Enterprise.Organizations.PageInfo.HasNextPage { + walked = true + break + } + after = githubv4.NewString(query.Enterprise.Organizations.PageInfo.EndCursor) + } + // Running out of pages is not proof the organization is missing. + if !walked { + return status.Errorf(codes.Internal, + "baton-github: gave up looking for organization %s in enterprise %s after %d pages", + c.org, enterprise, enterpriseMaxPages) + } + + // The sentinel is what tells this apart from the Internal above; the code + // is carried for C1, which reads FailedPrecondition as non-retryable. + return uhttp.WrapErrors(codes.FailedPrecondition, + fmt.Sprintf("baton-github: organization %s does not belong to enterprise %s, so its owners cannot be synced", + c.org, enterprise), + ErrOrganizationNotInEnterprise) +} + +// IsMember reports whether login belongs to the enterprise. Grants() can only see +// invitations addressed to members. +func (c *EnterpriseAdminClient) IsMember( + ctx context.Context, + enterprise string, + login string, +) (bool, annotations.Annotations, error) { + var ( + annos annotations.Annotations + after *githubv4.String + ) + for page := 0; page < enterpriseMaxPages; page++ { + var query enterpriseMemberLookupQuery + err := c.enterpriseClient.Query(ctx, &query, map[string]any{ + enterpriseSlugVariable: githubv4.String(enterprise), + enterpriseFirstVariable: githubv4.Int(enterpriseMemberLookupPageSize), + enterpriseAfterVariable: after, + enterpriseQueryVariable: githubv4.String(login), + }) + if err != nil { + return false, annos, fmt.Errorf("baton-github: error looking up member %s of enterprise %s: %w", login, enterprise, err) + } + if pageAnnos := query.RateLimit.annotations(); len(pageAnnos) > 0 { + annos = pageAnnos + } + + for _, node := range query.Enterprise.Members.Nodes { + found := string(node.EnterpriseUserAccount.Login) + if found == "" { + found = string(node.User.Login) + } + if strings.EqualFold(found, login) { + return true, annos, nil + } + } + + if !bool(query.Enterprise.Members.PageInfo.HasNextPage) { + return false, annos, nil + } + after = githubv4.NewString(query.Enterprise.Members.PageInfo.EndCursor) + } + + return false, annos, status.Errorf(codes.Unavailable, + "baton-github: gave up looking for %s in enterprise %s after %d pages of search results", + login, enterprise, enterpriseMaxPages) +} + +// Members returns one page of the enterprise's Members and the next cursor, +// skipping nodes without a database ID. +func (c *EnterpriseAdminClient) Members( + ctx context.Context, + enterprise string, + after *githubv4.String, +) ([]EnterpriseUser, string, annotations.Annotations, error) { + var query enterpriseMembersQuery + err := c.enterpriseClient.Query(ctx, &query, map[string]any{ + enterpriseSlugVariable: githubv4.String(enterprise), + enterpriseFirstVariable: githubv4.Int(enterpriseMemberPageSize), + enterpriseAfterVariable: after, + }) + if err != nil { + return nil, "", nil, fmt.Errorf("baton-github: error listing members of enterprise %s: %w", enterprise, err) + } + + members := make([]EnterpriseUser, 0, len(query.Enterprise.Members.Nodes)) + for _, node := range query.Enterprise.Members.Nodes { + member := EnterpriseUser{ + DatabaseID: int64(node.EnterpriseUserAccount.User.DatabaseID), + Login: string(node.EnterpriseUserAccount.Login), + } + if member.DatabaseID == 0 { + member.DatabaseID = int64(node.User.DatabaseID) + } + if member.Login == "" { + member.Login = string(node.User.Login) + } + if member.DatabaseID == 0 || member.Login == "" { + ctxzap.Extract(ctx).Debug("baton-github: skipping an enterprise member with no database ID or login", + zap.String("enterprise", enterprise), + zap.Int64("database_id", member.DatabaseID), + zap.String("login", member.Login), + ) + continue + } + members = append(members, member) + } + + nextCursor := "" + if query.Enterprise.Members.PageInfo.HasNextPage { + nextCursor = string(query.Enterprise.Members.PageInfo.EndCursor) + } + + return members, nextCursor, query.RateLimit.annotations(), nil +} + +// PendingOwnerInvitations returns the pending Owner invitation of each login that +// has one, in one aliased request. Invitations can't be listed with an +// installation token, so they're looked up per login. +// +// Logins are passed as variables; only generated aliases reach the query text. +// NOT_FOUND entries (no invitation) are dropped before classifying the rest. +// +// Dropping them is safe because NOT_FOUND here means absent, not forbidden, +// and two things establish that rather than assumption. graphQLErrorsCode +// ranks FORBIDDEN and UNAUTHENTICATED above NOT_FOUND precisely so a +// credential failure cannot arrive wearing the code a caller reads as +// absence. And these logins come from the Members query that ran immediately +// before on this same enterprise token, which would have failed first if the +// token could not read the enterprise at all. Were that not so, every alias +// would be dropped and every invitee grant revoked on the next sync. +func (c *EnterpriseAdminClient) PendingOwnerInvitations( + ctx context.Context, + enterprise string, + logins []string, +) (map[string]string, annotations.Annotations, error) { + if len(logins) == 0 { + return map[string]string{}, nil, nil + } + if len(logins) > enterpriseInvitationBatchSize { + return nil, nil, fmt.Errorf( + "baton-github: pending owner invitation lookup takes at most %d logins, got %d", + enterpriseInvitationBatchSize, len(logins)) + } + + declarations := []string{"$" + enterpriseSlugVariable + ":String!", "$" + enterpriseRoleVariable + ":EnterpriseAdministratorRole!"} + selections := make([]string, 0, len(logins)) + variables := map[string]any{ + enterpriseSlugVariable: enterprise, + enterpriseRoleVariable: string(githubv4.EnterpriseAdministratorRoleOwner), + } + aliasLogin := make(map[string]string, len(logins)) + for i, login := range logins { + alias := fmt.Sprintf("i%d", i) + variable := fmt.Sprintf("l%d", i) + aliasLogin[alias] = login + variables[variable] = login + declarations = append(declarations, "$"+variable+":String!") + selections = append(selections, fmt.Sprintf( + "%s: enterpriseAdministratorInvitation(enterpriseSlug: $%s, userLogin: $%s, role: $%s){id}", + alias, enterpriseSlugVariable, variable, enterpriseRoleVariable)) + } + + query := fmt.Sprintf("query(%s){%s %s{limit remaining resetAt}}", + strings.Join(declarations, ","), strings.Join(selections, " "), enterpriseRateLimitField) + + aliases, graphQLErrors, err := c.doGraphQL(ctx, query, variables) + + var rateLimit graphQLRateLimit + if raw, ok := aliases[enterpriseRateLimitField]; ok { + if unmarshalErr := json.Unmarshal(raw, &rateLimit); unmarshalErr != nil { + return nil, nil, fmt.Errorf("baton-github: error decoding the rate limit of enterprise %s: %w", enterprise, unmarshalErr) + } + } + annos := rateLimit.annotations() + if err != nil { + return nil, annos, fmt.Errorf("baton-github: error listing pending owner invitations of enterprise %s: %w", enterprise, err) + } + unexpected := make([]graphQLError, 0, len(graphQLErrors)) + for _, graphQLErr := range graphQLErrors { + if graphQLErrorType(graphQLErr) != graphQLErrorNotFound { + unexpected = append(unexpected, graphQLErr) + } + } + if len(unexpected) > 0 { + return nil, annos, status.Errorf(graphQLErrorsCode(unexpected), + "baton-github: error listing pending owner invitations of enterprise %s: %s", + enterprise, unexpected[0].Message) + } + + invitations := make(map[string]string, len(aliases)) + for alias, raw := range aliases { + login, ok := aliasLogin[alias] + if !ok { + continue + } + var node struct { + ID string `json:"id"` + } + // Only skip an absent invitation, not one that failed to decode. + if err := json.Unmarshal(raw, &node); err != nil { + return nil, annos, fmt.Errorf( + "baton-github: error decoding the invitation reported for %s: %w", login, err) + } + if node.ID == "" { + continue + } + invitations[login] = node.ID + } + + return invitations, annos, nil +} + +// doGraphQL runs a query built at runtime, which the typed client can't express, +// and returns the raw data fields and the errors array. +func (c *EnterpriseAdminClient) doGraphQL( + ctx context.Context, + query string, + variables map[string]any, +) (map[string]json.RawMessage, []graphQLError, error) { + req, err := c.batchClient.NewRequest(ctx, http.MethodPost, c.endpoint, + uhttp.WithContentTypeJSONHeader(), + uhttp.WithAcceptJSONHeader(), + uhttp.WithJSONBody(map[string]any{"query": query, "variables": variables}), + ) + if err != nil { + return nil, nil, fmt.Errorf("baton-github: error creating the GraphQL request: %w", err) + } + + var envelope graphQLEnvelope + resp, err := c.batchClient.Do(req, uhttp.WithJSONResponse(&envelope)) + if err != nil { + if resp != nil { + _ = resp.Body.Close() + } + return nil, nil, err + } + defer resp.Body.Close() + + return envelope.Data, envelope.Errors, nil +} + +// pendingOwnerInvitation returns the pending Owner invitation ID for a login, or +// "" if none. GitHub reports no invitation as NOT_FOUND. +func (c *EnterpriseAdminClient) pendingOwnerInvitation( + ctx context.Context, + enterprise string, + login string, +) (string, error) { + var query struct { + EnterpriseAdministratorInvitation struct { + ID githubv4.String + } `graphql:"enterpriseAdministratorInvitation(enterpriseSlug: $slug, userLogin: $login, role: $role)"` + } + err := c.enterpriseClient.Query(ctx, &query, map[string]any{ + enterpriseSlugVariable: githubv4.String(enterprise), + enterpriseLoginVariable: githubv4.String(login), + enterpriseRoleVariable: githubv4.EnterpriseAdministratorRoleOwner, + }) + if err != nil { + if status.Code(err) == codes.NotFound { + return "", nil + } + return "", fmt.Errorf("baton-github: error getting owner invitation for %s: %w", login, err) + } + + return string(query.EnterpriseAdministratorInvitation.ID), nil +} + +// OwnerState reports whether a login owns the enterprise and whether an Owner +// invitation is pending. Both are resolved because Revoke clears each. +// +// Owners are paged and matched by login rather than using the owners query +// argument, which is a search that can lag right after a mutation. +func (c *EnterpriseAdminClient) OwnerState( + ctx context.Context, + enterprise string, + login string, +) (EnterpriseOwnerState, annotations.Annotations, error) { + state := EnterpriseOwnerState{EnterpriseID: c.enterpriseNodeID} + + var annos annotations.Annotations + var after *githubv4.String + walked := false + for page := 0; page < enterpriseMaxPages; page++ { + owners, nextCursor, pageAnnos, err := c.Owners(ctx, after) + if err != nil { + return state, annos, err + } + if len(pageAnnos) > 0 { + annos = pageAnnos + } + for _, owner := range owners { + if strings.EqualFold(owner.Login, login) { + state.IsOwner = true + break + } + } + if state.IsOwner || nextCursor == "" { + walked = true + break + } + after = githubv4.NewString(githubv4.String(nextCursor)) + } + // An unfinished walk must not read as "not an owner". + if !walked { + return state, annos, status.Errorf(codes.Internal, + "baton-github: gave up reading the owners of enterprise %s after %d pages", + enterprise, enterpriseMaxPages) + } + + invitationID, err := c.pendingOwnerInvitation(ctx, enterprise, login) + if err != nil { + return state, annos, err + } + state.PendingInvitationID = invitationID + + return state, annos, nil +} + +// UpdateRole changes the role of someone who already administers the +// enterprise. It cannot promote a plain member. +func (c *EnterpriseAdminClient) UpdateRole( + ctx context.Context, + enterpriseID string, + login string, + role githubv4.EnterpriseAdministratorRole, +) error { + // GraphQL requires a selection set and rateLimit exists only on Query. + var mutation struct { + UpdateEnterpriseAdministratorRole struct { + ClientMutationID githubv4.String + } `graphql:"updateEnterpriseAdministratorRole(input: $input)"` + } + input := githubv4.UpdateEnterpriseAdministratorRoleInput{ + EnterpriseID: githubv4.ID(enterpriseID), + Login: githubv4.String(login), + Role: role, + } + if err := c.enterpriseClient.Mutate(ctx, &mutation, input, nil); err != nil { + return fmt.Errorf("baton-github: error setting enterprise role of %s to %s: %w", login, role, err) + } + + return nil +} + +// InviteOwner sends the Owner invitation a member has to accept. +func (c *EnterpriseAdminClient) InviteOwner(ctx context.Context, enterpriseID string, login string) error { + var mutation struct { + InviteEnterpriseAdmin struct { + ClientMutationID githubv4.String + } `graphql:"inviteEnterpriseAdmin(input: $input)"` + } + role := githubv4.EnterpriseAdministratorRoleOwner + input := githubv4.InviteEnterpriseAdminInput{ + EnterpriseID: githubv4.ID(enterpriseID), + Invitee: githubv4.NewString(githubv4.String(login)), + Role: &role, + } + if err := c.enterpriseClient.Mutate(ctx, &mutation, input, nil); err != nil { + return fmt.Errorf("baton-github: error inviting %s as enterprise owner: %w", login, err) + } + + return nil +} + +func (c *EnterpriseAdminClient) CancelInvitation(ctx context.Context, invitationID string) error { + var mutation struct { + CancelEnterpriseAdminInvitation struct { + ClientMutationID githubv4.String + } `graphql:"cancelEnterpriseAdminInvitation(input: $input)"` + } + input := githubv4.CancelEnterpriseAdminInvitationInput{InvitationID: githubv4.ID(invitationID)} + if err := c.enterpriseClient.Mutate(ctx, &mutation, input, nil); err != nil { + return fmt.Errorf("baton-github: error cancelling enterprise owner invitation: %w", err) + } + + return nil +} diff --git a/vendor/github.com/conductorone/baton-github/pkg/customclient/enterprise_admin_models.go b/vendor/github.com/conductorone/baton-github/pkg/customclient/enterprise_admin_models.go new file mode 100644 index 00000000..0ddc6ba1 --- /dev/null +++ b/vendor/github.com/conductorone/baton-github/pkg/customclient/enterprise_admin_models.go @@ -0,0 +1,138 @@ +package customclient + +import ( + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/shurcooL/githubv4" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// enterpriseOwnersQuery reads one page of the enterprise account's owners +// through the organization the app is installed on. +type enterpriseOwnersQuery struct { + Organization struct { + EnterpriseOwners struct { + Nodes []struct { + DatabaseID githubv4.Int + Login githubv4.String + } + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"enterpriseOwners(first: $first, after: $after)"` + } `graphql:"organization(login: $org)"` + RateLimit graphQLRateLimit +} + +// EnterpriseUser is a user account as the enterprise reports it. +type EnterpriseUser struct { + DatabaseID int64 + Login string +} + +// enterpriseOrganizationsQuery reads one page of an enterprise's +// organizations, narrowed by a search term. +type enterpriseOrganizationsQuery struct { + Enterprise struct { + Organizations struct { + Nodes []struct { + Login githubv4.String + } + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"organizations(first: $first, after: $after, query: $query)"` + } `graphql:"enterprise(slug: $slug)"` +} + +// enterpriseMemberLookupQuery searches the enterprise's members by login. The +// search also matches display names, so the caller pages until an exact login. +type enterpriseMemberLookupQuery struct { + Enterprise struct { + Members struct { + Nodes []struct { + EnterpriseUserAccount struct { + Login githubv4.String + } `graphql:"... on EnterpriseUserAccount"` + User struct { + Login githubv4.String + } `graphql:"... on User"` + } + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"members(first: $first, after: $after, query: $query)"` + } `graphql:"enterprise(slug: $slug)"` + RateLimit graphQLRateLimit +} + +// enterpriseMembersQuery reads one page of the enterprise's members. members is +// a union of EnterpriseUserAccount (EMU) and User, so both shapes are selected. +type enterpriseMembersQuery struct { + Enterprise struct { + Members struct { + Nodes []struct { + EnterpriseUserAccount struct { + Login githubv4.String + User struct { + DatabaseID githubv4.Int + Login githubv4.String + } + } `graphql:"... on EnterpriseUserAccount"` + User struct { + DatabaseID githubv4.Int + Login githubv4.String + } `graphql:"... on User"` + } + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"members(first: $first, after: $after)"` + } `graphql:"enterprise(slug: $slug)"` + RateLimit graphQLRateLimit +} + +type graphQLRateLimit struct { + Limit githubv4.Int + Remaining githubv4.Int + ResetAt githubv4.DateTime +} + +// EnterpriseOwnerState is a user's Owner role and pending Owner invitation, if any. +type EnterpriseOwnerState struct { + EnterpriseID string + IsOwner bool + PendingInvitationID string +} + +// HoldsRole reports whether C1 should see a grant. C1 has no pending state, so an +// unaccepted invitation counts. +func (s EnterpriseOwnerState) HoldsRole() bool { + return s.IsOwner || s.PendingInvitationID != "" +} + +// annotations returns the remaining GraphQL budget, or nil when the response had +// no rateLimit block. +func (r graphQLRateLimit) annotations() annotations.Annotations { + if r.Limit == 0 && r.ResetAt.IsZero() { + return nil + } + + rateLimit := &v2.RateLimitDescription{ + Status: v2.RateLimitDescription_STATUS_OK, + Limit: int64(r.Limit), + Remaining: int64(r.Remaining), + } + if r.Remaining <= 0 { + rateLimit.Status = v2.RateLimitDescription_STATUS_OVERLIMIT + } + if !r.ResetAt.IsZero() { + rateLimit.ResetAt = timestamppb.New(r.ResetAt.Time) + } + + return annotations.New(rateLimit) +} diff --git a/vendor/github.com/conductorone/baton-github/pkg/customclient/graphql.go b/vendor/github.com/conductorone/baton-github/pkg/customclient/graphql.go new file mode 100644 index 00000000..084a4f8a --- /dev/null +++ b/vendor/github.com/conductorone/baton-github/pkg/customclient/graphql.go @@ -0,0 +1,172 @@ +package customclient + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/conductorone/baton-sdk/pkg/ratelimit" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// statusClassifyingTransport converts non-2xx HTTP responses into +// gRPC-classified errors using the SDK's canonical status-to-code mapping +// (uhttp.GrpcCodeFromHTTPStatus). This matches how uhttp.BaseHttpClient.Do +// classifies its own responses. +// +// It exists because shurcooL/graphql surfaces non-200 responses as opaque +// fmt.Errorf strings, which otherwise propagate as codes.Unknown and abort +// the sync on a single transient blip — even when the underlying status +// (429, 5xx, 401, 403, 404, ...) carries enough information for the SDK +// retry layer to do the right thing. The REST path doesn't need this because +// go-github exposes structured response/error types that the connector +// package's wrapGitHubError already classifies at the call site. +type statusClassifyingTransport struct { + base http.RoundTripper +} + +// NewStatusClassifyingTransport wraps base so non-2xx GraphQL responses carry a +// gRPC code instead of the library's opaque error. +func NewStatusClassifyingTransport(base http.RoundTripper) http.RoundTripper { + return &statusClassifyingTransport{base: base} +} + +func (t *statusClassifyingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.base.RoundTrip(req) + if err != nil || resp == nil { + return resp, err + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp, nil + } + rlDesc, _ := ratelimit.ExtractRateLimitData(resp.StatusCode, &resp.Header) + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + _ = resp.Body.Close() + msg := fmt.Sprintf("%s %s: HTTP %d", req.Method, req.URL.Path, resp.StatusCode) + if s := resp.Header.Get("Server"); s != "" { + msg += " server=" + s + } + if rid := resp.Header.Get("X-GitHub-Request-Id"); rid != "" { + msg += " request-id=" + rid + } + if len(body) > 0 { + msg += ": " + string(body) + } + st := status.New(uhttp.GrpcCodeFromHTTPStatus(resp.StatusCode), msg) + if rlDesc != nil { + if withDetails, err := st.WithDetails(rlDesc); err == nil { + st = withDetails + } + } + return nil, st.Err() +} + +// graphQLEnvelope is a GraphQL response body. A partial result populates both +// Data and Errors. +type graphQLEnvelope struct { + Data map[string]json.RawMessage `json:"data"` + Errors []graphQLError `json:"errors"` +} + +type graphQLError struct { + Message string `json:"message"` + Type string `json:"type"` + Extensions struct { + Code string `json:"code"` + } `json:"extensions"` +} + +// GraphQL error types GitHub returns. +const ( + graphQLErrorNotFound = "NOT_FOUND" + graphQLErrorForbidden = "FORBIDDEN" + graphQLErrorUnauthenticated = "UNAUTHENTICATED" + graphQLErrorUnprocessable = "UNPROCESSABLE" +) + +// graphQLErrorType reads the type from either the top-level field or extensions. +func graphQLErrorType(graphQLErr graphQLError) string { + if code := strings.ToUpper(graphQLErr.Extensions.Code); code != "" { + return code + } + + return strings.ToUpper(graphQLErr.Type) +} + +// enterpriseGraphQLTransport turns errors GitHub returns inside an HTTP 200 +// GraphQL body into gRPC codes. It is not used by the shared GraphQL client, +// because the connector package's userResourceType.checkOrgSAML matches the +// text of that client's errors. +type enterpriseGraphQLTransport struct { + base http.RoundTripper +} + +func (t *enterpriseGraphQLTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.base.RoundTrip(req) + if err != nil || resp == nil { + return resp, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return resp, nil + } + + body, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("baton-github: error reading the GraphQL response: %w", readErr) + } + resp.Body = io.NopCloser(bytes.NewReader(body)) + + var envelope graphQLEnvelope + if unmarshalErr := json.Unmarshal(body, &envelope); unmarshalErr != nil { + // Leave unparseable bodies for the GraphQL library to report. + return resp, nil //nolint:nilerr // decoding the payload is the library's job + } + if len(envelope.Errors) == 0 { + return resp, nil + } + + messages := make([]string, 0, len(envelope.Errors)) + for _, graphQLErr := range envelope.Errors { + messages = append(messages, graphQLErr.Message) + } + st := status.New(graphQLErrorsCode(envelope.Errors), strings.Join(messages, "; ")) + if rlDesc, _ := ratelimit.ExtractRateLimitData(resp.StatusCode, &resp.Header); rlDesc != nil { + if withDetails, detailsErr := st.WithDetails(rlDesc); detailsErr == nil { + st = withDetails + } + } + + return nil, st.Err() +} + +// graphQLErrorsCode maps a GraphQL errors array onto a gRPC code. A rate limit +// wins so the SDK retries; any other classified error wins over NOT_FOUND, +// which callers treat as success; otherwise the first classified entry wins. +func graphQLErrorsCode(graphQLErrors []graphQLError) codes.Code { + code := codes.Internal + for _, graphQLErr := range graphQLErrors { + errorType := graphQLErrorType(graphQLErr) + + switch { + case strings.Contains(errorType, "RATE_LIMIT"), strings.Contains(errorType, "RATELIMIT"): + return codes.Unavailable + case errorType == graphQLErrorForbidden && (code == codes.Internal || code == codes.NotFound): + code = codes.PermissionDenied + case errorType == graphQLErrorUnauthenticated && (code == codes.Internal || code == codes.NotFound): + code = codes.Unauthenticated + // e.g. inviting someone who already administers the enterprise. + case errorType == graphQLErrorUnprocessable && (code == codes.Internal || code == codes.NotFound): + code = codes.FailedPrecondition + case errorType == graphQLErrorNotFound && code == codes.Internal: + code = codes.NotFound + } + } + + return code +} diff --git a/vendor/github.com/conductorone/baton-github/pkg/customclient/models.go b/vendor/github.com/conductorone/baton-github/pkg/customclient/models.go index c77da78d..ecf80e54 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/customclient/models.go +++ b/vendor/github.com/conductorone/baton-github/pkg/customclient/models.go @@ -1,5 +1,9 @@ package customclient +type AppInstallation struct { + ID int64 `json:"id"` +} + // https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/license?apiVersion=2022-11-28#list-enterprise-consumed-licenses type EnterpriseConsumedLicense struct { TotalSeatsConsumed int `json:"total_seats_consumed"` diff --git a/vendor/modules.txt b/vendor/modules.txt index 34bc4407..12cfd042 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -272,8 +272,8 @@ 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.4.7 -## explicit; go 1.25.2 +# github.com/conductorone/baton-github v0.4.8 +## explicit; go 1.27.1 github.com/conductorone/baton-github/pkg/config github.com/conductorone/baton-github/pkg/connector github.com/conductorone/baton-github/pkg/customclient