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:
		| @@ -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 |  | ||||||
|   | |||||||
| @@ -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,10 +239,23 @@ 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 err != nil { | 		if len(c.orgs) > 0 { | ||||||
| 			return identity, fmt.Errorf("github: get teams: %v", err) | 			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 { | ||||||
|  | 				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) | ||||||
|  | 			} | ||||||
| 		} | 		} | ||||||
| 		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 err != nil { | 		if len(c.orgs) > 0 { | ||||||
| 			return ident, fmt.Errorf("github: get teams: %v", err) | 			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 { | ||||||
|  | 				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) | ||||||
|  | 			} | ||||||
| 		} | 		} | ||||||
| 		ident.Groups = groups | 		identity.Groups = groups | ||||||
| 	} | 	} | ||||||
| 	return ident, nil |  | ||||||
|  | 	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) | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user