Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8609572
feat(github): provision the enterprise owner role under github app auth
mateoHernandez123 Sep 22, 2026
04872b2
chore(github): extract the repeated GET literal in the endpoint mocks
mateoHernandez123 Sep 22, 2026
212f445
fix(github): bound the installations walk and reject traversal segments
mateoHernandez123 Sep 22, 2026
4203291
fix(github): keep the connector context for the memoized token refresher
mateoHernandez123 Sep 22, 2026
118b0b7
refactor(github): drop the traversal guard and state what escaping co…
mateoHernandez123 Sep 22, 2026
3ae0c2b
refactor(github): look up the enterprise installation directly instea…
mateoHernandez123 Sep 22, 2026
c9de9ce
fix(github): report an exhausted page budget as itself and log the sk…
mateoHernandez123 Sep 22, 2026
8301561
fix(github): retry an unclassified client build failure instead of re…
mateoHernandez123 Sep 22, 2026
445cff9
fix(github): skip an enterprise without an app installation instead o…
mateoHernandez123 Sep 23, 2026
32d2e00
fix(github): stand down enterprise roles on an ambiguous config and r…
mateoHernandez123 Sep 23, 2026
7c843cb
fix(github): fail the sync when no enterprise client can be built ins…
mateoHernandez123 Sep 23, 2026
fcd304a
feat(github): put enterprise owner provisioning behind an opt-in flag
mateoHernandez123 Sep 23, 2026
0c93921
refactor(github): memoize only a successful client build so a fixed c…
mateoHernandez123 Sep 23, 2026
764bdec
docs(github): document the enterprise owner provisioning flag where t…
mateoHernandez123 Sep 23, 2026
a5b1304
fix(github): give the page-limit errors a grpc code like the rest of …
mateoHernandez123 Sep 23, 2026
4e7fa1d
fix(github): advertise enterprise role provisioning only where it can…
mateoHernandez123 Sep 23, 2026
ec0f946
fix(github): stop registering the pat-only license type under app auth
mateoHernandez123 Sep 23, 2026
adc027c
fix(github): keep the license type registered until enterprise owners…
mateoHernandez123 Sep 23, 2026
5a033f5
test(github): lock provisioning to the deployments that can use it
mateoHernandez123 Sep 23, 2026
e28eb7b
docs(github): correct what the opt-in changes and drop the limitation…
mateoHernandez123 Sep 23, 2026
2a8e890
fix(github): address enterprise owner review findings
mateoHernandez123 Sep 24, 2026
8943329
docs(github): drop the last mention of the removed promote fallback
mateoHernandez123 Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. Provisioning the built-in Enterprise Owner role additionally requires `--enable-enterprise-owner-provisioning` and a GitHub App installed on the enterprise account
- Enterprise licenses, only when `--enterprises` is set and the connector uses a personal access token. The API behind them is not available to GitHub Apps

By default, `baton-github` will sync information from any organizations that the provided credential has Administrator permissions on. You can specify exactly which organizations you would like to sync using the `--orgs` flag.

Expand Down Expand Up @@ -78,6 +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)
--enable-enterprise-owner-provisioning Sync and provision the built-in Enterprise Owner role. Requires the GitHub App to be installed on the enterprise account as well as on the organization, with the "Enterprise people: read and write" permission, and requires --enterprises to name that enterprise. Not available with a personal access token. ($BATON_ENABLE_ENTERPRISE_OWNER_PROVISIONING)
--enterprises strings Sync enterprise roles, must be an admin of the enterprise. ($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)
Expand Down
8 changes: 8 additions & 0 deletions config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@
"description": "Whether to sync secrets or not",
"boolField": {}
},
{
"name": "enable-enterprise-owner-provisioning",
"displayName": "Enable enterprise owner provisioning",
"description": "Sync and provision the built-in Enterprise Owner role. Requires the GitHub App to be installed on the enterprise account as well as on the organization, with the \"Enterprise people: read and write\" permission, and requires --enterprises to name that enterprise. Not available with a personal access token.",
"boolField": {}
},
{
"name": "omit-archived-repositories",
"displayName": "Omit syncing archived repositories",
Expand Down Expand Up @@ -207,7 +213,9 @@
"app-privatekey-path",
"app-privatekey",
"org",
"enterprises",
"sync-secrets",
"enable-enterprise-owner-provisioning",
"omit-archived-repositories",
"direct-collaborators-only"
]
Expand Down
354 changes: 354 additions & 0 deletions docs/docs-info.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pkg/config/conf.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 18 additions & 2 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ var (
field.WithDisplayName("Sync secrets"),
field.WithDescription(`Whether to sync secrets or not`),
)
// 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(
Comment thread
mateoHernandez123 marked this conversation as resolved.
"enable-enterprise-owner-provisioning",
field.WithDisplayName("Enable enterprise owner provisioning"),
field.WithDescription(
"Sync and provision the built-in Enterprise Owner role. Requires the GitHub App to be installed on "+
"the enterprise account as well as on the organization, with the \"Enterprise people: read and write\" "+
"permission, and requires --enterprises to name that enterprise. Not available with a personal access token.",
),
)
omitArchivedRepositories = field.BoolField(
"omit-archived-repositories",
field.WithDisplayName("Omit syncing archived repositories"),
Expand Down Expand Up @@ -120,6 +132,7 @@ var Config = field.NewConfiguration(
appPrivateKey,
orgField,
syncSecrets,
enableEnterpriseOwnerProvisioning,
omitArchivedRepositories,
directCollaboratorsOnly,
syncLastActivity,
Expand All @@ -139,8 +152,11 @@ var Config = field.NewConfiguration(
Name: GithubAppGroup,
DisplayName: "GitHub app",
HelpText: "Use a github app for authentication",
Fields: []field.SchemaField{appIDField, appPrivateKeyPath, appPrivateKey, orgField, syncSecrets, omitArchivedRepositories, directCollaboratorsOnly},
Default: false,
Fields: []field.SchemaField{
appIDField, appPrivateKeyPath, appPrivateKey, orgField, EnterprisesField, syncSecrets,
enableEnterpriseOwnerProvisioning, omitArchivedRepositories, directCollaboratorsOnly,
Comment thread
mateoHernandez123 marked this conversation as resolved.
},
Default: false,
},
}),
)
207 changes: 186 additions & 21 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

