mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-07-30 03:52:53 +00:00
Finish initial github login flow
Also: - fix github teams query: fix bug and sort/unique the results - add IDP display name to github downstream subject - fix error types returned by LoginFromCallback - add trace logs to github API results - update e2e test - implement placeholder version of refresh for github
This commit is contained in:
committed by
Joshua Casey
parent
ba2d122308
commit
8923704f3c
@@ -9,11 +9,13 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/google/go-github/v62/github"
|
||||
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
|
||||
"go.pinniped.dev/internal/plog"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -35,7 +37,7 @@ type TeamInfo struct {
|
||||
type GitHubInterface interface {
|
||||
GetUserInfo(ctx context.Context) (*UserInfo, error)
|
||||
GetOrgMembership(ctx context.Context) (sets.Set[string], error)
|
||||
GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]*TeamInfo, error)
|
||||
GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]TeamInfo, error)
|
||||
}
|
||||
|
||||
type githubClient struct {
|
||||
@@ -87,6 +89,7 @@ func (g *githubClient) GetUserInfo(ctx context.Context) (*UserInfo, error) {
|
||||
if user == nil { // untested
|
||||
return nil, fmt.Errorf("%s: user is nil", errorPrefix)
|
||||
}
|
||||
plog.Trace("got raw GitHub API user results", "user", user)
|
||||
|
||||
userInfo := &UserInfo{
|
||||
Login: user.GetLogin(),
|
||||
@@ -98,6 +101,8 @@ func (g *githubClient) GetUserInfo(ctx context.Context) (*UserInfo, error) {
|
||||
if userInfo.Login == "" {
|
||||
return nil, fmt.Errorf(`%s: the "login" attribute is missing`, errorPrefix)
|
||||
}
|
||||
|
||||
plog.Trace("calculated response from GitHub user endpoint", "user", userInfo)
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
@@ -114,6 +119,7 @@ func (g *githubClient) GetOrgMembership(ctx context.Context) (sets.Set[string],
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
|
||||
}
|
||||
plog.Trace("got raw GitHub API org results", "orgs", organizationResults, "hasNextPage", response.NextPage)
|
||||
|
||||
for _, organization := range organizationResults {
|
||||
organizationLogins.Insert(organization.GetLogin())
|
||||
@@ -128,6 +134,7 @@ func (g *githubClient) GetOrgMembership(ctx context.Context) (sets.Set[string],
|
||||
return nil, fmt.Errorf(`%s: one or more organizations is missing the "login" attribute`, errorPrefix)
|
||||
}
|
||||
|
||||
plog.Trace("calculated response from GitHub org membership endpoint", "orgs", organizationLogins.UnsortedList())
|
||||
return organizationLogins, nil
|
||||
}
|
||||
|
||||
@@ -135,6 +142,10 @@ func isOrgAllowed(allowedOrganizations sets.Set[string], login string) bool {
|
||||
return len(allowedOrganizations) == 0 || allowedOrganizations.Has(login)
|
||||
}
|
||||
|
||||
func buildAndValidateParentTeam(githubTeam *github.Team, organizationLogin string) (*TeamInfo, error) {
|
||||
return buildTeam(githubTeam, organizationLogin)
|
||||
}
|
||||
|
||||
func buildAndValidateTeam(githubTeam *github.Team) (*TeamInfo, error) {
|
||||
if githubTeam.GetOrganization() == nil {
|
||||
return nil, errors.New(`missing the "organization" attribute for a team`)
|
||||
@@ -144,6 +155,10 @@ func buildAndValidateTeam(githubTeam *github.Team) (*TeamInfo, error) {
|
||||
return nil, errors.New(`missing the organization's "login" attribute for a team`)
|
||||
}
|
||||
|
||||
return buildTeam(githubTeam, organizationLogin)
|
||||
}
|
||||
|
||||
func buildTeam(githubTeam *github.Team, organizationLogin string) (*TeamInfo, error) {
|
||||
teamInfo := &TeamInfo{
|
||||
Name: githubTeam.GetName(),
|
||||
Slug: githubTeam.GetSlug(),
|
||||
@@ -161,9 +176,9 @@ func buildAndValidateTeam(githubTeam *github.Team) (*TeamInfo, error) {
|
||||
// GetTeamMembership returns a description of each team to which the authenticated user belongs.
|
||||
// If allowedOrganizations is not empty, will filter the results to only those teams which belong to the allowed organizations.
|
||||
// Parent teams will also be returned.
|
||||
func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]*TeamInfo, error) {
|
||||
func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]TeamInfo, error) {
|
||||
const errorPrefix = "error fetching team membership for authenticated user"
|
||||
teamInfos := make([]*TeamInfo, 0)
|
||||
teamInfos := sets.New[TeamInfo]()
|
||||
|
||||
opt := &github.ListOptions{PerPage: pageSize}
|
||||
// get all pages of results
|
||||
@@ -172,6 +187,7 @@ func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizatio
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
|
||||
}
|
||||
plog.Trace("got raw GitHub API team results", "teams", teamsResults, "hasNextPage", response.NextPage)
|
||||
|
||||
for _, team := range teamsResults {
|
||||
teamInfo, err := buildAndValidateTeam(team)
|
||||
@@ -183,20 +199,18 @@ func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizatio
|
||||
continue
|
||||
}
|
||||
|
||||
teamInfos = append(teamInfos, teamInfo)
|
||||
teamInfos.Insert(*teamInfo)
|
||||
|
||||
parent := team.GetParent()
|
||||
if parent != nil {
|
||||
teamInfo, err := buildAndValidateTeam(parent)
|
||||
// The GitHub API does not return the Organization for the Parent of the team.
|
||||
// Use the org of the child as the org of the parent, since they must come from the same org.
|
||||
parentTeamInfo, err := buildAndValidateParentTeam(parent, teamInfo.Org)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
|
||||
}
|
||||
|
||||
if !isOrgAllowed(allowedOrganizations, teamInfo.Org) {
|
||||
continue
|
||||
}
|
||||
|
||||
teamInfos = append(teamInfos, teamInfo)
|
||||
teamInfos.Insert(*parentTeamInfo)
|
||||
}
|
||||
}
|
||||
if response.NextPage == 0 {
|
||||
@@ -205,5 +219,16 @@ func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizatio
|
||||
opt.Page = response.NextPage
|
||||
}
|
||||
|
||||
return teamInfos, nil
|
||||
// Sort by org and then by name, just so we always return teams in the same order.
|
||||
sortedTeams := teamInfos.UnsortedList()
|
||||
slices.SortStableFunc(sortedTeams, func(a, b TeamInfo) int {
|
||||
orgsCompared := strings.Compare(a.Org, b.Org)
|
||||
if orgsCompared == 0 {
|
||||
return strings.Compare(a.Slug, b.Slug)
|
||||
}
|
||||
return orgsCompared
|
||||
})
|
||||
|
||||
plog.Trace("calculated response from GitHub teams endpoint", "teams", sortedTeams)
|
||||
return sortedTeams, nil
|
||||
}
|
||||
|
||||
@@ -100,22 +100,23 @@ func TestNewGitHubClient(t *testing.T) {
|
||||
|
||||
if test.wantErr != "" {
|
||||
require.EqualError(t, err, test.wantErr)
|
||||
return
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, actualI)
|
||||
actual, ok := actualI.(*githubClient)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, actual.client.BaseURL)
|
||||
require.Equal(t, test.wantBaseURL, actual.client.BaseURL.String())
|
||||
|
||||
// Force the githubClient's httpClient roundTrippers to run and add the Authorization header
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testServer.URL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = actual.client.Client().Do(req) //nolint:bodyclose
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.NotNil(t, actualI)
|
||||
actual, ok := actualI.(*githubClient)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, actual.client.BaseURL)
|
||||
require.Equal(t, test.wantBaseURL, actual.client.BaseURL.String())
|
||||
|
||||
// Force the githubClient's httpClient roundTrippers to run and add the Authorization header
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testServer.URL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = actual.client.Client().Do(req) //nolint:bodyclose
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -241,11 +242,11 @@ func TestGetUser(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host)
|
||||
require.EqualError(t, err, test.wantErr)
|
||||
return
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, actual)
|
||||
require.Equal(t, test.wantUserInfo, *actual)
|
||||
}
|
||||
|
||||
require.NotNil(t, actual)
|
||||
require.Equal(t, test.wantUserInfo, *actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -395,7 +396,7 @@ func TestGetTeamMembership(t *testing.T) {
|
||||
ctx context.Context
|
||||
allowedOrganizations []string
|
||||
wantErr string
|
||||
wantTeams []*TeamInfo
|
||||
wantTeams []TeamInfo
|
||||
}{
|
||||
{
|
||||
name: "happy path",
|
||||
@@ -436,7 +437,7 @@ func TestGetTeamMembership(t *testing.T) {
|
||||
),
|
||||
token: "some-token",
|
||||
allowedOrganizations: []string{"alpha", "beta"},
|
||||
wantTeams: []*TeamInfo{
|
||||
wantTeams: []TeamInfo{
|
||||
{
|
||||
Name: "orgAlpha-team1-name",
|
||||
Slug: "orgAlpha-team1-slug",
|
||||
@@ -491,7 +492,7 @@ func TestGetTeamMembership(t *testing.T) {
|
||||
),
|
||||
token: "some-token",
|
||||
allowedOrganizations: []string{"alpha", "gamma"},
|
||||
wantTeams: []*TeamInfo{
|
||||
wantTeams: []TeamInfo{
|
||||
{
|
||||
Name: "team1-name",
|
||||
Slug: "team1-slug",
|
||||
@@ -528,11 +529,9 @@ func TestGetTeamMembership(t *testing.T) {
|
||||
Name: github.String("team3-name"),
|
||||
Slug: github.String("team3-slug"),
|
||||
Parent: &github.Team{
|
||||
Name: github.String("delta-team-name"),
|
||||
Slug: github.String("delta-team-slug"),
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("delta"),
|
||||
},
|
||||
Name: github.String("delta-team-name"),
|
||||
Slug: github.String("delta-team-slug"),
|
||||
Organization: nil, // the real GitHub API does not return Org on "Parent" team.
|
||||
},
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("gamma"),
|
||||
@@ -542,7 +541,7 @@ func TestGetTeamMembership(t *testing.T) {
|
||||
),
|
||||
),
|
||||
token: "some-token",
|
||||
wantTeams: []*TeamInfo{
|
||||
wantTeams: []TeamInfo{
|
||||
{
|
||||
Name: "team1-name",
|
||||
Slug: "team1-slug",
|
||||
@@ -553,20 +552,20 @@ func TestGetTeamMembership(t *testing.T) {
|
||||
Slug: "team2-slug",
|
||||
Org: "beta",
|
||||
},
|
||||
{
|
||||
Name: "delta-team-name",
|
||||
Slug: "delta-team-slug",
|
||||
Org: "gamma",
|
||||
},
|
||||
{
|
||||
Name: "team3-name",
|
||||
Slug: "team3-slug",
|
||||
Org: "gamma",
|
||||
},
|
||||
{
|
||||
Name: "delta-team-name",
|
||||
Slug: "delta-team-slug",
|
||||
Org: "delta",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "includes parent team if present",
|
||||
name: "includes parent team in allowed orgs if present",
|
||||
httpClient: mock.NewMockedHTTPClient(
|
||||
mock.WithRequestMatch(
|
||||
mock.GetUserTeams,
|
||||
@@ -575,33 +574,48 @@ func TestGetTeamMembership(t *testing.T) {
|
||||
Name: github.String("team-name-with-parent"),
|
||||
Slug: github.String("team-slug-with-parent"),
|
||||
Parent: &github.Team{
|
||||
Name: github.String("parent-team-name"),
|
||||
Slug: github.String("parent-team-slug"),
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("parent-team-org-that-in-reality-can-never-be-different-than-child-team-org"),
|
||||
},
|
||||
Name: github.String("parent-team-name"),
|
||||
Slug: github.String("parent-team-slug"),
|
||||
Organization: nil, // the real GitHub API does not return Org on "Parent" team.
|
||||
},
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("org-with-nested-teams"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: github.String("team-name-without-parent"),
|
||||
Slug: github.String("team-slug-without-parent"),
|
||||
Name: github.String("team-name-with-same-parent-again"),
|
||||
Slug: github.String("team-slug-with-same-parent-again"),
|
||||
Parent: &github.Team{
|
||||
Name: github.String("parent-team-name"),
|
||||
Slug: github.String("parent-team-slug"),
|
||||
Organization: nil, // the real GitHub API does not return Org on "Parent" team.
|
||||
},
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("beta"),
|
||||
Login: github.String("org-with-nested-teams"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: github.String("team-name-with-parent-in-disallowed-org"),
|
||||
Slug: github.String("team-slug-with-parent-in-disallowed-org"),
|
||||
Parent: &github.Team{
|
||||
Name: github.String("disallowed-parent-team-name"),
|
||||
Slug: github.String("disallowed-parent-team-slug"),
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("disallowed-org"),
|
||||
},
|
||||
Name: github.String("parent-team-name"),
|
||||
Slug: github.String("parent-team-slug"),
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("org-with-nested-teams"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: github.String("team-name-with-parent-from-disallowed-org"),
|
||||
Slug: github.String("team-slug-with-parent-from-disallowed-org"),
|
||||
Parent: &github.Team{
|
||||
Name: github.String("parent-team-name-from-disallowed-org"),
|
||||
Slug: github.String("parent-team-slug-from-disallowed-org"),
|
||||
Organization: nil, // the real GitHub API does not return Org on "Parent" team.
|
||||
},
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("disallowed-org"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: github.String("team-name-without-parent"),
|
||||
Slug: github.String("team-slug-without-parent"),
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("beta"),
|
||||
},
|
||||
@@ -612,29 +626,28 @@ func TestGetTeamMembership(t *testing.T) {
|
||||
token: "some-token",
|
||||
allowedOrganizations: []string{
|
||||
"org-with-nested-teams",
|
||||
"parent-team-org-that-in-reality-can-never-be-different-than-child-team-org",
|
||||
"beta",
|
||||
},
|
||||
wantTeams: []*TeamInfo{
|
||||
{
|
||||
Name: "team-name-with-parent",
|
||||
Slug: "team-slug-with-parent",
|
||||
Org: "org-with-nested-teams",
|
||||
},
|
||||
{
|
||||
Name: "parent-team-name",
|
||||
Slug: "parent-team-slug",
|
||||
Org: "parent-team-org-that-in-reality-can-never-be-different-than-child-team-org",
|
||||
},
|
||||
wantTeams: []TeamInfo{
|
||||
{
|
||||
Name: "team-name-without-parent",
|
||||
Slug: "team-slug-without-parent",
|
||||
Org: "beta",
|
||||
},
|
||||
{
|
||||
Name: "team-name-with-parent-in-disallowed-org",
|
||||
Slug: "team-slug-with-parent-in-disallowed-org",
|
||||
Org: "beta",
|
||||
Name: "parent-team-name",
|
||||
Slug: "parent-team-slug",
|
||||
Org: "org-with-nested-teams",
|
||||
},
|
||||
{
|
||||
Name: "team-name-with-parent",
|
||||
Slug: "team-slug-with-parent",
|
||||
Org: "org-with-nested-teams",
|
||||
},
|
||||
{
|
||||
Name: "team-name-with-same-parent-again",
|
||||
Slug: "team-slug-with-same-parent-again",
|
||||
Org: "org-with-nested-teams",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -665,7 +678,7 @@ func TestGetTeamMembership(t *testing.T) {
|
||||
),
|
||||
token: "some-token",
|
||||
allowedOrganizations: []string{"page1-org-name", "page2-org-name"},
|
||||
wantTeams: []*TeamInfo{
|
||||
wantTeams: []TeamInfo{
|
||||
{
|
||||
Name: "page1-team-name",
|
||||
Slug: "page1-team-slug",
|
||||
@@ -743,99 +756,6 @@ func TestGetTeamMembership(t *testing.T) {
|
||||
),
|
||||
wantErr: `error fetching team membership for authenticated user: the "slug" attribute is missing for a team`,
|
||||
},
|
||||
{
|
||||
name: "missing parent's organization attribute returns an error",
|
||||
httpClient: mock.NewMockedHTTPClient(
|
||||
mock.WithRequestMatch(
|
||||
mock.GetUserTeams,
|
||||
[]github.Team{
|
||||
{
|
||||
Name: github.String("team-name-with-parent"),
|
||||
Slug: github.String("team-slug-with-parent"),
|
||||
Parent: &github.Team{
|
||||
Name: github.String("parent-team-name"),
|
||||
Slug: github.String("parent-team-slug"),
|
||||
},
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("some-org"),
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
wantErr: `error fetching team membership for authenticated user: missing the "organization" attribute for a team`,
|
||||
},
|
||||
{
|
||||
name: "missing parent's organization's login attribute returns an error",
|
||||
httpClient: mock.NewMockedHTTPClient(
|
||||
mock.WithRequestMatch(
|
||||
mock.GetUserTeams,
|
||||
[]github.Team{
|
||||
{
|
||||
Name: github.String("team-name-with-parent"),
|
||||
Slug: github.String("team-slug-with-parent"),
|
||||
Parent: &github.Team{
|
||||
Name: github.String("parent-team-name"),
|
||||
Slug: github.String("parent-team-slug"),
|
||||
Organization: &github.Organization{},
|
||||
},
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("some-org"),
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
wantErr: `error fetching team membership for authenticated user: missing the organization's "login" attribute for a team`,
|
||||
},
|
||||
{
|
||||
name: "missing the name attribute for a parent team returns an error",
|
||||
httpClient: mock.NewMockedHTTPClient(
|
||||
mock.WithRequestMatch(
|
||||
mock.GetUserTeams,
|
||||
[]github.Team{
|
||||
{
|
||||
Name: github.String("team-name-with-parent"),
|
||||
Slug: github.String("team-slug-with-parent"),
|
||||
Parent: &github.Team{
|
||||
Slug: github.String("parent-team-slug"),
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("some-org"),
|
||||
},
|
||||
},
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("some-org"),
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
wantErr: `error fetching team membership for authenticated user: the "name" attribute is missing for a team`,
|
||||
},
|
||||
{
|
||||
name: "missing the slug attribute for a parent team returns an error",
|
||||
httpClient: mock.NewMockedHTTPClient(
|
||||
mock.WithRequestMatch(
|
||||
mock.GetUserTeams,
|
||||
[]github.Team{
|
||||
{
|
||||
Name: github.String("team-name-with-parent"),
|
||||
Slug: github.String("team-slug-with-parent"),
|
||||
Parent: &github.Team{
|
||||
Name: github.String("parent-team-name"),
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("some-org"),
|
||||
},
|
||||
},
|
||||
Organization: &github.Organization{
|
||||
Login: github.String("some-org"),
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
wantErr: `error fetching team membership for authenticated user: the "slug" attribute is missing for a team`,
|
||||
},
|
||||
{
|
||||
name: "the token is added in the Authorization header",
|
||||
httpClient: mock.NewMockedHTTPClient(
|
||||
@@ -851,7 +771,7 @@ func TestGetTeamMembership(t *testing.T) {
|
||||
),
|
||||
token: "does-this-token-work",
|
||||
allowedOrganizations: []string{"org-login"},
|
||||
wantTeams: []*TeamInfo{
|
||||
wantTeams: []TeamInfo{
|
||||
{
|
||||
Name: "team1-name",
|
||||
Slug: "team1-slug",
|
||||
@@ -907,11 +827,11 @@ func TestGetTeamMembership(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host)
|
||||
require.EqualError(t, err, test.wantErr)
|
||||
return
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, actual)
|
||||
require.Equal(t, test.wantTeams, actual)
|
||||
}
|
||||
|
||||
require.NotNil(t, actual)
|
||||
require.Equal(t, test.wantTeams, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user