connector/github: multiple orgs, query by teams

Documentation: examples of GitHub `orgs` field with multiple orgs
and org with teams; note legacy behavior
This commit is contained in:
Eric Stroczynski 2017-08-03 13:53:38 -07:00
parent 05e8d50eca
commit 9d154802a2
2 changed files with 211 additions and 29 deletions

View File

@ -9,7 +9,6 @@ When a client redeems a refresh token through dex, dex will re-query GitHub to u
## Caveats ## Caveats
* Please note that in order for a user to be authenticated via GitHub, the user needs to mark their email id as public on GitHub. This will enable the API to return the user's email to Dex. * Please note that in order for a user to be authenticated via GitHub, the user needs to mark their email id as public on GitHub. This will enable the API to return the user's email to Dex.
* Currently, authentication via GitHub allows users outside of the `Org` specified in the connector to login. This is being tracked by [issue #920][issue-920].
## Configuration ## Configuration
@ -29,11 +28,29 @@ connectors:
clientID: $GITHUB_CLIENT_ID clientID: $GITHUB_CLIENT_ID
clientSecret: $GITHUB_CLIENT_SECRET clientSecret: $GITHUB_CLIENT_SECRET
redirectURI: http://127.0.0.1:5556/dex/callback redirectURI: http://127.0.0.1:5556/dex/callback
# Optional organization to pull teams from, communicate through the # Optional organizations and teams, communicated through the "groups" scope.
# "groups" scope.
# #
# NOTE: This is an EXPERIMENTAL config option and will likely change. # NOTE: This is an EXPERIMENTAL config option and will likely change.
org: my-oranization #
# Legacy 'org' field. 'org' and 'orgs' cannot be used simultaneously. A user
# MUST be a member of the following org to authenticate with dex.
# org: my-organization
#
# Dex queries the following organizations for group information if the
# "groups" scope is provided. Group claims are formatted as "(org):(team)".
# For example if a user is part of the "engineering" team of the "coreos"
# org, the group claim would include "coreos:engineering".
#
# A user MUST be a member of at least one of the following orgs to
# authenticate with dex.
orgs:
- name: my-organization
# Include all teams as claims.
- name: my-organization-with-teams
# A white list of teams. Only include group claims for these teams.
teams:
- read-team
- blue-team
``` ```
## GitHub Enterprise ## GitHub Enterprise
@ -54,12 +71,29 @@ connectors:
clientID: $GITHUB_CLIENT_ID clientID: $GITHUB_CLIENT_ID
clientSecret: $GITHUB_CLIENT_SECRET clientSecret: $GITHUB_CLIENT_SECRET
redirectURI: http://127.0.0.1:5556/dex/callback redirectURI: http://127.0.0.1:5556/dex/callback
# Optional organization to pull teams from, communicate through the # Optional organizations and teams, communicated through the "groups" scope.
# "groups" scope.
# #
# NOTE: This is an EXPERIMENTAL config option and will likely change. # NOTE: This is an EXPERIMENTAL config option and will likely change.
org: my-oranization #
# Legacy 'org' field. 'org' and 'orgs' cannot be used simultaneously. A user
# MUST be a member of the following org to authenticate with dex.
# org: my-organization
#
# Dex queries the following organizations for group information if the
# "groups" scope is provided. Group claims are formatted as "(org):(team)".
# For example if a user is part of the "engineering" team of the "coreos"
# org, the group claim would include "coreos:engineering".
#
# A user MUST be a member of at least one of the following orgs to
# authenticate with dex.
orgs:
- name: my-organization
# Include all teams as claims.
- name: my-organization-with-teams
# A white list of teams. Only include group claims for these teams.
teams:
- read-team
- blue-team
# Required ONLY for GitHub Enterprise. # Required ONLY for GitHub Enterprise.
# This is the Hostname of the GitHub Enterprise account listed on the # This is the Hostname of the GitHub Enterprise account listed on the
# management console. Ensure this domain is routable on your network. # management console. Ensure this domain is routable on your network.
@ -70,4 +104,3 @@ connectors:
``` ```
[github-oauth2]: https://github.com/settings/applications/new [github-oauth2]: https://github.com/settings/applications/new
[issue-920]: https://github.com/coreos/dex/issues/920

View File

@ -35,15 +35,40 @@ type Config struct {
ClientSecret string `json:"clientSecret"` ClientSecret string `json:"clientSecret"`
RedirectURI string `json:"redirectURI"` RedirectURI string `json:"redirectURI"`
Org string `json:"org"` Org string `json:"org"`
Orgs []Org `json:"orgs"`
HostName string `json:"hostName"` HostName string `json:"hostName"`
RootCA string `json:"rootCA"` RootCA string `json:"rootCA"`
} }
// Org holds org-team filters, in which teams are optional.
type Org struct {
// Organization name in github (not slug, full name). Only users in this github
// organization can authenticate.
Name string `json:"name"`
// Names of teams in a github organization. A user will be able to
// authenticate if they are members of at least one of these teams. Users
// in the organization can authenticate if this field is omitted from the
// config file.
Teams []string `json:"teams,omitempty"`
}
// Open returns a strategy for logging in through GitHub. // Open returns a strategy for logging in through GitHub.
func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) { func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) {
if c.Org != "" {
// Return error if both 'org' and 'orgs' fields are used.
if len(c.Orgs) > 0 {
return nil, errors.New("github: cannot use both 'org' and 'orgs' fields simultaneously")
}
logger.Warnln("github: legacy field 'org' being used. Switch to the newer 'orgs' field structure")
}
g := githubConnector{ g := githubConnector{
redirectURI: c.RedirectURI, redirectURI: c.RedirectURI,
org: c.Org, org: c.Org,
orgs: c.Orgs,
clientID: c.ClientID, clientID: c.ClientID,
clientSecret: c.ClientSecret, clientSecret: c.ClientSecret,
apiURL: apiURL, apiURL: apiURL,
@ -89,6 +114,7 @@ var (
type githubConnector struct { type githubConnector struct {
redirectURI string redirectURI string
org string org string
orgs []Org
clientID string clientID string
clientSecret string clientSecret string
logger logrus.FieldLogger logger logrus.FieldLogger
@ -213,11 +239,24 @@ func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
EmailVerified: true, EmailVerified: true,
} }
if s.Groups && c.org != "" { if s.Groups {
groups, err := c.teams(ctx, client, c.org) var groups []string
if len(c.orgs) > 0 {
if groups, err = c.listGroups(ctx, client, username); err != nil {
return identity, err
}
} else if c.org != "" {
inOrg, err := c.userInOrg(ctx, client, username, c.org)
if err != nil { if err != nil {
return identity, err
}
if !inOrg {
return identity, fmt.Errorf("github: user %q not a member of org %q", username, c.org)
}
if groups, err = c.teams(ctx, client, c.org); err != nil {
return identity, fmt.Errorf("github: get teams: %v", err) return identity, fmt.Errorf("github: get teams: %v", err)
} }
}
identity.Groups = groups identity.Groups = groups
} }
@ -233,37 +272,112 @@ func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
return identity, nil return identity, nil
} }
func (c *githubConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) { func (c *githubConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
if len(ident.ConnectorData) == 0 { if len(identity.ConnectorData) == 0 {
return ident, errors.New("no upstream access token found") return identity, errors.New("no upstream access token found")
} }
var data connectorData var data connectorData
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil { if err := json.Unmarshal(identity.ConnectorData, &data); err != nil {
return ident, fmt.Errorf("github: unmarshal access token: %v", err) return identity, fmt.Errorf("github: unmarshal access token: %v", err)
} }
client := c.oauth2Config(s).Client(ctx, &oauth2.Token{AccessToken: data.AccessToken}) client := c.oauth2Config(s).Client(ctx, &oauth2.Token{AccessToken: data.AccessToken})
user, err := c.user(ctx, client) user, err := c.user(ctx, client)
if err != nil { if err != nil {
return ident, fmt.Errorf("github: get user: %v", err) return identity, fmt.Errorf("github: get user: %v", err)
} }
username := user.Name username := user.Name
if username == "" { if username == "" {
username = user.Login username = user.Login
} }
ident.Username = username identity.Username = username
ident.Email = user.Email identity.Email = user.Email
if s.Groups && c.org != "" { if s.Groups {
groups, err := c.teams(ctx, client, c.org) var groups []string
if len(c.orgs) > 0 {
if groups, err = c.listGroups(ctx, client, username); err != nil {
return identity, err
}
} else if c.org != "" {
inOrg, err := c.userInOrg(ctx, client, username, c.org)
if err != nil { if err != nil {
return ident, fmt.Errorf("github: get teams: %v", err) return identity, err
} }
ident.Groups = groups if !inOrg {
return identity, fmt.Errorf("github: user %q not a member of org %q", username, c.org)
} }
return ident, nil if groups, err = c.teams(ctx, client, c.org); err != nil {
return identity, fmt.Errorf("github: get teams: %v", err)
}
}
identity.Groups = groups
}
return identity, nil
}
// listGroups enforces org and team constraints on user authorization
// Cases in which user is authorized:
// N orgs, no teams: user is member of at least 1 org
// N orgs, M teams per org: user is member of any team from at least 1 org
// N-1 orgs, M teams per org, 1 org with no teams: user is member of any team
// from at least 1 org, or member of org with no teams
func (c *githubConnector) listGroups(ctx context.Context, client *http.Client, userName string) (groups []string, err error) {
var inOrgNoTeams bool
for _, org := range c.orgs {
inOrg, err := c.userInOrg(ctx, client, userName, org.Name)
if err != nil {
return groups, err
}
if !inOrg {
continue
}
teams, err := c.teams(ctx, client, org.Name)
if err != nil {
return groups, err
}
// User is in at least one org. User is authorized if no teams are specified
// in config; include all teams in claim. Otherwise filter out teams not in
// 'teams' list in config.
if len(org.Teams) == 0 {
inOrgNoTeams = true
c.logger.Debugf("github: user %q in org %q", userName, org.Name)
} else if teams = filterTeams(teams, org.Teams); len(teams) == 0 {
c.logger.Debugf("github: user %q in org %q but no teams", userName, org.Name)
}
// Orgs might have the same team names. We append orgPrefix to team name,
// i.e. "org:team", to make team names unique across orgs.
orgPrefix := org.Name + ":"
for _, teamName := range teams {
groups = append(groups, orgPrefix+teamName)
c.logger.Debugf("github: user %q in org %q team %q", userName, org.Name, teamName)
}
}
if inOrgNoTeams || len(groups) > 0 {
return
}
return groups, fmt.Errorf("github: user %q not in required orgs or teams", userName)
}
// Filter the users' team memberships by 'teams' from config.
func filterTeams(userTeams, configTeams []string) (teams []string) {
teamFilter := make(map[string]struct{})
for _, team := range configTeams {
if _, ok := teamFilter[team]; !ok {
teamFilter[team] = struct{}{}
}
}
for _, team := range userTeams {
if _, ok := teamFilter[team]; ok {
teams = append(teams, team)
}
}
return
} }
type user struct { type user struct {
@ -303,11 +417,46 @@ func (c *githubConnector) user(ctx context.Context, client *http.Client) (user,
return u, nil return u, nil
} }
// userInOrg queries the GitHub API for a users' org membership.
//
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
// which inserts a bearer token as part of the request.
func (c *githubConnector) userInOrg(ctx context.Context, client *http.Client, userName, orgName string) (bool, error) {
// requester == user, so GET-ing this endpoint should return 404/302 if user
// is not a member
//
// https://developer.github.com/v3/orgs/members/#check-membership
apiURL := fmt.Sprintf("%s/orgs/%s/members/%s", c.apiURL, orgName, userName)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return false, fmt.Errorf("github: new req: %v", err)
}
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("github: get teams: %v", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusNoContent:
case http.StatusFound, http.StatusNotFound:
c.logger.Debugf("github: user %q not in org %q", userName, orgName)
default:
err = fmt.Errorf("github: unexpected return status: %q", resp.Status)
}
// 204 if user is a member
return resp.StatusCode == http.StatusNoContent, err
}
// teams queries the GitHub API for team membership within a specific organization. // teams queries the GitHub API for team membership within a specific organization.
// //
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package, // The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
// which inserts a bearer token as part of the request. // which inserts a bearer token as part of the request.
func (c *githubConnector) teams(ctx context.Context, client *http.Client, org string) ([]string, error) { func (c *githubConnector) teams(ctx context.Context, client *http.Client, orgName string) ([]string, error) {
groups := []string{} groups := []string{}
@ -349,7 +498,7 @@ func (c *githubConnector) teams(ctx context.Context, client *http.Client, org st
} }
for _, team := range teams { for _, team := range teams {
if team.Org.Login == org { if team.Org.Login == orgName {
groups = append(groups, team.Name) groups = append(groups, team.Name)
} }
} }