From ff626341421dfad6fad4560cfab4e730884a1c93 Mon Sep 17 00:00:00 2001 From: "c1-dev-bot[bot]" <2740113+c1-dev-bot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:56:48 +0000 Subject: [PATCH] feat: add site-admin account creation mode for GHES Add an alternative account-creation path that uses the GHES site-admin POST /admin/users endpoint instead of org invitations. This supports SSO/LDAP-enforced GHES environments where email-based org invitations are disallowed by policy. New config options: - account-creation-mode: "invitation" (default) or "site_admin_create" - site-admin-token: separate PAT with site-admin privileges for GHES When mode is site_admin_create, CreateAccount calls Admin.CreateUser with the provided github_username as the login. The user is created immediately (no invitation email, no acceptance required), which enables downstream team/repo grants in the same provisioning tick. Fixes: CXH-1594 --- pkg/config/conf.gen.go | 2 + pkg/config/config.go | 25 +++++++ pkg/connector/connector.go | 63 ++++++++++++++--- pkg/connector/invitation.go | 130 +++++++++++++++++++++++++++++++++--- 4 files changed, 199 insertions(+), 21 deletions(-) diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index 5ed99148..e0641c2f 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -14,6 +14,8 @@ type Github struct { SyncSecrets bool `mapstructure:"sync-secrets"` OmitArchivedRepositories bool `mapstructure:"omit-archived-repositories"` DirectCollaboratorsOnly bool `mapstructure:"direct-collaborators-only"` + AccountCreationMode string `mapstructure:"account-creation-mode"` + SiteAdminToken string `mapstructure:"site-admin-token"` } func (c *Github) findFieldByTag(tagValue string) (any, bool) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 2b9c5e63..9f9fb988 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -79,6 +79,29 @@ var ( field.WithDescription("Organization of your github app"), field.WithRequired(true), ) + AccountCreationModeField = field.StringField( + "account-creation-mode", + field.WithDisplayName("Account creation mode"), + field.WithDescription( + `How the connector creates accounts. "invitation" (default) sends an org `+ + `invitation email. "site_admin_create" calls POST /admin/users using a `+ + `site-admin PAT, bypassing email invitations (GHES only).`, + ), + ) + SiteAdminTokenField = field.StringField( + "site-admin-token", + field.WithDisplayName("Site-admin personal access token"), + field.WithDescription( + "A GHES site-admin PAT used for account creation when account-creation-mode "+ + "is site_admin_create. This token requires site-admin privilege on the GHES instance.", + ), + field.WithIsSecret(true), + ) +) + +const ( + AccountCreationModeInvitation = "invitation" + AccountCreationModeSiteAdminCreate = "site_admin_create" ) //go:generate go run ./gen @@ -94,6 +117,8 @@ var Config = field.NewConfiguration( syncSecrets, omitArchivedRepositories, directCollaboratorsOnly, + AccountCreationModeField, + SiteAdminTokenField, }, field.WithConnectorDisplayName("GitHub v2"), field.WithHelpUrl("/docs/baton/github-v2"), diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 7bbbb55e..3cf19486 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -117,6 +117,7 @@ type GitHub struct { orgs []string client *github.Client appClient *github.Client + siteAdminClient *github.Client customClient *customclient.Client instanceURL string graphqlClient *githubv4.Client @@ -125,6 +126,7 @@ type GitHub struct { omitArchivedRepositories bool directCollaboratorsOnly bool enterprises []string + accountCreationMode string } func (gh *GitHub) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncerV2 { @@ -135,9 +137,12 @@ func (gh *GitHub) ResourceSyncers(ctx context.Context) []connectorbuilder.Resour RepositoryBuilder(gh.client, gh.orgCache, gh.omitArchivedRepositories, gh.directCollaboratorsOnly), OrgRoleBuilder(gh.client, gh.orgCache), InvitationBuilder(InvitationBuilderParams{ - client: gh.client, - orgCache: gh.orgCache, - orgs: gh.orgs, + client: gh.client, + orgCache: gh.orgCache, + orgs: gh.orgs, + accountCreationMode: gh.accountCreationMode, + siteAdminClient: gh.siteAdminClient, + instanceURL: gh.instanceURL, }), AppBuilder(gh.client, gh.orgCache), } @@ -157,14 +162,28 @@ func (gh *GitHub) ResourceSyncers(ctx context.Context) []connectorbuilder.Resour // Metadata returns metadata about the connector. func (gh *GitHub) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { + siteAdminMode := gh.accountCreationMode == cfg.AccountCreationModeSiteAdminCreate + + usernameRequired := siteAdminMode + usernameDesc := "The user's GitHub username (optional, used to look up the user if email is private)." + if siteAdminMode { + usernameDesc = "The user's GitHub/GHES login. Required when account-creation-mode is site_admin_create." + } + + emailRequired := !siteAdminMode + emailDesc := "This email will be used as the login for the user." + if siteAdminMode { + emailDesc = "The user's email address (optional for LDAP/SAML/CAS-authenticated GHES instances)." + } + return &v2.ConnectorMetadata{ DisplayName: "GitHub", AccountCreationSchema: &v2.ConnectorAccountCreationSchema{ FieldMap: map[string]*v2.ConnectorAccountCreationSchema_Field{ "email": { DisplayName: "Email", - Required: true, - Description: "This email will be used as the login for the user.", + Required: emailRequired, + Description: emailDesc, Field: &v2.ConnectorAccountCreationSchema_Field_StringField{ StringField: &v2.ConnectorAccountCreationSchema_StringField{}, }, @@ -183,8 +202,8 @@ func (gh *GitHub) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { }, "github_username": { DisplayName: "GitHub username", - Required: false, - Description: "The user's GitHub username (optional, used to look up the user if email is private).", + Required: usernameRequired, + Description: usernameDesc, Field: &v2.ConnectorAccountCreationSchema_Field_StringField{ StringField: &v2.ConnectorAccountCreationSchema_StringField{}, }, @@ -311,13 +330,35 @@ func NewLambdaConnector(ctx context.Context, ghc *cfg.Github, cliOpts *cli.Conne if err != nil { return nil, nil, err } - return cb, nil, nil + } else { + cb, err = newWithGithubPAT(ctx, ghc) + if err != nil { + return nil, nil, err + } } - cb, err = newWithGithubPAT(ctx, ghc) - if err != nil { - return nil, nil, err + mode := ghc.AccountCreationMode + if mode == "" { + mode = cfg.AccountCreationModeInvitation + } + if mode != cfg.AccountCreationModeInvitation && mode != cfg.AccountCreationModeSiteAdminCreate { + return nil, nil, fmt.Errorf("github-connector: invalid account-creation-mode %q (must be %q or %q)", + mode, cfg.AccountCreationModeInvitation, cfg.AccountCreationModeSiteAdminCreate) + } + cb.accountCreationMode = mode + + if mode == cfg.AccountCreationModeSiteAdminCreate { + if ghc.SiteAdminToken == "" { + return nil, nil, fmt.Errorf("github-connector: site-admin-token is required when account-creation-mode is %q", cfg.AccountCreationModeSiteAdminCreate) + } + siteAdminTS := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: ghc.SiteAdminToken}) + siteAdminClient, err := newGitHubClient(ctx, ghc.InstanceUrl, siteAdminTS) + if err != nil { + return nil, nil, fmt.Errorf("github-connector: failed to create site-admin client: %w", err) + } + cb.siteAdminClient = siteAdminClient } + return cb, nil, nil } diff --git a/pkg/connector/invitation.go b/pkg/connector/invitation.go index 52df44a2..5cfcfac1 100644 --- a/pkg/connector/invitation.go +++ b/pkg/connector/invitation.go @@ -9,6 +9,7 @@ import ( "strings" "time" + cfg "github.com/conductorone/baton-github/pkg/config" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/connectorbuilder" @@ -92,9 +93,12 @@ func invitationExpiresAt(invitation *github.Invitation, status string) (time.Tim } type invitationResourceType struct { - client *github.Client - orgCache *orgNameCache - orgs []string + client *github.Client + orgCache *orgNameCache + orgs []string + accountCreationMode string + siteAdminClient *github.Client + instanceURL string } func (i *invitationResourceType) ResourceType(_ context.Context) *v2.ResourceType { @@ -251,6 +255,21 @@ func (i *invitationResourceType) CreateAccount( []*v2.PlaintextData, annotations.Annotations, error, +) { + if i.accountCreationMode == cfg.AccountCreationModeSiteAdminCreate { + return i.createAccountViaSiteAdmin(ctx, accountInfo) + } + return i.createAccountViaInvitation(ctx, accountInfo) +} + +func (i *invitationResourceType) createAccountViaInvitation( + ctx context.Context, + accountInfo *v2.AccountInfo, +) ( + connectorbuilder.CreateAccountResponse, + []*v2.PlaintextData, + annotations.Annotations, + error, ) { l := ctxzap.Extract(ctx) @@ -288,7 +307,6 @@ func (i *invitationResourceType) CreateAccount( return nil, nil, nil, fmt.Errorf("github-connector: organization %s uses Enterprise Managed Users (EMU); accounts are provisioned by the IdP, not via org invitations", params.org) } - // Check for expired/failed invitations as diagnostic context for unexpected failures. failedInv, failedErr := i.lookupFailedInvitation(ctx, params.org, params.login, *params.email) if failedErr != nil { l.Warn("failed to check for expired invitations", zap.Error(failedErr)) @@ -321,6 +339,60 @@ func (i *invitationResourceType) CreateAccount( }, nil, annotations, nil } +func (i *invitationResourceType) createAccountViaSiteAdmin( + ctx context.Context, + accountInfo *v2.AccountInfo, +) ( + connectorbuilder.CreateAccountResponse, + []*v2.PlaintextData, + annotations.Annotations, + error, +) { + if i.siteAdminClient == nil { + return nil, nil, nil, fmt.Errorf("github-connector: site-admin client not configured; set site-admin-token when using account-creation-mode=site_admin_create") + } + + params, err := getSiteAdminCreateParams(accountInfo) + if err != nil { + return nil, nil, nil, fmt.Errorf("github-connector: failed to get site-admin create params: %w", err) + } + + createReq := github.CreateUserRequest{ + Login: params.login, + } + if params.email != "" { + createReq.Email = ¶ms.email + } + + user, resp, err := i.siteAdminClient.Admin.CreateUser(ctx, createReq) + if err != nil { + if isUserAlreadyExistsError(err, resp) { + existingUser, lookupErr := i.lookupUser(ctx, params.login, params.email) + if lookupErr != nil { + ctxzap.Extract(ctx).Warn("user already exists but lookup failed, returning AlreadyExistsResult without resource", zap.Error(lookupErr)) + return &v2.CreateAccountResponse_AlreadyExistsResult{}, nil, nil, nil + } + return &v2.CreateAccountResponse_AlreadyExistsResult{Resource: existingUser}, nil, nil, nil + } + return nil, nil, nil, wrapGitHubError(err, resp, "github-connector: failed to create user via site-admin API") + } + + restApiRateLimit, err := extractRateLimitData(resp) + if err != nil { + return nil, nil, nil, err + } + + var annos annotations.Annotations + annos.WithRateLimiting(restApiRateLimit) + + r, err := userResource(ctx, user, user.GetEmail(), nil) + if err != nil { + return nil, nil, nil, fmt.Errorf("github-connector: failed to build user resource from site-admin create response: %w", err) + } + + return &v2.CreateAccountResponse_SuccessResult{Resource: r}, nil, annos, nil +} + func (i *invitationResourceType) Delete(ctx context.Context, resourceId *v2.ResourceId) (annotations.Annotations, error) { if resourceId.ResourceType != resourceTypeInvitation.Id { return nil, fmt.Errorf("baton-github: non-invitation resource passed to invitation delete") @@ -516,20 +588,58 @@ func isGitHubValidationError(err error, resp *github.Response, substrings ...str return false } +func isUserAlreadyExistsError(err error, resp *github.Response) bool { + if resp == nil || resp.StatusCode != http.StatusUnprocessableEntity { + return false + } + var ghErr *github.ErrorResponse + if !errors.As(err, &ghErr) { + return false + } + return containsLower(ghErr.Message, "already exists") || containsLower(ghErr.Message, "login is already taken") +} + +type siteAdminCreateParams struct { + login string + email string +} + +func getSiteAdminCreateParams(accountInfo *v2.AccountInfo) (*siteAdminCreateParams, error) { + pMap := accountInfo.Profile.AsMap() + + login, _ := pMap["github_username"].(string) + if login == "" { + return nil, fmt.Errorf("github_username is required when account-creation-mode is %s", cfg.AccountCreationModeSiteAdminCreate) + } + + email, _ := pMap["email"].(string) + + return &siteAdminCreateParams{ + login: login, + email: email, + }, nil +} + func containsLower(s, substr string) bool { return strings.Contains(strings.ToLower(s), substr) } type InvitationBuilderParams struct { - client *github.Client - orgCache *orgNameCache - orgs []string + client *github.Client + orgCache *orgNameCache + orgs []string + accountCreationMode string + siteAdminClient *github.Client + instanceURL string } func InvitationBuilder(p InvitationBuilderParams) *invitationResourceType { return &invitationResourceType{ - client: p.client, - orgCache: p.orgCache, - orgs: p.orgs, + client: p.client, + orgCache: p.orgCache, + orgs: p.orgs, + accountCreationMode: p.accountCreationMode, + siteAdminClient: p.siteAdminClient, + instanceURL: p.instanceURL, } }