// Package github contains a directory provider for github. package github import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/url" "sort" "github.com/rs/zerolog" "github.com/tomnomnom/linkheader" "github.com/pomerium/pomerium/internal/encoding" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/pkg/grpc/directory" ) // Name is the provider name. const Name = "github" var defaultURL = &url.URL{ Scheme: "https", Host: "api.github.com", } type config struct { httpClient *http.Client serviceAccount *ServiceAccount url *url.URL } // An Option updates the github configuration. type Option func(cfg *config) // WithServiceAccount sets the service account in the config. func WithServiceAccount(serviceAccount *ServiceAccount) Option { return func(cfg *config) { cfg.serviceAccount = serviceAccount } } // WithHTTPClient sets the http client option. func WithHTTPClient(httpClient *http.Client) Option { return func(cfg *config) { cfg.httpClient = httputil.NewLoggingClient(httpClient, "github_idp_client", func(evt *zerolog.Event) *zerolog.Event { return evt.Str("provider", "github") }) } } // WithURL sets the api url in the config. func WithURL(u *url.URL) Option { return func(cfg *config) { cfg.url = u } } func getConfig(options ...Option) *config { cfg := new(config) WithHTTPClient(http.DefaultClient)(cfg) WithURL(defaultURL)(cfg) for _, option := range options { option(cfg) } return cfg } // The Provider retrieves users and groups from github. type Provider struct { cfg *config log zerolog.Logger } // New creates a new Provider. func New(options ...Option) *Provider { return &Provider{ cfg: getConfig(options...), log: log.With().Str("service", "directory").Str("provider", "github").Logger(), } } // User returns the user record for the given id. func (p *Provider) User(ctx context.Context, userID, accessToken string) (*directory.User, error) { if p.cfg.serviceAccount == nil { return nil, fmt.Errorf("github: service account not defined") } du := &directory.User{ Id: userID, } au, err := p.getUser(ctx, userID) if err != nil { return nil, err } du.DisplayName = au.Name du.Email = au.Email teamIDLookup := map[string]struct{}{} orgSlugs, err := p.listOrgs(ctx) if err != nil { return nil, err } for _, orgSlug := range orgSlugs { teamIDs, err := p.listUserOrganizationTeams(ctx, userID, orgSlug) if err != nil { return nil, err } for _, teamID := range teamIDs { teamIDLookup[teamID] = struct{}{} } } for teamID := range teamIDLookup { du.GroupIds = append(du.GroupIds, teamID) } sort.Strings(du.GroupIds) return du, nil } // UserGroups gets the directory user groups for github. func (p *Provider) UserGroups(ctx context.Context) ([]*directory.Group, []*directory.User, error) { if p.cfg.serviceAccount == nil { return nil, nil, fmt.Errorf("github: service account not defined") } orgSlugs, err := p.listOrgs(ctx) if err != nil { return nil, nil, err } userLoginToGroups := map[string][]string{} var allGroups []*directory.Group for _, orgSlug := range orgSlugs { teams, err := p.listOrganizationTeamsWithMemberIDs(ctx, orgSlug) if err != nil { return nil, nil, err } for _, team := range teams { allGroups = append(allGroups, &directory.Group{ Id: team.Slug, Name: team.Slug, }) for _, memberID := range team.MemberIDs { userLoginToGroups[memberID] = append(userLoginToGroups[memberID], team.Slug) } } } sort.Slice(allGroups, func(i, j int) bool { return allGroups[i].Id < allGroups[j].Id }) var allUsers []*directory.User for _, orgSlug := range orgSlugs { members, err := p.listOrganizationMembers(ctx, orgSlug) if err != nil { return nil, nil, err } for _, member := range members { du := &directory.User{ Id: member.Login, GroupIds: userLoginToGroups[member.ID], DisplayName: member.Name, Email: member.Email, } sort.Strings(du.GroupIds) allUsers = append(allUsers, du) } } sort.Slice(allUsers, func(i, j int) bool { return allUsers[i].Id < allUsers[j].Id }) return allGroups, allUsers, nil } func (p *Provider) listOrgs(ctx context.Context) (orgSlugs []string, err error) { nextURL := p.cfg.url.ResolveReference(&url.URL{ Path: "/user/orgs", }).String() for nextURL != "" { var results []struct { Login string `json:"login"` } hdrs, err := p.api(ctx, nextURL, &results) if err != nil { return nil, err } for _, result := range results { orgSlugs = append(orgSlugs, result.Login) } nextURL = getNextLink(hdrs) } return orgSlugs, nil } func (p *Provider) getUser(ctx context.Context, userLogin string) (*apiUserObject, error) { apiURL := p.cfg.url.ResolveReference(&url.URL{ Path: fmt.Sprintf("/users/%s", userLogin), }).String() var res apiUserObject _, err := p.api(ctx, apiURL, &res) if err != nil { return nil, err } return &res, nil } func (p *Provider) api(ctx context.Context, apiURL string, out interface{}) (http.Header, error) { req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { return nil, fmt.Errorf("github: failed to create http request: %w", err) } req.SetBasicAuth(p.cfg.serviceAccount.Username, p.cfg.serviceAccount.PersonalAccessToken) res, err := p.cfg.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("github: failed to make http request: %w", err) } defer res.Body.Close() if res.StatusCode/100 != 2 { return nil, fmt.Errorf("github: error from API: %s", res.Status) } if out != nil { err := json.NewDecoder(res.Body).Decode(out) if err != nil { return nil, fmt.Errorf("github: failed to decode json body: %w", err) } } return res.Header, nil } func (p *Provider) graphql(ctx context.Context, query string, out interface{}) (http.Header, error) { apiURL := p.cfg.url.ResolveReference(&url.URL{ Path: "/graphql", }).String() bs, _ := json.Marshal(struct { Query string `json:"query"` }{query}) req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bs)) if err != nil { return nil, fmt.Errorf("github: failed to create http request: %w", err) } req.SetBasicAuth(p.cfg.serviceAccount.Username, p.cfg.serviceAccount.PersonalAccessToken) res, err := p.cfg.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("github: failed to make http request: %w", err) } defer res.Body.Close() if res.StatusCode/100 != 2 { return nil, fmt.Errorf("github: error from API: %s", res.Status) } if out != nil { err := json.NewDecoder(res.Body).Decode(out) if err != nil { return nil, fmt.Errorf("github: failed to decode json body: %w", err) } } return res.Header, nil } func getNextLink(hdrs http.Header) string { for _, link := range linkheader.ParseMultiple(hdrs.Values("Link")) { if link.Rel == "next" { return link.URL } } return "" } // A ServiceAccount is used by the GitHub provider to query the GitHub API. type ServiceAccount struct { Username string `json:"username"` PersonalAccessToken string `json:"personal_access_token"` } // ParseServiceAccount parses the service account in the config options. func ParseServiceAccount(rawServiceAccount string) (*ServiceAccount, error) { var serviceAccount ServiceAccount if err := encoding.DecodeBase64OrJSON(rawServiceAccount, &serviceAccount); err != nil { return nil, err } if serviceAccount.Username == "" { return nil, fmt.Errorf("username is required") } if serviceAccount.PersonalAccessToken == "" { return nil, fmt.Errorf("personal_access_token is required") } return &serviceAccount, nil } // see: https://docs.github.com/en/free-pro-team@latest/rest/reference/users#get-a-user type apiUserObject struct { Login string `json:"login"` Name string `json:"name"` Email string `json:"email"` } type teamWithMemberIDs struct { ID string Slug string Name string MemberIDs []string }