diff --git a/config_schema.json b/config_schema.json index ed33673b..885269ab 100644 --- a/config_schema.json +++ b/config_schema.json @@ -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", @@ -191,7 +197,8 @@ "token", "orgs", "omit-archived-repositories", - "direct-collaborators-only" + "direct-collaborators-only", + "reinvite-pending-invitations" ], "default": true }, @@ -205,7 +212,8 @@ "org", "sync-secrets", "omit-archived-repositories", - "direct-collaborators-only" + "direct-collaborators-only", + "reinvite-pending-invitations" ] } ] diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index 5ed99148..78a6cb8f 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -14,6 +14,7 @@ type Github struct { SyncSecrets bool `mapstructure:"sync-secrets"` OmitArchivedRepositories bool `mapstructure:"omit-archived-repositories"` DirectCollaboratorsOnly bool `mapstructure:"direct-collaborators-only"` + ReinvitePendingInvitations bool `mapstructure:"reinvite-pending-invitations"` } func (c *Github) findFieldByTag(tagValue string) (any, bool) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 2b9c5e63..86549b1f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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"), @@ -94,6 +108,7 @@ var Config = field.NewConfiguration( syncSecrets, omitArchivedRepositories, directCollaboratorsOnly, + reinvitePendingInvitations, }, field.WithConnectorDisplayName("GitHub v2"), field.WithHelpUrl("/docs/baton/github-v2"), @@ -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, }, }), diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 2cdb5a62..b9b75437 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -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), @@ -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{}, }, @@ -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 } @@ -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 } diff --git a/pkg/connector/invitation.go b/pkg/connector/invitation.go index fe49b5c6..5ce1b9dc 100644 --- a/pkg/connector/invitation.go +++ b/pkg/connector/invitation.go @@ -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" @@ -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, @@ -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)), + ) + } else { + inviteOpts = &github.CreateOrgInvitationOptions{InviteeID: github.Ptr(invitee.GetID())} + } + } + + 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) diff --git a/pkg/connector/invitation_grants.go b/pkg/connector/invitation_grants.go new file mode 100644 index 00000000..d0937eec --- /dev/null +++ b/pkg/connector/invitation_grants.go @@ -0,0 +1,357 @@ +package connector + +import ( + "context" + "fmt" + "strconv" + "strings" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/session" + "github.com/conductorone/baton-sdk/pkg/types/sessions" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "github.com/google/go-github/v69/github" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" + "google.golang.org/grpc/codes" +) + +// GitHub does not expose an endpoint to fetch a single organization invitation +// by ID, so resolving one means paging the pending-invitations list. This cap +// bounds that walk; orgs onboarding more than this many people at once will log +// a truncation warning rather than page forever. +const maxPendingInvitationPages = 25 + +// GitHub's org role vocabulary for invitations. Only "admin" maps to the org +// admin entitlement; every other role (direct_member, billing_manager, +// hiring_manager, reinstate) is plain membership as far as C1 is concerned. +const ( + invitationRoleDirectMember = "direct_member" + invitationRoleAdmin = "admin" +) + +// isInvitationPrincipal reports whether a provisioning principal is a pending +// org invitation rather than an accepted GitHub user. +func isInvitationPrincipal(principal *v2.Resource) bool { + return principal.GetId().GetResourceType() == resourceTypeInvitation.Id +} + +// invitationNotProvisionableError explains why an invitation principal cannot +// receive the requested access. GitHub keys every post-hoc membership write off a +// username, so an invitation created from an email address alone has nothing to +// write against until the invitee has a GitHub account. +func invitationNotProvisionableError(operation string, inv *github.Invitation) error { + return uhttp.WrapErrors( + codes.FailedPrecondition, + fmt.Sprintf( + "github-connector: cannot %s for invitation %d: GitHub only accepts a username here, and this "+ + "invitation was sent to %q without a resolvable GitHub login. Either invite the user by GitHub "+ + "username (set github_username when creating the account) or turn on "+ + "reinvite-pending-invitations so the connector can re-issue the invitation with the requested "+ + "teams attached", + operation, inv.GetID(), inv.GetEmail(), + ), + ) +} + +// invitationRefusedError reports that GitHub declined a membership write for an +// invitee it *can* name. GitHub documents the team-membership endpoint as +// inviting non-members, but some org configurations refuse it while an +// invitation is already outstanding. +func invitationRefusedError(operation string, inv *github.Invitation, err error) error { + return uhttp.WrapErrors( + codes.FailedPrecondition, + fmt.Sprintf( + "github-connector: GitHub refused to %s for pending invitation %d (%s): %s. Turn on "+ + "reinvite-pending-invitations to let the connector re-issue the invitation with the requested "+ + "teams attached, or wait until the user accepts their org invitation", + operation, inv.GetID(), inv.GetLogin(), gitHubErrorMessage(err), + ), + err, + ) +} + +// resolvePendingInvitation finds a pending org invitation by its numeric ID. +// Returns (nil, nil) when the invitation is no longer pending — it was accepted, +// cancelled, or expired since the last sync. +func resolvePendingInvitation( + ctx context.Context, + client *github.Client, + orgName string, + invitationID int64, +) (*github.Invitation, error) { + l := ctxzap.Extract(ctx) + opts := &github.ListOptions{PerPage: maxPageSize} + for page := 0; page < maxPendingInvitationPages; page++ { + invitations, resp, err := client.Organizations.ListPendingOrgInvitations(ctx, orgName, opts) + if err != nil { + return nil, wrapGitHubError(err, resp, "github-connector: failed to list pending org invitations") + } + for _, inv := range invitations { + if inv.GetID() == invitationID { + return inv, nil + } + } + if resp.NextPage == 0 { + return nil, nil + } + opts.Page = resp.NextPage + } + 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 +} + +// parseInvitationPrincipal resolves an invitation principal to the live GitHub +// invitation it names. It fails rather than returning nil so callers do not have +// to distinguish "not an invitation" from "invitation vanished". +func parseInvitationPrincipal( + ctx context.Context, + client *github.Client, + orgName string, + principal *v2.Resource, +) (*github.Invitation, error) { + invitationID, err := strconv.ParseInt(principal.GetId().GetResource(), 10, 64) + if err != nil { + return nil, fmt.Errorf("github-connector: invalid invitation id %q: %w", principal.GetId().GetResource(), err) + } + + inv, err := resolvePendingInvitation(ctx, client, orgName, invitationID) + if err != nil { + return nil, err + } + if inv == nil { + return nil, uhttp.WrapErrors( + codes.FailedPrecondition, + fmt.Sprintf( + "github-connector: invitation %d is no longer pending in org %s; it was accepted, cancelled, or "+ + "expired. Retry against the accepted user once the next sync has run", + invitationID, orgName, + ), + ) + } + return inv, nil +} + +// invitationTeamIDs returns the IDs of the teams already attached to a pending +// invitation. +func invitationTeamIDs( + ctx context.Context, + client *github.Client, + orgName string, + invitationID int64, +) ([]int64, error) { + var ( + teamIDs []int64 + opts = &github.ListOptions{PerPage: maxPageSize} + ) + for { + teams, resp, err := client.Organizations.ListOrgInvitationTeams(ctx, orgName, strconv.FormatInt(invitationID, 10), opts) + if err != nil { + return nil, wrapGitHubError(err, resp, "github-connector: failed to list invitation teams") + } + for _, team := range teams { + teamIDs = append(teamIDs, team.GetID()) + } + if resp.NextPage == 0 { + return teamIDs, nil + } + opts.Page = resp.NextPage + } +} + +// reinviteWithTeams replaces a pending invitation with an equivalent one carrying +// teamIDs. +// +// GitHub has no endpoint that modifies a pending invitation, and team_ids can +// only be supplied at creation time, so attaching a team to an existing +// email-only invitation requires cancel-then-recreate. That is destructive: the +// original invitation link stops working, the invitee receives a second email, +// the 7-day expiry clock restarts, and the invitation ID changes (so C1 sees the +// old invitation resource disappear and a new one appear on the next sync). +// Because create rejects a duplicate invitation, the cancel must land first; if +// the create then fails, this restores the original team set on a best-effort +// basis before returning. +func reinviteWithTeams( + ctx context.Context, + client *github.Client, + orgName string, + inv *github.Invitation, + teamIDs []int64, +) (*github.Invitation, error) { + l := ctxzap.Extract(ctx) + + opts, err := reinviteOptions(ctx, client, inv, teamIDs) + if err != nil { + return nil, err + } + + originalTeamIDs, err := invitationTeamIDs(ctx, client, orgName, inv.GetID()) + if err != nil { + return nil, err + } + + resp, err := client.Organizations.CancelInvite(ctx, orgName, inv.GetID()) + if err != nil && !isNotFoundError(resp) { + return nil, wrapGitHubError(err, resp, "github-connector: failed to cancel invitation before re-inviting") + } + + newInv, resp, err := client.Organizations.CreateOrgInvitation(ctx, orgName, opts) + if err == nil { + l.Info("github-connector: re-issued org invitation to change its teams", + zap.Int64("old_invitation_id", inv.GetID()), + zap.Int64("new_invitation_id", newInv.GetID()), + zap.Int64s("team_ids", teamIDs), + ) + return newInv, nil + } + + createErr := wrapGitHubError(err, resp, "github-connector: failed to re-issue invitation with updated teams") + + // The cancel already landed, so the invitee currently has no invitation at + // 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) + if restoreOptsErr != nil { + l.Error("github-connector: could not rebuild original invitation after a failed re-invite", + zap.Int64("invitation_id", inv.GetID()), zap.Error(restoreOptsErr)) + return nil, createErr + } + if _, _, restoreErr := client.Organizations.CreateOrgInvitation(ctx, orgName, restoreOpts); restoreErr != nil { + l.Error("github-connector: re-invite failed and the original invitation could not be restored; the user has no pending invitation", + zap.Int64("invitation_id", inv.GetID()), + zap.String("email", inv.GetEmail()), + zap.String("login", inv.GetLogin()), + zap.Error(restoreErr), + ) + } + return nil, createErr +} + +// reinviteOptions rebuilds the create-invitation payload for an existing +// invitation, preserving its invitee and org role. invitee_id is preferred over +// email so the replacement invitation keeps a resolvable GitHub login. +func reinviteOptions( + ctx context.Context, + client *github.Client, + inv *github.Invitation, + teamIDs []int64, +) (*github.CreateOrgInvitationOptions, error) { + role := inv.GetRole() + if role == "" { + role = invitationRoleDirectMember + } + opts := &github.CreateOrgInvitationOptions{ + Role: github.Ptr(role), + TeamID: teamIDs, + } + + if login := inv.GetLogin(); login != "" { + user, resp, err := client.Users.Get(ctx, login) + if err != nil { + return nil, wrapGitHubError(err, resp, fmt.Sprintf("github-connector: failed to resolve invitation login %q", login)) + } + opts.InviteeID = github.Ptr(user.GetID()) + return opts, nil + } + + if email := inv.GetEmail(); email != "" { + opts.Email = github.Ptr(email) + return opts, nil + } + + return nil, fmt.Errorf("github-connector: invitation %d has neither a login nor an email to re-invite", inv.GetID()) +} + +// isNotAnOrgMemberError matches GitHub's refusal to act on a user who has not yet +// accepted their org invitation. +// +// PUT /orgs/{org}/teams/{team}/memberships/{username} is documented to invite +// non-members and leave the membership pending, and the "User isn't a member of +// this organization. Please invite them first." 422 belongs to the deprecated +// PUT /teams/{id}/members/{username} endpoint, which this connector does not +// 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") +} + +// isIdPManagedTeamError matches the rejection GitHub returns for teams whose +// membership is owned by an external identity provider. +func isIdPManagedTeamError(err error, resp *github.Response) bool { + return isGitHubValidationError(err, resp, "team synchronization", "externally managed", "identity provider") +} + +// pendingInvitationLoginsKey is the per-org session key holding the +// lowercased-login-to-invitation-ID index built by pendingInvitationsByLogin. +func pendingInvitationLoginsKey(orgResourceID string) string { + return "pending_invitation_logins:" + orgResourceID +} + +// pendingInvitationsByLogin returns a lowercased-GitHub-login to invitation-ID +// index of the org's pending invitations, cached for the duration of the sync. +// +// Repository invitations identify their invitee by GitHub user, not by org +// invitation, so correlating one back to the invitation resource C1 synced needs +// this index. +func pendingInvitationsByLogin( + ctx context.Context, + client *github.Client, + ss sessions.SessionStore, + orgName string, + orgResourceID string, +) (map[string]int64, error) { + key := pendingInvitationLoginsKey(orgResourceID) + cached, found, err := session.GetJSON[map[string]int64](ctx, ss, key) + 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} + for { + invitations, resp, err := client.Organizations.ListPendingOrgInvitations(ctx, orgName, opts) + if err != nil { + 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") + } + for _, inv := range invitations { + if login := inv.GetLogin(); login != "" { + index[strings.ToLower(login)] = inv.GetID() + } + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + if err := session.SetJSON(ctx, ss, key, index); err != nil { + return nil, fmt.Errorf("baton-github: error caching pending invitation index: %w", err) + } + return index, nil +} + +// invitationResourceID builds the resource ID for a pending invitation so grants +// point at the same resource invitationResourceType.List emits. +func invitationResourceID(invitationID int64) *v2.ResourceId { + return &v2.ResourceId{ + ResourceType: resourceTypeInvitation.Id, + Resource: strconv.FormatInt(invitationID, 10), + } +} diff --git a/pkg/connector/invitation_grants_test.go b/pkg/connector/invitation_grants_test.go new file mode 100644 index 00000000..3cf45b9e --- /dev/null +++ b/pkg/connector/invitation_grants_test.go @@ -0,0 +1,666 @@ +package connector + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "sync" + "testing" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/pagination" + entitlementSdk "github.com/conductorone/baton-sdk/pkg/types/entitlement" + resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/conductorone/baton-sdk/pkg/types/sessions" + "github.com/google/go-github/v69/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/require" + + "github.com/conductorone/baton-github/test/mocks" +) + +const ( + grantsTestOrgID = int64(12) + grantsTestOrgLogin = "test-org-12" + grantsTestTeamID = int64(78) + grantsTestRepoID = int64(34) + grantsTestRepoName = "repository-34" +) + +// memorySessionStore is a minimal in-process SessionStore so tests exercise the +// same caching path a real sync uses. +type memorySessionStore struct { + mu sync.Mutex + data map[string][]byte +} + +func newMemorySessionStore() *memorySessionStore { + return &memorySessionStore{data: map[string][]byte{}} +} + +func (m *memorySessionStore) Get(_ context.Context, key string, _ ...sessions.SessionStoreOption) ([]byte, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + v, ok := m.data[key] + return v, ok, nil +} + +func (m *memorySessionStore) GetMany(_ context.Context, keys []string, _ ...sessions.SessionStoreOption) (map[string][]byte, []string, error) { + m.mu.Lock() + defer m.mu.Unlock() + found := map[string][]byte{} + var missing []string + for _, k := range keys { + if v, ok := m.data[k]; ok { + found[k] = v + } else { + missing = append(missing, k) + } + } + return found, missing, nil +} + +func (m *memorySessionStore) Set(_ context.Context, key string, value []byte, _ ...sessions.SessionStoreOption) error { + m.mu.Lock() + defer m.mu.Unlock() + m.data[key] = value + return nil +} + +func (m *memorySessionStore) SetMany(_ context.Context, values map[string][]byte, _ ...sessions.SessionStoreOption) error { + m.mu.Lock() + defer m.mu.Unlock() + for k, v := range values { + m.data[k] = v + } + return nil +} + +func (m *memorySessionStore) Delete(_ context.Context, key string, _ ...sessions.SessionStoreOption) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.data, key) + return nil +} + +func (m *memorySessionStore) Clear(_ context.Context, _ ...sessions.SessionStoreOption) error { + m.mu.Lock() + defer m.mu.Unlock() + m.data = map[string][]byte{} + return nil +} + +func (m *memorySessionStore) GetAll(_ context.Context, _ string, _ ...sessions.SessionStoreOption) (map[string][]byte, string, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := map[string][]byte{} + for k, v := range m.data { + out[k] = v + } + return out, "", nil +} + +// respondWith registers an endpoint that always marshals payload back. +func respondWith(endpoint mock.EndpointPattern, payload any) mock.MockBackendOption { + return mock.WithRequestMatchHandler(endpoint, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(mock.MustMarshal(payload)) + })) +} + +// pathTail returns the last path segment, which for the membership and +// collaborator endpoints is the username the connector asked GitHub to act on. +func pathTail(p string) string { + parts := strings.Split(strings.TrimSuffix(p, "/"), "/") + return parts[len(parts)-1] +} + +func pathTailInt(p string) int64 { + v, _ := strconv.ParseInt(pathTail(p), 10, 64) + return v +} + +// grantsResourceSyncer is the slice of ResourceSyncerV2 the grant-walking helper +// needs; declared locally so the helper works for org, team, and repository. +type grantsResourceSyncer interface { + Grants(ctx context.Context, resource *v2.Resource, opts resourceSdk.SyncOpAttrs) ([]*v2.Grant, *resourceSdk.SyncOpResults, error) +} + +// collectGrants drives Grants to exhaustion the way the SDK does at runtime, +// feeding each NextPageToken back in. The iteration cap fails loudly rather than +// hanging if a new pagination state ever fails to terminate. +func collectGrants(t *testing.T, ctx context.Context, syncer grantsResourceSyncer, resource *v2.Resource, ss sessions.SessionStore) []*v2.Grant { + t.Helper() + var ( + all []*v2.Grant + token string + ) + for i := 0; i < 50; i++ { + grants, results, err := syncer.Grants(ctx, resource, resourceSdk.SyncOpAttrs{ + PageToken: pagination.Token{Token: token}, + Session: ss, + }) + require.NoError(t, err) + require.NotNil(t, results) + all = append(all, grants...) + if results.NextPageToken == "" { + return all + } + token = results.NextPageToken + } + t.Fatalf("Grants did not terminate within 50 iterations") + return nil +} + +func grantsTestOrgHandler() mock.MockBackendOption { + return respondWith(mocks.GetOrganizationById, github.Organization{ + ID: github.Ptr(grantsTestOrgID), + Login: github.Ptr(grantsTestOrgLogin), + }) +} + +func grantsTestTeamResource(t *testing.T) *v2.Resource { + t.Helper() + team, err := teamResource( + &github.Team{ID: github.Ptr(grantsTestTeamID)}, + grantsTestOrgID, + &v2.ResourceId{ResourceType: resourceTypeOrg.Id, Resource: "12"}, + ) + require.NoError(t, err) + return team +} + +// grantPrincipalKey identifies a grant's principal as ":". +func grantPrincipalKey(g *v2.Grant) string { + return g.GetPrincipal().GetId().GetResourceType() + ":" + g.GetPrincipal().GetId().GetResource() +} + +// grantEntitlementSlug pulls the permission off a grant's entitlement ID, which +// grant.NewGrant formats as "::". +func grantEntitlementSlug(g *v2.Grant) string { + return pathTailAfter(g.GetEntitlement().GetId(), ":") +} + +func pathTailAfter(s, sep string) string { + parts := strings.Split(s, sep) + return parts[len(parts)-1] +} + +func grantIDs(grants []*v2.Grant) map[string]string { + out := map[string]string{} + for _, g := range grants { + out[grantPrincipalKey(g)] = grantEntitlementSlug(g) + } + return out +} + +func TestTeamPendingInvitationGrants(t *testing.T) { + ctx := context.Background() + team := grantsTestTeamResource(t) + + client := mock.NewMockedHTTPClient( + grantsTestOrgHandler(), + // No accepted members; both role passes come back empty. + respondWith(mocks.GetOrganizationsTeamsMembersByTeamId, []github.User{}), + respondWith(mocks.GetOrganizationsTeamsInvitationsByTeamId, []*github.Invitation{ + { + ID: github.Ptr(int64(1001)), + Login: github.Ptr("alice"), + Email: github.Ptr("alice@example.com"), + Role: github.Ptr(invitationRoleDirectMember), + }, + { + // Email-only invitation: GitHub never resolved an account, so the + // team role cannot be looked up and must default to member. + ID: github.Ptr(int64(1002)), + Email: github.Ptr("bob@example.com"), + Role: github.Ptr(invitationRoleDirectMember), + }, + }), + respondWith(mocks.GetOrganizationsTeamsMembershipsByTeamIdByUsername, github.Membership{ + Role: github.Ptr(teamRoleMaintainer), + State: github.Ptr("pending"), + }), + ) + + builder := TeamBuilder(github.NewClient(client), newOrgNameCache(github.NewClient(client)), false, false) + grants := collectGrants(t, ctx, builder, team, newMemorySessionStore()) + + require.Len(t, grants, 2) + require.Equal(t, map[string]string{ + "invitation:1001": teamRoleMaintainer, + "invitation:1002": teamRoleMember, + }, grantIDs(grants)) +} + +func TestTeamGrantToInvitation(t *testing.T) { + ctx := context.Background() + team := grantsTestTeamResource(t) + en := &v2.Entitlement{ + Id: entitlementSdk.NewEntitlementID(team, teamRoleMember), + Slug: teamRoleMember, + Resource: team, + } + invitationPrincipal := func(id string) *v2.Resource { + return &v2.Resource{Id: &v2.ResourceId{ResourceType: resourceTypeInvitation.Id, Resource: id}} + } + + namedInvitation := []*github.Invitation{{ + ID: github.Ptr(int64(1001)), + Login: github.Ptr("alice"), + Email: github.Ptr("alice@example.com"), + Role: github.Ptr(invitationRoleDirectMember), + }} + emailOnlyInvitation := []*github.Invitation{{ + ID: github.Ptr(int64(2001)), + Email: github.Ptr("bob@example.com"), + Role: github.Ptr(invitationRoleDirectMember), + }} + + t.Run("named invitee is added to the team directly", func(t *testing.T) { + var addedUsername string + client := mock.NewMockedHTTPClient( + grantsTestOrgHandler(), + respondWith(mock.GetOrgsInvitationsByOrg, namedInvitation), + mock.WithRequestMatchHandler( + mocks.PutOrganizationsTeamsMembershipsByOrganizationByTeamIdByUsername, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + addedUsername = pathTail(r.URL.Path) + _, _ = w.Write(mock.MustMarshal(github.Membership{ + Role: github.Ptr(teamRoleMember), + State: github.Ptr("pending"), + })) + }), + ), + ) + + gh := github.NewClient(client) + builder := TeamBuilder(gh, newOrgNameCache(gh), false, false) + + annos, err := builder.Grant(ctx, invitationPrincipal("1001"), en) + require.NoError(t, err) + require.Empty(t, annos) + require.Equal(t, "alice", addedUsername, "the pending invitee's login must be what GitHub is asked to add") + }) + + t.Run("email-only invitee is refused when re-inviting is off", func(t *testing.T) { + client := mock.NewMockedHTTPClient( + grantsTestOrgHandler(), + respondWith(mock.GetOrgsInvitationsByOrg, emailOnlyInvitation), + respondWith(mock.GetOrgsInvitationsTeamsByOrgByInvitationId, []*github.Team{}), + ) + + gh := github.NewClient(client) + builder := TeamBuilder(gh, newOrgNameCache(gh), false, false) + + _, err := builder.Grant(ctx, invitationPrincipal("2001"), en) + require.Error(t, err) + require.Contains(t, err.Error(), "github_username") + require.Contains(t, err.Error(), "reinvite") + }) + + t.Run("email-only invitee is re-invited with the team when enabled", func(t *testing.T) { + var ( + cancelledID int64 + createBody github.CreateOrgInvitationOptions + ) + client := mock.NewMockedHTTPClient( + grantsTestOrgHandler(), + respondWith(mock.GetOrgsInvitationsByOrg, emailOnlyInvitation), + // The invitation already carries one team, which must survive. + respondWith(mock.GetOrgsInvitationsTeamsByOrgByInvitationId, []*github.Team{ + {ID: github.Ptr(int64(99))}, + }), + mock.WithRequestMatchHandler( + mock.DeleteOrgsInvitationsByOrgByInvitationId, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cancelledID = pathTailInt(r.URL.Path) + w.WriteHeader(http.StatusNoContent) + }), + ), + mock.WithRequestMatchHandler( + mock.PostOrgsInvitationsByOrg, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &createBody) + _, _ = w.Write(mock.MustMarshal(github.Invitation{ + ID: github.Ptr(int64(2002)), + Email: github.Ptr("bob@example.com"), + })) + }), + ), + ) + + gh := github.NewClient(client) + builder := TeamBuilder(gh, newOrgNameCache(gh), false, true) + + _, err := builder.Grant(ctx, invitationPrincipal("2001"), en) + require.NoError(t, err) + require.Equal(t, int64(2001), cancelledID, "the original invitation must be cancelled first") + require.Equal(t, "bob@example.com", createBody.GetEmail()) + require.Equal(t, invitationRoleDirectMember, createBody.GetRole()) + require.ElementsMatch(t, []int64{99, grantsTestTeamID}, createBody.TeamID, + "re-issuing must preserve the teams the invitation already had") + }) + + t.Run("already-attached team reports the grant as existing", func(t *testing.T) { + client := mock.NewMockedHTTPClient( + grantsTestOrgHandler(), + respondWith(mock.GetOrgsInvitationsByOrg, emailOnlyInvitation), + respondWith(mock.GetOrgsInvitationsTeamsByOrgByInvitationId, []*github.Team{ + {ID: github.Ptr(grantsTestTeamID)}, + }), + ) + + gh := github.NewClient(client) + builder := TeamBuilder(gh, newOrgNameCache(gh), false, false) + + annos, err := builder.Grant(ctx, invitationPrincipal("2001"), en) + require.NoError(t, err) + require.True(t, annos.Contains(&v2.GrantAlreadyExists{})) + }) + + t.Run("named invitee GitHub refuses reports the refusal, not a missing login", func(t *testing.T) { + client := mock.NewMockedHTTPClient( + grantsTestOrgHandler(), + respondWith(mock.GetOrgsInvitationsByOrg, namedInvitation), + respondWith(mock.GetOrgsInvitationsTeamsByOrgByInvitationId, []*github.Team{}), + mock.WithRequestMatchHandler( + mocks.PutOrganizationsTeamsMembershipsByOrganizationByTeamIdByUsername, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte( + `{"message":"Validation Failed","errors":[{"message":"User isn't a member of this organization. Please invite them first."}]}`)) + }), + ), + ) + + gh := github.NewClient(client) + builder := TeamBuilder(gh, newOrgNameCache(gh), false, false) + + _, err := builder.Grant(ctx, invitationPrincipal("1001"), en) + require.Error(t, err) + require.Contains(t, err.Error(), "GitHub refused") + require.NotContains(t, err.Error(), "without a resolvable GitHub login") + }) + + t.Run("named invitee GitHub refuses is re-invited when enabled", func(t *testing.T) { + var createBody github.CreateOrgInvitationOptions + client := mock.NewMockedHTTPClient( + grantsTestOrgHandler(), + respondWith(mock.GetOrgsInvitationsByOrg, namedInvitation), + respondWith(mock.GetOrgsInvitationsTeamsByOrgByInvitationId, []*github.Team{}), + respondWith(mock.GetUsersByUsername, github.User{ + ID: github.Ptr(int64(4242)), + Login: github.Ptr("alice"), + }), + mock.WithRequestMatchHandler( + mocks.PutOrganizationsTeamsMembershipsByOrganizationByTeamIdByUsername, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte( + `{"message":"Validation Failed","errors":[{"message":"User isn't a member of this organization. Please invite them first."}]}`)) + }), + ), + mock.WithRequestMatchHandler( + mock.DeleteOrgsInvitationsByOrgByInvitationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + mock.WithRequestMatchHandler( + mock.PostOrgsInvitationsByOrg, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &createBody) + _, _ = w.Write(mock.MustMarshal(github.Invitation{ID: github.Ptr(int64(1005))})) + }), + ), + ) + + gh := github.NewClient(client) + builder := TeamBuilder(gh, newOrgNameCache(gh), false, true) + + _, err := builder.Grant(ctx, invitationPrincipal("1001"), en) + require.NoError(t, err) + // A known login is re-invited by invitee_id, so the replacement invitation + // keeps a resolvable GitHub account. + require.Equal(t, int64(4242), createBody.GetInviteeID()) + require.Empty(t, createBody.GetEmail()) + require.ElementsMatch(t, []int64{grantsTestTeamID}, createBody.TeamID) + }) + + t.Run("invitation that is no longer pending fails with a retry hint", func(t *testing.T) { + client := mock.NewMockedHTTPClient( + grantsTestOrgHandler(), + respondWith(mock.GetOrgsInvitationsByOrg, []*github.Invitation{}), + ) + + gh := github.NewClient(client) + builder := TeamBuilder(gh, newOrgNameCache(gh), false, false) + + _, err := builder.Grant(ctx, invitationPrincipal("9999"), en) + require.Error(t, err) + require.Contains(t, err.Error(), "no longer pending") + }) +} + +func TestOrgPendingInvitationGrants(t *testing.T) { + ctx := context.Background() + + org, err := organizationResource(ctx, &github.Organization{ + ID: github.Ptr(grantsTestOrgID), + Login: github.Ptr(grantsTestOrgLogin), + }, nil, false) + require.NoError(t, err) + + client := mock.NewMockedHTTPClient( + grantsTestOrgHandler(), + respondWith(mock.GetOrgsByOrg, github.Organization{ + ID: github.Ptr(grantsTestOrgID), + Login: github.Ptr(grantsTestOrgLogin), + }), + // No accepted members; both role passes come back empty. + respondWith(mock.GetOrgsMembersByOrg, []github.User{}), + respondWith(mock.GetOrgsInvitationsByOrg, []*github.Invitation{ + {ID: github.Ptr(int64(1001)), Login: github.Ptr("alice"), Role: github.Ptr(invitationRoleAdmin)}, + {ID: github.Ptr(int64(1002)), Email: github.Ptr("bob@example.com"), Role: github.Ptr(invitationRoleDirectMember)}, + {ID: github.Ptr(int64(1003)), Email: github.Ptr("carol@example.com"), Role: github.Ptr("billing_manager")}, + }), + ) + + gh := github.NewClient(client) + builder := OrgBuilder(gh, nil, newOrgNameCache(gh), nil, false, false) + grants := collectGrants(t, ctx, builder, org, newMemorySessionStore()) + + // The admin invitation emits admin + member, mirroring the accepted-member + // pass; every other invitation role is plain membership. + byPrincipal := map[string][]string{} + for _, g := range grants { + byPrincipal[grantPrincipalKey(g)] = append(byPrincipal[grantPrincipalKey(g)], grantEntitlementSlug(g)) + } + require.Len(t, byPrincipal, 3) + require.ElementsMatch(t, []string{orgRoleAdmin, orgRoleMember}, byPrincipal["invitation:1001"]) + require.Equal(t, []string{orgRoleMember}, byPrincipal["invitation:1002"]) + require.Equal(t, []string{orgRoleMember}, byPrincipal["invitation:1003"]) +} + +func TestRepositoryPendingInvitationGrants(t *testing.T) { + ctx := context.Background() + + repo, err := repositoryResource(ctx, &github.Repository{ + ID: github.Ptr(grantsTestRepoID), + Name: github.Ptr(grantsTestRepoName), + }, &v2.ResourceId{ResourceType: resourceTypeOrg.Id, Resource: "12"}) + require.NoError(t, err) + + client := mock.NewMockedHTTPClient( + grantsTestOrgHandler(), + respondWith(mock.GetReposCollaboratorsByOwnerByRepo, []github.User{}), + respondWith(mock.GetReposTeamsByOwnerByRepo, []github.Team{}), + respondWith(mock.GetReposInvitationsByOwnerByRepo, []*github.RepositoryInvitation{ + { + ID: github.Ptr(int64(555)), + Invitee: &github.User{Login: github.Ptr("Alice")}, + Permissions: github.Ptr("write"), + }, + { + // No pending org invitation: a direct outside-collaborator invite + // has no invitation resource in the sync to hang a grant on. + ID: github.Ptr(int64(556)), + Invitee: &github.User{Login: github.Ptr("outsider")}, + Permissions: github.Ptr(readConst), + }, + { + // Expired invitations confer nothing. + ID: github.Ptr(int64(557)), + Invitee: &github.User{Login: github.Ptr("dave")}, + Permissions: github.Ptr("admin"), + Expired: github.Ptr(true), + }, + }), + respondWith(mock.GetOrgsInvitationsByOrg, []*github.Invitation{ + {ID: github.Ptr(int64(1001)), Login: github.Ptr("alice")}, + {ID: github.Ptr(int64(1004)), Login: github.Ptr("dave")}, + }), + ) + + gh := github.NewClient(client) + builder := RepositoryBuilder(gh, newOrgNameCache(gh), false, false) + grants := collectGrants(t, ctx, builder, repo, newMemorySessionStore()) + + // "write" maps onto the repository entitlement vocabulary as "push", and the + // invitee login is matched case-insensitively. + require.Equal(t, map[string]string{"invitation:1001": repoPermissionPush}, grantIDs(grants)) +} + +func TestRepositoryGrantToInvitation(t *testing.T) { + ctx := context.Background() + + repo, err := repositoryResource(ctx, &github.Repository{ + ID: github.Ptr(grantsTestRepoID), + Name: github.Ptr(grantsTestRepoName), + }, &v2.ResourceId{ResourceType: resourceTypeOrg.Id, Resource: "12"}) + require.NoError(t, err) + + en := &v2.Entitlement{ + Id: entitlementSdk.NewEntitlementID(repo, repoPermissionPush), + Slug: repoPermissionPush, + Resource: repo, + } + principal := &v2.Resource{Id: &v2.ResourceId{ResourceType: resourceTypeInvitation.Id, Resource: "1001"}} + + repoByIDHandler := respondWith(mocks.GetRepositoryById, github.Repository{ + ID: github.Ptr(grantsTestRepoID), + Name: github.Ptr(grantsTestRepoName), + Owner: &github.User{Login: github.Ptr(grantsTestOrgLogin)}, + Organization: &github.Organization{ + ID: github.Ptr(grantsTestOrgID), + Login: github.Ptr(grantsTestOrgLogin), + }, + }) + + t.Run("named invitee is invited to the repository", func(t *testing.T) { + var invitedUsername string + client := mock.NewMockedHTTPClient( + repoByIDHandler, + respondWith(mock.GetOrgsInvitationsByOrg, []*github.Invitation{ + {ID: github.Ptr(int64(1001)), Login: github.Ptr("alice"), Email: github.Ptr("alice@example.com")}, + }), + mock.WithRequestMatchHandler( + mock.PutReposCollaboratorsByOwnerByRepoByUsername, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + invitedUsername = pathTail(r.URL.Path) + _, _ = w.Write(mock.MustMarshal(github.RepositoryInvitation{ID: github.Ptr(int64(555))})) + }), + ), + ) + + gh := github.NewClient(client) + builder := RepositoryBuilder(gh, newOrgNameCache(gh), false, false) + + _, err := builder.Grant(ctx, principal, en) + require.NoError(t, err) + require.Equal(t, "alice", invitedUsername) + }) + + t.Run("email-only invitee cannot be given repository access", func(t *testing.T) { + client := mock.NewMockedHTTPClient( + repoByIDHandler, + respondWith(mock.GetOrgsInvitationsByOrg, []*github.Invitation{ + {ID: github.Ptr(int64(1001)), Email: github.Ptr("bob@example.com")}, + }), + ) + + gh := github.NewClient(client) + builder := RepositoryBuilder(gh, newOrgNameCache(gh), false, false) + + _, err := builder.Grant(ctx, principal, en) + require.Error(t, err) + require.Contains(t, err.Error(), "repository") + }) +} + +func TestReinviteRestoresOriginalOnCreateFailure(t *testing.T) { + ctx := context.Background() + + var createBodies []github.CreateOrgInvitationOptions + client := mock.NewMockedHTTPClient( + respondWith(mock.GetOrgsInvitationsTeamsByOrgByInvitationId, []*github.Team{ + {ID: github.Ptr(int64(99))}, + }), + mock.WithRequestMatchHandler( + mock.DeleteOrgsInvitationsByOrgByInvitationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + mock.WithRequestMatchHandler( + mock.PostOrgsInvitationsByOrg, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var opts github.CreateOrgInvitationOptions + _ = json.Unmarshal(body, &opts) + createBodies = append(createBodies, opts) + + // Fail the first create (the one carrying the new team) and let + // the restore of the original team set succeed. + if len(createBodies) == 1 { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"Validation Failed"}`)) + return + } + _, _ = w.Write(mock.MustMarshal(github.Invitation{ID: github.Ptr(int64(3001))})) + }), + ), + ) + + inv := &github.Invitation{ + ID: github.Ptr(int64(2001)), + Email: github.Ptr("bob@example.com"), + Role: github.Ptr(invitationRoleDirectMember), + } + + _, err := reinviteWithTeams(ctx, github.NewClient(client), grantsTestOrgLogin, inv, []int64{99, grantsTestTeamID}) + require.Error(t, err) + require.Len(t, createBodies, 2, "a failed re-invite must attempt to restore the original invitation") + require.ElementsMatch(t, []int64{99, grantsTestTeamID}, createBodies[0].TeamID) + require.ElementsMatch(t, []int64{99}, createBodies[1].TeamID, + "the restore must put back exactly the teams the invitation started with") +} + +func TestOrgRoleGrantRejectsInvitation(t *testing.T) { + ctx := context.Background() + + gh := github.NewClient(mock.NewMockedHTTPClient()) + builder := OrgRoleBuilder(gh, newOrgNameCache(gh)) + + _, err := builder.Grant(ctx, + &v2.Resource{Id: &v2.ResourceId{ResourceType: resourceTypeInvitation.Id, Resource: "1001"}}, + &v2.Entitlement{Id: "org_role:1:assigned", Resource: &v2.Resource{Id: &v2.ResourceId{Resource: "1"}}}, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "accepted their org invitation") +} diff --git a/pkg/connector/org.go b/pkg/connector/org.go index 4d17748d..6a9c8280 100644 --- a/pkg/connector/org.go +++ b/pkg/connector/org.go @@ -25,6 +25,10 @@ const ( orgRoleMember = "member" orgRoleDirectMember = "direct_member" // invite orgRoleAdmin = "admin" + + // Pagination bag state for the org's pending invitations. Namespaced so it + // cannot collide with the org role states, which double as bag states. + orgStateInvitations = "org:invitations" ) var orgAccessLevels = []string{ @@ -33,12 +37,13 @@ var orgAccessLevels = []string{ } type orgResourceType struct { - resourceType *v2.ResourceType - client *github.Client - appClient *github.Client - orgs map[string]struct{} - orgCache *orgNameCache - syncSecrets bool + resourceType *v2.ResourceType + client *github.Client + appClient *github.Client + orgs map[string]struct{} + orgCache *orgNameCache + syncSecrets bool + reinviteForGrants bool } func organizationResource( @@ -166,12 +171,12 @@ func (o *orgResourceType) StaticEntitlements( rv = append(rv, entitlement.NewAssignmentEntitlement(nil, orgRoleMember, entitlement.WithDisplayName("Org Member"), entitlement.WithDescription("Access to org in GitHub as member"), - entitlement.WithGrantableTo(resourceTypeUser), + entitlement.WithGrantableTo(resourceTypeUser, resourceTypeInvitation), )) rv = append(rv, entitlement.NewPermissionEntitlement(nil, orgRoleAdmin, entitlement.WithDisplayName("Org Admin"), entitlement.WithDescription("Access to org in GitHub as admin"), - entitlement.WithGrantableTo(resourceTypeUser), + entitlement.WithGrantableTo(resourceTypeUser, resourceTypeInvitation), )) return rv, &resourceSdk.SyncOpResults{}, nil @@ -202,12 +207,32 @@ func (o *orgResourceType) Grants( switch rId := bag.ResourceTypeID(); rId { case resourceTypeOrg.Id: bag.Pop() + // Pushed first so it drains last, after both accepted-member states. + bag.Push(pagination.PageState{ + ResourceTypeID: orgStateInvitations, + }) bag.Push(pagination.PageState{ ResourceTypeID: orgRoleAdmin, }) bag.Push(pagination.PageState{ ResourceTypeID: orgRoleMember, }) + case orgStateInvitations: + orgName, err := o.orgCache.GetOrgName(ctx, opts.Session, resource.Id) + if err != nil { + return nil, nil, err + } + + invitationGrants, nextPage, annos, err := o.pendingInvitationGrants(ctx, resource, orgName, page) + if err != nil { + return nil, nil, err + } + reqAnnos = annos + rv = append(rv, invitationGrants...) + + if err := bag.Next(nextPage); err != nil { + return nil, nil, err + } case orgRoleAdmin, orgRoleMember: orgName, err := o.orgCache.GetOrgName(ctx, opts.Session, resource.Id) @@ -267,16 +292,71 @@ func (o *orgResourceType) Grants( }, nil } +// pendingInvitationGrants emits org membership grants for people who have been +// invited but have not accepted yet, so a pending invitee shows up in C1 with the +// membership their invitation already carries. +// +// Admin invitations emit both grants, mirroring the accepted-member pass where +// admin implies membership. Every other invitation role (direct_member, +// billing_manager, hiring_manager, reinstate) maps to plain membership. +func (o *orgResourceType) pendingInvitationGrants( + ctx context.Context, + resource *v2.Resource, + orgName string, + page int, +) ([]*v2.Grant, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + listOpts := &github.ListOptions{Page: page, PerPage: maxPageSize} + invitations, resp, err := o.client.Organizations.ListPendingOrgInvitations(ctx, orgName, listOpts) + if err != nil { + if isNotFoundError(resp) || isPermissionError(resp) { + l.Debug("github-connector: cannot list pending org invitations, skipping", + zap.String("org", orgName), + zap.String("github_error", gitHubErrorMessage(err)), + ) + return nil, "", nil, nil + } + return nil, "", nil, wrapGitHubError(err, resp, "github-connector: failed to list pending org invitations") + } + + nextPage, reqAnnos, err := parseResp(resp) + if err != nil { + return nil, "", nil, err + } + + rv := make([]*v2.Grant, 0, len(invitations)) + for _, inv := range invitations { + principalID := invitationResourceID(inv.GetID()) + if inv.GetRole() == invitationRoleAdmin { + rv = append(rv, o.invitationGrant(orgRoleAdmin, resource, principalID, inv.GetID())) + } + rv = append(rv, o.invitationGrant(orgRoleMember, resource, principalID, inv.GetID())) + } + + return rv, nextPage, reqAnnos, nil +} + +func (o *orgResourceType) invitationGrant(roleName string, org *v2.Resource, principalID *v2.ResourceId, invitationID int64) *v2.Grant { + return grant.NewGrant(org, roleName, principalID, grant.WithAnnotation(&v2.V1Identifier{ + Id: fmt.Sprintf("org-invitation-grant:%s:%d:%s", org.Id.Resource, invitationID, roleName), + })) +} + func (o *orgResourceType) Grant(ctx context.Context, principal *v2.Resource, en *v2.Entitlement) (annotations.Annotations, error) { l := ctxzap.Extract(ctx) - if principal.Id.ResourceType != resourceTypeUser.Id { + if principal.Id.ResourceType != resourceTypeUser.Id && !isInvitationPrincipal(principal) { l.Error( - "github-connectorv2: only users can be granted org admin", + "github-connectorv2: only users and invitations can be granted org membership", zap.String("principal_type", principal.Id.ResourceType), zap.String("principal_id", principal.Id.Resource), ) - return nil, fmt.Errorf("github-connectorv2: only users can be granted org membership") + return nil, fmt.Errorf("github-connectorv2: only users and invitations can be granted org membership") + } + + if isInvitationPrincipal(principal) { + return o.grantToInvitation(ctx, principal, en) } adminRoleID := entitlement.NewEntitlementID(en.Resource, orgRoleAdmin) @@ -352,19 +432,71 @@ func (o *orgResourceType) Grant(ctx context.Context, principal *v2.Resource, en return nil, nil } +// grantToInvitation reconciles an org membership grant against an invitation +// that is already outstanding. +// +// The invitation itself IS the pending org membership, so a membership grant is +// already satisfied. Only an admin grant against a non-admin invitation needs +// real work, and GitHub cannot change a pending invitation's role in place — that +// takes re-issuing the invitation. +func (o *orgResourceType) grantToInvitation(ctx context.Context, principal *v2.Resource, en *v2.Entitlement) (annotations.Annotations, error) { + adminRoleID := entitlement.NewEntitlementID(en.Resource, orgRoleAdmin) + memberRoleID := entitlement.NewEntitlementID(en.Resource, orgRoleMember) + + var requestedRole string + switch en.Id { + case adminRoleID: + requestedRole = invitationRoleAdmin + case memberRoleID: + requestedRole = invitationRoleDirectMember + default: + return nil, fmt.Errorf("github-connectorv2: invalid entitlement id: %s", en.Id) + } + + orgName, err := o.orgCache.GetOrgNameFromRemoteServer(ctx, en.Resource.Id.GetResource()) + if err != nil { + return nil, err + } + + inv, err := parseInvitationPrincipal(ctx, o.client, orgName, principal) + if err != nil { + return nil, err + } + + if requestedRole == invitationRoleDirectMember || inv.GetRole() == invitationRoleAdmin { + return annotations.New(&v2.GrantAlreadyExists{}), nil + } + + if !o.reinviteForGrants { + return nil, invitationNotProvisionableError("promote invitation to org admin", inv) + } + + teamIDs, err := invitationTeamIDs(ctx, o.client, orgName, inv.GetID()) + if err != nil { + return nil, err + } + + upgraded := *inv + upgraded.Role = github.Ptr(invitationRoleAdmin) + if _, err := reinviteWithTeams(ctx, o.client, orgName, &upgraded, teamIDs); err != nil { + return nil, err + } + return nil, nil +} + func (o *orgResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) { l := ctxzap.Extract(ctx) en := grant.Entitlement principal := grant.Principal - if principal.Id.ResourceType != resourceTypeUser.Id { + if principal.Id.ResourceType != resourceTypeUser.Id && !isInvitationPrincipal(principal) { l.Error( - "github-connectorv2: org admin can only be revoked from users", + "github-connectorv2: org membership can only be revoked from users and invitations", zap.String("principal_type", principal.Id.ResourceType), zap.String("principal_id", principal.Id.Resource), ) - return nil, fmt.Errorf("github-connectorv2: org admin can only be revoked from users") + return nil, fmt.Errorf("github-connectorv2: org membership can only be revoked from users and invitations") } adminRoleID := entitlement.NewEntitlementID(en.Resource, orgRoleAdmin) @@ -379,6 +511,10 @@ func (o *orgResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annotati return nil, err } + if isInvitationPrincipal(principal) { + return o.revokeFromInvitation(ctx, principal, orgName, en.Id == memberRoleID) + } + principalID, err := strconv.ParseInt(principal.Id.Resource, 10, 64) if err != nil { return nil, err @@ -414,7 +550,58 @@ func (o *orgResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annotati return nil, nil } -func OrgBuilder(client, appClient *github.Client, orgCache *orgNameCache, orgs []string, syncSecrets bool) *orgResourceType { +// revokeFromInvitation withdraws pending org membership. Cancelling the +// invitation is the only way to take back membership that has not been accepted; +// demoting an admin invitation to plain membership needs the invitation re-issued. +func (o *orgResourceType) revokeFromInvitation( + ctx context.Context, + principal *v2.Resource, + orgName string, + isMembershipRevoke bool, +) (annotations.Annotations, error) { + invitationID, err := strconv.ParseInt(principal.GetId().GetResource(), 10, 64) + if err != nil { + return nil, fmt.Errorf("github-connector: invalid invitation id %q: %w", principal.GetId().GetResource(), err) + } + + inv, err := resolvePendingInvitation(ctx, o.client, orgName, invitationID) + if err != nil { + return nil, err + } + if inv == nil { + return annotations.New(&v2.GrantAlreadyRevoked{}), nil + } + + if isMembershipRevoke { + resp, err := o.client.Organizations.CancelInvite(ctx, orgName, invitationID) + if err != nil && !isNotFoundError(resp) { + return nil, wrapGitHubError(err, resp, "github-connector: failed to cancel org invitation") + } + return nil, nil + } + + if inv.GetRole() != invitationRoleAdmin { + return annotations.New(&v2.GrantAlreadyRevoked{}), nil + } + + if !o.reinviteForGrants { + return nil, invitationNotProvisionableError("demote invitation from org admin", inv) + } + + teamIDs, err := invitationTeamIDs(ctx, o.client, orgName, inv.GetID()) + if err != nil { + return nil, err + } + + demoted := *inv + demoted.Role = github.Ptr(invitationRoleDirectMember) + if _, err := reinviteWithTeams(ctx, o.client, orgName, &demoted, teamIDs); err != nil { + return nil, err + } + return nil, nil +} + +func OrgBuilder(client, appClient *github.Client, orgCache *orgNameCache, orgs []string, syncSecrets bool, reinviteForGrants bool) *orgResourceType { orgMap := make(map[string]struct{}) for _, o := range orgs { @@ -422,12 +609,13 @@ func OrgBuilder(client, appClient *github.Client, orgCache *orgNameCache, orgs [ } return &orgResourceType{ - resourceType: resourceTypeOrg, - orgs: orgMap, - client: client, - appClient: appClient, - orgCache: orgCache, - syncSecrets: syncSecrets, + resourceType: resourceTypeOrg, + orgs: orgMap, + client: client, + appClient: appClient, + orgCache: orgCache, + syncSecrets: syncSecrets, + reinviteForGrants: reinviteForGrants, } } diff --git a/pkg/connector/org_role.go b/pkg/connector/org_role.go index 32942686..0262204a 100644 --- a/pkg/connector/org_role.go +++ b/pkg/connector/org_role.go @@ -13,9 +13,11 @@ import ( "github.com/conductorone/baton-sdk/pkg/types/entitlement" "github.com/conductorone/baton-sdk/pkg/types/grant" resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/conductorone/baton-sdk/pkg/uhttp" "github.com/google/go-github/v69/github" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" + "google.golang.org/grpc/codes" ) type OrganizationRole struct { @@ -309,6 +311,14 @@ func (o *orgRoleResourceType) Grant(ctx context.Context, principal *v2.Resource, zap.String("principal_type", principal.Id.ResourceType), zap.String("principal_id", principal.Id.Resource), ) + // Unlike teams and repositories, GitHub's organization-role endpoints + // take a user ID and require active org membership, so there is nothing + // to pre-stage against a pending invitation. + if isInvitationPrincipal(principal) { + return nil, uhttp.WrapErrors(codes.FailedPrecondition, + "github-connector: organization roles cannot be assigned to a pending invitation; GitHub requires the "+ + "user to have accepted their org invitation first") + } return nil, fmt.Errorf("github-connector: only users can be granted organization roles") } diff --git a/pkg/connector/org_test.go b/pkg/connector/org_test.go index de32fe23..1ec35bb0 100644 --- a/pkg/connector/org_test.go +++ b/pkg/connector/org_test.go @@ -24,7 +24,7 @@ func TestOrganization(t *testing.T) { githubClient := github.NewClient(mgh.Server()) cache := newOrgNameCache(githubClient) - client := OrgBuilder(githubClient, nil, cache, nil, false) + client := OrgBuilder(githubClient, nil, cache, nil, false, false) organization, _ := organizationResource(ctx, githubOrganization, nil, false) user, _ := userResource(ctx, githubUser, *githubUser.Email, nil) @@ -44,7 +44,7 @@ func TestOrganization(t *testing.T) { }) require.Nil(t, err) test.AssertHasRatelimitAnnotations(t, results.Annotations) - require.Equal(t, "{\"states\":[{\"type\":\"admin\"}],\"current_state\":{\"type\":\"member\"}}", results.NextPageToken) + require.Equal(t, "{\"states\":[{\"type\":\"org:invitations\"},{\"type\":\"admin\"}],\"current_state\":{\"type\":\"member\"}}", results.NextPageToken) grant := v2.Grant{ Entitlement: &entitlement, diff --git a/pkg/connector/repository.go b/pkg/connector/repository.go index 85762ef7..f06bd6a1 100644 --- a/pkg/connector/repository.go +++ b/pkg/connector/repository.go @@ -33,6 +33,11 @@ const ( const readConst = "read" +// Pagination bag state for the repository's outstanding collaborator +// invitations. Namespaced so it cannot collide with the resource-type IDs that +// double as bag states. +const repoStateInvitations = "repository:invitations" + var repoAccessLevels = []string{ repoPermissionPull, repoPermissionTriage, @@ -152,7 +157,7 @@ func (o *repositoryResourceType) StaticEntitlements(_ context.Context, _ resourc level, entitlement.WithDisplayName(fmt.Sprintf("Repo %s", titleCase(level))), entitlement.WithDescription(fmt.Sprintf("Access to repository in GitHub as %s", level)), - entitlement.WithGrantableTo(resourceTypeUser, resourceTypeTeam), + entitlement.WithGrantableTo(resourceTypeUser, resourceTypeTeam, resourceTypeInvitation), entitlement.WithAnnotation(&v2.EntitlementExclusionGroup{ ExclusionGroupId: "repository", Order: uint32(i), @@ -187,6 +192,11 @@ func (o *repositoryResourceType) Grants( switch bag.ResourceTypeID() { case resourceTypeRepository.Id: bag.Pop() + // Pushed first so it drains last, after the accepted collaborators and + // teams. + bag.Push(pagination.PageState{ + ResourceTypeID: repoStateInvitations, + }) bag.Push(pagination.PageState{ ResourceTypeID: resourceTypeUser.Id, }) @@ -231,6 +241,18 @@ func (o *repositoryResourceType) Grants( } } + case repoStateInvitations: + invitationGrants, nextPage, respAnnos, err := o.pendingInvitationGrants(ctx, resource, opts.Session, orgName, page) + if err != nil { + return nil, nil, err + } + reqAnnos = respAnnos + rv = append(rv, invitationGrants...) + + if err := bag.Next(nextPage); err != nil { + return nil, nil, err + } + case resourceTypeUser.Id: affiliation := "all" if o.directCollaboratorsOnly { @@ -376,6 +398,87 @@ func (o *repositoryResourceType) Grants( }, nil } +// pendingInvitationGrants emits repository grants for outstanding collaborator +// invitations, so access C1 pre-staged for someone who has not accepted their org +// invitation yet shows up alongside grants for accepted collaborators. +// +// A repository invitation names a GitHub user, not an org invitation, and a +// user who is not an org member is not part of the synced user set — so the +// invitee is correlated back to the pending org invitation that C1 did sync. +// Repository invitations for people with no pending org invitation (direct +// outside-collaborator invites) have no principal to attach to and are skipped. +func (o *repositoryResourceType) pendingInvitationGrants( + ctx context.Context, + resource *v2.Resource, + ss sessions.SessionStore, + orgName string, + page int, +) ([]*v2.Grant, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + listOpts := &github.ListOptions{Page: page, PerPage: maxPageSize} + invitations, resp, err := o.client.Repositories.ListInvitations(ctx, orgName, resource.DisplayName, listOpts) + if err != nil { + if isNotFoundError(resp) || isPermissionError(resp) { + l.Debug("github-connector: cannot list repository invitations, skipping", + zap.String("org", orgName), + zap.String("repository", resource.DisplayName), + zap.String("github_error", gitHubErrorMessage(err)), + ) + return nil, "", nil, nil + } + return nil, "", nil, wrapGitHubError(err, resp, "github-connector: failed to list repository invitations") + } + + nextPage, reqAnnos, err := parseResp(resp) + if err != nil { + return nil, "", nil, err + } + if len(invitations) == 0 { + return nil, nextPage, reqAnnos, nil + } + + invitationsByLogin, err := pendingInvitationsByLogin(ctx, o.client, ss, orgName, resource.ParentResourceId.GetResource()) + if err != nil { + return nil, "", nil, err + } + + rv := make([]*v2.Grant, 0, len(invitations)) + for _, inv := range invitations { + if inv.GetExpired() { + continue + } + + login := inv.GetInvitee().GetLogin() + invitationID, ok := invitationsByLogin[strings.ToLower(login)] + if !ok { + l.Debug("github-connector: repository invitee has no pending org invitation, skipping grant", + zap.String("repository", resource.DisplayName), + zap.String("login", login), + ) + continue + } + + permission := roleNameToRepoPermission(inv.GetPermissions()) + if permission == "" { + l.Debug("github-connector: unrecognized repository invitation permission, skipping grant", + zap.String("repository", resource.DisplayName), + zap.String("login", login), + zap.String("permission", inv.GetPermissions()), + ) + continue + } + + rv = append(rv, grant.NewGrant(resource, permission, invitationResourceID(invitationID), + grant.WithAnnotation(&v2.V1Identifier{ + Id: fmt.Sprintf("repo-invitation-grant:%s:%d:%s", resource.Id.Resource, invitationID, permission), + }), + )) + } + + return rv, nextPage, reqAnnos, nil +} + func (o *repositoryResourceType) Grant(ctx context.Context, principal *v2.Resource, en *v2.Entitlement) (annotations.Annotations, error) { l := ctxzap.Extract(ctx) @@ -473,13 +576,42 @@ func (o *repositoryResourceType) Grant(ctx context.Context, principal *v2.Resour if err != nil { return nil, wrapGitHubError(err, resp, "github-connector: failed to add team to repository") } + case resourceTypeInvitation.Id: + // AddCollaborator on a non-member creates a repository invitation that + // converts to real access when accepted, so the pending invitee only has + // to be named. Unlike teams there is no email-only fallback: GitHub has + // no way to attach repository access to an org invitation. + // + // GitHub caps invitations to non-members at 50 per repository per 24 + // hours (org members are exempt, but a pending invitee is not one yet), + // so a bulk backfill across one repo can start returning 403s. + inv, err := parseInvitationPrincipal(ctx, o.client, repo.GetOwner().GetLogin(), principal) + if err != nil { + return nil, err + } + login := inv.GetLogin() + if login == "" { + return nil, invitationNotProvisionableError( + fmt.Sprintf("add invitation to repository %s", repo.GetName()), inv) + } + + _, resp, err := o.client.Repositories.AddCollaborator( + ctx, + repo.GetOwner().GetLogin(), + 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( - "github-connectorv2: only users and teams can be granted repository membership", + "github-connectorv2: only users, teams, and invitations can be granted repository membership", zap.String("principal_type", principal.Id.ResourceType), zap.String("principal_id", principal.Id.Resource), ) - return nil, fmt.Errorf("github-connectorv2: only users and teams can be granted team membership") + return nil, fmt.Errorf("github-connectorv2: only users, teams, and invitations can be granted repository access") } return nil, nil @@ -529,18 +661,78 @@ func (o *repositoryResourceType) Revoke(ctx context.Context, grant *v2.Grant) (a if err != nil { return nil, wrapGitHubError(err, resp, "github-connector: failed to remove team from repository") } + case resourceTypeInvitation.Id: + owner := repo.GetOwner().GetLogin() + + invitationID, err := strconv.ParseInt(principal.Id.Resource, 10, 64) + if err != nil { + return nil, fmt.Errorf("github-connector: invalid invitation id %q: %w", principal.Id.Resource, err) + } + inv, err := resolvePendingInvitation(ctx, o.client, owner, invitationID) + if err != nil { + return nil, err + } + if inv == nil || inv.GetLogin() == "" { + // No invitation, or one GitHub never resolved to an account, means + // there is no repository invitation to withdraw either. + return annotations.New(&v2.GrantAlreadyRevoked{}), nil + } + + // Deleting the outstanding repository invitation is what actually + // withdraws pre-staged access; RemoveCollaborator alone does not always + // clear it. + repoInvitationID, err := o.findRepositoryInvitation(ctx, owner, repo.GetName(), inv.GetLogin()) + if err != nil { + return nil, err + } + if repoInvitationID != 0 { + resp, err := o.client.Repositories.DeleteInvitation(ctx, owner, repo.GetName(), repoInvitationID) + if err != nil && !isNotFoundError(resp) { + return nil, wrapGitHubError(err, resp, "github-connector: failed to delete repository invitation") + } + return nil, nil + } + + resp, err := o.client.Repositories.RemoveCollaborator(ctx, owner, repo.GetName(), inv.GetLogin()) + if err != nil && !isNotFoundError(resp) { + return nil, wrapGitHubError(err, resp, "github-connector: failed to remove invited user from repository") + } default: l.Error( - "github-connectorv2: only users and teams can have repository membership revoked", + "github-connectorv2: only users, teams, and invitations can have repository access revoked", zap.String("principal_type", principal.Id.ResourceType), zap.String("principal_id", principal.Id.Resource), ) - return nil, fmt.Errorf("github-connectorv2: only users and teams can be granted team membership") + return nil, fmt.Errorf("github-connectorv2: only users, teams, and invitations can have repository access revoked") } return nil, nil } +// findRepositoryInvitation returns the ID of the outstanding repository +// invitation for login, or 0 when there is none. +func (o *repositoryResourceType) findRepositoryInvitation(ctx context.Context, owner, repoName, login string) (int64, error) { + opts := &github.ListOptions{PerPage: maxPageSize} + for { + invitations, resp, err := o.client.Repositories.ListInvitations(ctx, owner, repoName, opts) + if err != nil { + if isNotFoundError(resp) { + return 0, nil + } + return 0, wrapGitHubError(err, resp, "github-connector: failed to list repository invitations") + } + for _, inv := range invitations { + if strings.EqualFold(inv.GetInvitee().GetLogin(), login) { + return inv.GetID(), nil + } + } + if resp.NextPage == 0 { + return 0, nil + } + opts.Page = resp.NextPage + } +} + // orgBasePermissionSessionKey returns the session key for caching the org's default repo permission. func orgBasePermissionSessionKey(orgID string) string { return "org_base_perm:" + orgID diff --git a/pkg/connector/team.go b/pkg/connector/team.go index 0dc2eb24..3106fa04 100644 --- a/pkg/connector/team.go +++ b/pkg/connector/team.go @@ -3,6 +3,7 @@ package connector import ( "context" "fmt" + "slices" "strconv" "strings" @@ -22,6 +23,10 @@ import ( const ( teamRoleMember = "member" teamRoleMaintainer = "maintainer" + + // Pagination bag state for the team's pending invitations. Namespaced so it + // cannot collide with the team role states, which double as bag states. + teamStateInvitations = "team:invitations" ) var teamAccessLevels = []string{ @@ -66,6 +71,7 @@ type teamResourceType struct { client *github.Client orgCache *orgNameCache directCollaboratorsOnly bool + reinviteForGrants bool } func (o *teamResourceType) ResourceType(_ context.Context) *v2.ResourceType { @@ -147,7 +153,7 @@ func (o *teamResourceType) StaticEntitlements(_ context.Context, _ rType.SyncOpA level, entitlement.WithDisplayName(fmt.Sprintf("Team %s", titleCase(level))), entitlement.WithDescription(fmt.Sprintf("Access to team in GitHub as %s", level)), - entitlement.WithGrantableTo(resourceTypeUser), + entitlement.WithGrantableTo(resourceTypeUser, resourceTypeInvitation), ), ) } @@ -187,12 +193,27 @@ func (o *teamResourceType) Grants(ctx context.Context, resource *v2.Resource, op switch rId := bag.ResourceTypeID(); rId { case resourceTypeTeam.Id: bag.Pop() + // Pushed first so it drains last, after both accepted-member states. + bag.Push(pagination.PageState{ + ResourceTypeID: teamStateInvitations, + }) bag.Push(pagination.PageState{ ResourceTypeID: teamRoleMember, }) bag.Push(pagination.PageState{ ResourceTypeID: teamRoleMaintainer, }) + case teamStateInvitations: + invitationGrants, nextPage, annos, err := o.pendingInvitationGrants(ctx, resource, org.GetLogin(), org.GetID(), githubID, page) + if err != nil { + return nil, nil, err + } + reqAnnos = annos + rv = append(rv, invitationGrants...) + + if err := bag.Next(nextPage); err != nil { + return nil, nil, err + } case teamRoleMember, teamRoleMaintainer: listOpts := github.TeamListTeamMembersOptions{ ListOptions: github.ListOptions{ @@ -248,16 +269,91 @@ func (o *teamResourceType) Grants(ctx context.Context, resource *v2.Resource, op }, nil } +// pendingInvitationGrants emits team grants for people who have been invited to +// the team but have not accepted yet. GitHub's team-members endpoints only +// return accepted members, so without this pass a birthright grant that C1 has +// already provisioned looks unfulfilled until the invitee clicks through. +// +// The listing returns organization-invitation objects, which carry the *org* +// role (direct_member/admin), not the team role. The team role comes from a +// per-invitation membership lookup, which is only possible for invitees GitHub +// can name; email-only invitations fall back to plain membership. +func (o *teamResourceType) pendingInvitationGrants( + ctx context.Context, + resource *v2.Resource, + orgName string, + orgID int64, + teamID int64, + page int, +) ([]*v2.Grant, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + listOpts := &github.ListOptions{Page: page, PerPage: maxPageSize} + invitations, resp, err := o.client.Teams.ListPendingTeamInvitationsByID(ctx, orgID, teamID, listOpts) + if err != nil { + if isNotFoundError(resp) || isPermissionError(resp) { + l.Debug("github-connector: cannot list pending team invitations, skipping", + zap.String("org", orgName), + zap.Int64("team_id", teamID), + zap.String("github_error", gitHubErrorMessage(err)), + ) + return nil, "", nil, nil + } + return nil, "", nil, wrapGitHubError(err, resp, "github-connector: failed to list pending team invitations") + } + + nextPage, reqAnnos, err := parseResp(resp) + if err != nil { + return nil, "", nil, err + } + + rv := make([]*v2.Grant, 0, len(invitations)) + for _, inv := range invitations { + role := o.pendingTeamRole(ctx, orgID, teamID, inv) + rv = append(rv, grant.NewGrant(resource, role, invitationResourceID(inv.GetID()), + grant.WithAnnotation(&v2.V1Identifier{ + Id: fmt.Sprintf("team-invitation-grant:%s:%d:%s", resource.Id.Resource, inv.GetID(), role), + }), + )) + } + + return rv, nextPage, reqAnnos, nil +} + +// pendingTeamRole resolves the team role of a pending invitee, defaulting to +// 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)), + ) + return teamRoleMember + } + + if membership.GetRole() == teamRoleMaintainer { + return teamRoleMaintainer + } + return teamRoleMember +} + func (o *teamResourceType) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { l := ctxzap.Extract(ctx) - if principal.Id.ResourceType != resourceTypeUser.Id { + if principal.Id.ResourceType != resourceTypeUser.Id && !isInvitationPrincipal(principal) { l.Warn( - "github-connectorv2: only users can be granted team membership", + "github-connectorv2: only users and invitations can be granted team membership", zap.String("principal_type", principal.Id.ResourceType), zap.String("principal_id", principal.Id.Resource), ) - return nil, fmt.Errorf("github-connectorv2: only users can be granted team membership") + return nil, fmt.Errorf("github-connectorv2: only users and invitations can be granted team membership") } teamId, err := strconv.ParseInt(entitlement.Resource.Id.Resource, 10, 64) @@ -290,6 +386,16 @@ func (o *teamResourceType) Grant(ctx context.Context, principal *v2.Resource, en orgId = orgID } + enIDParts := strings.Split(entitlement.Id, ":") + if len(enIDParts) != 3 { + return nil, fmt.Errorf("github-connectorv2: invalid entitlement ID: %s", entitlement.Id) + } + permission := enIDParts[2] + + if isInvitationPrincipal(principal) { + return o.grantToInvitation(ctx, principal, orgId, teamId, permission) + } + userId, err := strconv.ParseInt(principal.Id.Resource, 10, 64) if err != nil { return nil, err @@ -300,12 +406,6 @@ func (o *teamResourceType) Grant(ctx context.Context, principal *v2.Resource, en return nil, wrapGitHubError(err, resp, fmt.Sprintf("github-connector: failed to get user %d", userId)) } - enIDParts := strings.Split(entitlement.Id, ":") - if len(enIDParts) != 3 { - return nil, fmt.Errorf("github-connectorv2: invalid entitlement ID: %s", entitlement.Id) - } - permission := enIDParts[2] - _, resp, er := o.client.Teams.AddTeamMembershipByID( ctx, orgId, @@ -321,19 +421,106 @@ func (o *teamResourceType) Grant(ctx context.Context, principal *v2.Resource, en return nil, nil } +// grantToInvitation pre-stages team access for someone whose org invitation is +// still pending, so the access is already in place when they accept. +// +// Two paths, because GitHub gives us two very different amounts of room: +// +// - The invitee has a GitHub login. Adding them to the team creates a pending +// team membership, non-destructively, exactly as it would for a member. +// - The invitation was sent to a bare email address. GitHub accepts team_ids +// only when an invitation is created, so the only way to attach a team is to +// re-issue the invitation — gated behind reinviteForGrants because it +// invalidates the outstanding invite link. +func (o *teamResourceType) grantToInvitation( + ctx context.Context, + principal *v2.Resource, + orgID int64, + teamID int64, + permission string, +) (annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + orgName, err := o.orgCache.GetOrgNameFromRemoteServer(ctx, strconv.FormatInt(orgID, 10)) + if err != nil { + return nil, err + } + + inv, err := parseInvitationPrincipal(ctx, o.client, orgName, principal) + if err != nil { + return nil, err + } + + // Tracks a refusal from the direct path so the eventual error names the real + // cause instead of blaming a missing login. + var refusedErr error + + if login := inv.GetLogin(); login != "" { + _, resp, err := o.client.Teams.AddTeamMembershipByID(ctx, orgID, teamID, login, &github.TeamAddTeamMembershipOptions{ + Role: permission, + }) + if err == nil { + return nil, nil + } + if isIdPManagedTeamError(err, resp) { + return nil, uhttp.WrapErrors(codes.FailedPrecondition, fmt.Sprintf( + "github-connector: team %d membership is managed by an external identity provider; grant the access "+ + "in the identity provider instead", teamID), err) + } + if !isNotAnOrgMemberError(err, resp) { + return nil, wrapGitHubError(err, resp, "github-connector: failed to add invited user to team") + } + // GitHub declined to invite through the team endpoint. Re-issuing the + // invitation with the team attached is the remaining option. + refusedErr = err + l.Debug("github-connector: team membership rejected for pending invitee, falling back to re-invite", + zap.Int64("invitation_id", inv.GetID()), + zap.String("github_error", gitHubErrorMessage(err)), + ) + } + + existingTeamIDs, err := invitationTeamIDs(ctx, o.client, orgName, inv.GetID()) + if err != nil { + return nil, err + } + if slices.Contains(existingTeamIDs, teamID) { + return annotations.New(&v2.GrantAlreadyExists{}), nil + } + + if !o.reinviteForGrants { + operation := fmt.Sprintf("add invitation to team %d", teamID) + if refusedErr != nil { + return nil, invitationRefusedError(operation, inv, refusedErr) + } + return nil, invitationNotProvisionableError(operation, inv) + } + + if _, err := reinviteWithTeams(ctx, o.client, orgName, inv, append(existingTeamIDs, teamID)); err != nil { + return nil, err + } + + // The replacement invitation has a new ID, so the invitation resource this + // grant was made against no longer exists. C1 reconciles on the next sync. + l.Info("github-connector: pre-staged team access by re-issuing a pending invitation", + zap.Int64("old_invitation_id", inv.GetID()), + zap.Int64("team_id", teamID), + ) + return nil, nil +} + func (o *teamResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) { l := ctxzap.Extract(ctx) entitlement := grant.Entitlement principal := grant.Principal - if principal.Id.ResourceType != resourceTypeUser.Id { + if principal.Id.ResourceType != resourceTypeUser.Id && !isInvitationPrincipal(principal) { l.Warn( - "github-connectorv2: only users can have team membership revoked", + "github-connectorv2: only users and invitations can have team membership revoked", zap.String("principal_type", principal.Id.ResourceType), zap.String("principal_id", principal.Id.Resource), ) - return nil, fmt.Errorf("github-connectorv2: only users can have team membership revoked") + return nil, fmt.Errorf("github-connectorv2: only users and invitations can have team membership revoked") } teamId, err := strconv.ParseInt(entitlement.Resource.Id.Resource, 10, 64) @@ -350,6 +537,10 @@ func (o *teamResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annotat return nil, err } + if isInvitationPrincipal(principal) { + return o.revokeFromInvitation(ctx, principal, orgId, teamId) + } + userId, err := strconv.ParseInt(principal.Id.Resource, 10, 64) if err != nil { return nil, err @@ -367,11 +558,67 @@ func (o *teamResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annotat return nil, nil } -func TeamBuilder(client *github.Client, orgCache *orgNameCache, directCollaboratorsOnly bool) *teamResourceType { +// revokeFromInvitation withdraws a team from a still-pending invitation. As with +// granting, a named invitee can be removed directly; an email-only invitation can +// only have its team set changed by re-issuing it. +func (o *teamResourceType) revokeFromInvitation( + ctx context.Context, + principal *v2.Resource, + orgID int64, + teamID int64, +) (annotations.Annotations, error) { + orgName, err := o.orgCache.GetOrgNameFromRemoteServer(ctx, strconv.FormatInt(orgID, 10)) + if err != nil { + return nil, err + } + + invitationID, err := strconv.ParseInt(principal.GetId().GetResource(), 10, 64) + if err != nil { + return nil, fmt.Errorf("github-connector: invalid invitation id %q: %w", principal.GetId().GetResource(), err) + } + + inv, err := resolvePendingInvitation(ctx, o.client, orgName, invitationID) + if err != nil { + return nil, err + } + if inv == nil { + // The invitation is gone, so the team membership it carried is too. + return annotations.New(&v2.GrantAlreadyRevoked{}), nil + } + + if login := inv.GetLogin(); login != "" { + resp, err := o.client.Teams.RemoveTeamMembershipByID(ctx, orgID, teamID, login) + if err != nil && !isNotFoundError(resp) { + return nil, wrapGitHubError(err, resp, "github-connector: failed to revoke invited user's team membership") + } + return nil, nil + } + + existingTeamIDs, err := invitationTeamIDs(ctx, o.client, orgName, inv.GetID()) + if err != nil { + return nil, err + } + if !slices.Contains(existingTeamIDs, teamID) { + return annotations.New(&v2.GrantAlreadyRevoked{}), nil + } + remaining := slices.DeleteFunc(existingTeamIDs, func(id int64) bool { return id == teamID }) + + if !o.reinviteForGrants { + return nil, invitationNotProvisionableError(fmt.Sprintf("remove invitation from team %d", teamID), inv) + } + + if _, err := reinviteWithTeams(ctx, o.client, orgName, inv, remaining); err != nil { + return nil, err + } + return nil, nil +} + +func TeamBuilder(client *github.Client, orgCache *orgNameCache, directCollaboratorsOnly bool, reinviteForGrants bool) *teamResourceType { return &teamResourceType{ resourceType: resourceTypeTeam, client: client, orgCache: orgCache, directCollaboratorsOnly: directCollaboratorsOnly, + reinviteForGrants: reinviteForGrants, } } diff --git a/pkg/connector/team_test.go b/pkg/connector/team_test.go index 5856f421..46f98c94 100644 --- a/pkg/connector/team_test.go +++ b/pkg/connector/team_test.go @@ -25,7 +25,7 @@ func TestTeam(t *testing.T) { githubClient := github.NewClient(mgh.Server()) cache := newOrgNameCache(githubClient) - client := TeamBuilder(githubClient, cache, false) + client := TeamBuilder(githubClient, cache, false, false) organization, _ := organizationResource(ctx, githubOrganization, nil, false) team, _ := teamResource(githubTeam, githubOrganization.GetID(), organization.Id) @@ -46,7 +46,7 @@ func TestTeam(t *testing.T) { }) require.Nil(t, err) test.AssertHasRatelimitAnnotations(t, results.Annotations) - require.Equal(t, "{\"states\":[{\"type\":\"member\"}],\"current_state\":{\"type\":\"maintainer\"}}", results.NextPageToken) + require.Equal(t, "{\"states\":[{\"type\":\"team:invitations\"},{\"type\":\"member\"}],\"current_state\":{\"type\":\"maintainer\"}}", results.NextPageToken) grant := v2.Grant{ Entitlement: &entitlement, diff --git a/test/mocks/endpointpattern.go b/test/mocks/endpointpattern.go index b7506972..9a9ef2c9 100644 --- a/test/mocks/endpointpattern.go +++ b/test/mocks/endpointpattern.go @@ -47,6 +47,11 @@ var GetOrganizationsTeamsMembershipsByTeamIdByUsername = mock.EndpointPattern{ Method: "GET", } +var GetOrganizationsTeamsInvitationsByTeamId = mock.EndpointPattern{ + Pattern: "/organizations/{org_id}/team/{team_id}/invitations", + Method: "GET", +} + // Organization role endpoints. var GetOrgsRolesByOrg = mock.EndpointPattern{ Pattern: "/orgs/{org}/organization-roles",