diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index ef3e3b81..416bc02b 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -49,6 +49,12 @@ var ( }, Annotations: v1AnnotationsForResourceType("user"), } + resourceTypeOrgRole = &v2.ResourceType{ + Id: "org_role", + DisplayName: "Organization Role", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE}, + Annotations: v1AnnotationsForResourceType("org_role"), + } ) type GitHub struct { @@ -66,6 +72,7 @@ func (gh *GitHub) ResourceSyncers(ctx context.Context) []connectorbuilder.Resour teamBuilder(gh.client, gh.orgCache), userBuilder(gh.client, gh.hasSAMLEnabled, gh.graphqlClient, gh.orgCache), repositoryBuilder(gh.client, gh.orgCache), + orgRoleBuilder(gh.client, gh.orgCache), } } diff --git a/pkg/connector/org.go b/pkg/connector/org.go index 53b4eb56..d463555a 100644 --- a/pkg/connector/org.go +++ b/pkg/connector/org.go @@ -52,6 +52,7 @@ func organizationResource( &v2.ChildResourceType{ResourceTypeId: resourceTypeUser.Id}, &v2.ChildResourceType{ResourceTypeId: resourceTypeTeam.Id}, &v2.ChildResourceType{ResourceTypeId: resourceTypeRepository.Id}, + &v2.ChildResourceType{ResourceTypeId: resourceTypeOrgRole.Id}, ), ) } diff --git a/pkg/connector/org_role.go b/pkg/connector/org_role.go new file mode 100644 index 00000000..d2cbe4fe --- /dev/null +++ b/pkg/connector/org_role.go @@ -0,0 +1,422 @@ +package connector + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/conductorone/baton-sdk/pkg/types/grant" + "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/google/go-github/v63/github" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +type OrganizationRole struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +type OrganizationRoleResponse struct { + TotalCount int `json:"total_count"` + Roles []OrganizationRole `json:"roles"` +} + +type OrganizationRoleTeam struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type orgRoleResourceType struct { + resourceType *v2.ResourceType + client *github.Client + orgCache *orgNameCache +} + +func orgRoleResource( + ctx context.Context, + role *OrganizationRole, + org *v2.Resource, +) (*v2.Resource, error) { + profile := map[string]interface{}{ + "description": role.Description, + } + + return resource.NewRoleResource( + role.Name, + resourceTypeOrgRole, + role.ID, + []resource.RoleTraitOption{ + resource.WithRoleProfile(profile), + }, + resource.WithParentResourceID(org.Id), + resource.WithAnnotation( + &v2.V1Identifier{Id: fmt.Sprintf("org_role:%d", role.ID)}, + ), + ) +} + +func (o *orgRoleResourceType) ResourceType(_ context.Context) *v2.ResourceType { + return o.resourceType +} + +func (o *orgRoleResourceType) List( + ctx context.Context, + parentID *v2.ResourceId, + pToken *pagination.Token, +) ([]*v2.Resource, string, annotations.Annotations, error) { + if parentID == nil { + return nil, "", nil, nil + } + + orgName, err := o.orgCache.GetOrgName(ctx, parentID) + if err != nil { + return nil, "", nil, err + } + + roles, resp, err := o.client.Organizations.ListRoles(ctx, orgName) + if err != nil { + // Handle permission errors gracefully + if resp != nil && (resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound) { + // Return empty list with no error to indicate we skipped this resource + return nil, "", nil, nil + } + return nil, "", nil, fmt.Errorf("failed to list organization roles: %w", err) + } + + var ret []*v2.Resource + for _, role := range roles.CustomRepoRoles { + roleResource, err := orgRoleResource(ctx, &OrganizationRole{ + ID: role.GetID(), + Name: role.GetName(), + Description: role.GetDescription(), + }, &v2.Resource{Id: parentID}) + if err != nil { + return nil, "", nil, err + } + ret = append(ret, roleResource) + } + + return ret, "", nil, nil +} + +func (o *orgRoleResourceType) Entitlements( + _ context.Context, + resource *v2.Resource, + _ *pagination.Token, +) ([]*v2.Entitlement, string, annotations.Annotations, error) { + rv := make([]*v2.Entitlement, 0, 1) + rv = append(rv, entitlement.NewAssignmentEntitlement(resource, "assigned", + entitlement.WithDisplayName(resource.DisplayName), + entitlement.WithDescription(fmt.Sprintf("Assignment to %s role in GitHub", resource.DisplayName)), + entitlement.WithAnnotation(&v2.V1Identifier{ + Id: fmt.Sprintf("org_role:%s", resource.Id.Resource), + }), + entitlement.WithGrantableTo(resourceTypeUser), + )) + + return rv, "", nil, nil +} + +func (o *orgRoleResourceType) Grants( + ctx context.Context, + resource *v2.Resource, + pToken *pagination.Token, +) ([]*v2.Grant, string, annotations.Annotations, error) { + if resource == nil { + return nil, "", nil, nil + } + + bag, page, err := parsePageToken(pToken.Token, resource.Id) + if err != nil { + return nil, "", nil, err + } + + orgName, err := o.orgCache.GetOrgName(ctx, resource.ParentResourceId) + if err != nil { + return nil, "", nil, err + } + + var rv []*v2.Grant + var reqAnnos annotations.Annotations + + roleID, err := strconv.ParseInt(resource.Id.Resource, 10, 64) + if err != nil { + return nil, "", nil, fmt.Errorf("invalid role ID: %w", err) + } + + switch bag.ResourceTypeID() { + case resourceTypeOrgRole.Id: + bag.Pop() + bag.Push(pagination.PageState{ + ResourceTypeID: resourceTypeUser.Id, + }) + bag.Push(pagination.PageState{ + ResourceTypeID: resourceTypeTeam.Id, + }) + case resourceTypeUser.Id: + opts := &github.ListOptions{ + Page: page, + } + users, resp, err := o.client.Organizations.ListUsersAssignedToOrgRole(ctx, orgName, roleID, opts) + if err != nil { + if resp != nil && (resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound) { + pageToken, err := bag.NextToken("") + if err != nil { + return nil, "", nil, err + } + return rv, pageToken, nil, nil + } + return nil, "", nil, fmt.Errorf("failed to list role users: %w", err) + } + nextPage, respAnnos, err := parseResp(resp) + if err != nil { + return nil, "", nil, err + } + reqAnnos = respAnnos + + err = bag.Next(nextPage) + if err != nil { + return nil, "", nil, err + } + + // Create regular grants for direct user assignments. + for _, user := range users { + userResource, err := userResource(ctx, user, user.GetEmail(), nil) + if err != nil { + return nil, "", nil, err + } + + grant := grant.NewGrant( + resource, + "assigned", + userResource.Id, + grant.WithAnnotation(&v2.V1Identifier{ + Id: fmt.Sprintf("org-role:%s:%d:%d", resource.Id.Resource, user.GetID(), roleID), + }), + ) + grant.Principal = userResource + rv = append(rv, grant) + } + case resourceTypeTeam.Id: + opts := &github.ListOptions{ + Page: page, + } + teams, resp, err := o.client.Organizations.ListTeamsAssignedToOrgRole(ctx, orgName, roleID, opts) + if err != nil { + // Handle permission errors without erroring out. Some customers may not want to give us permissions to get org roles and members. + if resp != nil && (resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound) { + // Return empty list with no error to indicate we skipped this resource + pageToken, err := bag.NextToken("") + if err != nil { + return nil, "", nil, err + } + return nil, pageToken, nil, nil + } + return nil, "", nil, fmt.Errorf("failed to list role teams: %w", err) + } + + nextPage, respAnnos, err := parseResp(resp) + if err != nil { + return nil, "", nil, err + } + reqAnnos = respAnnos + + err = bag.Next(nextPage) + if err != nil { + return nil, "", nil, err + } + + // Create expandable grants for teams. To show inherited roles, we need to show the teams that have the role. + for _, team := range teams { + teamResource, err := teamResource(team, resource.ParentResourceId) + if err != nil { + return nil, "", nil, err + } + rv = append(rv, grant.NewGrant( + resource, + "assigned", + teamResource.Id, + grant.WithAnnotation(&v2.V1Identifier{ + Id: fmt.Sprintf("org-role-grant:%s:%d:%s", resource.Id.Resource, team.GetID(), "assigned"), + }, + &v2.GrantExpandable{ + EntitlementIds: []string{ + entitlement.NewEntitlementID(teamResource, teamRoleMaintainer), + entitlement.NewEntitlementID(teamResource, teamRoleMember), + }, + Shallow: true, + }, + ), + )) + } + default: + return nil, "", nil, fmt.Errorf("unexpected resource type while fetching grants for org role") + } + pageToken, err := bag.Marshal() + if err != nil { + return nil, "", nil, err + } + + return rv, pageToken, reqAnnos, nil +} + +func (o *orgRoleResourceType) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + if principal.Id.ResourceType != resourceTypeUser.Id { + l.Warn( + "github-connector: only users can be granted organization roles", + zap.String("principal_type", principal.Id.ResourceType), + zap.String("principal_id", principal.Id.Resource), + ) + return nil, fmt.Errorf("github-connector: only users can be granted organization roles") + } + + roleID, err := strconv.ParseInt(entitlement.Resource.Id.Resource, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid role ID: %w", err) + } + + orgName, err := o.orgCache.GetOrgName(ctx, entitlement.Resource.ParentResourceId) + if err != nil { + return nil, fmt.Errorf("failed to get org name: %w", err) + } + + // First verify that the role exists + req, err := o.client.NewRequest("GET", fmt.Sprintf("orgs/%s/organization-roles/%d", orgName, roleID), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := o.client.Do(ctx, req, nil) + if err != nil { + return nil, fmt.Errorf("failed to get role existence: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("role with ID %d not found in organization %s", roleID, orgName) + } + + userID, err := strconv.ParseInt(principal.Id.Resource, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid user ID: %w", err) + } + + enIDParts := strings.Split(entitlement.Id, ":") + if len(enIDParts) != 3 { + return nil, fmt.Errorf("github-connectorv2: invalid entitlement ID: %s", entitlement.Id) + } + + user, _, err := o.client.Users.GetByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + reqs, err := o.client.NewRequest("PUT", fmt.Sprintf("orgs/%s/organization-roles/users/%s/%d", orgName, user.GetLogin(), roleID), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + resp, err = o.client.Do(ctx, reqs, nil) + if err != nil { + if resp != nil { + l.Error("failed to assign role", + zap.String("org", orgName), + zap.Int64("role_id", roleID), + zap.String("user", user.GetLogin()), + zap.Int("status_code", resp.StatusCode), + zap.String("status", resp.Status), + zap.Error(err), + ) + } + return nil, fmt.Errorf("failed to assign role: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + l.Error("failed to assign role", + zap.String("org", orgName), + zap.Int64("role_id", roleID), + zap.String("user", user.GetLogin()), + zap.Int("status_code", resp.StatusCode), + zap.String("status", resp.Status), + ) + return nil, fmt.Errorf("failed to assign role: %s", resp.Status) + } + + l.Info("successfully assigned role", + zap.String("org", orgName), + zap.Int64("role_id", roleID), + zap.String("user", user.GetLogin()), + ) + + return nil, nil +} + +func (o *orgRoleResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + entitlement := grant.Entitlement + principal := grant.Principal + + // Needs review, I copied this from the team grant function, but roles can be granted to teams as well, but we don't necessarily support that so wasn't sure if this was the intended behavior. + if principal.Id.ResourceType != resourceTypeUser.Id { + l.Warn( + "github-connector: only users can have organization roles revoked", + zap.String("principal_type", principal.Id.ResourceType), + zap.String("principal_id", principal.Id.Resource), + ) + return nil, fmt.Errorf("github-connector: only users can have organization roles revoked") + } + + roleID, err := strconv.ParseInt(entitlement.Resource.Id.Resource, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid role ID: %w", err) + } + + orgName, err := o.orgCache.GetOrgName(ctx, entitlement.Resource.ParentResourceId) + if err != nil { + return nil, fmt.Errorf("failed to get org name: %w", err) + } + + userID, err := strconv.ParseInt(principal.Id.Resource, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid user ID: %w", err) + } + + user, _, err := o.client.Users.GetByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + url := fmt.Sprintf("orgs/%s/organization-roles/users/%s/%d", orgName, user.GetLogin(), roleID) + req, err := o.client.NewRequest("DELETE", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := o.client.Do(ctx, req, nil) + if err != nil { + return nil, fmt.Errorf("failed to revoke role: %w", err) + } + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to revoke role: %s", resp.Status) + } + + return nil, nil +} + +func orgRoleBuilder(client *github.Client, orgCache *orgNameCache) *orgRoleResourceType { + return &orgRoleResourceType{ + resourceType: resourceTypeOrgRole, + client: client, + orgCache: orgCache, + } +} diff --git a/pkg/connector/org_role_test.go b/pkg/connector/org_role_test.go new file mode 100644 index 00000000..c3adeb6f --- /dev/null +++ b/pkg/connector/org_role_test.go @@ -0,0 +1,117 @@ +package connector + +import ( + "context" + "testing" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/pagination" + entitlement2 "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/google/go-github/v63/github" + "github.com/stretchr/testify/require" + + "github.com/conductorone/baton-github/test" + "github.com/conductorone/baton-github/test/mocks" +) + +func TestOrgRole(t *testing.T) { + ctx := context.Background() + + t.Run("should grant and revoke entitlements", func(t *testing.T) { + mgh := mocks.NewMockGitHub() + + githubOrganization, _, _, githubUser, orgRole, _ := mgh.Seed() + + githubClient := github.NewClient(mgh.Server()) + cache := newOrgNameCache(githubClient) + client := orgRoleBuilder(githubClient, cache) + + organization, _ := organizationResource(ctx, githubOrganization, nil) + roleResource, _ := orgRoleResource(ctx, &OrganizationRole{ + ID: orgRole.ID, + Name: orgRole.Name, + Description: orgRole.Description, + }, organization) + user, _ := userResource(ctx, githubUser, *githubUser.Email, nil) + + entitlement := v2.Entitlement{ + Id: entitlement2.NewEntitlementID(roleResource, "assigned"), + Resource: roleResource, + } + + // Grant the role to the user + grantAnnotations, err := client.Grant(ctx, user, &entitlement) + require.Nil(t, err) + require.Empty(t, grantAnnotations) + + grants := make([]*v2.Grant, 0) + bag := &pagination.Bag{} + for { + pToken := pagination.Token{} + state := bag.Current() + if state != nil { + token, _ := bag.Marshal() + pToken.Token = token + } + + nextGrants, nextToken, grantsAnnotations, err := client.Grants(ctx, roleResource, &pToken) + grants = append(grants, nextGrants...) + + require.Nil(t, err) + test.AssertNoRatelimitAnnotations(t, grantsAnnotations) + if nextToken == "" { + break + } + + err = bag.Unmarshal(nextToken) + if err != nil { + t.Error(err) + } + } + + require.Len(t, grants, 2) + + grant := v2.Grant{ + Entitlement: &entitlement, + Principal: user, + } + + revokeAnnotations, err := client.Revoke(ctx, &grant) + require.Nil(t, err) + require.Empty(t, revokeAnnotations) + }) + + t.Run("should handle permission errors gracefully", func(t *testing.T) { + mockGithub := mocks.NewMockGitHub() + mockGithub.SimulateOrgRolePermErr = true + + githubOrganization, _, _, _, orgRole, _ := mockGithub.Seed() + + githubClient := github.NewClient(mockGithub.Server()) + cache := newOrgNameCache(githubClient) + client := orgRoleBuilder(githubClient, cache) + + organization, _ := organizationResource(ctx, githubOrganization, nil) + + // Test List with permission error + resources, nextToken, annotations, err := client.List(ctx, organization.Id, &pagination.Token{}) + require.Nil(t, err) + require.Empty(t, resources) + require.Empty(t, nextToken) + test.AssertNoRatelimitAnnotations(t, annotations) + + // Test Grants with permission error + role, _ := orgRoleResource(ctx, &OrganizationRole{ + ID: orgRole.ID, + Name: orgRole.Name, + Description: orgRole.Description, + }, organization) + + grants, nextToken, grantsAnnotations, err := client.Grants(ctx, role, &pagination.Token{}) + require.Nil(t, err) + require.Empty(t, grants) + // The token should contain the initial state for users + require.NotEmpty(t, nextToken) + test.AssertNoRatelimitAnnotations(t, grantsAnnotations) + }) +} diff --git a/pkg/connector/org_test.go b/pkg/connector/org_test.go index fa8dc4a2..070dd5ca 100644 --- a/pkg/connector/org_test.go +++ b/pkg/connector/org_test.go @@ -19,7 +19,7 @@ func TestOrganization(t *testing.T) { t.Run("should grant and revoke entitlements", func(t *testing.T) { mgh := mocks.NewMockGitHub() - githubOrganization, _, _, githubUser, _ := mgh.Seed() + githubOrganization, _, _, githubUser, _, _ := mgh.Seed() githubClient := github.NewClient(mgh.Server()) cache := newOrgNameCache(githubClient) diff --git a/pkg/connector/repository_test.go b/pkg/connector/repository_test.go index 9a747bb5..3b55663b 100644 --- a/pkg/connector/repository_test.go +++ b/pkg/connector/repository_test.go @@ -20,7 +20,7 @@ func TestRepository(t *testing.T) { t.Run("should grant and revoke entitlements", func(t *testing.T) { mgh := mocks.NewMockGitHub() - githubOrganization, githubRepository, _, githubUser, _ := mgh.Seed() + githubOrganization, githubRepository, _, githubUser, _, _ := mgh.Seed() githubClient := github.NewClient(mgh.Server()) cache := newOrgNameCache(githubClient) diff --git a/pkg/connector/team_test.go b/pkg/connector/team_test.go index 7b1e89f9..639a02f5 100644 --- a/pkg/connector/team_test.go +++ b/pkg/connector/team_test.go @@ -20,7 +20,7 @@ func TestTeam(t *testing.T) { t.Run("should grant and revoke entitlements", func(t *testing.T) { mgh := mocks.NewMockGitHub() - githubOrganization, _, githubTeam, githubUser, _ := mgh.Seed() + githubOrganization, _, githubTeam, githubUser, _, _ := mgh.Seed() githubClient := github.NewClient(mgh.Server()) cache := newOrgNameCache(githubClient) diff --git a/pkg/connector/user_test.go b/pkg/connector/user_test.go index c91a66b1..51e87546 100644 --- a/pkg/connector/user_test.go +++ b/pkg/connector/user_test.go @@ -29,7 +29,7 @@ func TestUsersList(t *testing.T) { t.Run(fmt.Sprintf("should get a list of users (SAML:%s)", testCase.message), func(t *testing.T) { mgh := mocks.NewMockGitHub() - githubOrganization, _, _, githubUser, _ := mgh.Seed() + githubOrganization, _, _, githubUser, _, _ := mgh.Seed() organization, err := organizationResource( ctx, diff --git a/test/mocks/endpointpattern.go b/test/mocks/endpointpattern.go index a628c2af..c57cefff 100644 --- a/test/mocks/endpointpattern.go +++ b/test/mocks/endpointpattern.go @@ -41,3 +41,29 @@ var GetOrganizationsTeamsMembershipsByTeamIdByUsername = mock.EndpointPattern{ Pattern: "/organizations/{org_id}/team/{team_id}/memberships/{username}", Method: "GET", } + +// Organization role endpoints. +var GetOrgsRolesByOrg = mock.EndpointPattern{ + Pattern: "/orgs/{org}/organization-roles", + Method: "GET", +} + +var GetOrgsRolesTeamsByOrgByRoleId = mock.EndpointPattern{ + Pattern: "/orgs/{org}/organization-roles/{role_id}/teams", + Method: "GET", +} + +var GetOrgsRolesUsersByOrgByRoleId = mock.EndpointPattern{ + Pattern: "/orgs/{org}/organization-roles/{role_id}/users", + Method: "GET", +} + +var PutOrgsRolesUsersByOrgByRoleIdByUsername = mock.EndpointPattern{ + Pattern: "/orgs/{org}/organization-roles/users/{username}/{role_id}", + Method: "PUT", +} + +var DeleteOrgsRolesUsersByOrgByRoleIdByUsername = mock.EndpointPattern{ + Pattern: "/orgs/{org}/organization-roles/users/{username}/{role_id}", + Method: "DELETE", +} diff --git a/test/mocks/github.go b/test/mocks/github.go index 57db08ed..42cdb407 100644 --- a/test/mocks/github.go +++ b/test/mocks/github.go @@ -22,6 +22,8 @@ type MockGitHub struct { repositories map[int64]github.Repository teams map[int64]github.Team users map[int64]github.User + orgRoles map[int64]mapset.Set[int64] // Maps role ID to set of user IDs + SimulateOrgRolePermErr bool // Simulate permission error for org roles } func NewMockGitHub() *MockGitHub { @@ -33,6 +35,7 @@ func NewMockGitHub() *MockGitHub { repositories: map[int64]github.Repository{}, teams: map[int64]github.Team{}, users: map[int64]github.User{}, + orgRoles: map[int64]mapset.Set[int64]{}, } } @@ -90,6 +93,7 @@ func (mgh MockGitHub) Seed() ( *github.Repository, *github.Team, *github.User, + *OrganizationRole, error, ) { organizationId := int64(12) @@ -134,7 +138,16 @@ func (mgh MockGitHub) Seed() ( mgh.teamMemberships[teamId] = mapset.NewSet[int64](userId) mgh.organizationMemberships[organizationId] = mapset.NewSet[int64](userId) - return &githubOrganization, &githubRepository, &githubTeam, &githubUser, nil + // Add a mock org role + roleId := int64(1) + orgRole := &OrganizationRole{ + ID: roleId, + Name: "Test Role", + Description: "Test Role Description", + } + mgh.orgRoles[roleId] = mapset.NewSet[int64]() // Initialize the set for this role + + return &githubOrganization, &githubRepository, &githubTeam, &githubUser, orgRole, nil } func getResource[T interface{}]( @@ -494,6 +507,196 @@ func (mgh MockGitHub) removeRepositoryCollaborator( ) } +type OrganizationRole struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +type OrganizationRoles struct { + CustomRepoRoles []*OrganizationRole `json:"roles"` +} + +func (mgh MockGitHub) getOrgRoles( + w http.ResponseWriter, + variables map[string]string, +) { + if mgh.SimulateOrgRolePermErr { + w.WriteHeader(http.StatusForbidden) + return + } + orgID, _ := getCrossTableId(w, variables, "org") + if _, ok := mgh.organizations[orgID]; !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + // Return a mock role + role := &OrganizationRole{ + ID: 1, + Name: "Test Role", + Description: "Test Role Description", + } + + roles := &OrganizationRoles{ + CustomRepoRoles: []*OrganizationRole{role}, + } + + _, _ = w.Write(mock.MustMarshal(roles)) +} + +func (mgh MockGitHub) getOrgRoleTeams( + w http.ResponseWriter, + variables map[string]string, +) { + roleID, _ := getCrossTableId(w, variables, "role_id") + if _, ok := mgh.orgRoles[roleID]; !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + // Get pagination parameters + page := 1 + perPage := 30 + if pageStr, ok := variables["page"]; ok { + if p, err := strconv.Atoi(pageStr); err == nil { + page = p + } + } + if perPageStr, ok := variables["per_page"]; ok { + if pp, err := strconv.Atoi(perPageStr); err == nil { + perPage = pp + } + } + + // Return paginated teams + teams := make([]*github.Team, 0) + start := (page - 1) * perPage + end := start + perPage + i := 0 + + // Get users with this role + roleUsers := mgh.orgRoles[roleID] + + // Find teams that have members with this role + for teamID, team := range mgh.teams { + teamMembers := mgh.teamMemberships[teamID] + if teamMembers == nil { + continue + } + + // Check if any team member has the role + for _, userID := range teamMembers.ToSlice() { + if roleUsers.Contains(userID) { + if i >= start && i < end { + teams = append(teams, &team) + } + i++ + if i >= end { + break + } + break // Found a member with the role, no need to check other members + } + } + } + + _, _ = w.Write(mock.MustMarshal(teams)) +} + +func (mgh MockGitHub) getOrgRoleUsers( + w http.ResponseWriter, + variables map[string]string, +) { + roleID, _ := getCrossTableId(w, variables, "role_id") + memberships, ok := mgh.orgRoles[roleID] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + // Get pagination parameters + page := 1 + perPage := 30 + if pageStr, ok := variables["page"]; ok { + if p, err := strconv.Atoi(pageStr); err == nil { + page = p + } + } + if perPageStr, ok := variables["per_page"]; ok { + if pp, err := strconv.Atoi(perPageStr); err == nil { + perPage = pp + } + } + + // Return paginated users + users := make([]github.User, 0) + start := (page - 1) * perPage + end := start + perPage + i := 0 + for _, userID := range memberships.ToSlice() { + if i >= start && i < end { + if user, ok := mgh.users[userID]; ok { + users = append(users, user) + } + } + i++ + if i >= end { + break + } + } + + _, _ = w.Write(mock.MustMarshal(users)) +} + +func (mgh MockGitHub) addOrgRoleUser( + w http.ResponseWriter, + variables map[string]string, +) { + roleID, _ := getCrossTableId(w, variables, "role_id") + userID, _ := getUserId(w, variables) + + if _, ok := mgh.orgRoles[roleID]; !ok { + mgh.orgRoles[roleID] = mapset.NewSet[int64]() + } + mgh.orgRoles[roleID].Add(userID) +} + +func (mgh MockGitHub) removeOrgRoleUser( + w http.ResponseWriter, + variables map[string]string, +) { + roleID, _ := getCrossTableId(w, variables, "role_id") + userID, _ := getUserId(w, variables) + + if memberships, ok := mgh.orgRoles[roleID]; ok { + memberships.Remove(userID) + } +} + +// Add a handler for GET /orgs/{org}/organization-roles/{role_id}. +func (mgh MockGitHub) getOrgRoleByID( + w http.ResponseWriter, + variables map[string]string, +) { + orgID, _ := getCrossTableId(w, variables, "org") + roleID, _ := getCrossTableId(w, variables, "role_id") + if _, ok := mgh.organizations[orgID]; !ok { + w.WriteHeader(http.StatusNotFound) + return + } + // Only support role ID 1 for the mock + if roleID != 1 { + w.WriteHeader(http.StatusNotFound) + return + } + role := &OrganizationRole{ + ID: 1, + Name: "Test Role", + Description: "Test Role Description", + } + _, _ = w.Write(mock.MustMarshal(role)) +} + type handler = func(w http.ResponseWriter, variables map[string]string) // addEndpointHandler takes a string interpolation pattern and a handler @@ -540,6 +743,16 @@ func (mgh MockGitHub) Server() *http.Client { mock.PutReposCollaboratorsByOwnerByRepoByUsername: mgh.addRepositoryCollaborator, DeleteOrganizationsTeamsMembershipsByOrganizationByTeamIdByUsername: mgh.removeMembership, PutOrganizationsTeamsMembershipsByOrganizationByTeamIdByUsername: mgh.addMembership, + // Add organization role endpoints + GetOrgsRolesByOrg: mgh.getOrgRoles, + GetOrgsRolesTeamsByOrgByRoleId: mgh.getOrgRoleTeams, + GetOrgsRolesUsersByOrgByRoleId: mgh.getOrgRoleUsers, + mock.EndpointPattern{ + Pattern: "/orgs/{org}/organization-roles/{role_id}", + Method: "GET", + }: mgh.getOrgRoleByID, + PutOrgsRolesUsersByOrgByRoleIdByUsername: mgh.addOrgRoleUser, + DeleteOrgsRolesUsersByOrgByRoleIdByUsername: mgh.removeOrgRoleUser, } options := make([]mock.MockBackendOption, 0) @@ -548,3 +761,24 @@ func (mgh MockGitHub) Server() *http.Client { } return mock.NewMockedHTTPClient(options...) } + +// AddTeam adds a team to the mock server for testing purposes. +func (mgh *MockGitHub) AddTeam(team github.Team) { + mgh.teams[*team.ID] = team +} + +// AddUserToOrgRole adds a user to an org role for testing purposes. +func (mgh *MockGitHub) AddUserToOrgRole(roleID int64, userID int64) { + if _, ok := mgh.orgRoles[roleID]; !ok { + mgh.orgRoles[roleID] = mapset.NewSet[int64]() + } + mgh.orgRoles[roleID].Add(userID) +} + +// AddMembership adds a user to a team for testing purposes. +func (mgh *MockGitHub) AddMembership(teamID int64, userID int64) { + if _, ok := mgh.teamMemberships[teamID]; !ok { + mgh.teamMemberships[teamID] = mapset.NewSet[int64]() + } + mgh.teamMemberships[teamID].Add(userID) +}