mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-25 00:19:07 +08:00
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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user