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:
Ryan Richard
2024-05-22 21:21:45 -05:00
committed by Joshua Casey
parent ba2d122308
commit 8923704f3c
14 changed files with 453 additions and 275 deletions
+37 -12
View File
@@ -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
}
+84 -164
View File
@@ -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)
})
}
}