[CXH-2123] - Add provisioning for the Enterprise Owner role - GitHub Connector - #194
mateoHernandez123 wants to merge 28 commits into
Conversation
Superseded — see the current review report for commit
|
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
8738a07 to
04872b2
Compare
Co-authored-by: Cursor <cursoragent@cursor.com>
The GitHub App form exposes Enterprises and Enable enterprise owner provisioning, but the customer-facing setup doc mentioned neither, in the app permissions, the connector configuration form, or the self-hosted manifest. The app creation flow also needed a correction rather than an addition: it starts under the organization, but an app that has to read and mutate enterprise administrators must be created under the enterprise account and installed on both. Following the steps as written produced an app that could not serve the capability, with nothing to indicate why. Co-authored-by: Cursor <cursoragent@cursor.com>
Superseded — see the current review report for commit
|
Superseded — see the current review report for commit
|
There was a problem hiding this comment.
No blocking issues found — see the full review report
…se roles The enterprise-account install step landed between the permissions and Create GitHub App, telling the reader to install an app that does not exist yet. Move it after the organization install. Add the Enterprise roles row to the capabilities table, which still described the connector as it was before this change. Co-authored-by: Cursor <cursoragent@cursor.com>
Superseded — see the current review report for commit
|
Superseded — see the current review report for commit
|
There was a problem hiding this comment.
No blocking issues found — see the full review report
luisina-santos
left a comment
There was a problem hiding this comment.
I would like to revisit this connector and baton-github-enterprise too. if it is an enterprise only feature, should we add it there directly? it is actually an enterprise only feature?
| | Orgs | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | | ||
| | GitHub Apps (NHI) | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | | | ||
| | Secrets - API keys | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | | | ||
| | Enterprise roles | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | |
There was a problem hiding this comment.
it is a role valid JUST for github enterprise? if so, it should not be documented here
There was a problem hiding this comment.
It is GitHub Enterprise Cloud, which is a different product from the GitHub Enterprise integration: Enterprise Cloud is accessed at github.com, so this page is its home. The /baton/github-enterprise page says so itself ("If you access GitHub at github.com, go to the GitHub integration"), and that connector marks instance-url as required, so an Enterprise Cloud customer on github.com cannot use it.
It also is not new here: enterprises is already a top-level field on main and enterprise_role already syncs when it is set (connector.go:157-162 on main). The feature was validated end to end against a real Enterprise Cloud enterprise through this connector with no instance-url set.
That said the naming collision is real and the row as written invited exactly this question, so in b91d9b7 I reworded it to name Enterprise Cloud explicitly and point at the other integration for custom domains.
| // Off by default because it needs setup nobody has done yet: turning it on | ||
| // without the enterprise installation fails the sync, which is only a fair | ||
| // trade for someone who asked for the capability. | ||
| enableEnterpriseOwnerProvisioning = field.BoolField( |
There was a problem hiding this comment.
it is a failure produced because of enterprise needed? we should not expose enterprise owner role in regular github connector if it is limited JUST for enterprise apps
There was a problem hiding this comment.
Same as the docs thread: GitHub Enterprise Cloud enterprise accounts live at github.com, so this is the connector for them — baton-github-enterprise requires instance-url and targets custom domains.
On the failure: it is about the enterprise-level app installation, not the product. With the flag on, the connector fails the sync when it cannot reach the enterprise, because C1 deletes every resource of a type a completed sync did not report; finishing while reading no owners would silently drop the Owner role and every grant on it, and GitHub answers 404 for an uninstalled app, a revoked permission and a slug typo alike. The flag exists so that failure is only reachable by someone who asked for the capability.
In b91d9b7 and 8b5a601 the field descriptions now name Enterprise Cloud and state the consequence, so the same ambiguity does not reach a C1 admin.
sergiocorral-conductorone
left a comment
There was a problem hiding this comment.
automated review — Round 2
…ses, and correct the enterprise docs Enterprises reached the GitHub App field group but not the personal access token one, so a hosted token deployment could not configure the capability at all -- and the token path is the one that syncs every enterprise role without the opt-in. The sibling baton-github-enterprise already lists the field in both groups. Both field descriptions now name GitHub Enterprise Cloud, which is accessed at github.com and therefore belongs to this connector rather than to the separate GitHub Enterprise integration, and they refer to the settings by the names the ConductorOne form shows instead of by flag. The documentation had three errors and one gap. The README described the license type as unregistered under App authentication when it stays registered and its 403 deliberately fails the sync, presented the opt-in as provisioning-only when it also gates syncing, and omitted the enterprise permission the whole capability rests on. docs-info claimed GitHub calls organization roles "enterprise licenses", which it does not, and its monorepo references read as paths in this repo. The setup page never mentioned the field on the token path. Also records that the consumed-licenses endpoint is absent from GitHub Enterprise Server, so that resource type is unusable there for either credential -- an open question for the wrapper rather than something this package can diagnose. Removes capabilities_and_config.yaml per review. The metadata is now regenerated by hand until baton-admin enables the managed workflow, which docs-info states. The default capabilities guard now also asserts capability parity, so swapping the provisioning syncer for the read-only one fails the test rather than silently dropping CAPABILITY_PROVISION. Co-authored-by: Cursor <cursoragent@cursor.com>
Superseded — see the current review report for commit
|
Superseded — see the current review report for commit
|
There was a problem hiding this comment.
No blocking issues found — see the full review report
…under app auth The field description said the setting does nothing on its own under a GitHub App. It does something worse: the license resource type stays registered and its 403 fails the whole sync. That description is one of the places an operator is expected to learn this before configuring the connector, so it has to state the consequence rather than understate it. Co-authored-by: Cursor <cursoragent@cursor.com>
Superseded — see the current review report for commit
|
Superseded — see the current review report for commit
|
There was a problem hiding this comment.
No blocking issues found — see the full review report
Code Review Re-check — Sweep 64 (2026-09-29)Verdict: CHANGES REQUESTED (unchanged) · HEAD The new commits deliver docs and the B1 — Sync-path
|
…ps resolving
NotFound is the one code the SDK downgrades to a warning: SyncGrantsOp
skips the action and the sync still completes, and C1 deletes the grants
a completed sync did not report. So a NotFound escaping Grants revokes
every Owner silently, which is the failure the rest of this resource
type is built to avoid.
Confirmed against the live API with an installation token. An
unresolvable organization answers HTTP 200 with a GraphQL
errors[{type: NOT_FOUND}], which the enterprise transport maps to
codes.NotFound. An organization the app cannot access answers FORBIDDEN
instead, and the transport already ranks that above NOT_FOUND, so the
permission case fails loudly and is unchanged.
A statically wrong organization never reaches this path -- startup fails
on the REST installation lookup. The reachable case is service mode,
where the clients are memoized once and the organization is renamed or
uninstalled while the process keeps running.
Scoped to the sync path. Revoke still reads NotFound as 'already gone'
for its idempotency, which is why this is not in the shared client.
Co-authored-by: Cursor <cursoragent@cursor.com>
Superseded — see the current review report for commit
|
Connector PR Review: [CXH-2123] - Add provisioning for the Enterprise Owner role - GitHub ConnectorBlocking Issues: 0 | Suggestions: 3 | Threads Resolved: 0 Review SummaryThe new commit adds I scanned the full PR diff for security and correctness. I applied the trusted criteria as follows:
The incremental artifact was complete ( Security IssuesNone found. Correctness IssuesNone found. Suggestions
Resolved prior findings
Prompt for AI agentsReviewed commit: |
There was a problem hiding this comment.
No blocking issues found — see the full review report
|
B1 — fixed in 414af6a. Two corrections to the diagnosis, both checked against the live API with an installation token:
On the non-blocking workflow item: the deletion was requested in review, and |
| // about known logins. The enterprise members are that candidate set, which | ||
| // means an invitation sent to someone who is not a member of the enterprise is | ||
| // invisible to the sync. | ||
| func (o *enterpriseRoleResourceType) pendingInvitationGrants( |
There was a problem hiding this comment.
Heads up on an edge case: Grant() will happily invite anyone who's a user principal, but this only finds invitations for logins that come back from Enterprise.members. Outside collaborators can show up in C1 as principals through the repo collaborator grants (repository.go ~235-290), so if someone grants Owner to one of them, the invite gets created, then the next sync can't see it and C1 drops the grant even though the invite's still live on GitHub. Normal org members are fine, so it's not a big deal, but maybe reject non-members in Grant() with InvalidArgument? Or at least call it out next to the comment above.
| 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) |
There was a problem hiding this comment.
Not blocking, just flagging cost: this walks the whole enterprise membership on every sync, not just the configured org. For something like 50k members that's ~500 member pages + ~500 aliased batches, so around 1k GraphQL calls back to back each sync (and every batch returns ~99 NOT_FOUND errors). Memory's fine since it streams by page token, and it's behind the opt-in flag. I'd just add a line to docs-info.md about the ~N/100 extra calls per sync so big tenants aren't surprised.
| var node struct { | ||
| ID string `json:"id"` | ||
| } | ||
| if err := json.Unmarshal(raw, &node); err != nil || node.ID == "" { |
There was a problem hiding this comment.
Small hardening thing: a json.Unmarshal error here gets treated the same as "no invitation" and we just continue. Super unlikely to happen, but if it ever does, the grant just quietly disappears from sync, which C1 reads as a revoke. I'd return the error instead. Same idea for the NOT_FOUND filter above: maybe only drop NOT_FOUNDs whose path is one of the i<N> aliases.
Related, down in pendingOwnerInvitation (~562): any codes.NotFound, including a plain HTTP 404 that the transport classifies, becomes "no invitation". The bad-slug case is already covered at client build time, so this is mostly a stray-404 thing, but a stray 404 during Revoke's post-check could hide an invite that survived.
| c.org, enterprise, enterpriseMaxPages) | ||
| } | ||
|
|
||
| // FailedPrecondition so the caller remembers it: only a configuration |
There was a problem hiding this comment.
nit: the comment says FailedPrecondition is "so the caller remembers it", but clients() in enterprise_role.go only caches successes and retries every failure. The real reason is that C1 treats FailedPrecondition as non-retryable, like the comment on provisioningTarget says. Maybe reword to something like "so C1 treats it as non-retryable"?
|
|
||
| Enterprise roles apply to organizations that belong to a **GitHub Enterprise Cloud** enterprise account. Enterprise Cloud is accessed at `github.com`, so it is covered by this integration — not by the separate [GitHub Enterprise](/baton/github-enterprise) integration, which is for custom domains. Skip this row if your organization does not belong to an enterprise account. | ||
|
|
||
| The capability is opt-in and off by default. Syncing the roles requires the **Enterprises** setting; provisioning the built-in **Enterprise Owner** role additionally requires **Enable enterprise owner provisioning** and a GitHub app installed on both the enterprise account and the organization. See the setup steps below. |
There was a problem hiding this comment.
This line kinda reads like setting Enterprises is enough to sync roles, but that's only true with a PAT. Under App auth without the toggle you get no Owner role and the license type 403s the sync. The App setup section further down gets it right, it's just the intro that could trip someone up. Maybe add "with a personal access token" here, or link to the App note?
| // only panics later inside Do. | ||
| installationClient := customclient.New(appClient) | ||
| if installationClient.BaseHttpClient == nil { | ||
| return nil, fmt.Errorf("github-connector: error building the enterprise installation client") |
There was a problem hiding this comment.
nit: the new code mostly uses baton-github:, but this one (and 570, 590) use github-connector:. Main already mixes the two, so not a regression, but it'd be nice to keep the new lines on baton-github:. Same for the unprefixed ones in graphql_transport.go and the URL/request errors in enterprise_administrator_client.go.
| body, readErr := io.ReadAll(resp.Body) | ||
| _ = resp.Body.Close() | ||
| if readErr != nil { | ||
| return nil, fmt.Errorf("read GraphQL response: %w", readErr) |
There was a problem hiding this comment.
nit: no prefix here. Could be baton-github: read GraphQL response: %w to match the rest (same for creating GraphQL request in the admin client).
| return nil, annos, err | ||
| } | ||
| switch { | ||
| case state.isOwner, state.pendingInvitationID != "": |
There was a problem hiding this comment.
nit: isOwner || pendingInvitationID != "" shows up four times across Grant/Revoke (532, here, 594 negated, 617). A little func (s enterpriseOwnerState) holdsRole() bool would read nicer. Also this tagless switch is really just an if, and Revoke already uses the if form, so it'd match.
| @@ -1,51 +0,0 @@ | |||
| name: Generate capabilities and config schema | |||
There was a problem hiding this comment.
Question on this delete: I built the branch and ran capabilities and config, and both match the committed JSON exactly, so we're good today. But now nothing catches drift down the road. docs-info.md says baton-admin has ci_workflows.capabilities: false for this repo. Can you confirm that's true? And if so, is it worth adding a small CI drift check? Also the PR description says the capabilities command "runs in CI", which isn't the case anymore after this.
Description
Adds Grant and Revoke for the built-in Enterprise Owner role under GitHub App authentication, which CXH-2123 asks for on behalf of DoorDash: they want that role requestable and time-bound from C1.
The ticket's implementation note pointed at a GraphQL mutation called
updateEnterpriseOwnerMembership. That mutation does not exist, and the real model is more involved: GitHub has no single operation that assigns Owner. A member becomes one by accepting an invitation, and GitHub rejects that invitation outright when the user already holds an enterprise administrator role such as Billing manager. An installation token cannot read that prior role —Enterprise.ownerInforesolves tonull— so the connector refuses that case withFailedPreconditionrather than promoting in place: Revoke could only demote toUNAFFILIATED, discarding a role the grant never gave. I corrected the ticket description with what the API actually offers.Contrary to the original support thread, this does not require a personal access token. It works with a GitHub App installed on both the enterprise account and the organization.
Sync:
enterprise_role) — under GitHub App authentication the connector now lists the built-in Owner role and emits its grants. Owners are read fromOrganization.enterpriseOwnerswith the organization installation token;Enterprise.members(role: OWNER)looks like the right field but returns owners of organizations inside the enterprise, not owners of the enterprise account. Under PAT authentication the resource type is unchanged.assignedentitlement as accepted owners. C1 has no pending state for a grant, so an invitee is indistinguishable from a real owner until they accept or the invitation lapses. That is a deliberate trade: emitting nothing would leave the request invisible in C1 for up to seven days with no record that it was made. An access review or offboarding sweep will count an invitee as holding Owner — documented indocs/docs-info.mdand in the enterprise connector docs.Provisioning:
UNAFFILIATED, which keeps their enterprise membership instead of evicting them, and cancels an unaccepted invitation.GrantAlreadyExistswhen the user already holds the role or already has a pending invitation,GrantAlreadyRevokedwhen neither is present. ANOT_FOUNDfrom either mutation is treated as success, because it means the requested state is already in place.What C1 shows for each state:
C1 has no pending state for a grant — it either exists or it does not — so an invitation and an accepted owner map onto the same grant:
The grant ID being stable across the second row matters: if it changed on acceptance, C1 would read the transition as a revoke followed by a new grant and would corrupt the history exactly where it is most useful. Nothing tracks an expiry — GitHub stops resolving an invitation once it is accepted, cancelled or expired, so it simply stops being emitted.
Every row was exercised against a live GitHub Enterprise Cloud account with the app installed on both levels, driven end to end through a full ConductorOne stack: granting to an org member created the invitation and C1 kept the grant across the following sync, a teammate accepting it turned them into an active owner under the same grant ID, and revoking cleared each state through its own mutation. Also covered live: re-granting while an invitation is pending returns
GrantAlreadyExistswithout sending a second invitation, revoking with nothing to revoke reports success rather than an error, and revoking an active owner leaves their enterprise membership intact.Auth:
Enterprise Owner provisioning is opt-in:
--enable-enterprise-owner-provisioning, off by default. While it is off the connector behaves exactly as it did before this change, so upgrading cannot break an existing deployment. Turning it on requires GitHub App authentication with the app installed on both the enterprise account and the organization, and the Enterprise → People: Read and write permission.Once enabled, a missing enterprise installation fails the sync rather than emitting no owners. That is deliberate: C1 deletes every resource of a type that a completed sync did not report, so finishing the sync while reading nothing would silently drop the Owner role and every grant on it — and GitHub answers
404for an uninstalled app, a revoked permission and a slug typo alike, so the connector cannot tell them apart. Failing keeps the sync from completing, so nothing is deleted. The flag is what confines that failure to operators who asked for the capability.Only one enterprise can be served per connector under App authentication, because owners are read through the single configured organization and an organization belongs to exactly one enterprise. A configuration naming several is rejected with an explanatory error. The PAT path still accepts a list.
Architecture highlights:
Enterprise.ownerInfo.pendingAdminInvitationsis the only connection GitHub offers and it isnullfor installation tokens, so the sync resolves invitations by asking about the enterprise members, aliasing up to 100 logins into one request — measured at a single rate-limit point. An invitation addressed to somebody outside the enterprise is therefore invisible to the sync; invitations created from C1 are always visible, because C1 grants to a user it has already synced.Grants()walks owners and invitations as two phases of one page token viapagination.Bag.Unavailable. It is layered only on the enterprise clients: the shared GraphQL client is untouched becauseuser.godetects enterprise SAML by matching that error's text. The aliased batch deliberately bypasses it, since it always carriesNOT_FOUNDentries, and filters those out before classifying the rest — leaving them in would let them claim the code for the whole response, andNOT_FOUNDis the one code the SDK downgrades to a warning.customclientnow resolves URLs against the go-github client'sBaseURLand escapes each path segment, so these endpoints follow--instance-urlinstead of hardcodingapi.github.com.capabilitiescommand runs in CI without credentials, so a connector built from real config omitted every resource type its configuration did not switch on — enterprise roles and licenses need--enterprises, API keys need--sync-secrets, the usage app and its event feed need--sync-last-activity. ADefaultCapabilitiesBuilderregistered for that command alone (the pattern baton-aws uses) restores them, which is what lets the catalog advertiseenterprise_roleas provisionable at all. This does not weaken the per-deployment gating described above:MakeGRPCServerCommandnever receives the option, and C1 overwrites the capabilities it stores from the live connector on Validate and on every sync, so a PAT deployment still reports the role as sync-only. The distinction is that the catalog describes what the connector can do once configured, while each tenant's record describes what its own deployment does.licenseresource type still cannot sync under GitHub App authentication, because GitHub does not offer theenterprise_administrationpermission to Apps. Pre-existing and unrelated to this change, but it means that type has to stay disabled when running the App path with enterprises configured.Useful links:
inviteEnterpriseAdminupdateEnterpriseAdministratorRolecancelEnterpriseAdminInvitation