Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions pkg/config/conf.gen.go

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

25 changes: 25 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -94,6 +117,8 @@ var Config = field.NewConfiguration(
syncSecrets,
omitArchivedRepositories,
directCollaboratorsOnly,
AccountCreationModeField,
SiteAdminTokenField,
},
field.WithConnectorDisplayName("GitHub v2"),
field.WithHelpUrl("/docs/baton/github-v2"),
Expand Down
63 changes: 52 additions & 11 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -125,6 +126,7 @@ type GitHub struct {
omitArchivedRepositories bool
directCollaboratorsOnly bool
enterprises []string
accountCreationMode string
}

func (gh *GitHub) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncerV2 {
Expand All @@ -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),
}
Expand All @@ -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{},
},
Expand All @@ -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{},
},
Expand Down Expand Up @@ -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
}

Expand Down
130 changes: 120 additions & 10 deletions pkg/connector/invitation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: instanceURL is stored on the struct and passed through InvitationBuilderParams, but no method on invitationResourceType ever reads it. Consider removing it to avoid dead code (R2).

}

func (i *invitationResourceType) ResourceType(_ context.Context) *v2.ResourceType {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 = &params.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")
Expand Down Expand Up @@ -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,
}
}
Loading