Skip to content

feat: grant teams, repos, and org access to pending invitations - #182

Closed
mstanbCO wants to merge 3 commits into
mainfrom
mstanbCO/invitation-grants
Closed

feat: grant teams, repos, and org access to pending invitations#182
mstanbCO wants to merge 3 commits into
mainfrom
mstanbCO/invitation-grants

Conversation

@mstanbCO

@mstanbCO mstanbCO commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Problem

A customer uses birthright access for GitHub. Birthright fires before the employee's start date, so C1 creates the GitHub account first — which, for GitHub, means sending an organization invitation. The follow-on grant tasks then try to add that person to a team.

Until they accept (in this customer's case, by clicking their Okta tile), the C1 app user is backed by the connector's invitation resource type, not user. Every provisioning path here rejected that principal on the first line:

if principal.Id.ResourceType != resourceTypeUser.Id {
    return nil, fmt.Errorf("github-connectorv2: only users can be granted team membership")
}

So the access couldn't be pre-staged: the grants failed, and the entitlements only landed after the user clicked through. Pending invitations also produced no grants at all during sync, so a pending invitee looked like they had nothing — even org membership their invitation already carried.

What this does

Provisioninginvitation is now an accepted principal for org membership/admin, team member/maintainer, and all five repository permission levels, and is listed in each entitlement's grantable_to.

Sync — three new pagination states emit grants for pending invitations, so invite-backed and member-backed grants both show up in C1:

Resource Source Principal
org GET /orgs/{org}/invitations invitation (admin role emits admin + member, mirroring the accepted-member pass)
team GET /orgs/{org}/teams/{team}/invitations invitation (team role from a per-invitee membership lookup; email-only invitations default to member)
repository GET /repos/{o}/{r}/invitations invitation, correlated by invitee login

Each state is pushed first so it drains last, and each degrades to a debug log on 403/404 rather than failing the whole sync.

The hard constraint, and how it's handled

Every GitHub endpoint that writes membership takes a username. An invitation created from a bare email address has no login until GitHub resolves one, so there is nothing to write against. This splits into two paths:

  • Invitee has a loginPUT /orgs/{org}/teams/{team}/memberships/{login} and PUT /repos/{o}/{r}/collaborators/{login} both create a pending membership for non-members. Non-destructive; this is the normal path.
  • Email-only invitation — GitHub accepts team_ids only when an invitation is created, and there is no PATCH /orgs/{org}/invitations/{id}. The only mechanism is cancel-then-recreate.

Because cancel-and-reissue invalidates the outstanding invite link, sends a second email, restarts the 7-day expiry, and changes the invitation ID, it is opt-in behind a new reinvite-pending-invitations config field (default off). When off, the grant fails with an error naming both remedies. When on, reinviteWithTeams reads the existing team set first, cancels, recreates with the union, and restores the original invitation on a best-effort basis if the recreate fails — otherwise a failed grant would strip an invitation the user already had.

CreateAccount now prefers invitee_id when github_username is supplied, so an invitation starts life with a resolvable login and the non-destructive path is available at all. This is the cheapest lever the customer has: populating GitHub usernames in Okta avoids the reissue path entirely.

Organization roles remain unsupported for invitationsPUT /orgs/{org}/organization-roles/users/{user_id}/{role_id} requires active org membership — but now return an explicit FailedPrecondition explaining that instead of a generic type error.

Things a reviewer should weigh

  • The 422 I originally worried about is misattributed — resolved. "User isn't a member of this organization. Please invite them first." belongs to the deprecated PUT /teams/{id}/members/{username}, not the PUT /orgs/{org}/teams/{team}/memberships/{username} this connector calls. The memberships endpoint is documented to invite non-members and leave the membership pending, so the login path should be the normal case and the reissue fallback genuinely defensive. What remains undocumented is the behavior when an org invitation for that user is already outstanding — GitHub's docs are silent. The guard now covers that case specifically (including "already invited") and falls back rather than failing.
  • Repository invitations for non-members are capped at 50 per repo per 24 hours. Org members are exempt, but a pending invitee is not one yet, so a bulk backfill across a single repo can start returning 403s. Normal onboarding volume is nowhere near this; a migration could hit it.
  • Grant churn on acceptance is expected. When the invite is accepted, the invitation resource disappears and its grants go with it, while the accepted user's grants appear. Two different app users, so C1 sees revoke-then-grant across the boundary and self-heals. SkipSyncAnomalyDetection is already set on the invitation resource type.
  • Pending invitees now count as org members in C1. That is the requested behavior, but it means access reviews and any "revoke unmatched access" automation can now act on invitations — and revoking org membership from an invitation cancels the invitation (same as the existing invitation Delete).
  • resolvePendingInvitation pages the pending list because GitHub has no get-invitation-by-ID. One call for a typical org, capped at 25 pages with a truncation warning. Same pattern the existing lookupPendingInvitation uses.
  • Repository invitations for people with no pending org invitation are skipped (direct outside-collaborator invites). They have no synced principal to attach to; surfacing them would mean giving repo invitations their own resource type, whose IDs live in a different space from org invitation IDs. Out of scope here.
  • The one golangci-lint hit (G115 on repository.go uint32(i)) is pre-existing on main; left alone rather than folded into this PR.

Testing

go test ./... and golangci-lint run pass. New table of cases in invitation_grants_test.go covers, against a mocked GitHub: grant sync for all three resources (including role resolution, case-insensitive login correlation, expired-invitation and unmatched-invitee skips), the login grant path, the refusal-vs-missing-login error distinction, GrantAlreadyExists on an already-attached team, the no-longer-pending failure, reissue preserving pre-existing teams, reissue by invitee_id for known logins, and the restore-on-failure path.

Three pre-existing tests changed only their exact NextPageToken assertions to account for the new pagination state.

Birthright access provisioned before someone's start date targets an app
user that is still a pending GitHub org invitation, not an accepted
member. Every provisioning path rejected that principal outright ("only
users can be granted team membership"), so the access could not be
pre-staged and only landed after the user clicked through.

Accept invitation principals in the org, team, and repository grant and
revoke paths, and emit grants for pending invitations during sync so
invitees show up next to accepted members instead of looking unfulfilled.

GitHub keys every post-hoc membership write off a username, so an
invitation created from a bare email address has nothing to write
against. For teams and org roles the only mechanism GitHub offers is
team_ids at invitation-creation time, which means cancel-and-reissue;
that is gated behind the new reinvite-pending-invitations option because
it invalidates the outstanding invite link and restarts the 7-day expiry.
CreateAccount now prefers invitee_id when github_username is supplied so
the non-destructive path is available in the first place.

Organization roles stay unsupported for invitations — GitHub requires
active org membership — but now say so explicitly.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment on lines +215 to +217
// all. Put the original one back so a failed grant does not silently strip
// access the user already had.
restoreOpts, restoreOptsErr := reinviteOptions(ctx, client, inv, originalTeamIDs)

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.

🟠 Bug: the restore rebuilds its payload from inv, but the org promote/demote callers pass a mutated copy (org.go:479-481 sets Role = admin, org.go:596-598 sets Role = direct_member). So this "put the original one back" path re-creates the invitation with the new role, not the original one. Two bad outcomes: if the create failed because of the role change, the restore fails identically and the invitee is left with no invitation at all; if it failed transiently, the restore silently applies the role change that the grant/revoke just reported as failed.

Fix: have reinviteWithTeams capture the original role (and email/invitee) before any mutation — e.g. take the untouched *github.Invitation plus an explicit newRole parameter, and build restoreOpts from the untouched original. TestReinviteRestoresOriginalOnCreateFailure only exercises a team-set change, so this gap is untested.

Comment thread pkg/connector/team.go
return nil, invitationNotProvisionableError(operation, inv)
}

if _, err := reinviteWithTeams(ctx, o.client, orgName, inv, append(existingTeamIDs, teamID)); err != nil {

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.

🟠 Bug: permission is dropped on this path. POST /orgs/{org}/invitations accepts only team_ids — there is no per-team role — so an invitation re-issued this way confers plain team member. A maintainer grant against an email-only (or GitHub-refused) invitation therefore returns success while granting member, and pendingTeamRole will keep reporting member at every sync, so the maintainer entitlement stays permanently unfulfilled with no error surfaced.

Fix: only take the reinvite path when permission == teamRoleMember; for teamRoleMaintainer, return a FailedPrecondition explaining that GitHub cannot pre-stage a maintainer role on a pending invitation (same treatment org_role.go already gives org roles). The slices.Contains short-circuit at line 486 also can't distinguish member from maintainer for an already-attached team, so it will report GrantAlreadyExists for a maintainer request that is only a member.

Comment on lines +316 to +326
if isNotFoundError(resp) || isPermissionError(resp) {
// Without invitation visibility we simply cannot correlate repo
// invitations; cache the empty index so every repo in this sync
// does not retry the same failing call.
ctxzap.Extract(ctx).Debug("github-connector: cannot list pending org invitations, skipping invitation-backed repo grants",
zap.String("org", orgName),
zap.String("github_error", gitHubErrorMessage(err)),
)
break
}
return nil, wrapGitHubError(err, resp, "github-connector: failed to list pending org invitations")

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.

🟠 Bug: isPermissionError(resp) is just StatusCode == 403, which is also how GitHub reports both primary rate limiting (403 + X-RateLimit-Remaining: 0) and secondary rate limiting (403 + Retry-After). A rate-limited sync therefore falls into this branch, and because the (possibly empty or partial) index is then written to the session at line 336, every remaining repository in that sync silently emits zero invitation-backed grants — C1 sees those grants disappear rather than a retryable rate-limit error.

Fix: check isRatelimited(resp) first and fall through to wrapGitHubError so the SDK can back off, and don't persist the index into the session when the walk ended early. The same 403-swallow appears in org.go:311 and team.go:307 (pendingInvitationGrants) — a rate-limited page there returns no grants and no error, which drops org-membership grants for pending invitees.

Comment on lines +100 to +106
l.Warn("github-connector: gave up looking for pending invitation",
zap.Int64("invitation_id", invitationID),
zap.String("org", orgName),
zap.Int("pages_searched", maxPendingInvitationPages),
)
return nil, nil
}

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: hitting the 25-page cap returns (nil, nil), which is indistinguishable from "not pending". Callers read that as a definitive answer: org.go:566 and team.go:583 return GrantAlreadyRevoked, and repository.go:663 does the same — i.e. a truncated search reports a revoke as successfully completed while the access is still in place. Consider returning a distinct error on truncation so revokes surface a retryable failure instead of a false success.

Comment thread pkg/connector/org.go
Comment on lines +331 to +334
if inv.GetRole() == invitationRoleAdmin {
rv = append(rv, o.invitationGrant(orgRoleAdmin, resource, principalID, inv.GetID()))
}
rv = append(rv, o.invitationGrant(orgRoleMember, resource, principalID, inv.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: every non-admin role gets an orgRoleMember grant, including billing_manager and hiring_manager. Those invitees are not org members in GitHub and do not appear in ListMembers, so the grant shows up while the invitation is pending and then vanishes on acceptance — and in the meantime an access review sees org membership they never had. Consider emitting membership only for direct_member/admin/reinstate and skipping the billing/hiring roles.

Comment on lines +304 to +312
if err != nil {
return nil, fmt.Errorf("baton-github: error reading pending invitation index from session: %w", err)
}
if found {
return cached, nil
}

index := make(map[string]int64)
opts := &github.ListOptions{PerPage: maxPageSize}

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: this walk is uncapped, unlike resolvePendingInvitation's maxPendingInvitationPages, and the whole index is JSON-serialized into the session. An org mid-onboarding with tens of thousands of pending invitations will page indefinitely on the first repository that has any invitation and store a correspondingly large session blob. Applying the same page cap (with a truncation warning) would bound both.

Comment thread pkg/connector/team.go
Comment on lines +324 to +336
// member when GitHub cannot tell us (no login to query by, or the lookup fails).
func (o *teamResourceType) pendingTeamRole(ctx context.Context, orgID, teamID int64, inv *github.Invitation) string {
login := inv.GetLogin()
if login == "" {
return teamRoleMember
}

membership, _, err := o.client.Teams.GetTeamMembershipByID(ctx, orgID, teamID, login)
if err != nil {
ctxzap.Extract(ctx).Debug("github-connector: could not read pending team membership role, assuming member",
zap.Int64("team_id", teamID),
zap.String("login", login),
zap.String("github_error", gitHubErrorMessage(err)),

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: pendingTeamRole issues one GetTeamMembershipByID per invitation, so a team with a page of 100 pending invitees costs 100 extra API calls per Grants page, and the *github.Response from each is discarded — its rate-limit headers never reach the SDK as a RateLimitDescription (C3). Returning and merging those annotations, or skipping the lookup unless the team actually has maintainers, would reduce both the call volume and the blind spot.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: feat: grant teams, repos, and org access to pending invitations

Blocking Issues: 0 | Suggestions: 2 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 509706590b7e.
Review mode: incremental since b8f9e0c
View review run

Review Summary

The new commits are almost entirely documentation: expanded rationale comments on isNotAnOrgMemberError, the repository-invitation cap, and the github_username account-creation description. The one behavior change is adding "already invited" to the 422 substrings that route a pending invitee to the re-invite fallback, which I flagged as a suggestion because that wording can also mean the team invitation is already outstanding. The full PR diff was re-scanned for security and correctness; the previously reported findings (re-invite restore payload, dropped team permission on reissue, 403 swallowing in the login index, page-cap false success, billing_manager/hiring_manager member grants, uncapped login-index walk, pendingTeamRole N+1, undocumented reinvite-pending-invitations in docs/connector.mdx) are all still present and unchanged — no new blocking issues.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/connector/invitation_grants.go:279"already invited" also matches a 422 meaning the invitee already has a pending team invitation; that grant is effectively fulfilled, but it now falls through to a destructive cancel-and-recreate (or a FailedPrecondition instead of GrantAlreadyExists). The new substring is also untested — the 422 tests only cover the "isn't a member" wording.
  • pkg/connector/repository.go:601-609 — the newly documented 50-invites/24h cap surfaces through wrapGitHubError as codes.PermissionDenied, so a bulk backfill fails as a permission problem rather than a retryable throttle.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/invitation_grants.go`:
- Around line 279: `isNotAnOrgMemberError` now matches the substring "already invited",
  which GitHub can also return to mean "this user already has a pending invitation to
  this team" rather than "an org invitation is outstanding". In that case
  `grantToInvitation` (pkg/connector/team.go) treats the grant as unfulfilled:
  `ListOrgInvitationTeams` does not report a team attached through the membership
  endpoint, so with `reinvite-pending-invitations` enabled the connector cancels and
  re-creates the org invitation destructively, and with it disabled it returns
  `FailedPrecondition` instead of a `GrantAlreadyExists` annotation. Narrow the substring
  to the org-scoped wording (for example "already invited to this organization"), or
  check the pending team membership state before falling back to re-invite. Also add a
  table-driven test for `isNotAnOrgMemberError` in
  `pkg/connector/invitation_grants_test.go` covering each accepted substring; the two
  existing 422 tests only exercise "User isn't a member of this organization."

In `pkg/connector/repository.go`:
- Around line 601-609: the comment documents GitHub's cap of 50 invitations per
  repository per 24 hours, but the error path still routes that 403 through
  `wrapGitHubError`, which maps it to `codes.PermissionDenied` — a durable
  permission failure rather than a throttle. Detect the cap / secondary-rate-limit
  message from the `AddCollaborator` response and wrap it with
  `codes.ResourceExhausted` so the platform backs off and retries instead of failing
  the grant outright.

@github-actions github-actions Bot left a comment

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.

Blocking issues found — see review comments.

The "User isn't a member of this organization. Please invite them first."
422 belongs to the deprecated PUT /teams/{id}/members/{username}, not the
memberships endpoint this connector calls, which is documented to invite
non-members and leave the membership pending. Narrow the comment to the
case the guard actually covers (an org invitation already outstanding) and
also match "already invited".

Note GitHub's 50-invitations-per-repo-per-24h cap on the repository path,
which applies to pending invitees because they are not org members yet.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment on lines +598 to +604
_, resp, err := o.client.Repositories.AddCollaborator(
ctx,
repo.GetOwner().GetLogin(),
repo.GetName(),
login,
&github.RepositoryAddCollaboratorOptions{Permission: permission},
)

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: unlike the resourceTypeUser.Id case above (lines 515-566), the invitation path never checks the invitee's current repository access, so it can neither return GrantAlreadyExists nor a GrantReplaced annotation. Since repoAccessLevels are an EntitlementExclusionGroup and AddCollaborator overwrites the outstanding repository invitation's permission, re-granting admin over a pending push invitation leaves the stale push grant live in C1 until the next sync. Consider reusing the same findRepositoryInvitation lookup (added below for Revoke) to compare inv.GetPermissions() against permission and emit GrantAlreadyExists/GrantReplaced accordingly.

Comment on lines +605 to +607
if err != nil {
return nil, wrapGitHubError(err, resp, "github-connector: failed to add invited user to repository")
}

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: the comment just added at lines 585-587 documents the 50-invitations-per-repo-per-24-hours cap, but nothing here acts on it. wrapGitHubError only treats a 403 as retryable when isRatelimited matches (X-RateLimit-Remaining: 0 / a 429), and the invitation-cap 403 sets neither — so it maps to codes.PermissionDenied and a bulk backfill's grants fail permanently instead of being retried. Consider detecting that refusal (403 whose message mentions the invitation limit) and returning codes.ResourceExhausted/Unavailable via uhttp.WrapErrors so the task is retried after the window resets.

Comment on lines +291 to +296
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)),
)

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.

zap.String("github_error", gitHubErrorMessage(err)),
)
} 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.

The field now decides whether the invitation is addressed to a GitHub
account or just an email address, which in turn decides whether teams and
repository access can be pre-staged at all. The old description ("used to
look up the user if email is private") understated that to whoever
configures account provisioning.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
// use. This guard therefore covers the undocumented case: a write refused
// because an org invitation for that user is already outstanding.
func isNotAnOrgMemberError(err error, resp *github.Response) bool {
return isGitHubValidationError(err, resp, "not a member", "isn't a member", "invite them first", "already invited")

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: "already invited" is broad enough to also catch a 422 that means "already invited to this team", not "an org invitation is outstanding". In that case the grant is effectively already fulfilled, but grantToInvitation treats it as unfulfilled: ListOrgInvitationTeams won't list a team attached via the membership endpoint, so with reinvite-pending-invitations on this triggers a destructive cancel-and-recreate of the org invitation (and with it off, a FailedPrecondition instead of GrantAlreadyExists). Consider narrowing to the org-scoped wording (e.g. "already invited to this organization"), and adding a table-driven test for isNotAnOrgMemberError — the two 422 tests in invitation_grants_test.go only exercise the "isn't a member" wording, so this new branch is uncovered. (confidence: medium)

Comment on lines +601 to 609
repo.GetName(),
login,
&github.RepositoryAddCollaboratorOptions{Permission: permission},
)
if err != nil {
return nil, wrapGitHubError(err, resp, "github-connector: failed to add invited user to repository")
}
default:
l.Error(

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: the new comment documents the 50-invites-per-repo-per-24h cap, but the code does nothing with it — wrapGitHubError maps that 403 to codes.PermissionDenied, which reads as a durable permission problem rather than a throttle, so a bulk backfill fails hard instead of being retried later. Consider detecting the cap/abuse message and wrapping with codes.ResourceExhausted so the platform backs off and retries. (confidence: medium)

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

@mstanbCO mstanbCO closed this Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant