From cf0a996a3cc6e2e5d680bf70b81dd33b54074368 Mon Sep 17 00:00:00 2001 From: Brandon High <759848+highb@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:26:07 -0700 Subject: [PATCH 1/5] fix: remove TRAIT_USER from invitation resource type Invitations are transient pending actions, not identities. Having TRAIT_USER caused the C1 uplift pipeline to create fake AppUsers with invitation-typed SourceConnectorIds that broke downstream grant operations. Invitations should only exist as pending state until the user accepts and appears as a real user resource via sync. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/connector/connector.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 9442536b..fa60d493 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -68,9 +68,7 @@ var ( resourceTypeInvitation = &v2.ResourceType{ Id: "invitation", DisplayName: "Invitation", - Traits: []v2.ResourceType_Trait{ - v2.ResourceType_TRAIT_USER, - }, + Traits: []v2.ResourceType_Trait{}, Annotations: v1AnnotationsForResourceType("invitation"), } resourceTypeApiToken = &v2.ResourceType{ From ff6a9c50315b17e1a81495f54d0150cf384e53e6 Mon Sep 17 00:00:00 2001 From: Brandon High <759848+highb@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:27:53 -0700 Subject: [PATCH 2/5] feat: handle GitHub invite lifecycle in CreateAccount Rewrite CreateAccount to properly handle the async invitation lifecycle across GitHub product tiers (Free, Team, Enterprise Cloud with personal accounts): - Return ActionRequiredResult (not SuccessResult) for new invites, since the user must accept before team membership can be granted - Detect "already a member" (422) and return AlreadyExistsResult with the user resource when possible, or nil resource to let C1 resolve from its DB - Detect "already invited" (422) and return ActionRequiredResult with the pending invitation resource - Add github_username as an optional schema field for reliable user lookup when email is private - Add lookupUser (tries Users.Get by username, falls back to email search) and lookupPendingInvitation (matches by login or email) - Match GitHub's actual error message "already a part of" in addition to "already a member" Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/connector/connector.go | 10 +++ pkg/connector/invitation.go | 133 +++++++++++++++++++++++++++++++++++- 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index fa60d493..3e561fb0 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -154,6 +154,16 @@ func (gh *GitHub) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { Placeholder: "organization name", Order: 2, }, + "github_username": { + DisplayName: "GitHub username", + Required: false, + Description: "The user's GitHub username (optional, used to look up the user if email is private).", + Field: &v2.ConnectorAccountCreationSchema_Field_StringField{ + StringField: &v2.ConnectorAccountCreationSchema_StringField{}, + }, + Placeholder: "octocat", + Order: 3, + }, }, }, }, nil diff --git a/pkg/connector/invitation.go b/pkg/connector/invitation.go index c74f6d38..3255b4b6 100644 --- a/pkg/connector/invitation.go +++ b/pkg/connector/invitation.go @@ -2,14 +2,19 @@ package connector import ( "context" + "errors" "fmt" + "net/http" "strconv" + "strings" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/connectorbuilder" 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" + "go.uber.org/zap" ) func invitationToUserResource(invitation *github.Invitation) (*v2.Resource, error) { @@ -131,6 +136,9 @@ func (i *invitationResourceType) CreateAccount( annotations.Annotations, error, ) { + l := ctxzap.Extract(ctx) + l.Info("CreateAccount called", zap.Any("account_info_profile", accountInfo.GetProfile().AsMap())) + params, err := getCreateUserParams(accountInfo) if err != nil { return nil, nil, nil, fmt.Errorf("github-connectorv2: failed to get CreateUserParams: %w", err) @@ -140,6 +148,24 @@ func (i *invitationResourceType) CreateAccount( Email: params.email, }) if err != nil { + if isAlreadyOrgMemberError(err, resp) { + memberResource, lookupErr := i.lookupUser(ctx, params.login, *params.email) + if lookupErr != nil { + l.Warn("failed to look up existing org member, returning AlreadyExistsResult without resource", zap.Error(lookupErr)) + return &v2.CreateAccountResponse_AlreadyExistsResult{}, nil, nil, nil + } + return &v2.CreateAccountResponse_AlreadyExistsResult{Resource: memberResource}, nil, nil, nil + } + if isAlreadyInvitedError(err, resp) { + invitationResource, lookupErr := i.lookupPendingInvitation(ctx, params.org, params.login, *params.email) + if lookupErr != nil { + l.Warn("failed to look up existing invitation", zap.Error(lookupErr)) + } + return &v2.CreateAccountResponse_ActionRequiredResult{ + Resource: invitationResource, + Message: "GitHub org invite already pending. User must accept the existing invitation.", + }, nil, nil, nil + } return nil, nil, nil, wrapGitHubError(err, resp, "github-connector: failed to create org invitation") } @@ -155,8 +181,9 @@ func (i *invitationResourceType) CreateAccount( if err != nil { return nil, nil, nil, fmt.Errorf("github-connectorv2: cannot create user resource: %w", err) } - return &v2.CreateAccountResponse_SuccessResult{ + return &v2.CreateAccountResponse_ActionRequiredResult{ Resource: r, + Message: "GitHub org invite sent. User must accept the invitation before team membership can be granted.", }, nil, annotations, nil } @@ -204,6 +231,7 @@ func (i *invitationResourceType) Delete(ctx context.Context, resourceId *v2.Reso type createUserParams struct { org string email *string + login string // optional GitHub username } func getCreateUserParams(accountInfo *v2.AccountInfo) (*createUserParams, error) { @@ -218,12 +246,115 @@ func getCreateUserParams(accountInfo *v2.AccountInfo) (*createUserParams, error) return nil, fmt.Errorf("email is required") } + login, _ := pMap["github_username"].(string) + return &createUserParams{ org: org, email: &e, + login: login, }, nil } +// lookupUser resolves a GitHub user resource. Tries login via Users.Get first +// (works regardless of email privacy), then falls back to email search. +func (i *invitationResourceType) lookupUser(ctx context.Context, login, email string) (*v2.Resource, error) { + if login != "" { + ghUser, _, err := i.client.Users.Get(ctx, login) + if err == nil { + userEmail := ghUser.GetEmail() + if userEmail == "" { + userEmail = email + } + return userResource(ctx, ghUser, userEmail, nil) + } + } + + result, _, err := i.client.Search.Users(ctx, email+" in:email", nil) + if err != nil { + return nil, fmt.Errorf("github-connector: failed to search users by email: %w", err) + } + if len(result.Users) == 0 { + return nil, fmt.Errorf("github-connector: no user found with login %q or email %s", login, email) + } + return userResource(ctx, result.Users[0], email, nil) +} + +// lookupPendingInvitation searches pending org invitations matching by login or email. +func (i *invitationResourceType) lookupPendingInvitation(ctx context.Context, org, login, email string) (*v2.Resource, error) { + opts := &github.ListOptions{PerPage: 100} + for { + invitations, resp, err := i.client.Organizations.ListPendingOrgInvitations(ctx, org, opts) + if err != nil { + return nil, fmt.Errorf("github-connector: failed to list pending invitations: %w", err) + } + for _, inv := range invitations { + if invitationMatches(inv, login, email) { + return invitationToUserResource(inv) + } + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return nil, fmt.Errorf("github-connector: no pending invitation found for login %q or email %s", login, email) +} + +// invitationMatches returns true if the invitation matches the given login or email. +func invitationMatches(inv *github.Invitation, login, email string) bool { + if login != "" && strings.EqualFold(inv.GetLogin(), login) { + return true + } + if email != "" && strings.EqualFold(inv.GetEmail(), email) { + return true + } + return false +} + +// isAlreadyOrgMemberError returns true if the GitHub API error indicates +// the user is already an organization member. +func isAlreadyOrgMemberError(err error, resp *github.Response) bool { + if resp == nil || resp.StatusCode != http.StatusUnprocessableEntity { + return false + } + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + msg := strings.ToLower(ghErr.Message) + if strings.Contains(msg, "already a member") || strings.Contains(msg, "already a part of") { + return true + } + for _, e := range ghErr.Errors { + eMsg := strings.ToLower(e.Message) + if strings.Contains(eMsg, "already a member") || strings.Contains(eMsg, "already a part of") { + return true + } + } + } + return false +} + +// isAlreadyInvitedError returns true if the GitHub API error indicates +// the user already has a pending invitation. +func isAlreadyInvitedError(err error, resp *github.Response) bool { + if resp == nil || resp.StatusCode != http.StatusUnprocessableEntity { + return false + } + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + msg := strings.ToLower(ghErr.Message) + if strings.Contains(msg, "already invited") || strings.Contains(msg, "already been invited") { + return true + } + for _, e := range ghErr.Errors { + lower := strings.ToLower(e.Message) + if strings.Contains(lower, "already invited") || strings.Contains(lower, "already been invited") { + return true + } + } + } + return false +} + type invitationBuilderParams struct { client *github.Client orgCache *orgNameCache From 868f100f027a87b7495c06ae9920b7c980e36a08 Mon Sep 17 00:00:00 2001 From: Brandon High <759848+highb@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:29:21 -0700 Subject: [PATCH 3/5] feat: detect EMU orgs and return clear error for invite attempts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enterprise Managed Users (EMU) orgs don't support the invitation API — accounts are provisioned directly by the IdP via SCIM. Detect the EMU-specific 422 error and return a clear message instead of a confusing generic failure. The EMU check runs after already-a-member and already-invited checks since those are success cases regardless of org type. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/connector/invitation.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pkg/connector/invitation.go b/pkg/connector/invitation.go index 3255b4b6..1f791d62 100644 --- a/pkg/connector/invitation.go +++ b/pkg/connector/invitation.go @@ -166,6 +166,9 @@ func (i *invitationResourceType) CreateAccount( Message: "GitHub org invite already pending. User must accept the existing invitation.", }, nil, nil, nil } + if isEMUOrgError(err, resp) { + 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) + } return nil, nil, nil, wrapGitHubError(err, resp, "github-connector: failed to create org invitation") } @@ -355,6 +358,28 @@ func isAlreadyInvitedError(err error, resp *github.Response) bool { return false } +// isEMUOrgError returns true if the GitHub API error indicates the org uses +// Enterprise Managed Users, where invitations are not supported. +func isEMUOrgError(err error, resp *github.Response) bool { + if resp == nil || resp.StatusCode != http.StatusUnprocessableEntity { + return false + } + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + msg := strings.ToLower(ghErr.Message) + if strings.Contains(msg, "managed by an enterprise") || strings.Contains(msg, "enterprise managed") { + return true + } + for _, e := range ghErr.Errors { + eMsg := strings.ToLower(e.Message) + if strings.Contains(eMsg, "managed by an enterprise") || strings.Contains(eMsg, "enterprise managed") { + return true + } + } + } + return false +} + type invitationBuilderParams struct { client *github.Client orgCache *orgNameCache From 9e3354dbeb3cf07cda20c088befb6ea0e01ebf47 Mon Sep 17 00:00:00 2001 From: Brandon High <759848+highb@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:30:25 -0700 Subject: [PATCH 4/5] feat: detect expired/failed invitations before creating new ones Check ListFailedOrgInvitations before calling CreateOrgInvitation. If a matching expired/failed invite is found, log a warning with the failure reason and timestamp, then proceed to send a new invitation. This provides an audit trail of expired invites without blocking re-invitations. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/connector/invitation.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/pkg/connector/invitation.go b/pkg/connector/invitation.go index 1f791d62..93eb9eeb 100644 --- a/pkg/connector/invitation.go +++ b/pkg/connector/invitation.go @@ -144,6 +144,19 @@ func (i *invitationResourceType) CreateAccount( return nil, nil, nil, fmt.Errorf("github-connectorv2: failed to get CreateUserParams: %w", err) } + // Log if a previous invite expired so there's a record, then proceed + // to send a new one. + failedInv, failedErr := i.lookupFailedInvitation(ctx, params.org, params.login, *params.email) + if failedErr != nil { + l.Debug("failed to check for expired invitations", zap.Error(failedErr)) + } + if failedErr == nil && failedInv != nil { + l.Warn("previous invitation expired or failed, sending a new one", + zap.String("failed_reason", failedInv.GetFailedReason()), + zap.Time("failed_at", failedInv.GetFailedAt().Time), + ) + } + invitation, resp, err := i.client.Organizations.CreateOrgInvitation(ctx, params.org, &github.CreateOrgInvitationOptions{ Email: params.email, }) @@ -303,6 +316,27 @@ func (i *invitationResourceType) lookupPendingInvitation(ctx context.Context, or return nil, fmt.Errorf("github-connector: no pending invitation found for login %q or email %s", login, email) } +// lookupFailedInvitation searches failed/expired org invitations matching by login or email. +func (i *invitationResourceType) lookupFailedInvitation(ctx context.Context, org, login, email string) (*github.Invitation, error) { + opts := &github.ListOptions{PerPage: 100} + for { + invitations, resp, err := i.client.Organizations.ListFailedOrgInvitations(ctx, org, opts) + if err != nil { + return nil, fmt.Errorf("github-connector: failed to list failed invitations: %w", err) + } + for _, inv := range invitations { + if invitationMatches(inv, login, email) { + return inv, nil + } + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return nil, fmt.Errorf("github-connector: no failed invitation found for login %q or email %s", login, email) +} + // invitationMatches returns true if the invitation matches the given login or email. func invitationMatches(inv *github.Invitation, login, email string) bool { if login != "" && strings.EqualFold(inv.GetLogin(), login) { From 1a11f83b59b869ebed259a6bb26fc988601ce235 Mon Sep 17 00:00:00 2001 From: Brandon High <759848+highb@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:46:49 -0700 Subject: [PATCH 5/5] fix: address PR review feedback for invitation handling Remove PII-logging of account profile, quote email in search query, bound invitation lookup pagination to 5 pages, return (nil, nil) for not-found instead of error, and log Users.Get fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/connector/invitation.go | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/pkg/connector/invitation.go b/pkg/connector/invitation.go index 93eb9eeb..b48481d9 100644 --- a/pkg/connector/invitation.go +++ b/pkg/connector/invitation.go @@ -137,7 +137,6 @@ func (i *invitationResourceType) CreateAccount( error, ) { l := ctxzap.Extract(ctx) - l.Info("CreateAccount called", zap.Any("account_info_profile", accountInfo.GetProfile().AsMap())) params, err := getCreateUserParams(accountInfo) if err != nil { @@ -148,9 +147,9 @@ func (i *invitationResourceType) CreateAccount( // to send a new one. failedInv, failedErr := i.lookupFailedInvitation(ctx, params.org, params.login, *params.email) if failedErr != nil { - l.Debug("failed to check for expired invitations", zap.Error(failedErr)) + l.Warn("failed to check for expired invitations", zap.Error(failedErr)) } - if failedErr == nil && failedInv != nil { + if failedInv != nil { l.Warn("previous invitation expired or failed, sending a new one", zap.String("failed_reason", failedInv.GetFailedReason()), zap.Time("failed_at", failedInv.GetFailedAt().Time), @@ -173,6 +172,9 @@ func (i *invitationResourceType) CreateAccount( invitationResource, lookupErr := i.lookupPendingInvitation(ctx, params.org, params.login, *params.email) if lookupErr != nil { l.Warn("failed to look up existing invitation", zap.Error(lookupErr)) + } else if invitationResource == nil { + l.Warn("pending invitation not found despite 'already invited' response from GitHub", + zap.String("org", params.org), zap.String("email", *params.email)) } return &v2.CreateAccountResponse_ActionRequiredResult{ Resource: invitationResource, @@ -274,6 +276,7 @@ func getCreateUserParams(accountInfo *v2.AccountInfo) (*createUserParams, error) // lookupUser resolves a GitHub user resource. Tries login via Users.Get first // (works regardless of email privacy), then falls back to email search. func (i *invitationResourceType) lookupUser(ctx context.Context, login, email string) (*v2.Resource, error) { + l := ctxzap.Extract(ctx) if login != "" { ghUser, _, err := i.client.Users.Get(ctx, login) if err == nil { @@ -283,9 +286,11 @@ func (i *invitationResourceType) lookupUser(ctx context.Context, login, email st } return userResource(ctx, ghUser, userEmail, nil) } + l.Debug("user lookup by login failed, falling back to email search", + zap.String("login", login), zap.Error(err)) } - result, _, err := i.client.Search.Users(ctx, email+" in:email", nil) + result, _, err := i.client.Search.Users(ctx, fmt.Sprintf(`"%s" in:email`, email), nil) if err != nil { return nil, fmt.Errorf("github-connector: failed to search users by email: %w", err) } @@ -295,10 +300,15 @@ func (i *invitationResourceType) lookupUser(ctx context.Context, login, email st return userResource(ctx, result.Users[0], email, nil) } +// maxLookupPages limits pagination in invitation lookups to avoid excessive +// API calls and rate limit consumption for orgs with long invitation histories. +const maxLookupPages = 5 + // lookupPendingInvitation searches pending org invitations matching by login or email. +// Returns (nil, nil) if no matching invitation is found. func (i *invitationResourceType) lookupPendingInvitation(ctx context.Context, org, login, email string) (*v2.Resource, error) { opts := &github.ListOptions{PerPage: 100} - for { + for page := 0; page < maxLookupPages; page++ { invitations, resp, err := i.client.Organizations.ListPendingOrgInvitations(ctx, org, opts) if err != nil { return nil, fmt.Errorf("github-connector: failed to list pending invitations: %w", err) @@ -313,13 +323,14 @@ func (i *invitationResourceType) lookupPendingInvitation(ctx context.Context, or } opts.Page = resp.NextPage } - return nil, fmt.Errorf("github-connector: no pending invitation found for login %q or email %s", login, email) + return nil, nil } // lookupFailedInvitation searches failed/expired org invitations matching by login or email. +// Returns (nil, nil) if no matching invitation is found. func (i *invitationResourceType) lookupFailedInvitation(ctx context.Context, org, login, email string) (*github.Invitation, error) { opts := &github.ListOptions{PerPage: 100} - for { + for page := 0; page < maxLookupPages; page++ { invitations, resp, err := i.client.Organizations.ListFailedOrgInvitations(ctx, org, opts) if err != nil { return nil, fmt.Errorf("github-connector: failed to list failed invitations: %w", err) @@ -334,7 +345,7 @@ func (i *invitationResourceType) lookupFailedInvitation(ctx context.Context, org } opts.Page = resp.NextPage } - return nil, fmt.Errorf("github-connector: no failed invitation found for login %q or email %s", login, email) + return nil, nil } // invitationMatches returns true if the invitation matches the given login or email.