Expand Down Expand Up @@ -127,6 +126,7 @@ type GitHub struct {
omitArchivedRepositories bool
directCollaboratorsOnly bool
enterprises []string
newEnterpriseRoleClients enterpriseClientProvider
syncLastActivity bool
}

Expand Down Expand Up @@ -155,10 +155,33 @@ 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),
LicenseBuilder(gh.customClient, gh.enterprises),
)
// The provisioning-capable syncer is registered only where the role can
// actually be provisioned. The SDK reads CAPABILITY_PROVISION off the
// methods a syncer implements, so registering it everywhere would offer
// the role as requestable to PAT deployments, where every request fails.
if gh.newEnterpriseRoleClients != nil {
Comment thread
mateoHernandez123 marked this conversation as resolved.
resourceSyncers = append(resourceSyncers, EnterpriseRoleProvisioningBuilder(
gh.client, gh.appClient, gh.customClient, gh.enterprises,
gh.newEnterpriseRoleClients,
))
} else {
resourceSyncers = append(resourceSyncers, EnterpriseRoleBuilder(
gh.client, gh.appClient, gh.customClient, gh.enterprises, nil,
))
}
// The consumed-licenses API behind this type is PAT-only, so under app
// auth its 403 fails the whole sync and it has never emitted a
// resource. It is dropped only once the operator opts into enterprise
// owner provisioning, which is the setup whose sync it would break.
//
// Keyed on the provider rather than on the credential on purpose:
// with the opt-in off this type has to stay registered, because its
// failure is what stops the run. Dropping it there would let the sync
// complete while reporting no enterprise roles, and C1 deletes the
// stored resources of a type a completed sync did not report.
if gh.newEnterpriseRoleClients == nil {
resourceSyncers = append(resourceSyncers, LicenseBuilder(gh.customClient, gh.enterprises))
}
}
return resourceSyncers
}
Expand Down Expand Up @@ -288,9 +311,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. "+
Comment thread
mateoHernandez123 marked this conversation as resolved.
"GitHub App authentication cannot access the consumed-licenses API, "+
"so the license resource type cannot sync.",
zap.Error(err))
}
}
Expand Down Expand Up @@ -340,6 +363,7 @@ func NewLambdaConnector(ctx context.Context, ghc *cfg.Github, cliOpts *cli.Conne
}

