Sasha/forward ports (#2491)

* Fetch groups for GSuite SSO. (#2456)

Fixes #2455

This commit adds support for fetching
groups for GSuite SSO logins via
OIDC connector interface.

If OIDC connector has a special scope:

`https://www.googleapis.com/auth/admin.directory.group.readonly`

teleport will fetch user's group membership and populate
groups claim.

* Pass kubernetes groups to the remote cluster. (#2484)

This commit allows remote cluster to reference
the kubernetes groups coming from the roles
of the main cluster in the trusted clusters
configuration.

For example, main cluster can have a user
with a role 'main' and kubernetes groups:

kube_groups: ['system:masters']

and SSH logins:

logins: ['root']

Remote cluster can choose to map
this 'main' cluster to it's own:
'remote-admin' cluster in the trusted cluster config:

role_map:
  - remote: 'main'
    local: 'remote-admin'

The role 'remote-admin' of the remote cluster
can now be templated to use the main cluster role main
logins and kubernetes_groups using variables:

logins: ['{{internal.logins}}']
kubernetes_groups: ['{{internal.kubernetes_groups}}']

This is possible because teleport now encodes
both values in X509 certificate metadata
and remote cluster passes these values as 'internal' traits
to the template engine.
This commit is contained in:
Alexander Klizhentas
2019-01-16 18:55:59 -08:00
committed by GitHub
parent 7fc238e5e5
commit fc0566a24f
7 changed files with 212 additions and 23 deletions
+9
View File
@@ -387,6 +387,15 @@ const (
TraitInternalKubeGroupsVariable = "{{internal.kubernetes_groups}}"
)
const (
// GSuiteIssuerURL is issuer URL used for GSuite provider
GSuiteIssuerURL = "https://accounts.google.com"
// GSuiteGroupsEndpoint is gsuite API endpoint
GSuiteGroupsEndpoint = "https://www.googleapis.com/admin/directory/v1/groups"
// GSuiteGroupsScope is a scope to get access to admin groups API
GSuiteGroupsScope = "https://www.googleapis.com/auth/admin.directory.group.readonly"
)
// SCP is Secure Copy.
const SCP = "scp"
+5 -2
View File
@@ -365,8 +365,11 @@ func (s *KubeSuite) TestKubeTrustedClusters(c *check.C) {
// role specified by mapping remote role "aux-kube" to local role "main-kube"
auxRole, err := services.NewRole("aux-kube", services.RoleSpecV3{
Allow: services.RoleConditions{
Logins: []string{username},
KubeGroups: []string{teleport.KubeSystemMasters},
Logins: []string{username},
// Note that main cluster can pass it's kubernetes groups
// to the remote cluster, and remote cluster
// can choose to use them by using special variable
KubeGroups: []string{teleport.TraitInternalKubeGroupsVariable},
},
})
c.Assert(err, check.IsNil)
+10 -1
View File
@@ -130,6 +130,13 @@ func (s *AuthServer) ProcessKubeCSR(req KubeCSR) (*KubeCSRResponse, error) {
ttl := roles.AdjustSessionTTL(defaults.CertDuration)
// extract and encode the kubernetes groups of the authenticated
// user in the newly issued certificate
kubernetesGroups, err := roles.CheckKubeGroups(0)
if err != nil {
return nil, trace.Wrap(err)
}
csr, err := tlsca.ParseCertificateRequestPEM(req.CSR)
if err != nil {
return nil, trace.Wrap(err)
@@ -147,13 +154,15 @@ func (s *AuthServer) ProcessKubeCSR(req KubeCSR) (*KubeCSRResponse, error) {
if err != nil {
return nil, trace.Wrap(err)
}
identity := tlsca.Identity{
Username: user.GetName(),
Groups: roles.RoleNames(),
// Generate a certificate restricted for
// use against a kubernetes endpoint, and not the API server endpoint
// otherwise proxies can generate certs for any user.
Usage: []string{teleport.UsageKubeOnly},
Usage: []string{teleport.UsageKubeOnly},
KubernetesGroups: kubernetesGroups,
}
certRequest := tlsca.CertificateRequest{
Clock: s.clock,
+5 -4
View File
@@ -240,10 +240,11 @@ func (a *AuthMiddleware) GetUser(r *http.Request) (interface{}, error) {
}, nil
}
return RemoteUser{
ClusterName: certClusterName,
Username: identity.Username,
Principals: identity.Principals,
RemoteRoles: identity.Groups,
ClusterName: certClusterName,
Username: identity.Username,
Principals: identity.Principals,
KubernetesGroups: identity.KubernetesGroups,
RemoteRoles: identity.Groups,
}, nil
}
// code below expects user or service from local cluster, to distinguish between
+161 -3
View File
@@ -1,5 +1,5 @@
/*
Copyright 2017 Gravitational, Inc.
Copyright 2017-2019 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@ package auth
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"time"
@@ -29,6 +30,7 @@ import (
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/utils"
phttp "github.com/coreos/go-oidc/http"
"github.com/coreos/go-oidc/jose"
"github.com/coreos/go-oidc/oauth2"
"github.com/coreos/go-oidc/oidc"
@@ -175,7 +177,7 @@ func (a *AuthServer) validateOIDCAuthCallback(q url.Values) (*OIDCAuthResponse,
}
// extract claims from both the id token and the userinfo endpoint and merge them
claims, err := a.getClaims(oidcClient, connector.GetIssuerURL(), code)
claims, err := a.getClaims(oidcClient, connector.GetIssuerURL(), connector.GetScope(), code)
if err != nil {
return nil, trace.OAuth2(
oauth2.ErrorUnsupportedResponseType, "unable to construct claims", q)
@@ -476,6 +478,138 @@ func claimsFromUserInfo(oidcClient *oidc.Client, issuerURL string, accessToken s
return claims, nil
}
func (a *AuthServer) claimsFromGSuite(oidcClient *oidc.Client, issuerURL string, userEmail string, accessToken string) (jose.Claims, error) {
client, err := a.newGsuiteClient(oidcClient, issuerURL, userEmail, accessToken)
if err != nil {
return nil, trace.Wrap(err)
}
return client.fetchGroups()
}
func (a *AuthServer) newGsuiteClient(oidcClient *oidc.Client, issuerURL string, userEmail string, accessToken string) (*gsuiteClient, error) {
err := isHTTPS(issuerURL)
if err != nil {
return nil, trace.Wrap(err)
}
oac, err := oidcClient.OAuthClient()
if err != nil {
return nil, trace.Wrap(err)
}
u, err := url.Parse(teleport.GSuiteGroupsEndpoint)
if err != nil {
return nil, trace.Wrap(err)
}
return &gsuiteClient{
Client: oac.HttpClient(),
url: *u,
userEmail: userEmail,
accessToken: accessToken,
auditLog: a,
}, nil
}
type gsuiteClient struct {
phttp.Client
url url.URL
userEmail string
accessToken string
auditLog events.IAuditLog
}
// fetchGroups fetches GSuite groups a user belongs to and returns
// "groups" claim with
func (g *gsuiteClient) fetchGroups() (jose.Claims, error) {
count := 0
var groups []string
var nextPageToken string
collect:
for {
if count > MaxPages {
warningMessage := "Truncating list of teams used to populate claims: " +
"hit maximum number pages that can be fetched from GSuite."
// Print warning to Teleport logs as well as the Audit Log.
log.Warnf(warningMessage)
g.auditLog.EmitAuditEvent(events.UserLoginEvent, events.EventFields{
events.LoginMethod: events.LoginMethodOIDC,
events.AuthAttemptMessage: warningMessage,
})
break collect
}
response, err := g.fetchGroupsPage(nextPageToken)
if err != nil {
return nil, trace.Wrap(err)
}
groups = append(groups, response.groups()...)
if response.NextPageToken == "" {
break collect
}
count++
nextPageToken = response.NextPageToken
}
return jose.Claims{"groups": groups}, nil
}
func (g *gsuiteClient) fetchGroupsPage(pageToken string) (*gsuiteGroups, error) {
// copy URL to avoid modifying the same url
// with query parameters
u := g.url
q := u.Query()
q.Set("userKey", g.userEmail)
if pageToken != "" {
q.Set("pageToken", pageToken)
}
u.RawQuery = q.Encode()
endpoint := u.String()
log.Debugf("Fetching OIDC claims from GSuite groups endpoint: %q.", endpoint)
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return nil, trace.Wrap(err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", g.accessToken))
resp, err := g.Do(req)
if err != nil {
return nil, trace.Wrap(err)
}
defer resp.Body.Close()
bytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, trace.Wrap(err)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, trace.AccessDenied("bad status code: %v %v", resp.StatusCode, string(bytes))
}
var response gsuiteGroups
if err := json.Unmarshal(bytes, &response); err != nil {
return nil, trace.BadParameter("failed to parse response: %v", err)
}
return &response, nil
}
type gsuiteGroups struct {
NextPageToken string `json:"nextPageToken"`
Groups []gsuiteGroup `json:"groups"`
}
func (g gsuiteGroups) groups() []string {
groups := make([]string, len(g.Groups))
for i, group := range g.Groups {
groups[i] = group.Email
}
return groups
}
type gsuiteGroup struct {
Email string `json:"email"`
}
// mergeClaims merges b into a.
func mergeClaims(a jose.Claims, b jose.Claims) (jose.Claims, error) {
for k, v := range b {
@@ -489,7 +623,7 @@ func mergeClaims(a jose.Claims, b jose.Claims) (jose.Claims, error) {
}
// getClaims gets claims from ID token and UserInfo and returns UserInfo claims merged into ID token claims.
func (a *AuthServer) getClaims(oidcClient *oidc.Client, issuerURL string, code string) (jose.Claims, error) {
func (a *AuthServer) getClaims(oidcClient *oidc.Client, issuerURL string, scope []string, code string) (jose.Claims, error) {
var err error
oac, err := oidcClient.OAuthClient()
@@ -545,6 +679,30 @@ func (a *AuthServer) getClaims(oidcClient *oidc.Client, issuerURL string, code s
return nil, trace.Wrap(err)
}
// for GSuite users, fetch extra data from the proprietary google API
// only if scope includes admin groups readonly scope
if issuerURL == teleport.GSuiteIssuerURL && utils.SliceContainsStr(scope, teleport.GSuiteGroupsScope) {
email, _, err := claims.StringClaim("email")
if err != nil {
return nil, trace.Wrap(err)
}
gsuiteClaims, err := a.claimsFromGSuite(oidcClient, issuerURL, email, t.AccessToken)
if err != nil {
if !trace.IsNotFound(err) {
return nil, trace.Wrap(err)
}
log.Debugf("Found no GSuite claims.")
} else {
if gsuiteClaims != nil {
log.Debugf("Got GSuiteclaims: %v.", gsuiteClaims)
}
claims, err = mergeClaims(claims, gsuiteClaims)
if err != nil {
return nil, trace.Wrap(err)
}
}
}
return claims, nil
}
+14 -9
View File
@@ -138,8 +138,17 @@ func (a *authorizer) authorizeRemoteUser(u RemoteUser) (*AuthContext, error) {
if len(roleNames) == 0 {
return nil, trace.AccessDenied("no roles mapped for remote user %q from cluster %q", u.Username, u.ClusterName)
}
log.Debugf("Mapped roles %v of remote user %q to local roles %v.", u.RemoteRoles, u.Username, roleNames)
checker, err := services.FetchRoles(roleNames, a.access, nil)
// Set "logins" trait and "kubernetes_groups" for the remote user. This allows Teleport to work by
// passing exact logins and kubernetes groups to the remote cluster. Note that claims (OIDC/SAML)
// are not passed, but rather the exact logins, this is done to prevent
// leaking too much of identity to the remote cluster, and instead of focus
// on main cluster's interpretation of this identity
traits := map[string][]string{
teleport.TraitLogins: u.Principals,
teleport.TraitKubeGroups: u.KubernetesGroups,
}
log.Debugf("Mapped roles %v of remote user %q to local roles %v and traits %v.", u.RemoteRoles, u.Username, roleNames, traits)
checker, err := services.FetchRoles(roleNames, a.access, traits)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -149,13 +158,6 @@ func (a *authorizer) authorizeRemoteUser(u RemoteUser) (*AuthContext, error) {
if err != nil {
return nil, trace.Wrap(err)
}
// Set "logins" trait for the remote user. This allows Teleport to work by
// passing exact logins to the remote cluster. Note that claims (OIDC/SAML)
// are not passed, but rather the exact logins.
traits := map[string][]string{
teleport.TraitLogins: u.Principals,
}
user.SetTraits(traits)
// Set the list of roles this user has in the remote cluster.
@@ -518,6 +520,9 @@ type RemoteUser struct {
// Principals is a list of Unix logins.
Principals []string `json:"principals"`
// KubernetesGroups is a list of Kubernetes groups
KubernetesGroups []string `json:"kubernetes_groups"`
}
// GetClusterConfigFunc returns a cached services.ClusterConfig.
+8 -4
View File
@@ -73,6 +73,8 @@ type Identity struct {
Usage []string
// Principals is a list of Unix logins allowed.
Principals []string
// KubernetesGroups is a list of Kubernetes groups allowed
KubernetesGroups []string
}
// CheckAndSetDefaults checks and sets default values
@@ -94,16 +96,18 @@ func (id *Identity) Subject() pkix.Name {
subject.Organization = append([]string{}, id.Groups...)
subject.OrganizationalUnit = append([]string{}, id.Usage...)
subject.Locality = append([]string{}, id.Principals...)
subject.Province = append([]string{}, id.KubernetesGroups...)
return subject
}
// FromSubject returns identity from subject name
func FromSubject(subject pkix.Name) (*Identity, error) {
i := &Identity{
Username: subject.CommonName,
Groups: subject.Organization,
Usage: subject.OrganizationalUnit,
Principals: subject.Locality,
Username: subject.CommonName,
Groups: subject.Organization,
Usage: subject.OrganizationalUnit,
Principals: subject.Locality,
KubernetesGroups: subject.Province,
}
if err := i.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)