diff --git a/.github/workflows/capabilities_and_config.yaml b/.github/workflows/capabilities_and_config.yaml deleted file mode 100644 index 55d9d19d..00000000 --- a/.github/workflows/capabilities_and_config.yaml +++ /dev/null @@ -1,51 +0,0 @@ -name: Generate capabilities and config schema - -on: - push: - branches: - - main - -jobs: - generate_outputs: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - - steps: - - name: Mint baton-ci app token - id: ci-token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ vars.BATON_CI_CLIENT_ID }} - private-key: ${{ secrets.BATON_CI_SECRET_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - - - name: Checkout code - uses: actions/checkout@v4 - with: - token: ${{ steps.ci-token.outputs.token }} - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: "go.mod" - - - name: Build - run: go build -o connector ./cmd/baton-github - - - name: Run and save config output - run: ./connector config > config_schema.json - - - name: Run and save capabilities output - env: - BATON_TOKEN: test - run: ./connector --sync-secrets capabilities > baton_capabilities.json - - - name: Commit changes - uses: EndBug/add-and-commit@v9 - with: - default_author: github_actions - message: "Updating baton config schema and capabilities." - add: | - config_schema.json - baton_capabilities.json diff --git a/README.md b/README.md index 98ce535b..8ee3daa5 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,11 @@ baton resources - Users - Teams - Repositories +- Organization roles +- Invitations (users invited to an organization who have not accepted yet) - GitHub Apps (installed in organizations, synced as non-human identities) +- Enterprise roles, only when `--enterprises` is set, and only on GitHub Enterprise Cloud — `github.com` or a `ghe.com` data-residency host. A personal access token syncs every role; a GitHub App syncs the built-in Enterprise Owner role and can also grant and revoke it, with the app installed on both the enterprise account and the organization +- Enterprise licenses, only when `--enterprises` is set. The API behind them requires a personal access token; under GitHub App authentication the type answers 403, so leave it disabled on the app path By default, `baton-github` will sync information from any organizations that the provided credential has Administrator permissions on. You can specify exactly which organizations you would like to sync using the `--orgs` flag. @@ -78,7 +82,7 @@ Flags: --app-privatekey-path string Path to private key that is used to connect to the GitHub App. Ignored when app-privatekey is set. ($BATON_APP_PRIVATEKEY_PATH) --client-id string The client ID used to authenticate with ConductorOne ($BATON_CLIENT_ID) --client-secret string The client secret used to authenticate with ConductorOne ($BATON_CLIENT_SECRET) - --enterprises strings Sync enterprise roles, must be an admin of the enterprise. ($BATON_ENTERPRISES) + --enterprises strings 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. ($BATON_ENTERPRISES) --external-resource-c1z string The path to the c1z file to sync external baton resources with ($BATON_EXTERNAL_RESOURCE_C1Z) --external-resource-entitlement-id-filter string The entitlement that external users, groups must have access to sync external baton resources ($BATON_EXTERNAL_RESOURCE_ENTITLEMENT_ID_FILTER) -f, --file string The path to the c1z file to sync with ($BATON_FILE) (default "sync.c1z") @@ -113,3 +117,10 @@ Org: Repo: - Administrator: Read and Write - This permission implies Metadata: Read + +Enterprise, only when `--enterprises` is set: +- GitHub App: People: Read and Write, and the app installed on the enterprise + account as well as the organization — required to sync and provision the + built-in Enterprise Owner role +- Personal access token: `read:enterprise` — required to sync enterprise roles + and licenses diff --git a/baton_capabilities.json b/baton_capabilities.json index ee90c5b4..5ea7ab93 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -166,6 +166,24 @@ ], "permissions": {} }, + { + "resourceType": { + "id": "usage-app", + "displayName": "GitHub Activity", + "traits": [ + "TRAIT_APP" + ], + "annotations": [ + { + "@type": "type.googleapis.com/c1.connector.v2.SkipGrants" + } + ] + }, + "capabilities": [ + "CAPABILITY_SYNC" + ], + "permissions": {} + }, { "resourceType": { "id": "user", @@ -191,7 +209,8 @@ "CAPABILITY_PROVISION", "CAPABILITY_SYNC", "CAPABILITY_ACCOUNT_PROVISIONING", - "CAPABILITY_RESOURCE_DELETE" + "CAPABILITY_RESOURCE_DELETE", + "CAPABILITY_EVENT_FEED_V2" ], "credentialDetails": { "capabilityAccountProvisioning": { diff --git a/cmd/baton-github/main.go b/cmd/baton-github/main.go index 56742ec8..9063fd69 100644 --- a/cmd/baton-github/main.go +++ b/cmd/baton-github/main.go @@ -14,5 +14,8 @@ var version = "dev" func main() { ctx := context.Background() - config.RunConnector(ctx, "baton-github", version, cfg.Config, connector.NewLambdaConnector, connectorrunner.WithSessionStoreEnabled()) + config.RunConnector(ctx, "baton-github", version, cfg.Config, connector.NewLambdaConnector, + connectorrunner.WithSessionStoreEnabled(), + connectorrunner.WithDefaultCapabilitiesConnectorBuilderV2(&connector.DefaultCapabilitiesBuilder{}), + ) } diff --git a/config_schema.json b/config_schema.json index 6b0dce9b..a52e1a8e 100644 --- a/config_schema.json +++ b/config_schema.json @@ -113,7 +113,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/docs-info.md b/docs/docs-info.md new file mode 100644 index 00000000..05b4d086 --- /dev/null +++ b/docs/docs-info.md @@ -0,0 +1,377 @@ +# GitHub Connector Setup Guide + +--- + +## Requirements + +- A **GitHub** organization, and for enterprise features a **GitHub Enterprise Cloud** account +- Either a **personal access token (classic)** or a **GitHub App** owned by the organization or the enterprise +- For the built-in Enterprise Owner role: a GitHub App installed on **both** the enterprise account and the organization + +--- + +## Connector capabilities + +1. **What resources does the connector sync?** + This connector syncs: + - Organizations (the orgs the credential can administer, or the ones named in `--orgs` / `--org`) + - Users (organization members, with SAML identity emails when SAML is configured) + - Invitations (users invited to an organization who have not accepted, and invitations that expired) + - Teams (including nested teams, with their parent team as the parent resource) + - Repositories (optionally excluding archived ones) + - Organization roles (GitHub's built-in and custom organization roles) + - Enterprise roles (only when `--enterprises` is set; under App authentication this is the built-in Owner role) + - Licenses (enterprise seat consumption, only when `--enterprises` is set) + - GitHub Apps installed on the organization + - API keys (fine-grained personal access tokens with access to the org, only when `--sync-secrets` is set) + +2. **Can the connector provision any resources? If so, which ones?** + The connector can provision: + - Organization membership and admin role via Grant and Revoke. Granting to a user who is not yet a member sends an org invitation + - Team membership (`member`, `maintainer`) via Grant and Revoke + - Repository access (`pull`, `triage`, `push`, `maintain`, `admin`) via Grant and Revoke, for users and for teams + - Organization role assignment via Grant and Revoke + - The built-in Enterprise **Owner** role via Grant and Revoke, GitHub App authentication only + - Accounts, via the invitation resource type: `CreateAccount` sends an org invitation, and `Delete` cancels it + - User removal from the organization via `Delete` on the user resource type + +3. **Does the connector emit any event feeds?** + Yes, when `--sync-last-activity` is set. `github_usage_event_feed` streams member activity from each organization's audit log as usage events, which is what drives last-login and activity reporting. The flag also registers a synthetic app resource that exists only to carry those events. + +4. **Does the connector support grant expansion?** + Yes, in three places: + - Repository permissions implied by the organization's `default_repository_permission` are emitted against the organization and expanded through the org's `member` and `admin` entitlements, so every member's baseline repository access surfaces without enumerating collaborators + - An organization role assigned to a team is expanded through that team's `member` and `maintainer` entitlements + - An enterprise license held by a role is expanded through that role's `assigned` entitlement + + All three are `Shallow`, so the SDK does not recurse further. `--direct-collaborators-only` leans on this expansion instead of fetching per-team repository detail, which cuts API calls on large organizations. + +--- + +## Connector credentials + +1. **What credentials or information are needed to set up the connector?** + This connector accepts one of two authentication methods. + + **Personal access token (classic)** + + **Args**: + `--token` — the GitHub personal access token + `--instance-url` — the GitHub instance URL, defaults to `https://github.com` + `--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` — the GitHub instance URL, defaults to `https://github.com` + + Common to both: + `--enterprises` — enterprises to sync enterprise roles and licenses for + `--sync-secrets` — sync fine-grained personal access tokens as API keys + `--sync-last-activity` — emit the audit-log usage event feed. Hidden from `--help` and from the GUI config here, because it only applies to GitHub Enterprise audit-log access; `baton-github-enterprise` sets it directly instead of going through this CLI layer + `--omit-archived-repositories` — skip archived repositories + `--direct-collaborators-only` — reduce API calls on large organizations + +2. **For each item in the list above:** + - **How does a user create or look up that credential or info?** + + **Personal access token (classic):** + 1. In GitHub, click your profile photo, then **Settings** + 2. Go to **Developer settings** > **Personal access tokens** > **Tokens (classic)** + 3. Click **Generate new token** > **Generate new token (classic)** + 4. Name the token, optionally set an expiration, and select the scopes below + 5. Click **Generate token** and copy it — it is shown only once + + **GitHub App:** + 1. For enterprise features, create the App under the enterprise account: **Settings** > **GitHub Apps** > **New GitHub App**. Otherwise create it under the organization + 2. Give it a globally unique name, and use a placeholder URL for Homepage and Callback + 3. Uncheck **Active** under Webhook + 4. Select the permissions below + 5. Under **Where can this app be installed?** choose **Only on this account** + 6. Create the App, copy the **App ID**, then generate and save a **private key** + 7. Install the App. For enterprise features install it twice: once on the **enterprise account**, once on the **organization** the connector syncs + + - **Does the credential need any specific scopes or permissions?** + + **Personal access token (classic)** scopes: + - `repo` — all + - `admin:org` — all for organization-level provisioning, otherwise `read:org` + - `user` — all + - `admin:enterprise` — `read:enterprise`, for enterprise roles and licenses + + If the organization uses SAML single sign-on, the token must also be authorized for that organization. + + **GitHub App** permissions: + - Repository: **Administration** read and write (implies **Metadata** read) + - Organization: **Administration** read-only (detects SAML/SSO configuration), **Members** read and write, **Custom organization roles** read and write + - Enterprise: **Enterprise people** read and write, required to sync and provision the built-in Owner role + + - **Is the list of scopes or permissions different to sync (read) versus provision (read-write)?** + Yes. Read-only syncing needs `read:org` rather than `admin:org` on a PAT, and read-only equivalents of the App's organization permissions. Provisioning the built-in Enterprise Owner role requires **Enterprise people: read and write**; read-only is not enough because the connector issues invitations and role mutations. + + - **What level of access or permissions does the user need in order to create the credentials?** + A personal access token must be created by a user with **Enterprise Owner** access when enterprise features are used, and organization admin access otherwise. Creating an enterprise-owned GitHub App requires someone who can manage GitHub Apps for the enterprise; installing it on an organization requires **Org Owner** on that organization. + +--- + +## Resource Details + +### Organizations + +- **Resource type ID**: `org` +- **Description**: The GitHub organizations the credential can administer, or the ones named in `--orgs` / `--org` +- **Traits**: None +- **Entitlements**: `member` (assignment) and `admin` (permission) +- **Grants**: One grant per organization member for their role. Members are read from the members list; the connector distinguishes admins from plain members +- **Children**: Users, Invitations, Teams, Repositories, Organization roles, GitHub Apps, API keys +- **Provisioning**: Grant adds the member or promotes them to admin. A user who is not yet a member is sent an organization invitation instead, so the membership only exists once they accept. Revoke removes the organization membership + +### Users + +- **Resource type ID**: `user` +- **Description**: Members of the synced organizations +- **Traits**: User trait with login, email and profile +- **Parent**: Organization +- **Entitlements**: None +- **Grants**: None. Access is emitted by the organization, team, repository and role builders +- **Provisioning**: `Delete` removes the user from the organization +- **Note**: When the organization has SAML single sign-on, emails are read from the SAML identity rather than the public profile. Enterprise-level SAML is read from the enterprise consumed-licenses API, which is PAT-only; when that is unavailable the connector falls back to the REST email + +### Invitations + +- **Resource type ID**: `invitation` +- **Description**: Users invited to an organization who have not accepted, and invitations GitHub expired +- **Traits**: User trait with `RESOURCE_STATUS_PENDING`, plus `invitation_status` and `invitation_expires_at` profile fields +- **Parent**: Organization +- **Entitlements**: None +- **Grants**: None +- **Provisioning**: `CreateAccount` sends an organization invitation; `Delete` cancels it +- **Note**: Organization invitations expire seven days after creation. Because a pending invitation disappears once accepted, this resource type opts out of sync anomaly detection + +### Teams + +- **Resource type ID**: `team` +- **Description**: GitHub teams, including nested teams +- **Traits**: Group trait +- **Parent**: Organization, or the parent team for a nested team +- **Entitlements**: `member`, `maintainer` (permission) +- **Grants**: One grant per team member for their role +- **Provisioning**: Grant and Revoke add or remove team membership + +### Repositories + +- **Resource type ID**: `repository` +- **Description**: Repositories of the synced organizations +- **Traits**: None +- **Parent**: Organization +- **Entitlements**: `pull`, `triage`, `push`, `maintain`, `admin` (permission), grantable to users and teams, and declared as an exclusion group because a principal holds one level at a time +- **Grants**: One grant per collaborator for their permission level, and one per team with repository access. The organization's `default_repository_permission` is expanded into the cumulative levels it implies and emitted against the organization, annotated as expandable through the org's `member` and `admin` entitlements +- **Provisioning**: Grant and Revoke add or remove a collaborator, or a team's repository access +- **Note**: `--omit-archived-repositories` skips archived repositories. `--direct-collaborators-only` relies on grant expansion for team access instead of fetching per-team detail + +### Organization roles + +- **Resource type ID**: `org_role` +- **Description**: GitHub's built-in and custom organization roles +- **Traits**: Role trait +- **Parent**: Organization +- **Entitlements**: `assigned` (assignment) +- **Grants**: One grant per user and per team assigned to the role. A team's grant is expandable through that team's `member` and `maintainer` entitlements +- **Provisioning**: Grant and Revoke assign or unassign the role + +### Enterprise roles + +- **Resource type ID**: `enterprise_role` +- **Description**: Roles of an enterprise account, only synced when `--enterprises` is set. A personal access token reads every role from the consumed-licenses API; a GitHub App reads the built-in Owner role through the enterprise administrator API, which is the only one it can use +- **Traits**: Role trait +- **Entitlements**: `assigned` (assignment) +- **Grants**: Under PAT authentication, one grant per user holding each role, read from the enterprise consumed-licenses API. Under GitHub App authentication, only the built-in **Owner** role is visible, and its grants are the users who hold it plus the users who have been invited and have not accepted. The two are emitted against the same entitlement and C1 cannot tell them apart +- **Provisioning**: Only the built-in **Owner** role, and it only succeeds under GitHub App authentication. The capability is advertised on both credentials; a request made with a personal access token fails with a message naming the credential it needs. See [Enterprise Owner provisioning](#enterprise-owner-provisioning) +- **Limitation**: A GitHub App cannot read `Enterprise.ownerInfo`, so under App authentication the connector sees only the Owner role, not billing managers or custom enterprise roles + +### Licenses + +- **Resource type ID**: `license` +- **Description**: Enterprise seat consumption. Only synced when `--enterprises` is set +- **Traits**: License profile trait +- **Entitlements**: `assigned` (assignment) +- **Grants**: One grant for the enterprise member role holding the license, expandable through that role's `assigned` entitlement +- **Limitation**: Requires a personal access token. GitHub does not offer the enterprise administration permission to GitHub Apps, so this resource type cannot sync with an App installation token and must stay disabled on the app path. It carries `OptInRequired`, so it is left out of the default selection. Nothing is lost there: the same `--enterprises` configuration gives an app the built-in Owner role through the enterprise administrator API instead + +### GitHub Apps + +- **Resource type ID**: `app` +- **Description**: GitHub Apps installed on the organization +- **Traits**: App trait, annotated as a non-human identity of type app registration +- **Parent**: Organization +- **Entitlements**: None +- **Grants**: None + +### API keys + +- **Resource type ID**: `api-key` +- **Description**: Fine-grained personal access tokens with access to the organization. Only synced when `--sync-secrets` is set +- **Traits**: Secret trait +- **Parent**: Organization +- **Entitlements**: None +- **Grants**: None + +--- + +## Enterprise Owner provisioning + +Only the built-in **Owner** role of an enterprise is provisionable, and only under GitHub App authentication. The design is shaped by three GitHub constraints: + +**`Enterprise.ownerInfo` is invisible to an App.** It holds `admins` and `pendingAdminInvitations`, and it resolves to `null` for an installation token regardless of which permissions the App declares. + +**`Enterprise.members(role: OWNER)` is the wrong list.** That argument is an `EnterpriseUserAccountMembershipRole`, whose `OWNER` means "owner of an *organization* in the enterprise" — a different enum from the `EnterpriseAdministratorRole` the mutations take. Owners are read from `Organization.enterpriseOwners` instead, which returns every owner of the organization's enterprise account annotated with their role in that organization. The query must not pass `organizationRole`, because that would drop owners who are not owners of the organization. + +**There is no single operation that safely assigns Owner in every case.** A member becomes an Owner by accepting an invitation, but GitHub rejects that invitation when the user already has an enterprise administrator role such as Billing Manager. A GitHub App cannot read that prior role. The connector therefore returns `FailedPrecondition` instead of promoting the administrator in place: otherwise Revoke could only demote them to `UNAFFILIATED`, permanently discarding the role they held before the grant. + +Consequences worth knowing: + +- Grant returns the grant when it creates an invitation. `Grants()` emits pending invitations alongside accepted Owners, so C1 keeps a record of the request from the moment it is made +- Revoke clears both states rather than treating them as alternatives: it demotes an active Owner to `UNAFFILIATED`, which keeps them as a member of the enterprise rather than evicting them, and cancels an unaccepted invitation. A `NOT_FOUND` on either is success, because it means the state being asked for is already in place +- Reading owners uses the **organization** installation token and every mutation uses the **enterprise** installation token; the enterprise token is rejected on organization fields. Startup verifies that the configured organization belongs to the configured enterprise; when it does not, the sync completes with no enterprise roles and logs a warning +- Only one enterprise can be served under App authentication, because the owners are read through the single configured organization and an organization belongs to exactly one enterprise. No separate check enforces that: the clients are built one per configured slug and each verifies that the organization belongs to its enterprise, so naming several means at most one can pass and the error names the slug that does not. The PAT path does accept a list + +### The invitation model is GitHub Enterprise Cloud only, which matters for baton-github-enterprise + +This connector serves `github.com`, where enterprise accounts are GitHub Enterprise Cloud, so the invitation-based mutations above are the right ones. The wrapper `baton-github-enterprise` embeds this package and points it at a custom domain, and a custom domain is either Enterprise Cloud with data residency (`*.ghe.com`, same schema, everything here applies) **or** GitHub Enterprise Server, which models enterprise administrators differently. + +Checked against the GHES GraphQL reference for 3.14 and 3.15. `Organization.enterpriseOwners` and the `EnterpriseAdministratorRole` enum both exist there, so reading owners would work. The write path does not: GHES has `addEnterpriseAdmin` and `removeEnterpriseAdmin` and has no `inviteEnterpriseAdmin`, `updateEnterpriseAdministratorRole` or `cancelEnterpriseAdminInvitation`, and no `enterpriseAdministratorInvitation` query. That is a model difference rather than a version gap: a GHES user already exists on the instance, so an administrator is added directly instead of being emailed an invitation. + +A GHES deployment under app authentication with `--enterprises` set was already failing before this change, and not for the reason first written here. It is not `license`: that type carries `&v2.OptInRequired{}` and is left out of the default selection on a hosted tenant, so it never ran. It is `enterprise_role` itself. Its token-path fallback calls the consumed-licenses API, which is absent from the GHES REST reference, so the call answers `404` — and `fillCache` only swallows a `403` from an app (`enterprise_role.go:142`), so a `404` propagates and fails the sync. + +What the change would have done is replace that failure with a worse one. The enterprise installation endpoint is absent from GHES too, so the client build would answer `404`, which this package maps to "install the app on the enterprise account" — an instruction a Server operator cannot follow, on an instance that has no enterprise administrator API to install against. So the host is classified before any request is made: `github.com` and anything under `ghe.com`, which GitHub owns, are Enterprise Cloud, and everything else is a customer-hosted Server instance that is told the capability does not exist there. Implementing the `addEnterpriseAdmin` / `removeEnterpriseAdmin` path GHES does offer is a separate piece of work, and it belongs behind that same classification. + +The `license` resource type has the same shape of problem, one step further along: `GET /enterprises/{enterprise}/consumed-licenses` is absent from the GHES REST reference for 3.14 and 3.15 entirely, so on a GHES instance it answers `404` rather than the `403` a GitHub App gets on `github.com`. That type is therefore unusable there for **either** credential, not only for an App. The `--enterprises` path runs against three targets — `github.com`, `*.ghe.com` data residency, and GHES — and the first two serve it while the third does not, which is why the host is classified rather than the credential. The token path still reaches `license` on a Server instance and gets its `404`; telling that operator the type does not exist there is the remaining gap, and it belongs in the `baton-github-enterprise` PR, which is where the instance kind is chosen. + +### There is no separate switch for this capability + +`--enterprises` already says the deployment has an enterprise, and the credential already says which API can serve it, so `ResourceSyncers` keys on the two values it has rather than on a third the operator would have to discover. The enterprise role is registered with Grant and Revoke either way; a token cannot reach the enterprise administrator API, so a request made against it fails saying so rather than the role being hidden from C1. Licenses are registered on either credential too; their API answers 403 to anything but a token, which is why the type is opt-in. + +An app deployment that already passes `--enterprises` used to report no enterprise roles, because the consumed-licenses API it fell back to is token-only. It now reads the Owner role through the enterprise administrator API when the app is set up for it. When it is not, the sync keeps its old outcome rather than starting to fail, so upgrading does not turn a green sync red for a customer who never relied on enterprise roles. + +### A setup problem skips the role; anything else fails the sync + +When the enterprise administration client cannot be built because of how the deployment is set up (the app is not installed on the enterprise account, or the organization does not belong to the configured enterprise -- which is also what naming several enterprises reduces to), the sync completes with no enterprise roles and logs a warning naming the fix. That is what these deployments did before the enterprise administrator API was used, and failing the sync now would break customers who do not care about enterprise roles. Grant and Revoke still return the error, since there is nothing to fall back to. + +Any other build failure (a rate limit, a 5xx, a cancelled context) fails the whole sync. C1 deletes every resource of a type that a completed sync did not report, so finishing the sync while reading no owners for a transient reason would delete the Owner role and every grant on it. + +The trade-off: GitHub answers `404` for an uninstalled app, a revoked permission and a slug typo alike. A deployment that synced owners and then loses its enterprise installation gets one sync with no enterprise roles, and C1 drops the Owner role and its grants until the installation is restored. GitHub Enterprise Server is not skipped; it was already failing the sync on the consumed-licenses fallback, and it now fails with an error that says the capability is Cloud-only. + +### The published capability set describes an account without an enterprise + +`baton_capabilities.json` is generated by running the `capabilities` command with no credentials and no flags, through `DefaultCapabilitiesBuilder`. That builder lists what a GitHub account without an enterprise can sync, including the types `--sync-secrets` and `--sync-last-activity` enable, and leaves out `enterprise_role` and `license`. A running connector configured with `--enterprises` still reports both types live. + +The consequence is in how C1 selects types. When a connector is created, C1 stamps its resource type selection from the **catalog release's** capabilities (in the `ductone/c1` monorepo, `pkg/controller/app/controller/connector.go` reduces them with `DefaultSyncResourceTypeIDs`), and at sync time it intersects that saved selection with the declared capabilities (`EffectiveSyncResourceTypeFilter`, `pkg/connector/resource_types.go`). On a hosted tenant with selective sync enabled, the enterprise types are therefore not in the default selection, and an admin enables them for a deployment that uses `--enterprises`. Tenants without selective sync are unaffected. + +Enabling by hand is possible, which is the part worth stating rather than assuming: the update RPC the customer goes through (`ductone/c1` `pkg/api/app/rpc_app_connector.go`) assigns the requested resource type IDs and validates only that no *deprecated* type is newly enabled — it does not require the type to appear in the catalog release — and `EffectiveSyncResourceTypeFilter` intersects the saved selection against the **live** connector's capabilities, which do carry `enterprise_role` once `--enterprises` is set. So the type is off by default, not unreachable. The cost is that the capability does nothing until someone performs that step, and it is silent: nothing in a green sync says a type was never selected. The customer-facing instruction belongs with the connector the enterprise customer actually installs, which is `baton-github-enterprise` for a data-residency host, so it ships in that repo's docs rather than on the `github.com` tile. + +Regenerate `baton_capabilities.json` and `config_schema.json` by hand after any change to the resource types or the config fields: `./baton-github capabilities` and `./baton-github config`, run without credentials. This repo has no workflow that does it. `baton-admin`'s `connectors.yaml` sets `ci_workflows.capabilities: false` for `baton-github`, so it does not push the managed `generate-baton-metadata.yaml`, and the repo's own `capabilities_and_config.yaml` was removed. Enabling that flag in `baton-admin` restores automatic regeneration. + +### Pending invitations look the same as real access + +C1 has no pending state for a grant, so an invitation nobody has accepted and an accepted Owner are emitted as the same grant on the same entitlement, and nothing distinguishes them. That is a deliberate trade: emitting nothing until the invitee accepts would leave the request invisible for up to seven days and leave reviewers no record that it was made. + +- An access review or an offboarding sweep counts an invitee as holding Owner. They do not hold it — GitHub assigns the role only on acceptance +- Nothing tracks an expiry. GitHub stops resolving an invitation once it is accepted, cancelled, or expired, so it simply stops being emitted and C1 drops the grant on that sync. `EnterpriseAdministratorInvitation` exposes no `expiresAt`, so there is nothing to compute from either +- **Time-bound access is measured from the invitation, not from acceptance.** C1 starts the clock when Grant reports success, which is when the invitation is sent. Someone who accepts three hours into a four-hour window holds the role for one hour, and the record still reads four. If the window closes first, Revoke cancels the unaccepted invitation — the right action, since the request expired, but C1 logs a completed Owner grant for someone who never held the role. Prefer windows comfortably longer than the invitee takes to accept + +### The list of pending invitations cannot be complete + +`Enterprise.ownerInfo.pendingAdminInvitations` is the only connection of invitations GitHub offers, and it resolves to `null` for an installation token. The root `enterpriseAdministratorInvitation` field answers for one login at a time, so the sync resolves invitations by asking about the enterprise members, batching up to 100 logins into one aliased request — GitHub charges that whole request a single rate-limit point. + +An invitation sent to someone who is not a member of the enterprise is therefore invisible to the sync, and that is not hypothetical: an owner invitation can be addressed to any GitHub user. It is not enough that C1 has already synced the principal either — outside collaborators reach C1 through repository access without being enterprise members, so a grant to one of them would create an invitation on GitHub that no later sync could read, and C1 would drop the grant while it stayed live. Grant therefore checks membership first and rejects a non-member with `InvalidArgument` rather than creating state it cannot read back. The check is one filtered `Enterprise.members` lookup, and it compares the returned logins because that argument is a search rather than an exact match. + +The cost of resolving invitations scales with the enterprise, not with the organization: every sync walks the whole enterprise membership, one page of 100 per request, and asks one aliased batch per page. An enterprise of N members therefore adds roughly N/50 GraphQL requests per sync — about 1,000 for 50,000 members. Memory is unaffected, since the walk streams through the page token, and none of it happens unless the capability is enabled. + +### Owner grants can reference a user the sync did not emit + +The `user` resource type is populated from the members of the **configured organization**, while `Organization.enterpriseOwners` returns owners of the whole **enterprise account** and the invitation candidates come from `Enterprise.members`, which spans every organization in it. An owner who belongs to a different organization in the same enterprise therefore produces a grant whose principal this sync never created. Widening the user sync is out of scope here — it would change the connector's user population for every deployment — so the grant is emitted and this limitation is recorded instead. + +--- + +## Authentication + +The connector supports two methods, selected by which credentials are supplied. + +1. **Personal access token (classic)**: a single bearer token used for REST and GraphQL. + +2. **GitHub App**: the App's private key signs a JWT, which is exchanged for installation access tokens. Tokens are refreshed automatically when they expire. A connector using enterprise features holds two installation tokens at once, one for the organization and one for the enterprise account, because GitHub splits the data between them. + +GraphQL is used for SAML identity lookups, the audit log, and all enterprise owner reads and mutations. Everything else is REST. + +--- + +## API Endpoints Used + +**REST** (via `go-github`): + +- `GET /user`, `GET /users/{username}`, `GET /user/{id}` — resolve users +- `GET /organizations`, `GET /orgs/{org}`, `GET /organizations/{id}` — list and resolve organizations +- `GET /orgs/{org}/members` — organization members +- `GET /orgs/{org}/memberships/{username}` — a member's role +- `PUT /orgs/{org}/memberships/{username}` — promote to admin (Grant) +- `DELETE /orgs/{org}/memberships/{username}` — remove membership (Revoke, user Delete) +- `POST /orgs/{org}/invitations` — invite a user (Grant, CreateAccount) +- `GET /orgs/{org}/invitations`, `GET /orgs/{org}/failed_invitations` — pending and expired invitations +- `DELETE /orgs/{org}/invitations/{invitation_id}` — cancel an invitation (invitation Delete) +- `GET /orgs/{org}/teams`, `GET /teams/{team_id}` — teams +- `GET /teams/{team_id}/members` — team members +- `PUT /teams/{team_id}/memberships/{username}`, `DELETE /teams/{team_id}/memberships/{username}` — team membership (Grant, Revoke) +- `GET /orgs/{org}/repos`, `GET /repositories/{id}` — repositories +- `GET /repos/{owner}/{repo}/collaborators`, `GET /repos/{owner}/{repo}/collaborators/{username}/permission` — repository access +- `PUT /repos/{owner}/{repo}/collaborators/{username}`, `DELETE /repos/{owner}/{repo}/collaborators/{username}` — repository access (Grant, Revoke) +- `GET /repos/{owner}/{repo}/teams` — teams with repository access +- `PUT /orgs/{org}/teams/{team_slug}/repos/{owner}/{repo}`, `DELETE /orgs/{org}/teams/{team_slug}/repos/{owner}/{repo}` — team repository access (Grant, Revoke) +- `GET /orgs/{org}/organization-roles` — organization roles +- `GET /orgs/{org}/organization-roles/{role_id}/users`, `.../teams` — role assignments +- `GET /orgs/{org}/installations` — installed GitHub Apps, requires `organization_administration=read` +- `GET /orgs/{org}/personal-access-tokens` — fine-grained PATs, only with `--sync-secrets` +- `GET /orgs/{org}/audit-log` — organization audit log +- `GET /enterprises/{enterprise}/installation` — this App's installation on one enterprise, authenticated with the App JWT +- `GET /enterprises/{enterprise}/consumed-licenses` — enterprise license consumption and enterprise SAML identities. **PAT only** + +**GraphQL**: + +- `organization(login:) { samlIdentityProvider { externalIdentities } }` — SAML identity emails +- `organization(login:) { enterpriseOwners }` — the enterprise account's owners, read with the organization token +- `enterprise(slug:) { id }`, `enterprise(slug:) { organizations }` — enterprise node ID, and the organization-belongs-to-enterprise check +- `enterpriseAdministratorInvitation(enterpriseSlug:, userLogin:, role:)` — a pending Owner invitation, for one login; the sync aliases up to 100 of these into a single request +- `enterprise(slug:).members` — the candidate logins the pending-invitation lookup asks about +- `inviteEnterpriseAdmin`, `updateEnterpriseAdministratorRole`, `cancelEnterpriseAdminInvitation` — Owner Grant and Revoke + +--- + +## Pagination + +- REST endpoints use GitHub's `page` and `per_page` parameters, 100 per page, driven one page per SDK call +- `GET /enterprises/{enterprise}/consumed-licenses` is 1-indexed; page 0 is undocumented and can repeat page 1, producing duplicates +- GraphQL connections use cursor pagination, 100 per page, with the `endCursor` passed through as the SDK page token +- The audit log event feed keeps its own cursor, which carries both the current organization index and GitHub's `after` token, so the feed resumes mid-organization + +--- + +## Rate Limits + +- REST: 5,000 requests per hour for a PAT. A GitHub App installation gets a larger budget that scales with the account; installations on this connector's test enterprise reported 15,000 +- GraphQL: a separate points-based budget, reported per query in the `rateLimit` field; App installations reported 10,000 +- The connector returns GitHub's rate limit headers to the SDK as rate limit annotations, so it backs off rather than failing the sync. The enterprise owner queries additionally report the GraphQL `rateLimit` field, and are the ones that classify the errors GitHub returns inside an HTTP 200 body, so a rate limit there surfaces as retryable rather than as an opaque failure + +--- + +## API Documentation + +**Official GitHub API references:** + +- **REST**: https://docs.github.com/en/rest +- **GraphQL**: https://docs.github.com/en/graphql +- **Enterprise administration (GraphQL)**: https://docs.github.com/en/graphql/reference/enterprise-admin +- **Permissions required for GitHub Apps**: https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps +- **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 (REST)**: https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/license diff --git a/pkg/config/config.go b/pkg/config/config.go index 60856c56..ecfb0d51 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 b1bd1017..22e16bfb 100644 --- a/pkg/connector/connector.go +++ b/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/pkg/connector/default_capabilities.go b/pkg/connector/default_capabilities.go new file mode 100644 index 00000000..f0dbe55d --- /dev/null +++ b/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/pkg/connector/default_capabilities_test.go b/pkg/connector/default_capabilities_test.go new file mode 100644 index 00000000..48151e4b --- /dev/null +++ b/pkg/connector/default_capabilities_test.go @@ -0,0 +1,74 @@ +package connector + +import ( + "context" + "testing" + + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/stretchr/testify/require" +) + +// The default builder's list is hand-maintained, and anything such a +// deployment can register but it omits is silently dropped from the published +// metadata -- which is the exact understatement the builder exists to fix. +// The enterprise types are the deliberate exception, pinned by the test below. +func TestDefaultCapabilitiesCoverEverySyncerANonEnterpriseDeploymentRegisters(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + advertised := make(map[string]connectorbuilder.ResourceSyncerV2) + for _, syncer := range (&DefaultCapabilitiesBuilder{}).ResourceSyncers(ctx) { + advertised[syncer.ResourceType(ctx).GetId()] = syncer + } + + gh := &GitHub{syncSecrets: true, syncLastActivity: true} + for _, syncer := range gh.ResourceSyncers(ctx) { + id := syncer.ResourceType(ctx).GetId() + defaultSyncer, ok := advertised[id] + require.True(t, ok, + "resource type %q is registered by a real deployment but missing from DefaultCapabilitiesBuilder", id) + + // The SDK derives CAPABILITY_PROVISION by type-asserting the + // syncer, so a matching ID does not imply a matching capability. + if _, deploymentProvisions := syncer.(connectorbuilder.ResourceProvisionerV2Limited); deploymentProvisions { + _, defaultProvisions := defaultSyncer.(connectorbuilder.ResourceProvisionerV2Limited) + require.True(t, defaultProvisions, + "resource type %q provisions in a real deployment, so DefaultCapabilitiesBuilder must register a provisioning syncer for it", id) + } + } +} + +// The published metadata describes an account without an enterprise, so the +// --enterprises types stay out of it. +func TestDefaultCapabilitiesLeaveOutEnterpriseTypes(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, syncer := range (&DefaultCapabilitiesBuilder{}).ResourceSyncers(ctx) { + id := syncer.ResourceType(ctx).GetId() + require.NotEqual(t, resourceTypeEnterpriseRole.Id, id) + require.NotEqual(t, resourceTypeLicense.Id, id) + } +} + +// CAPABILITY_EVENT_FEED_V2 is derived from the registered feeds, so a feed the +// default builder does not list disappears from the published metadata. +func TestDefaultCapabilitiesCoverEveryEventFeed(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + advertised := make(map[string]bool) + for _, feed := range (&DefaultCapabilitiesBuilder{}).EventFeeds(ctx) { + advertised[feed.EventFeedMetadata(ctx).GetId()] = true + } + + gh := &GitHub{syncLastActivity: true} + for _, feed := range gh.EventFeeds(ctx) { + id := feed.EventFeedMetadata(ctx).GetId() + require.True(t, advertised[id], + "event feed %q is registered by a real deployment but missing from DefaultCapabilitiesBuilder", id) + } +} diff --git a/pkg/connector/enterprise_installations_test.go b/pkg/connector/enterprise_installations_test.go new file mode 100644 index 00000000..6cfb64d7 --- /dev/null +++ b/pkg/connector/enterprise_installations_test.go @@ -0,0 +1,352 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/google/go-github/v69/github" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/conductorone/baton-github/pkg/customclient" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource" +) + +// newGitHubAPITestClient points the client at a test server through BaseURL +// alone. Nothing rewrites the host, so a customclient endpoint that ignored +// BaseURL would leave the test reaching for api.github.com. +func newGitHubAPITestClient(t *testing.T, handler http.Handler) *github.Client { + t.Helper() + + return newGitHubAPITestClientAt(t, handler, "/") +} + +func newGitHubAPITestClientAt(t *testing.T, handler http.Handler, basePath string) *github.Client { + t.Helper() + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + baseURL, err := url.Parse(srv.URL + basePath) + require.NoError(t, err) + + client := github.NewClient(srv.Client()) + client.BaseURL = baseURL + + return client +} + +func TestGetEnterpriseInstallation(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client := newGitHubAPITestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/enterprises/example-enterprise/installation", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "id": int64(22), + })) + })) + + installation, _, err := customclient.New(client).GetEnterpriseInstallation(ctx, "example-enterprise") + require.NoError(t, err) + require.Equal(t, int64(22), installation.ID) +} + +// A slug is operator-supplied, so it has to survive as one path segment +// instead of being pasted into the URL. +func TestGetEnterpriseInstallationEscapesTheSlug(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client := newGitHubAPITestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/enterprises/a%2Fb/installation", r.URL.EscapedPath()) + w.WriteHeader(http.StatusNotFound) + })) + + _, _, err := customclient.New(client).GetEnterpriseInstallation(ctx, "a/b") + require.Error(t, err) +} + +// On GitHub Enterprise Server, WithEnterpriseURLs puts the REST API under +// /api/v3. Asking api.github.com instead would report the app as uninstalled +// on an enterprise that does have it. +func TestGetEnterpriseInstallationUsesTheInstanceBaseURL(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client := newGitHubAPITestClientAt(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v3/enterprises/ghes-enterprise/installation", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "id": int64(33), + })) + }), "/api/v3/") + + installation, _, err := customclient.New(client).GetEnterpriseInstallation(ctx, "ghes-enterprise") + require.NoError(t, err) + require.Equal(t, int64(33), installation.ID) +} + +// A nil client provider is the token path, where enterprise roles come from +// the consumed-licenses API. A credential that cannot read it fails the sync +// rather than reporting an empty list, which is the shape that path has always +// had: a token without read:enterprise is a configuration the operator has to +// hear about, and reporting nothing would read to C1 as every role being gone. +// +// The provider being nil is the only thing that distinguishes the two paths, +// so this is pinned alongside the app-path tests. +func TestEnterpriseRoleListFailsOnTheTokenPathWithoutEnterpriseScope(t *testing.T) { + t.Parallel() + + ctx := context.Background() + apiClient := newGitHubAPITestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"message": "Resource not accessible by integration"})) + })) + + // nil provider is what newWithGithubPAT leaves behind. + builder := EnterpriseRoleBuilder(apiClient, customclient.New(apiClient), + []string{"example-enterprise"}, nil) + + _, _, err := builder.List(ctx, nil, resourceSdk.SyncOpAttrs{}) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, status.Code(err)) +} + +// GitHub answers 404 when the app is not installed on the enterprise. Failing +// is what protects the data: C1 deletes every resource of a type that a +// completed sync did not report, so letting the sync finish while reading no +// owners would drop the Owner role and every grant on it. An error keeps the +// sync from completing at all. +func TestNewEnterpriseRoleClientsRequiresEnterpriseInstall(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client := newGitHubAPITestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/enterprises/example-enterprise/installation", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"message": "Not Found"})) + })) + + _, err := newEnterpriseRoleClients( + ctx, + ctx, + "https://github.com", + client, + oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "unused"}), + []string{"example-enterprise"}, + nil, + "example-org", + ) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Contains(t, err.Error(), `not installed on enterprise "example-enterprise"`) + require.True(t, isEnterpriseSetupError(err)) + require.NotContains(t, err.Error(), "personal access token") +} + +// The enterprise role is registered with Grant and Revoke on both credentials +// on purpose. A token cannot reach the enterprise administrator API, so a +// request made against it fails with a message saying so rather than being +// hidden from C1 -- the alternative, registering the read-only type there, +// also advertises a role nobody can be granted. +func TestResourceSyncersRegisterTheEnterpriseRoleWithProvisioning(t *testing.T) { + t.Parallel() + + ctx := context.Background() + for _, tc := range []struct { + name string + provider enterpriseClientProvider + }{ + {"token", nil}, + {"app", func(context.Context, []string) (map[string]*customclient.EnterpriseAdminClient, error) { + return nil, nil + }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gh := &GitHub{ + enterprises: []string{testEnterprise}, + newEnterpriseRoleClients: tc.provider, + } + + var found bool + for _, syncer := range gh.ResourceSyncers(ctx) { + if syncer.ResourceType(ctx).GetId() != resourceTypeEnterpriseRole.Id { + continue + } + found = true + _, provisions := syncer.(connectorbuilder.ResourceProvisionerV2Limited) + require.True(t, provisions) + } + require.True(t, found, "enterprise_role must be synced either way") + }) + } +} + +// Setting the flag in both the environment and the command line lands the +// same enterprise twice. The clients are keyed by the slug as configured, so +// without the fold that would build two entries for one enterprise and emit +// its Owner role twice. +func TestNewEnterpriseRoleClientsFoldsRepeatedEnterprises(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client := newGitHubAPITestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/enterprises/example-enterprise/installation", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"message": "Not Found"})) + })) + + _, err := newEnterpriseRoleClients( + ctx, + ctx, + "https://github.com", + client, + oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "unused"}), + []string{"example-enterprise", "Example-Enterprise"}, + nil, + "example-org", + ) + // Reaches the installation lookup rather than the several-enterprises + // guard, which is what proves the fold happened. + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Contains(t, err.Error(), "not installed on enterprise") +} + +func TestConnectorFoldsRepeatedEnterprisesBeforeBuildingSyncers(t *testing.T) { + t.Parallel() + + // The clients are keyed by the slug as configured, so the same enterprise + // named twice would build two entries and emit the Owner role twice. + // GitHub matches slugs case-insensitively, so the fold does too. + enterprises := distinctEnterprises([]string{"example-enterprise", "Example-Enterprise"}) + require.Equal(t, []string{"example-enterprise"}, enterprises) + + resources, _, err := appList( + enterprises, + map[string]*customclient.EnterpriseAdminClient{"example-enterprise": nil}, + ) + require.NoError(t, err) + require.Len(t, resources, 1) +} + +// A slug the app cannot reach must not cost the enterprise that works its +// owners. The clients are built per enterprise, so a configuration error skips +// only its own: discarding the whole map would complete a sync with no Owner +// role, which C1 reads as every grant on it being revoked. +func TestNewEnterpriseRoleClientsReturnsTheSetupErrorWhenNoneBuild(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client := newGitHubAPITestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"message": "Not Found"})) + })) + + _, err := newEnterpriseRoleClients( + ctx, ctx, "https://github.com", client, + oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "unused"}), + []string{"example-enterprise", "typo-enterprise"}, nil, "example-org", + ) + + // Every slug failed, so the first configuration error is what surfaces, + // and it stays a setup error, so the sync skips the type. + require.Error(t, err) + require.True(t, isEnterpriseSetupError(err)) + require.Contains(t, err.Error(), `not installed on enterprise "example-enterprise"`) +} + +// A setup error on one slug must not discard the client that built. Returning +// on the first error would drop the good enterprise and complete a sync with +// no owners. +func TestNewEnterpriseRoleClientsKeepTheEnterprisesThatBuilt(t *testing.T) { + t.Parallel() + + ctx := context.Background() + good := &customclient.EnterpriseAdminClient{} + clients, err := collectEnterpriseRoleClients(ctx, []string{"typo-enterprise", "example-enterprise"}, + func(enterprise string) (*customclient.EnterpriseAdminClient, error) { + if enterprise == "typo-enterprise" { + return nil, enterpriseSetupError{status.Error(codes.FailedPrecondition, + enterpriseNotInstalledMessage(enterprise, "example-org", 2))} + } + return good, nil + }, + ) + + require.NoError(t, err) + require.Equal(t, map[string]*customclient.EnterpriseAdminClient{"example-enterprise": good}, clients) + + // A rate limit after one client built still fails the build. Keeping the + // partial map would report the other enterprise's owners as gone. + _, err = collectEnterpriseRoleClients(ctx, []string{"example-enterprise", "typo-enterprise"}, + func(enterprise string) (*customclient.EnterpriseAdminClient, error) { + if enterprise == "example-enterprise" { + return good, nil + } + return nil, status.Error(codes.Unavailable, "rate limited") + }, + ) + require.Equal(t, codes.Unavailable, status.Code(err)) +} + +// GitHub Enterprise Server has no enterprise administrator API, so naming an +// enterprise there can only fail. Failing on the host rather than on the 404 +// that follows matters because that 404 reads as "install the app on the +// enterprise account", which is not something a Server operator can do. +func TestEnterpriseCloudHostsAreTheOnlyOnesServingTheOwnerRole(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + instanceURL string + cloud bool + }{ + {"", true}, + {"https://github.com", true}, + {"https://github.com/", true}, + {"https://acme.ghe.com", true}, + {"https://ghe.com", true}, + {"https://github.acme.com", false}, + {"https://ghe.acme.com", false}, + {"https://acme.ghe.com.evil.test", false}, + } { + t.Run(tc.instanceURL, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.cloud, isEnterpriseCloud(tc.instanceURL)) + }) + } +} + +// The check runs before any request, so a Server instance is told the +// capability does not exist there instead of being sent to install something. +func TestNewEnterpriseRoleClientsRejectsANonCloudInstance(t *testing.T) { + t.Parallel() + + // Two enterprises as well, so the instance is named before the count: + // a Server operator asked to trim the list would still have nothing to + // trim it to. + _, err := newEnterpriseRoleClients( + context.Background(), context.Background(), + "https://github.acme.com", + github.NewClient(nil), nil, + []string{testEnterprise, "another-enterprise"}, nil, "example-org", + ) + + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Contains(t, err.Error(), "GitHub Enterprise Cloud capability") + require.NotContains(t, err.Error(), "install it on the enterprise account") + require.False(t, isEnterpriseSetupError(err)) +} diff --git a/pkg/connector/enterprise_role.go b/pkg/connector/enterprise_role.go index a6e79aab..54d49556 100644 --- a/pkg/connector/enterprise_role.go +++ b/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/pkg/connector/enterprise_role_test.go b/pkg/connector/enterprise_role_test.go new file mode 100644 index 00000000..b251d383 --- /dev/null +++ b/pkg/connector/enterprise_role_test.go @@ -0,0 +1,1413 @@ +package connector + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + entitlementSdk "github.com/conductorone/baton-sdk/pkg/types/entitlement" + resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "github.com/google/go-github/v69/github" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/conductorone/baton-github/pkg/customclient" + "github.com/conductorone/baton-github/test/mocks" +) + +const ( + testEnterprise = "example-enterprise" + testEnterpriseID = "E_example" + // The seeded mock user has ID 56 and login "56". + testLogin = "56" + testLoginID = 56 + testOrg = "example-org" +) + +// enterpriseStub is an in-memory GitHub GraphQL enterprise: it answers the +// owner and invitation queries from its own state and applies the mutations to +// it, so the tests exercise the documents the connector actually sends. +type enterpriseStub struct { + // ownerPages holds the users that currently hold the Owner role, split + // into the pages the members connection returns. + ownerPages [][]enterpriseStubOwner + // invitations maps a login to its pending Owner invitation ID. + invitations map[string]string + // memberPages holds the enterprise member accounts, split into the pages + // the members connection returns. They are the candidate set the pending + // invitation lookup asks about. + memberPages [][]enterpriseStubOwner + // nonMembers marks the logins the single-login membership lookup reports + // as outside the enterprise. Everyone else is a member, so the cases that + // exercise the invitation path do not have to declare one. + nonMembers map[string]bool + // lookupDecoyPages are pages of accounts the membership search returns + // before the page holding the login itself. + lookupDecoyPages [][]string + lookupPagesServed int + + // inviteErrorType makes inviteEnterpriseAdmin fail with that GraphQL error + // type. UNPROCESSABLE is what GitHub returns for someone who already + // administers the enterprise. + inviteErrorType string + inviteErrorMessage string + updateFails bool + cancelNotFound bool + // ownersRateLimited makes the owners read answer the way GitHub reports a + // GraphQL budget error: HTTP 200 carrying errors[]. + ownersRateLimited bool + // batchErrorType adds one entry of that type to the invitation batch, + // alongside the NOT_FOUND entries the batch always produces. + batchErrorType string + // silentMutations make the mutations report success without changing any + // state, which is how a phantom grant or revoke would look. + silentMutations bool + + ownerQueries int + invitationQueries int + invitationBatches int + memberQueries int + organizationChecks int + + updatedRole githubv4.EnterpriseAdministratorRole + invitedLogin string + cancelledID string +} + +type enterpriseStubOwner struct { + id int64 + login string +} + +func (s *enterpriseStub) handle(t *testing.T, w http.ResponseWriter, r *http.Request) { + t.Helper() + + var body struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(body.Query, "enterpriseAdministratorInvitation("): + // The sync resolves many logins in one aliased request; Grant and + // Revoke ask about a single login through the typed client. + if _, single := body.Variables["login"]; single { + s.writeInvitation(t, w, body.Variables) + } else { + s.writeInvitationBatch(t, w, body.Query, body.Variables) + } + + case strings.Contains(body.Query, "enterpriseOwners("): + require.Contains(t, body.Query, "databaseId") + require.NotContains(t, body.Query, "members(", + "owners must come from Organization.enterpriseOwners, not Enterprise.members") + // An organizationRole filter would drop every enterprise owner whose + // role in this organization is not OWNER, which is the bug this read + // path replaced. + require.NotContains(t, body.Query, "organizationRole", + "the owners query must not filter by the owner's role in the organization") + s.writeOwners(t, w, body.Variables) + + case strings.Contains(body.Query, "members(") && body.Variables["query"] != nil: + s.writeMemberLookup(t, w, body.Variables) + + case strings.Contains(body.Query, "members("): + s.writeMembers(t, w, body.Variables) + + case strings.Contains(body.Query, "organizations("): + s.organizationChecks++ + _, _ = fmt.Fprintf(w, `{"data":{"enterprise":{"organizations":{"nodes":[{"login":%q}]}}}}`, testOrg) + + case strings.Contains(body.Query, "updateEnterpriseAdministratorRole("): + requireMutationShape(t, body.Query, "updateEnterpriseAdministratorRole") + input := mutationInput(t, body.Variables) + login, _ := input["login"].(string) + role, _ := input["role"].(string) + if s.updateFails { + _, _ = w.Write([]byte(`{"data":{"updateEnterpriseAdministratorRole":null},` + + `"errors":[{"type":"RATE_LIMITED","message":"rate limit exceeded"}]}`)) + return + } + s.updatedRole = githubv4.EnterpriseAdministratorRole(role) + if !s.silentMutations { + if role == string(enterpriseAdministratorRoleUnaffiliated) { + s.removeOwner(login) + } else { + s.ownerPages = [][]enterpriseStubOwner{{{id: testLoginID, login: login}}} + } + } + _, _ = w.Write([]byte(`{"data":{"updateEnterpriseAdministratorRole":{"clientMutationId":null}}}`)) + + case strings.Contains(body.Query, "inviteEnterpriseAdmin("): + requireMutationShape(t, body.Query, "inviteEnterpriseAdmin") + input := mutationInput(t, body.Variables) + login, _ := input["invitee"].(string) + if s.inviteErrorType != "" { + message := s.inviteErrorMessage + if message == "" { + // Observed live against GitHub for an existing administrator. + message = "Invitee is already an owner of this enterprise" + } + _, _ = fmt.Fprintf(w, + `{"data":{"inviteEnterpriseAdmin":null},"errors":[{"type":%q,"message":%q}]}`, + s.inviteErrorType, message) + return + } + s.invitedLogin = login + if !s.silentMutations { + s.invitations[login] = "EAI_" + login + } + _, _ = w.Write([]byte(`{"data":{"inviteEnterpriseAdmin":{"clientMutationId":null}}}`)) + + case strings.Contains(body.Query, "cancelEnterpriseAdminInvitation("): + requireMutationShape(t, body.Query, "cancelEnterpriseAdminInvitation") + input := mutationInput(t, body.Variables) + id, _ := input["invitationId"].(string) + s.cancelledID = id + for login, invitation := range s.invitations { + if invitation == id { + delete(s.invitations, login) + } + } + if s.cancelNotFound { + _, _ = w.Write([]byte(`{"data":{"cancelEnterpriseAdminInvitation":null},` + + `"errors":[{"type":"NOT_FOUND","message":"invitation not found"}]}`)) + return + } + _, _ = w.Write([]byte(`{"data":{"cancelEnterpriseAdminInvitation":{"clientMutationId":null}}}`)) + + case strings.Contains(body.Query, "enterprise(slug: $slug){id}"): + _, _ = fmt.Fprintf(w, `{"data":{"enterprise":{"id":%q}}}`, testEnterpriseID) + + default: + t.Fatalf("unexpected GraphQL operation: %s", body.Query) + } +} + +func (s *enterpriseStub) removeOwner(login string) { + for pageIndex, page := range s.ownerPages { + remaining := make([]enterpriseStubOwner, 0, len(page)) + for _, owner := range page { + if !strings.EqualFold(owner.login, login) { + remaining = append(remaining, owner) + } + } + s.ownerPages[pageIndex] = remaining + } +} + +func (s *enterpriseStub) writeOwners(t *testing.T, w http.ResponseWriter, variables map[string]any) { + t.Helper() + s.ownerQueries++ + + if s.ownersRateLimited { + _, err := w.Write([]byte(`{"data":{"organization":null},` + + `"errors":[{"type":"RATE_LIMITED","message":"API rate limit exceeded"}]}`)) + require.NoError(t, err) + return + } + + pageIndex := 0 + if after, ok := variables["after"].(string); ok && after != "" { + _, err := fmt.Sscanf(after, "cursor-%d", &pageIndex) + require.NoError(t, err) + } + + owners := []enterpriseStubOwner{} + if pageIndex < len(s.ownerPages) { + owners = s.ownerPages[pageIndex] + } + hasNextPage := pageIndex+1 < len(s.ownerPages) + + nodes := make([]string, 0, len(owners)) + for _, owner := range owners { + nodes = append(nodes, fmt.Sprintf(`{"databaseId":%d,"login":%q}`, owner.id, owner.login)) + } + + _, err := fmt.Fprintf( + w, + `{"data":{"organization":{"enterpriseOwners":{"nodes":[%s],`+ + `"pageInfo":{"hasNextPage":%t,"endCursor":"cursor-%d"}}},`+ + `"rateLimit":{"limit":5000,"remaining":4999,"resetAt":"2026-09-18T23:00:00Z"}}}`, + strings.Join(nodes, ","), hasNextPage, pageIndex+1, + ) + require.NoError(t, err) +} + +// writeInvitation answers the way GitHub does: the invitation when there is +// one, and a NOT_FOUND error when there is not. +// writeMembers answers one page of the enterprise member accounts, in the +// EnterpriseUserAccount shape GitHub returns for this connection. +func (s *enterpriseStub) writeMembers(t *testing.T, w http.ResponseWriter, variables map[string]any) { + t.Helper() + s.memberQueries++ + + pageIndex := 0 + if after, ok := variables["after"].(string); ok && after != "" { + _, err := fmt.Sscanf(after, "member-cursor-%d", &pageIndex) + require.NoError(t, err) + } + + members := []enterpriseStubOwner{} + if pageIndex < len(s.memberPages) { + members = s.memberPages[pageIndex] + } + hasNextPage := pageIndex+1 < len(s.memberPages) + + nodes := make([]string, 0, len(members)) + for _, member := range members { + // Only the selected fields come back, so no __typename here: the query + // resolves the union with inline fragments instead. + nodes = append(nodes, fmt.Sprintf( + `{"login":%q,"user":{"databaseId":%d,"login":%q}}`, + member.login, member.id, member.login)) + } + + _, err := fmt.Fprintf(w, + `{"data":{"enterprise":{"members":{"nodes":[%s],`+ + `"pageInfo":{"hasNextPage":%t,"endCursor":"member-cursor-%d"}}},`+ + `"rateLimit":{"limit":5000,"remaining":4998,"resetAt":"2026-09-18T23:00:00Z"}}}`, + strings.Join(nodes, ","), hasNextPage, pageIndex+1) + require.NoError(t, err) +} + +// writeInvitationBatch answers the aliased lookup the sync uses. Every login +// without an invitation contributes a NOT_FOUND entry to errors[] next to the +// aliases that did resolve, which is how GitHub answers a partial batch. +func (s *enterpriseStub) writeInvitationBatch(t *testing.T, w http.ResponseWriter, query string, variables map[string]any) { + t.Helper() + s.invitationBatches++ + + require.NotContains(t, query, `userLogin: "`, "logins must travel as variables, not in the query text") + require.Equal(t, string(githubv4.EnterpriseAdministratorRoleOwner), variables["role"]) + + aliases, failures := []string{}, []string{} + for i := 0; ; i++ { + login, ok := variables[fmt.Sprintf("l%d", i)].(string) + if !ok { + break + } + alias := fmt.Sprintf("i%d", i) + require.Contains(t, query, alias+": enterpriseAdministratorInvitation") + if invitationID, invited := s.invitations[login]; invited { + aliases = append(aliases, fmt.Sprintf(`%q:{"id":%q}`, alias, invitationID)) + continue + } + aliases = append(aliases, fmt.Sprintf(`%q:null`, alias)) + failures = append(failures, fmt.Sprintf( + `{"type":"NOT_FOUND","message":"Could not resolve to a pending invitation for %s."}`, login)) + } + + if s.batchErrorType != "" { + failures = append(failures, fmt.Sprintf( + `{"type":%q,"message":"the app lost access to the enterprise"}`, s.batchErrorType)) + } + + aliases = append(aliases, `"rateLimit":{"limit":5000,"remaining":4997,"resetAt":"2026-09-18T23:00:00Z"}`) + body := fmt.Sprintf(`{"data":{%s}`, strings.Join(aliases, ",")) + if len(failures) > 0 { + body += fmt.Sprintf(`,"errors":[%s]`, strings.Join(failures, ",")) + } + _, err := w.Write([]byte(body + "}")) + require.NoError(t, err) +} + +func (s *enterpriseStub) writeInvitation(t *testing.T, w http.ResponseWriter, variables map[string]any) { + t.Helper() + s.invitationQueries++ + + login, ok := variables["login"].(string) + require.True(t, ok, "invitation lookup must send the login as a variable") + require.Equal(t, string(githubv4.EnterpriseAdministratorRoleOwner), variables["role"]) + + invitationID, invited := s.invitations[login] + if !invited { + _, _ = fmt.Fprintf(w, + `{"data":{"enterpriseAdministratorInvitation":null},`+ + `"errors":[{"type":"NOT_FOUND","message":"Could not resolve to an invitation for %s."}]}`, login) + return + } + _, _ = fmt.Fprintf(w, `{"data":{"enterpriseAdministratorInvitation":{"id":%q}}}`, invitationID) +} + +// requireMutationShape rejects the two mutation bodies GitHub answers with a +// 200 plus an errors array: a payload without a selection set, and a +// Query-only rateLimit field selected on Mutation. +func requireMutationShape(t *testing.T, query string, field string) { + t.Helper() + + _, payload, ok := strings.Cut(query, field+"(input: $input)") + require.True(t, ok, "mutation %s must take its input as a variable", field) + require.True(t, strings.HasPrefix(payload, "{"), "mutation %s must select payload fields", field) + require.NotEqual(t, "{}", strings.TrimSuffix(payload, "}"), "mutation %s selection set is empty", field) + require.NotContains(t, query, "rateLimit", "rateLimit does not exist on type Mutation") +} + +func mutationInput(t *testing.T, variables map[string]any) map[string]any { + t.Helper() + + input, ok := variables["input"].(map[string]any) + require.True(t, ok, "mutation must send its input as a variable") + // The enterprise node ID is resolved once at construction. An empty one + // here means that step was skipped, which GitHub would reject at runtime. + if enterpriseID, present := input["enterpriseId"]; present { + require.Equal(t, testEnterpriseID, enterpriseID, + "mutation must carry the enterprise node ID resolved at construction") + } + return input +} + +// requireNoIdempotencyClaim asserts the operation actually acted instead of +// reporting the state as already correct. Emptiness is not the assertion: +// every path also carries the GraphQL budget left after reading the owners. +func requireNoIdempotencyClaim(t *testing.T, annos annotations.Annotations) { + t.Helper() + + var alreadyExists v2.GrantAlreadyExists + var alreadyRevoked v2.GrantAlreadyRevoked + require.False(t, annos.Contains(&alreadyExists)) + require.False(t, annos.Contains(&alreadyRevoked)) +} + +// newTestEnterpriseAdminClient serves both installations from the same stub: +// the tests exercise the queries, not the two-token split. The organization is +// a parameter because verifyOrganization is about a mismatched one. +func newTestEnterpriseAdminClient( + t *testing.T, + stub *enterpriseStub, + org string, +) *customclient.EnterpriseAdminClient { + t.Helper() + + if stub.invitations == nil { + stub.invitations = make(map[string]string) + } + + graphqlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + stub.handle(t, w, r) + })) + t.Cleanup(graphqlSrv.Close) + + client, err := customclient.NewEnterpriseAdminClient( + graphqlSrv.URL, graphqlSrv.Client(), graphqlSrv.Client(), org) + require.NoError(t, err) + // Mirrors construction: the node ID is resolved once, not per operation. + require.NoError(t, client.ResolveEnterpriseNodeID(context.Background(), testEnterprise)) + + return client +} + +func newTestEnterpriseRoleBuilder( + t *testing.T, + stub *enterpriseStub, +) (*enterpriseRoleProvisioner, *v2.Resource, *v2.Entitlement) { + t.Helper() + + if stub.invitations == nil { + stub.invitations = make(map[string]string) + } + + enterpriseClient := newTestEnterpriseAdminClient(t, stub, testOrg) + + mgh := mocks.NewMockGitHub() + _, _, _, githubUser, _, err := mgh.Seed() + require.NoError(t, err) + + builder := EnterpriseRoleProvisioningBuilder( + github.NewClient(mgh.Server()), + nil, + []string{testEnterprise}, + func(context.Context, []string) (map[string]*customclient.EnterpriseAdminClient, error) { + return map[string]*customclient.EnterpriseAdminClient{testEnterprise: enterpriseClient}, nil + }, + ) + + principalID, err := resourceSdk.NewResourceID(resourceTypeUser, githubUser.GetID()) + require.NoError(t, err) + + roleResource, err := resourceSdk.NewRoleResource( + enterpriseRoleOwner, + resourceTypeEnterpriseRole, + testEnterprise+":"+enterpriseRoleOwner, + []resourceSdk.RoleTraitOption{}, + ) + require.NoError(t, err) + + ent := &v2.Entitlement{ + Id: entitlementSdk.NewEntitlementID(roleResource, enterpriseRoleAssigned), + Slug: enterpriseRoleAssigned, + Resource: roleResource, + } + + return builder, &v2.Resource{Id: principalID}, ent +} + +func TestEnterpriseRoleGrant(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // updateEnterpriseAdministratorRole rejects anyone who is not already an + // administrator, so a plain member can only be invited. + // The invitation is reported as the grant it will become, which is what + // the sync emits too: returning nothing here would make C1 drop an overlay + // that the next sync puts straight back. + t.Run("invites a member and reports the grant", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{} + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + grants, annos, err := builder.Grant(ctx, principal, ent) + require.NoError(t, err) + requireNoIdempotencyClaim(t, annos) + require.Len(t, grants, 1) + require.Equal(t, testLogin, grants[0].GetPrincipal().GetId().GetResource()) + require.Equal(t, testLogin, stub.invitedLogin) + require.Empty(t, stub.updatedRole) + }) + + // A billing manager cannot be invited. Which existing role caused the + // rejection is unreadable for a GitHub App, so promoting in place would + // make Revoke unable to restore the user's previous role. + t.Run("rejects an unsafe promotion after the invitation is rejected", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{inviteErrorType: "UNPROCESSABLE"} + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + grants, _, err := builder.Grant(ctx, principal, ent) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.ErrorContains(t, err, "promoting in place is not attempted") + require.Empty(t, grants) + require.Empty(t, stub.updatedRole) + require.Empty(t, stub.invitedLogin) + }) + + // Any other invitation failure must surface instead of triggering a second + // mutation the user never asked for. + t.Run("does not promote after an unrelated invitation failure", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{ + inviteErrorType: "RATE_LIMITED", + inviteErrorMessage: "rate limit exceeded", + } + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + _, _, err := builder.Grant(ctx, principal, ent) + require.Equal(t, codes.Unavailable, status.Code(err)) + require.Empty(t, stub.updatedRole) + }) + + // UNPROCESSABLE covers more than "already an administrator": seat limits + // and SSO or EMU restrictions land here too. Preserve GitHub's reason so + // the operator can distinguish those cases. + t.Run("preserves the invitation rejection reason", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{ + inviteErrorType: "UNPROCESSABLE", + inviteErrorMessage: "enterprise owner seat limit reached", + } + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + _, _, err := builder.Grant(ctx, principal, ent) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.ErrorContains(t, err, stub.inviteErrorMessage) + require.Empty(t, stub.updatedRole) + }) + + t.Run("reports an owner as already granted", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{ + ownerPages: [][]enterpriseStubOwner{{{id: testLoginID, login: testLogin}}}, + } + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + grants, annos, err := builder.Grant(ctx, principal, ent) + require.NoError(t, err) + require.Len(t, grants, 1) + + var alreadyExists v2.GrantAlreadyExists + require.True(t, annos.Contains(&alreadyExists)) + require.Empty(t, stub.updatedRole) + require.Empty(t, stub.invitedLogin) + + // Reading the owners spends GraphQL budget, so the remaining budget + // travels with the idempotency annotation instead of being dropped. + var rateLimit v2.RateLimitDescription + require.True(t, annos.Contains(&rateLimit)) + }) + + // The owner check has to page: the owners connection has no login filter. + t.Run("finds an owner on a later page", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{ + ownerPages: [][]enterpriseStubOwner{ + {{id: 99, login: "another-owner"}}, + {{id: testLoginID, login: testLogin}}, + }, + } + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + grants, annos, err := builder.Grant(ctx, principal, ent) + require.NoError(t, err) + require.Len(t, grants, 1) + + var alreadyExists v2.GrantAlreadyExists + require.True(t, annos.Contains(&alreadyExists)) + require.Equal(t, 2, stub.ownerQueries) + require.Empty(t, stub.invitedLogin) + }) + + // Re-inviting returns the same invitation, so a repeat grant must not send + // a second one. + t.Run("does not resend a pending invitation", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{invitations: map[string]string{testLogin: "EAI_existing"}} + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + grants, annos, err := builder.Grant(ctx, principal, ent) + require.NoError(t, err) + require.Len(t, grants, 1) + + var alreadyExists v2.GrantAlreadyExists + require.True(t, annos.Contains(&alreadyExists)) + require.Empty(t, stub.invitedLogin, "an existing invitation must not be sent again") + }) + + // The mutation reporting success is not evidence that GitHub applied it. + // Without this guard C1 would record access that does not exist. + t.Run("rejects a grant GitHub did not apply", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{silentMutations: true} + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + grants, _, err := builder.Grant(ctx, principal, ent) + require.Equal(t, codes.Unavailable, status.Code(err)) + require.Empty(t, grants) + require.Equal(t, testLogin, stub.invitedLogin) + }) + + t.Run("rejects a role other than owner", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{} + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + ent.Resource.Id.Resource = testEnterprise + ":Member" + + _, _, err := builder.Grant(ctx, principal, ent) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + require.Empty(t, stub.invitedLogin) + }) +} + +func TestEnterpriseRoleRevoke(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // UNAFFILIATED demotes the administrator but keeps enterprise membership; + // removeEnterpriseAdmin would evict them from the enterprise. + t.Run("demotes an active owner", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{ + ownerPages: [][]enterpriseStubOwner{{{id: testLoginID, login: testLogin}}}, + } + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + annos, err := builder.Revoke(ctx, &v2.Grant{Principal: principal, Entitlement: ent}) + require.NoError(t, err) + requireNoIdempotencyClaim(t, annos) + require.Equal(t, enterpriseAdministratorRoleUnaffiliated, stub.updatedRole) + }) + + // Holding the role and carrying an invitation are not alternatives. If the + // revoke only demoted, the invitation would survive and the verification + // that follows would report a retryable failure for a demotion that had + // already gone through. + t.Run("clears both the role and a leftover invitation", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{ + ownerPages: [][]enterpriseStubOwner{{{id: testLoginID, login: testLogin}}}, + invitations: map[string]string{testLogin: "EAI_leftover"}, + } + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + annos, err := builder.Revoke(ctx, &v2.Grant{Principal: principal, Entitlement: ent}) + require.NoError(t, err) + requireNoIdempotencyClaim(t, annos) + require.Equal(t, enterpriseAdministratorRoleUnaffiliated, stub.updatedRole) + require.Equal(t, "EAI_leftover", stub.cancelledID) + }) + + t.Run("cancels an invitation that was never accepted", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{invitations: map[string]string{testLogin: "EAI_existing"}} + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + annos, err := builder.Revoke(ctx, &v2.Grant{Principal: principal, Entitlement: ent}) + require.NoError(t, err) + requireNoIdempotencyClaim(t, annos) + require.Equal(t, "EAI_existing", stub.cancelledID) + require.Empty(t, stub.updatedRole) + }) + + // GitHub expires an invitation after seven days, so a time-bound revoke can + // arrive once it is already gone. + t.Run("tolerates an invitation that expired mid-revoke", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{ + invitations: map[string]string{testLogin: "EAI_existing"}, + cancelNotFound: true, + } + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + annos, err := builder.Revoke(ctx, &v2.Grant{Principal: principal, Entitlement: ent}) + require.NoError(t, err) + requireNoIdempotencyClaim(t, annos) + require.Equal(t, "EAI_existing", stub.cancelledID) + }) + + // The mirror of the grant guard: reporting a revoke that GitHub did not + // apply would let C1 believe the access is gone while it is still there. + t.Run("rejects a revoke GitHub did not apply", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{ + ownerPages: [][]enterpriseStubOwner{{{id: testLoginID, login: testLogin}}}, + silentMutations: true, + } + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + _, err := builder.Revoke(ctx, &v2.Grant{Principal: principal, Entitlement: ent}) + require.Equal(t, codes.Unavailable, status.Code(err)) + require.Equal(t, enterpriseAdministratorRoleUnaffiliated, stub.updatedRole) + }) + + t.Run("reports no owner access as already revoked", func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{} + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + annos, err := builder.Revoke(ctx, &v2.Grant{Principal: principal, Entitlement: ent}) + require.NoError(t, err) + require.Empty(t, stub.updatedRole) + require.Empty(t, stub.cancelledID) + + var alreadyRevoked v2.GrantAlreadyRevoked + require.True(t, annos.Contains(&alreadyRevoked)) + }) +} + +// drainGrants runs the sync the way the SDK does, following the page token +// until it empties, and returns every grant across both phases. +func drainGrants( + t *testing.T, + builder *enterpriseRoleProvisioner, + resource *v2.Resource, +) []*v2.Grant { + t.Helper() + + var all []*v2.Grant + token := "" + for calls := 0; ; calls++ { + require.Less(t, calls, 20, "the page token never emptied") + grants, result, err := builder.Grants(context.Background(), resource, + resourceSdk.SyncOpAttrs{PageToken: pagination.Token{Token: token}}) + require.NoError(t, err) + all = append(all, grants...) + if result.NextPageToken == "" { + return all + } + token = result.NextPageToken + } +} + +// principals reduces grants to the principal IDs, which is what C1 keys access +// on and therefore what these tests care about. +func principals(grants []*v2.Grant) []string { + out := make([]string, 0, len(grants)) + for _, g := range grants { + out = append(out, g.GetPrincipal().GetId().GetResource()) + } + return out +} + +// An invitation nobody has accepted is emitted as a grant so C1 keeps a record +// of the request. C1 has no pending state, so it is the same entitlement an +// accepted owner gets; the connector tells them apart only when revoking. +func TestEnterpriseRoleGrantsIncludePendingInvitations(t *testing.T) { + t.Parallel() + + stub := &enterpriseStub{ + ownerPages: [][]enterpriseStubOwner{{{id: 101, login: "accepted-owner"}}}, + memberPages: [][]enterpriseStubOwner{{{id: 202, login: "invited-member"}, {id: 303, login: "plain-member"}}}, + invitations: map[string]string{"invited-member": "EAI_invited"}, + } + builder, _, ent := newTestEnterpriseRoleBuilder(t, stub) + + require.ElementsMatch(t, []string{"101", "202"}, principals(drainGrants(t, builder, ent.Resource))) + // One request answered both members, rather than one lookup per login. + require.Equal(t, 1, stub.invitationBatches) + require.Zero(t, stub.invitationQueries, "the sync must not use the single-login lookup") +} + +// GitHub stops resolving an invitation that expires or is cancelled, so it +// simply stops being emitted and C1 drops the grant. Nothing tracks an expiry. +func TestEnterpriseRoleGrantsDropInvitationsThatStoppedResolving(t *testing.T) { + t.Parallel() + + stub := &enterpriseStub{ + memberPages: [][]enterpriseStubOwner{{{id: 202, login: "invited-member"}}}, + invitations: map[string]string{"invited-member": "EAI_invited"}, + } + builder, _, ent := newTestEnterpriseRoleBuilder(t, stub) + require.Equal(t, []string{"202"}, principals(drainGrants(t, builder, ent.Resource))) + + // The invitation lapses on GitHub's side. + delete(stub.invitations, "invited-member") + require.Empty(t, drainGrants(t, builder, ent.Resource)) + + // Had the invitee accepted instead, they would surface as an owner. + stub.ownerPages = [][]enterpriseStubOwner{{{id: 202, login: "invited-member"}}} + require.Equal(t, []string{"202"}, principals(drainGrants(t, builder, ent.Resource))) +} + +// A member who was never invited must not produce a grant, even though the +// batch reports them as NOT_FOUND alongside the invitation that did resolve. +func TestEnterpriseRoleGrantsIgnoreMembersWithoutAnInvitation(t *testing.T) { + t.Parallel() + + stub := &enterpriseStub{ + memberPages: [][]enterpriseStubOwner{{ + {id: 202, login: "invited-member"}, + {id: 303, login: "plain-member"}, + {id: 404, login: "another-plain-member"}, + }}, + invitations: map[string]string{"invited-member": "EAI_invited"}, + } + builder, _, ent := newTestEnterpriseRoleBuilder(t, stub) + + require.Equal(t, []string{"202"}, principals(drainGrants(t, builder, ent.Resource))) +} + +// The invitation batch always reports NOT_FOUND for the members with no +// invitation, so those entries must not decide the code for the whole +// response. NOT_FOUND is the one code the SDK downgrades to a warning: if a +// real failure were reported as NOT_FOUND, the sync would finish green having +// emitted no pending invitations, and C1 would read that as a revoke. +func TestEnterpriseRoleGrantsFailOnARealErrorInsideTheInvitationBatch(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + errorType string + want codes.Code + }{ + {errorType: "FORBIDDEN", want: codes.PermissionDenied}, + {errorType: "UNAUTHENTICATED", want: codes.Unauthenticated}, + {errorType: "RATE_LIMITED", want: codes.Unavailable}, + // An error type the classifier has no rule for must still fail the + // batch rather than inherit NOT_FOUND from its neighbours. + {errorType: "SERVICE_UNAVAILABLE", want: codes.Internal}, + } { + t.Run(tc.errorType, func(t *testing.T) { + t.Parallel() + stub := &enterpriseStub{ + memberPages: [][]enterpriseStubOwner{{{id: 202, login: "plain-member"}}}, + batchErrorType: tc.errorType, + } + builder, _, ent := newTestEnterpriseRoleBuilder(t, stub) + + // Phase one reports no owners, then the invitation phase fails. + _, result, err := builder.Grants(context.Background(), ent.Resource, resourceSdk.SyncOpAttrs{}) + require.NoError(t, err) + _, _, err = builder.Grants(context.Background(), ent.Resource, + resourceSdk.SyncOpAttrs{PageToken: pagination.Token{Token: result.NextPageToken}}) + require.Equal(t, tc.want, status.Code(err)) + }) + } +} + +// The members are paged, and every page gets its own batched lookup. +func TestEnterpriseRoleGrantsPageThroughMembers(t *testing.T) { + t.Parallel() + + stub := &enterpriseStub{ + memberPages: [][]enterpriseStubOwner{ + {{id: 202, login: "invited-member"}}, + {{id: 303, login: "second-page-invitee"}}, + }, + invitations: map[string]string{ + "invited-member": "EAI_one", + "second-page-invitee": "EAI_two", + }, + } + builder, _, ent := newTestEnterpriseRoleBuilder(t, stub) + + require.ElementsMatch(t, []string{"202", "303"}, principals(drainGrants(t, builder, ent.Resource))) + require.Equal(t, 2, stub.memberQueries) + require.Equal(t, 2, stub.invitationBatches) +} + +// The owners are read through the organization, so an organization that +// belongs to a different enterprise would report the wrong owners. +func TestEnterpriseRoleVerifyOrganization(t *testing.T) { + t.Parallel() + ctx := context.Background() + + stub := &enterpriseStub{} + builder, _, _ := newTestEnterpriseRoleBuilder(t, stub) + enterpriseClients, err := builder.clients(ctx, true) + require.NoError(t, err) + require.NoError(t, enterpriseClients[testEnterprise].VerifyOrganization(ctx, testEnterprise)) + require.Equal(t, 1, stub.organizationChecks) + + other := newTestEnterpriseAdminClient(t, &enterpriseStub{}, "org-of-another-enterprise") + err = other.VerifyOrganization(ctx, testEnterprise) + require.ErrorContains(t, err, "does not belong to enterprise") +} + +// VerifyOrganization lives in the client package now, so it returns a plain +// status rather than the connector's error type and the caller classifies it. +// Only the configuration answer may skip the sync. +func TestOrganizationMismatchIsASetupErrorButGivingUpIsNot(t *testing.T) { + t.Parallel() + + mismatch := uhttp.WrapErrors(codes.FailedPrecondition, + "baton-github: organization x does not belong to enterprise y", + customclient.ErrOrganizationNotInEnterprise) + require.True(t, isEnterpriseSetupError(asEnterpriseSetupError(mismatch))) + + gaveUp := status.Error(codes.Internal, + "baton-github: gave up looking for organization x in enterprise y after 1000 pages") + require.False(t, isEnterpriseSetupError(asEnterpriseSetupError(gaveUp))) + require.Equal(t, codes.Internal, status.Code(asEnterpriseSetupError(gaveUp))) + + // The walk wraps its query failures with %w, and status.Code unwraps + // through them, so the code alone would read a GraphQL UNPROCESSABLE as a + // configuration problem and skip the sync. + queryFailed := fmt.Errorf("baton-github: error listing organizations of enterprise y: %w", + status.Error(codes.FailedPrecondition, "UNPROCESSABLE")) + require.Equal(t, codes.FailedPrecondition, status.Code(queryFailed)) + require.False(t, isEnterpriseSetupError(asEnterpriseSetupError(queryFailed))) + + require.NoError(t, asEnterpriseSetupError(nil)) +} + +// A client build that fails for any reason other than configuration must +// reach the syncer as an error: GitHub being unreachable is not evidence that +// the owners are gone, and C1 deletes every resource of a type that a +// completed sync did not report. +func TestEnterpriseRoleFailsClosedWithoutEnterpriseClients(t *testing.T) { + t.Parallel() + ctx := context.Background() + + clientsErr := status.Error(codes.Unavailable, "github-connector: rate limited") + builds := 0 + builder := EnterpriseRoleBuilder(nil, nil, []string{testEnterprise}, + func(context.Context, []string) (map[string]*customclient.EnterpriseAdminClient, error) { + builds++ + return nil, clientsErr + }, + ) + + _, _, err := builder.List(ctx, nil, resourceSdk.SyncOpAttrs{}) + require.ErrorIs(t, err, clientsErr) + + roleResource, err := resourceSdk.NewRoleResource( + enterpriseRoleOwner, + resourceTypeEnterpriseRole, + testEnterprise+":"+enterpriseRoleOwner, + []resourceSdk.RoleTraitOption{}, + ) + require.NoError(t, err) + + _, _, err = builder.Grants(ctx, roleResource, resourceSdk.SyncOpAttrs{}) + require.ErrorIs(t, err, clientsErr) + + // Nothing is remembered from a failure: GitHub answers 404 for an + // uninstalled app, a revoked permission and a typo alike, so an operator + // who fixes it is picked up by the next call rather than by a restart. + require.Equal(t, 2, builds) +} + +// An app deployment that is not set up for the enterprise administrator API +// synced no enterprise roles before that API was used, without failing, so it +// still does. Provisioning has nothing to fall back to and returns the error, +// which names the fix. +func TestEnterpriseRoleSkipsSyncOnASetupError(t *testing.T) { + t.Parallel() + ctx := context.Background() + + clientsErr := enterpriseSetupError{status.Error(codes.FailedPrecondition, + "github-connector: GitHub App is not installed on enterprise")} + builds := 0 + builder := EnterpriseRoleProvisioningBuilder(nil, nil, []string{testEnterprise}, + func(context.Context, []string) (map[string]*customclient.EnterpriseAdminClient, error) { + builds++ + return nil, clientsErr + }, + ) + + resources, _, err := builder.List(ctx, nil, resourceSdk.SyncOpAttrs{}) + require.NoError(t, err) + require.Empty(t, resources) + + roleResource, err := resourceSdk.NewRoleResource( + enterpriseRoleOwner, + resourceTypeEnterpriseRole, + testEnterprise+":"+enterpriseRoleOwner, + []resourceSdk.RoleTraitOption{}, + ) + require.NoError(t, err) + + grants, _, err := builder.Grants(ctx, roleResource, resourceSdk.SyncOpAttrs{}) + require.NoError(t, err) + require.Empty(t, grants) + + principal := &v2.Resource{Id: &v2.ResourceId{ResourceType: resourceTypeUser.Id, Resource: "1"}} + ent := &v2.Entitlement{Resource: roleResource} + _, _, err = builder.Grant(ctx, principal, ent) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Contains(t, err.Error(), "not installed on enterprise") + + // Still not remembered, so installing the app is picked up by the next sync. + require.Equal(t, 3, builds) +} + +// Clients that built are kept. Grants pages do not retry a skipped slug. +// The next List does, and that is what stores an enterprise that builds later. +func TestEnterpriseRoleRetriesAnEnterpriseSkippedWhileClientsWereBuilt(t *testing.T) { + t.Parallel() + ctx := context.Background() + + builds := 0 + builder := EnterpriseRoleBuilder(nil, nil, []string{"example-enterprise", "typo-enterprise"}, + func(context.Context, []string) (map[string]*customclient.EnterpriseAdminClient, error) { + builds++ + clients := map[string]*customclient.EnterpriseAdminClient{ + "example-enterprise": {}, + } + if builds > 1 { + clients["typo-enterprise"] = &customclient.EnterpriseAdminClient{} + } + return clients, nil + }, + ) + + resources, _, err := builder.List(ctx, nil, resourceSdk.SyncOpAttrs{}) + require.NoError(t, err) + require.Len(t, resources, 1) + require.Equal(t, 1, builds) + + // Grants pages reuse the clients that built and do not retry the skipped slug. + _, err = builder.clients(ctx, false) + require.NoError(t, err) + require.Equal(t, 1, builds) + + resources, _, err = builder.List(ctx, nil, resourceSdk.SyncOpAttrs{}) + require.NoError(t, err) + require.Len(t, resources, 2) + require.Equal(t, 2, builds) + + _, _, err = builder.List(ctx, nil, resourceSdk.SyncOpAttrs{}) + require.NoError(t, err) + require.Equal(t, 2, builds) +} + +// A retry that is still a setup error must not discard the clients that +// already built. Returning that error would make List skip the whole type. +func TestEnterpriseRoleKeepsBuiltClientsWhenARetryIsStillASetupError(t *testing.T) { + t.Parallel() + ctx := context.Background() + + var asked [][]string + builds := 0 + builder := EnterpriseRoleBuilder(nil, nil, []string{"example-enterprise", "typo-enterprise"}, + func(_ context.Context, enterprises []string) (map[string]*customclient.EnterpriseAdminClient, error) { + builds++ + asked = append(asked, append([]string(nil), enterprises...)) + if builds == 1 { + return map[string]*customclient.EnterpriseAdminClient{ + "example-enterprise": {}, + }, nil + } + return nil, enterpriseSetupError{status.Error(codes.FailedPrecondition, "not installed on the enterprise")} + }, + ) + + resources, _, err := builder.List(ctx, nil, resourceSdk.SyncOpAttrs{}) + require.NoError(t, err) + require.Len(t, resources, 1) + + resources, _, err = builder.List(ctx, nil, resourceSdk.SyncOpAttrs{}) + require.NoError(t, err) + require.Len(t, resources, 1) + require.Equal(t, [][]string{ + {"example-enterprise", "typo-enterprise"}, + {"typo-enterprise"}, + }, asked) +} + +// No build failure is remembered. GitHub answers 404 for an uninstalled app, +// a revoked permission and a typo alike, and errors from go-github or a +// cancelled context arrive wrapped with %w carrying no gRPC status at all, so +// nothing here can be classified as permanent. Remembering any of them would +// disable the resource type until the process restarts, and would leave an +// operator who fixed the configuration still reading the stale answer. +func TestEnterpriseRoleRetriesAClientBuildFailure(t *testing.T) { + t.Parallel() + ctx := context.Background() + + for _, tc := range []struct { + name string + err error + }{ + {"a misconfiguration", status.Error(codes.FailedPrecondition, "not installed on the enterprise")}, + {"a rate limit", status.Error(codes.Unavailable, "rate limited")}, + {"a go-github failure", fmt.Errorf("github-connector: failed to create installation token: %w", + &github.ErrorResponse{ + Response: &http.Response{StatusCode: http.StatusBadGateway, Request: &http.Request{}}, + Message: "Bad gateway", + })}, + {"a cancelled sync", fmt.Errorf("github-connector: discovering installations: %w", context.Canceled)}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + builds := 0 + builder := EnterpriseRoleBuilder(nil, nil, []string{testEnterprise}, + func(context.Context, []string) (map[string]*customclient.EnterpriseAdminClient, error) { + builds++ + if builds == 1 { + return nil, tc.err + } + // List only checks the enterprise is present, so the + // client itself is never dereferenced here. + return map[string]*customclient.EnterpriseAdminClient{testEnterprise: nil}, nil + }, + ) + + _, _, err := builder.List(ctx, nil, resourceSdk.SyncOpAttrs{}) + require.ErrorIs(t, err, tc.err) + + resources, _, err := builder.List(ctx, nil, resourceSdk.SyncOpAttrs{}) + require.NoError(t, err) + require.Len(t, resources, 1) + require.Equal(t, 2, builds) + + // Once it succeeds the result is memoized. + _, _, err = builder.List(ctx, nil, resourceSdk.SyncOpAttrs{}) + require.NoError(t, err) + require.Equal(t, 2, builds) + }) + } +} + +// Provisioning is only possible through an enterprise installation, so the PAT +// path must reject it rather than attempt a mutation it cannot make. +func TestEnterpriseRoleProvisioningTargetGuards(t *testing.T) { + t.Parallel() + ctx := context.Background() + + stub := &enterpriseStub{} + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + t.Run("rejects a principal that is not a user", func(t *testing.T) { + t.Parallel() + notAUser := &v2.Resource{Id: &v2.ResourceId{ + ResourceType: resourceTypeTeam.Id, + Resource: "1", + }} + _, _, err := builder.Grant(ctx, notAUser, ent) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + + // Under a PAT there is no app to install, so naming one sends the operator + // after a fix that cannot apply. + t.Run("names the credential when the token cannot provision", func(t *testing.T) { + t.Parallel() + patBuilder := EnterpriseRoleProvisioningBuilder(nil, nil, []string{testEnterprise}, nil) + _, _, err := patBuilder.Grant(ctx, principal, ent) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Contains(t, err.Error(), "needs GitHub App authentication") + }) + + // The enterprise is configured, but its client was not built. Saying it is + // not configured sends the operator to edit a slug that is already there. + t.Run("names an enterprise the app is not set up to read", func(t *testing.T) { + t.Parallel() + appBuilder := EnterpriseRoleProvisioningBuilder(nil, nil, []string{testEnterprise}, + func(context.Context, []string) (map[string]*customclient.EnterpriseAdminClient, error) { + return map[string]*customclient.EnterpriseAdminClient{}, nil + }, + ) + _, _, err := appBuilder.Grant(ctx, principal, ent) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Contains(t, err.Error(), "not set up to read it") + }) + + t.Run("names an enterprise that is not configured", func(t *testing.T) { + t.Parallel() + appBuilder := EnterpriseRoleProvisioningBuilder(nil, nil, []string{"other-enterprise"}, + func(context.Context, []string) (map[string]*customclient.EnterpriseAdminClient, error) { + return map[string]*customclient.EnterpriseAdminClient{ + "other-enterprise": {}, + }, nil + }, + ) + _, _, err := appBuilder.Grant(ctx, principal, ent) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Contains(t, err.Error(), "not one of the configured enterprises") + }) +} + +// GitHub reports a GraphQL budget error as an HTTP 200 carrying errors[], so +// the status of the response says nothing. Reading the owners is the hottest +// GraphQL path in this role, and an unclassified budget error reaches the SDK +// as Unknown, which it does not retry: the sync aborts instead of backing off. +func TestEnterpriseRoleGrantsClassifyARateLimitedOwnersRead(t *testing.T) { + t.Parallel() + + builder, _, ent := newTestEnterpriseRoleBuilder(t, &enterpriseStub{ownersRateLimited: true}) + + _, _, err := builder.Grants(context.Background(), ent.Resource, resourceSdk.SyncOpAttrs{}) + require.Equal(t, codes.Unavailable, status.Code(err)) +} + +// An enterprise owner does not have to be an owner of the organization the app +// reads them through: observed live with a user whose organizationRole was +// DIRECT_MEMBER. Organization.enterpriseOwners returns them regardless, and the +// connector must emit their grant. +func TestEnterpriseRoleGrantsIncludeOwnerWhoIsNotAnOrgOwner(t *testing.T) { + t.Parallel() + + stub := &enterpriseStub{ + ownerPages: [][]enterpriseStubOwner{{ + {id: 158784853, login: "org-owner"}, + {id: 162376288, login: "enterprise-owner-only"}, + }}, + } + builder, _, ent := newTestEnterpriseRoleBuilder(t, stub) + + require.ElementsMatch(t, []string{"158784853", "162376288"}, + principals(drainGrants(t, builder, ent.Resource))) +} + +func TestEnterpriseRoleGrantsPagination(t *testing.T) { + t.Parallel() + ctx := context.Background() + + stub := &enterpriseStub{ + ownerPages: [][]enterpriseStubOwner{ + {{id: 101, login: "owner-page-one"}}, + {{id: 102, login: "owner-page-two"}}, + }, + } + builder, _, ent := newTestEnterpriseRoleBuilder(t, stub) + + resources, _, err := builder.List(ctx, nil, resourceSdk.SyncOpAttrs{}) + require.NoError(t, err) + require.Len(t, resources, 1) + require.Equal(t, testEnterprise+":"+enterpriseRoleOwner, resources[0].GetId().GetResource()) + require.Zero(t, stub.ownerQueries) + + grants, result, err := builder.Grants(ctx, ent.Resource, resourceSdk.SyncOpAttrs{}) + require.NoError(t, err) + require.Len(t, grants, 1) + require.Equal(t, "101", grants[0].GetPrincipal().GetId().GetResource()) + require.NotEmpty(t, result.NextPageToken) + require.NotEmpty(t, result.Annotations) + + // The SDK drives the second page with the cursor from the first. + grants, result, err = builder.Grants(ctx, ent.Resource, resourceSdk.SyncOpAttrs{ + PageToken: pagination.Token{Token: result.NextPageToken}, + }) + require.NoError(t, err) + require.Len(t, grants, 1) + require.Equal(t, "102", grants[0].GetPrincipal().GetId().GetResource()) + require.Equal(t, 2, stub.ownerQueries) + + // The owners are exhausted, so the token moves on to the invitations + // rather than ending the sync. + require.NotEmpty(t, result.NextPageToken) + grants, result, err = builder.Grants(ctx, ent.Resource, resourceSdk.SyncOpAttrs{ + PageToken: pagination.Token{Token: result.NextPageToken}, + }) + require.NoError(t, err) + require.Empty(t, grants) + require.Empty(t, result.NextPageToken) + require.Equal(t, 2, stub.ownerQueries, "the invitation phase must not re-read the owners") + require.Equal(t, 1, stub.memberQueries) +} + +// GitHub answers NOT_FOUND when the configured organization stops resolving: +// renamed, the app uninstalled, or the name mistyped. NotFound is the one code +// the SDK downgrades to a warning, so letting it out of Grants would finish +// the sync reporting no owners, and C1 deletes the grants a completed sync did +// not report -- silently revoking every Owner. +func TestEnterpriseRoleGrantsDoNotLetANotFoundSilenceTheSync(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte( + `{"data":null,"errors":[{"type":"NOT_FOUND","message":"Could not resolve to an Organization with the login of 'gone'."}]}`)) + })) + t.Cleanup(srv.Close) + + client, err := customclient.NewEnterpriseAdminClient(srv.URL, srv.Client(), srv.Client(), testOrg) + require.NoError(t, err) + + builder := EnterpriseRoleProvisioningBuilder(nil, nil, []string{testEnterprise}, + func(context.Context, []string) (map[string]*customclient.EnterpriseAdminClient, error) { + return map[string]*customclient.EnterpriseAdminClient{testEnterprise: client}, nil + }) + + roleResource, err := resourceSdk.NewRoleResource( + enterpriseRoleOwner, + resourceTypeEnterpriseRole, + testEnterprise+":"+enterpriseRoleOwner, + []resourceSdk.RoleTraitOption{}, + ) + require.NoError(t, err) + + grants, _, err := builder.Grants(context.Background(), roleResource, resourceSdk.SyncOpAttrs{}) + + require.Error(t, err) + require.Empty(t, grants) + require.Equal(t, codes.FailedPrecondition, status.Code(err), + "NotFound would be downgraded to a warning and the sync would complete with no owners") +} + +// writeMemberLookup answers the membership check Grant runs before inviting. +// The search matches display names too, so lookupDecoyPages lets a case put +// the real member behind pages of accounts that merely matched the query. +func (s *enterpriseStub) writeMemberLookup(t *testing.T, w http.ResponseWriter, vars map[string]any) { + t.Helper() + + login, _ := vars["query"].(string) + if s.nonMembers[login] { + s.writeMemberLookupPage(t, w, nil, false) + return + } + + if s.lookupPagesServed < len(s.lookupDecoyPages) { + page := s.lookupDecoyPages[s.lookupPagesServed] + s.lookupPagesServed++ + s.writeMemberLookupPage(t, w, page, true) + return + } + + s.writeMemberLookupPage(t, w, []string{login}, false) +} + +func (s *enterpriseStub) writeMemberLookupPage(t *testing.T, w http.ResponseWriter, logins []string, more bool) { + t.Helper() + + nodes := make([]string, 0, len(logins)) + for _, login := range logins { + nodes = append(nodes, fmt.Sprintf(`{"login":%q}`, login)) + } + _, _ = fmt.Fprintf(w, + `{"data":{"enterprise":{"members":{"nodes":[%s],"pageInfo":{"hasNextPage":%t,"endCursor":"c%d"}}}}}`, + strings.Join(nodes, ","), more, s.lookupPagesServed) +} + +// GitHub accepts an owner invitation for any user, but Grants() resolves +// invitations by asking about the enterprise members, so one addressed to +// anyone else is invisible to every later sync and C1 drops the grant while +// the invitation stays live. Outside collaborators reach C1 as principals +// through repository access, so the case is reachable. +func TestEnterpriseRoleGrantRejectsANonMember(t *testing.T) { + t.Parallel() + + stub := &enterpriseStub{nonMembers: map[string]bool{testLogin: true}} + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + grants, annos, err := builder.Grant(context.Background(), principal, ent) + + require.Error(t, err) + require.Empty(t, grants) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + require.Contains(t, err.Error(), "not a member of enterprise") + requireNoIdempotencyClaim(t, annos) + require.Empty(t, stub.invitations, "no invitation may be created for a non-member") +} + +// The membership search matches display names as well as logins and returns +// its results ordered by login, so a member whose login is short can sit +// behind other accounts that merely matched. Reading one page would report +// them as a non-member and reject a grant they are entitled to. +func TestEnterpriseRoleGrantFindsAMemberBehindSearchNoise(t *testing.T) { + t.Parallel() + + stub := &enterpriseStub{lookupDecoyPages: [][]string{ + {"aardvark", "beatriz"}, + {"carolina", "dimitri"}, + }} + builder, principal, ent := newTestEnterpriseRoleBuilder(t, stub) + + grants, _, err := builder.Grant(context.Background(), principal, ent) + + require.NoError(t, err) + require.Len(t, grants, 1) + require.Equal(t, testLogin, stub.invitedLogin, "the invitation must still be sent") +} + +// The client build runs on the first List and queries enterprise(slug:) twice. +// A NOT_FOUND there used to leave List and Grants raw, and NotFound is the one +// code the SDK downgrades to a warning: the type would report nothing, the +// sync would complete, and C1 would delete the role and every grant on it. +func TestEnterpriseRoleFailsClosedWhenTheClientBuildCannotSeeTheEnterprise(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte( + `{"data":null,"errors":[{"type":"NOT_FOUND","message":"Could not resolve to an Enterprise with the slug of 'gone'."}]}`)) + })) + t.Cleanup(srv.Close) + + build := func(context.Context, []string) (map[string]*customclient.EnterpriseAdminClient, error) { + client, err := customclient.NewEnterpriseAdminClient(srv.URL, srv.Client(), srv.Client(), testOrg) + require.NoError(t, err) + return nil, client.ResolveEnterpriseNodeID(context.Background(), testEnterprise) + } + + roleResource, err := resourceSdk.NewRoleResource( + enterpriseRoleOwner, resourceTypeEnterpriseRole, + testEnterprise+":"+enterpriseRoleOwner, []resourceSdk.RoleTraitOption{}) + require.NoError(t, err) + + for _, tc := range []struct { + name string + call func(b *enterpriseRoleProvisioner) error + }{ + {"List", func(b *enterpriseRoleProvisioner) error { + _, _, err := b.List(context.Background(), nil, resourceSdk.SyncOpAttrs{}) + return err + }}, + {"Grants", func(b *enterpriseRoleProvisioner) error { + _, _, err := b.Grants(context.Background(), roleResource, resourceSdk.SyncOpAttrs{}) + return err + }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + builder := EnterpriseRoleProvisioningBuilder(nil, nil, []string{testEnterprise}, build) + err := tc.call(builder) + + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition, status.Code(err), + "NotFound would be downgraded to a warning and the sync would complete with no owners") + }) + } +} diff --git a/pkg/connector/graphql_transport.go b/pkg/connector/graphql_transport.go deleted file mode 100644 index d18fbf0c..00000000 --- a/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/pkg/connector/helpers.go b/pkg/connector/helpers.go index ece84762..b4bc5f41 100644 --- a/pkg/connector/helpers.go +++ b/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/pkg/connector/token_refresh.go b/pkg/connector/token_refresh.go index e34db985..4af900a6 100644 --- a/pkg/connector/token_refresh.go +++ b/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/pkg/customclient/client.go b/pkg/customclient/client.go index 85485447..70ddc888 100644 --- a/pkg/customclient/client.go +++ b/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/pkg/customclient/enterprise_admin.go b/pkg/customclient/enterprise_admin.go new file mode 100644 index 00000000..c68b419f --- /dev/null +++ b/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/pkg/customclient/enterprise_admin_models.go b/pkg/customclient/enterprise_admin_models.go new file mode 100644 index 00000000..0ddc6ba1 --- /dev/null +++ b/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/pkg/customclient/graphql.go b/pkg/customclient/graphql.go new file mode 100644 index 00000000..084a4f8a --- /dev/null +++ b/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/pkg/connector/graphql_transport_test.go b/pkg/customclient/graphql_test.go similarity index 61% rename from pkg/connector/graphql_transport_test.go rename to pkg/customclient/graphql_test.go index f5384efc..bc0cd611 100644 --- a/pkg/connector/graphql_transport_test.go +++ b/pkg/customclient/graphql_test.go @@ -1,7 +1,8 @@ -package connector +package customclient import ( "context" + "io" "net/http" "net/http/httptest" "strconv" @@ -70,6 +71,77 @@ func TestStatusClassifyingTransport_PassesThrough2xx(t *testing.T) { require.Equal(t, http.StatusOK, resp.StatusCode) } +func TestGraphQLErrorsCode(t *testing.T) { + cases := []struct { + name string + types []string + want codes.Code + }{ + {name: "unclassified", types: []string{"SOMETHING_NEW"}, want: codes.Internal}, + {name: "forbidden", types: []string{"FORBIDDEN"}, want: codes.PermissionDenied}, + {name: "not found", types: []string{"NOT_FOUND"}, want: codes.NotFound}, + {name: "unauthenticated", types: []string{"UNAUTHENTICATED"}, want: codes.Unauthenticated}, + {name: "unprocessable", types: []string{"UNPROCESSABLE"}, want: codes.FailedPrecondition}, + // A rate limit outranks everything: the SDK has to retry rather than + // fail the sync. + {name: "rate limit wins", types: []string{"FORBIDDEN", "RATE_LIMITED"}, want: codes.Unavailable}, + // Past a rate limit the first classified entry wins, so a later error + // cannot mask it. Grant must preserve UNPROCESSABLE as the actionable + // FailedPrecondition, and a trailing FORBIDDEN used to overwrite it. + {name: "first classified wins", types: []string{"UNPROCESSABLE", "FORBIDDEN"}, want: codes.FailedPrecondition}, + {name: "first classified entry wins", types: []string{"FORBIDDEN", "UNPROCESSABLE"}, want: codes.PermissionDenied}, + // NOT_FOUND is the one code a credential error may override. Callers + // read it as "already gone" and report success, so a batch whose + // missing invitations hide a FORBIDDEN must not look benign. + {name: "credential error beats not found", types: []string{"NOT_FOUND", "FORBIDDEN"}, want: codes.PermissionDenied}, + {name: "credential error beats not found, unauthenticated", types: []string{"NOT_FOUND", "UNAUTHENTICATED"}, want: codes.Unauthenticated}, + {name: "not found alone still maps to not found", types: []string{"NOT_FOUND", "NOT_FOUND"}, want: codes.NotFound}, + // Order must not hide Grant's unsafe-promotion rejection, so + // UNPROCESSABLE outranks NOT_FOUND from either position. + {name: "unprocessable beats not found", types: []string{"NOT_FOUND", "UNPROCESSABLE"}, want: codes.FailedPrecondition}, + {name: "unprocessable beats not found, reversed", types: []string{"UNPROCESSABLE", "NOT_FOUND"}, want: codes.FailedPrecondition}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + graphQLErrors := make([]graphQLError, 0, len(tc.types)) + for _, errorType := range tc.types { + graphQLErrors = append(graphQLErrors, graphQLError{Type: errorType}) + } + require.Equal(t, tc.want, graphQLErrorsCode(graphQLErrors)) + }) + } +} + +// extensions.code is preferred over the top-level type, because GitHub sets it +// on the errors that carry a machine-readable classification. +func TestGraphQLErrorsCodePrefersExtensionsCode(t *testing.T) { + graphQLErr := graphQLError{Type: "FORBIDDEN"} + graphQLErr.Extensions.Code = "RATE_LIMITED" + + require.Equal(t, codes.Unavailable, graphQLErrorsCode([]graphQLError{graphQLErr})) +} + +func TestEnterpriseGraphQLTransport_PassesThroughUnparseableBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`not json`)) + })) + t.Cleanup(srv.Close) + + client := &http.Client{ + Transport: &enterpriseGraphQLTransport{base: http.DefaultTransport}, + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL+"/graphql", nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + require.NotNil(t, resp) + t.Cleanup(func() { _ = resp.Body.Close() }) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "not json", string(body)) +} + func TestStatusClassifyingTransport_ClassifiesClient4xx(t *testing.T) { cases := []struct { httpStatus int diff --git a/pkg/customclient/models.go b/pkg/customclient/models.go index c77da78d..ecf80e54 100644 --- a/pkg/customclient/models.go +++ b/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/test/mocks/endpointpattern.go b/test/mocks/endpointpattern.go index b7506972..2c293942 100644 --- a/test/mocks/endpointpattern.go +++ b/test/mocks/endpointpattern.go @@ -2,9 +2,11 @@ package mocks import "github.com/migueleliasweb/go-github-mock/src/mock" +const methodGet = "GET" + var GetUserById = mock.EndpointPattern{ Pattern: "/user/{id}", - Method: "GET", + Method: methodGet, } var PutOrganizationsTeamsMembershipsByOrganizationByTeamIdByUsername = mock.EndpointPattern{ @@ -19,48 +21,48 @@ var DeleteOrganizationsTeamsMembershipsByOrganizationByTeamIdByUsername = mock.E var GetOrganizationById = mock.EndpointPattern{ Pattern: "/organizations/{org_id}", - Method: "GET", + Method: methodGet, } var GetOrgsByOrg = mock.EndpointPattern{ Pattern: "/orgs/{org}", - Method: "GET", + Method: methodGet, } var GetRepositoryById = mock.EndpointPattern{ Pattern: "/repositories/{repository_id}", - Method: "GET", + Method: methodGet, } var GetOrganizationsTeamByTeamId = mock.EndpointPattern{ Pattern: "/organizations/{org_id}/team/{team_id}", - Method: "GET", + Method: methodGet, } var GetOrganizationsTeamsMembersByTeamId = mock.EndpointPattern{ Pattern: "/organizations/{org_id}/team/{team_id}/members", - Method: "GET", + Method: methodGet, } var GetOrganizationsTeamsMembershipsByTeamIdByUsername = mock.EndpointPattern{ Pattern: "/organizations/{org_id}/team/{team_id}/memberships/{username}", - Method: "GET", + Method: methodGet, } // Organization role endpoints. var GetOrgsRolesByOrg = mock.EndpointPattern{ Pattern: "/orgs/{org}/organization-roles", - Method: "GET", + Method: methodGet, } var GetOrgsRolesTeamsByOrgByRoleId = mock.EndpointPattern{ Pattern: "/orgs/{org}/organization-roles/{role_id}/teams", - Method: "GET", + Method: methodGet, } var GetOrgsRolesUsersByOrgByRoleId = mock.EndpointPattern{ Pattern: "/orgs/{org}/organization-roles/{role_id}/users", - Method: "GET", + Method: methodGet, } var PutOrgsRolesUsersByOrgByRoleIdByUsername = mock.EndpointPattern{