func newWithGithubPAT(ctx context.Context, ghc *cfg.Github) (*GitHub, error) {
enterprises := distinctEnterprises(ghc.Enterprises)
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: ghc.Token},
)
Expand All @@ -356,7 +380,7 @@ func newWithGithubPAT(ctx context.Context, ghc *cfg.Github) (*GitHub, error) {
customClient: customclient.New(ghClient),
instanceURL: ghc.InstanceUrl,
orgs: ghc.Orgs,
enterprises: ghc.Enterprises,
enterprises: enterprises,
graphqlClient: graphqlClient,
orgCache: newOrgNameCache(ghClient),
syncSecrets: ghc.SyncSecrets,
Expand All @@ -381,6 +405,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
Expand Down Expand Up @@ -457,13 +482,44 @@ func newWithGithubApp(ctx context.Context, ghc *cfg.Github) (*GitHub, error) {
return nil, err
}

// Enterprise administration needs its own installation token: the org
// installation token above carries no enterprise permissions. Reading the
// owners needs the org token, so both are handed to the client.
//
// Built on first use rather than here, so connector construction and
// Validate do not depend on the enterprise installation and a later sync
// retries the build. It does not narrow the blast radius of a failure:
// the error surfaces from List, which fails the whole sync, and that is
// deliberate — a sync that completed without owners would read to C1 as a
// revoke of every owner assignment.
//
// The construction context is captured separately: the clients are
// memoized for the process lifetime, so the token refresher inside them
// must not hold the context of whichever RPC happened to build them.
connectorCtx := ctx
// Left nil unless the operator opted in. Reading enterprise owners needs
// the app installed on the enterprise account too, which no existing
// deployment has done, and the connector fails the sync when it cannot
// read them. Nil keeps that path inert: enterprise roles then come from
// the consumed-licenses cache exactly as they did before this capability,
// which under app auth means nothing, so an upgrade changes no behaviour
// until it is asked for.
var newEnterpriseRoleClientsFn enterpriseClientProvider
if ghc.EnableEnterpriseOwnerProvisioning {
Comment thread
mateoHernandez123 marked this conversation as resolved.
newEnterpriseRoleClientsFn = func(ctx context.Context) (map[string]*githubEnterpriseAdministratorClient, 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,
Expand All @@ -474,17 +530,129 @@ 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, each
// with that enterprise's own installation token: enterprise and organization
// installations are separate, and an enterprise mutation rejects the org token.
//
// Fails closed when the app is not installed on an enterprise, or when the
// organization does not belong to it. Returning no clients instead would let
// the sync complete while reading no owners, and C1 deletes every resource of
// a type that a completed sync did not report — the Owner role and every
// grant on it. Failing is what stops a sync from being completed at all, so
// an operator who has not installed the app on the enterprise account, or who
// configured several, hears about it instead of losing the assignments.
//
// ctx scopes the discovery requests to the caller. connectorCtx outlives them
// and is what the memoized clients keep for refreshing the installation token,
// which expires after an hour or on the first 401.
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]*githubEnterpriseAdministratorClient, error) {
// Naming the same enterprise twice, which happens when the flag is set in
// both the environment and the command line, is one enterprise.
enterprises = distinctEnterprises(enterprises)
if len(enterprises) == 0 {
return nil, nil
}
// The owners are read through the single configured organization, and an
// organization belongs to exactly one enterprise, so app auth cannot serve
// a list and picking one of them would be a guess. The PAT path does
// support a list.
if len(enterprises) > 1 {
return nil, status.Errorf(codes.FailedPrecondition,
"github-connector: GitHub App authentication serves one enterprise at a time, "+
"because the owners are read through organization %q, which belongs to a single enterprise; "+
"%d were configured", org, len(enterprises))
}

// NewBaseHttpClient reports a failed cache setup by returning nil, which
// only panics later inside Do.
installationClient := customclient.New(appClient)
Comment thread
mateoHernandez123 marked this conversation as resolved.
if installationClient.BaseHttpClient == nil {
return nil, fmt.Errorf("github-connector: error building the enterprise installation client")
}

clients := make(map[string]*githubEnterpriseAdministratorClient, len(enterprises))
for _, enterprise := range enterprises {
installation, _, err := installationClient.GetEnterpriseInstallation(ctx, enterprise)
if err != nil {
// A 404 is the app not being installed on the enterprise, which is
// the misconfiguration worth naming.
if status.Code(err) == codes.NotFound {
return nil, status.Errorf(codes.FailedPrecondition,
"github-connector: GitHub App is not installed on enterprise %q; install it on the enterprise account "+
"with the Enterprise people read and write permission", enterprise)
}
return nil, err
}
installationID := installation.ID

var enterpriseGqlURL string
if instanceURL != "" && instanceURL != githubDotCom {
parsed, err := url.Parse(instanceURL)
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
}
Comment thread
mateoHernandez123 marked this conversation as resolved.
parsed.Path = "/api/graphql"
enterpriseGqlURL = parsed.String()

client, err := newEnterpriseAdministratorClient(instanceURL, httpClient, orgHTTPClient, org)
if err != nil {
return nil, err
}
if err := client.verifyOrganization(ctx, enterprise); err != nil {
return nil, err
}
if err := client.resolveEnterpriseNodeID(ctx, enterprise); err != nil {
return nil, err
}
clients[enterprise] = client
}

return clients, nil
}

// distinctEnterprises folds the slugs, which GitHub matches case-insensitively,
// so a repeated value does not read as several enterprises.
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 := enterpriseGraphQLEndpoint(instanceURL)
if err != nil {
return nil, err
}

httpClient, err := uhttp.NewClient(ctx, uhttp.WithLogger(true, ctxzap.Extract(ctx)))
Expand All @@ -496,10 +664,7 @@ func newGitHubGraphqlClient(ctx context.Context, instanceURL string, ts oauth2.T
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`,
Expand Down
Loading
Loading