Skip to content
Closed
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
12 changes: 10 additions & 2 deletions config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@
"displayName": "Optimize sync for large organizations",
"description": "Reduces API calls by using grant expansion for team-based repo access and skipping per-team detail fetches. Recommended for large orgs.",
"boolField": {}
},
{
"name": "reinvite-pending-invitations",
"displayName": "Re-issue pending invitations to pre-stage access",
"description": "Allow granting teams and org roles to people who were invited by email and have no GitHub username yet. GitHub can only set an invitation's teams when the invitation is created, so the connector cancels and re-sends the invitation, which invalidates the original invite link, sends a second email, and restarts the 7-day expiry.",
"boolField": {}
}
],
"displayName": "GitHub v2",
Expand All @@ -191,7 +197,8 @@
"token",
"orgs",
"omit-archived-repositories",
"direct-collaborators-only"
"direct-collaborators-only",
"reinvite-pending-invitations"
],
"default": true
},
Expand All @@ -205,7 +212,8 @@
"org",
"sync-secrets",
"omit-archived-repositories",
"direct-collaborators-only"
"direct-collaborators-only",
"reinvite-pending-invitations"
]
}
]
Expand Down
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.

19 changes: 17 additions & 2 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ var (
"and skipping per-team detail fetches. Recommended for large orgs.",
),
)
// GitHub can only attach teams to an organization invitation at creation
// time, so pre-staging team access for someone who was invited by email
// alone means cancelling their invitation and re-issuing it. That is
// destructive enough to stay opt-in: see reinviteWithTeams.
reinvitePendingInvitations = field.BoolField(
"reinvite-pending-invitations",
field.WithDisplayName("Re-issue pending invitations to pre-stage access"),
field.WithDescription(
"Allow granting teams and org roles to people who were invited by email and have no GitHub username yet. "+
"GitHub can only set an invitation's teams when the invitation is created, so the connector cancels "+
"and re-sends the invitation, which invalidates the original invite link, sends a second email, and "+
"restarts the 7-day expiry.",
),
)
orgField = field.StringField(
"org",
field.WithDisplayName("Github App Organization"),
Expand All @@ -94,6 +108,7 @@ var Config = field.NewConfiguration(
syncSecrets,
omitArchivedRepositories,
directCollaboratorsOnly,
reinvitePendingInvitations,
},
field.WithConnectorDisplayName("GitHub v2"),
field.WithHelpUrl("/docs/baton/github-v2"),
Expand All @@ -103,14 +118,14 @@ var Config = field.NewConfiguration(
Name: GithubPersonalAccessTokenGroup,
DisplayName: "Personal access token",
HelpText: "Use a personal access token for authentication.",
Fields: []field.SchemaField{accessTokenField, orgsField, omitArchivedRepositories, directCollaboratorsOnly},
Fields: []field.SchemaField{accessTokenField, orgsField, omitArchivedRepositories, directCollaboratorsOnly, reinvitePendingInvitations},
Default: true,
},
{
Name: GithubAppGroup,
DisplayName: "GitHub app",
HelpText: "Use a github app for authentication",
Fields: []field.SchemaField{appIDField, appPrivateKeyPath, orgField, syncSecrets, omitArchivedRepositories, directCollaboratorsOnly},
Fields: []field.SchemaField{appIDField, appPrivateKeyPath, orgField, syncSecrets, omitArchivedRepositories, directCollaboratorsOnly, reinvitePendingInvitations},
Default: false,
},
}),
Expand Down
17 changes: 14 additions & 3 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,16 @@ type GitHub struct {
omitArchivedRepositories bool
directCollaboratorsOnly bool
enterprises []string
// reinvitePendingInvitations allows provisioning to cancel and re-issue a
// pending org invitation when that is the only way GitHub will accept the
// requested access. See reinviteWithTeams.
reinvitePendingInvitations bool
}

func (gh *GitHub) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncerV2 {
resourceSyncers := []connectorbuilder.ResourceSyncerV2{
OrgBuilder(gh.client, gh.appClient, gh.orgCache, gh.orgs, gh.syncSecrets),
TeamBuilder(gh.client, gh.orgCache, gh.directCollaboratorsOnly),
OrgBuilder(gh.client, gh.appClient, gh.orgCache, gh.orgs, gh.syncSecrets, gh.reinvitePendingInvitations),
TeamBuilder(gh.client, gh.orgCache, gh.directCollaboratorsOnly, gh.reinvitePendingInvitations),
UserBuilder(gh.client, gh.graphqlClient, gh.orgCache, gh.orgs, gh.customClient, gh.enterprises),
RepositoryBuilder(gh.client, gh.orgCache, gh.omitArchivedRepositories, gh.directCollaboratorsOnly),
OrgRoleBuilder(gh.client, gh.orgCache),
Expand Down Expand Up @@ -186,7 +190,10 @@ 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).",
Description: "The user's GitHub username. Optional, but strongly recommended: when it is set the org " +
"invitation is addressed to that GitHub account, which is what lets teams and repository access be " +
"granted before the user accepts. Without it the invitation is only addressed to an email address, " +
"and GitHub cannot attach team or repository access to it.",
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{
StringField: &v2.ConnectorAccountCreationSchema_StringField{},
},
Expand Down Expand Up @@ -346,6 +353,8 @@ func newWithGithubPAT(ctx context.Context, ghc *cfg.Github) (*GitHub, error) {
syncSecrets: ghc.SyncSecrets,
omitArchivedRepositories: ghc.OmitArchivedRepositories,
directCollaboratorsOnly: ghc.DirectCollaboratorsOnly,

reinvitePendingInvitations: ghc.ReinvitePendingInvitations,
}, nil
}

Expand Down Expand Up @@ -433,6 +442,8 @@ func newWithGithubApp(ctx context.Context, ghc *cfg.Github) (*GitHub, error) {
syncSecrets: ghc.SyncSecrets,
omitArchivedRepositories: ghc.OmitArchivedRepositories,
directCollaboratorsOnly: ghc.DirectCollaboratorsOnly,

reinvitePendingInvitations: ghc.ReinvitePendingInvitations,
}
return gh, nil
}
Expand Down
35 changes: 32 additions & 3 deletions pkg/connector/invitation.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ const (
invitationProfileKeyStatus = "invitation_status"
invitationProfileKeyExpiresAt = "invitation_expires_at"

// invitation_github_login is set only when GitHub resolved the invitee to a
// real account. Its absence is what makes an invitation unable to receive
// team or repository access directly — every GitHub membership write takes a
// username. Surfacing it lets an operator see that from C1.
invitationProfileKeyGitHubLogin = "invitation_github_login"
invitationProfileKeyRole = "invitation_role"

// Values exposed via invitation_status.
invitationStatusPendingAcceptance = "invitation_pending_acceptance"
invitationStatusExpired = "invitation_expired"
Expand Down Expand Up @@ -57,6 +64,12 @@ func invitationToUserResource(invitation *github.Invitation, status string) (*v2
if expiresAt, ok := invitationExpiresAt(invitation, status); ok {
profile[invitationProfileKeyExpiresAt] = expiresAt.UTC().Format(time.RFC3339)
}
if ghLogin := invitation.GetLogin(); ghLogin != "" {
profile[invitationProfileKeyGitHubLogin] = ghLogin
}
if role := invitation.GetRole(); role != "" {
profile[invitationProfileKeyRole] = role
}

ret, err := resourceSdk.NewUserResource(
login,
Expand Down Expand Up @@ -268,9 +281,25 @@ func (i *invitationResourceType) CreateAccount(
return nil, nil, nil, fmt.Errorf("github-connectorv2: failed to get CreateUserParams: %w", err)
}

invitation, resp, err := i.client.Organizations.CreateOrgInvitation(ctx, params.org, &github.CreateOrgInvitationOptions{
Email: params.email,
})
// Prefer invitee_id: an invitation created that way carries a GitHub login,
// which is what lets team and repository access be pre-staged before the
// invitation is accepted. An email-only invitation has no login until (and
// unless) GitHub resolves one, and GitHub can then only attach teams by
// re-issuing the invitation.
inviteOpts := &github.CreateOrgInvitationOptions{Email: params.email}
if params.login != "" {
invitee, _, err := i.client.Users.Get(ctx, params.login)
if err != nil {
l.Debug("github-connector: could not resolve github_username, inviting by email instead",
zap.String("github_username", params.login),
zap.String("github_error", gitHubErrorMessage(err)),
)
Comment on lines +291 to +296

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: any Users.Get failure — 403 secondary rate limit, 5xx, or a real 404 — is logged at Debug and silently downgrades the account to an email-only invitation, which is exactly the case that later forces the destructive reinvite-pending-invitations path. Per the repo's log-level criteria this non-404 fallback deserves Warn, and the discarded *github.Response means the extra call's rate-limit headers never surface as a RateLimitDescription annotation. Consider capturing resp, distinguishing 404 (Debug) from transient failures (Warn), and merging the rate-limit annotation into the response.

} else {
inviteOpts = &github.CreateOrgInvitationOptions{InviteeID: github.Ptr(invitee.GetID())}

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: replacing the whole options struct drops params.email, and GitHub returns email: null for an invitee_id invitation — so invitationToUserResource at line 352 builds the CreateAccount response with WithEmail("", true) and no email in the profile, whereas the previous email-only path always carried one. The connector already knows the address (getCreateUserParams requires it), so downstream identity matching loses data for no reason. Consider setting InviteeID on the existing inviteOpts and backfilling params.email onto the returned resource when invitation.GetEmail() is empty.

}
}

invitation, resp, err := i.client.Organizations.CreateOrgInvitation(ctx, params.org, inviteOpts)
if err != nil {
if isAlreadyOrgMemberError(err, resp) {
memberResource, lookupErr := i.lookupUser(ctx, params.login, *params.email)
Expand Down
Loading
Loading