Adds support for kubernetes_users, extend interpolation (#3404) (#3418)

This commit fixes #3369, refs #3374

It adds support for kuberenetes_users section in roles,
allowing Teleport proxy to impersonate user identities.

It also extends variable interpolation syntax by adding
suffix and prefix to variables and function `email.local`:

Example:

```yaml
kind: role
version: v3
metadata:
  name: admin
spec:
  allow:
    # extract email local part from the email claim
    logins: ['{{email.local(external.email)}}']

    # impersonate a kubernetes user with IAM prefix
    kubernetes_users: ['IAM#{{external.email}}']

  # the deny section uses the identical format as the 'allow' section.
  # the deny rules always override allow rules.
  deny: {}
```

Some notes on email.local behavior:

* This is the only function supported in the template variables for now
* In case if the email.local will encounter invalid email address,
it will interpolate to empty value, will be removed from resulting
output.

Changes in impersonation behavior:

* By default, if no kubernetes_users is set, which is a majority of cases,
  user will impersonate themselves, which is the backwards-compatible behavior.

* As long as at least one `kubernetes_users` is set, the forwarder will start
  limiting the list of users allowed by the client to impersonate.

* If the users' role set does not include actual user name, it will be rejected,
  otherwise there will be no way to exclude the user from the list).

* If the `kuberentes_users` role set includes only one user
  (quite frequently that's the real intent), teleport will default to it,
  otherwise it will refuse to select.

  This will enable the use case when `kubernetes_users` has just one field to
  link the user identity with the IAM role, for example `IAM#{{external.email}}`

* Previous versions of the forwarding proxy were denying all external
impersonation headers, this commit allows 'Impesrsonate-User' and
'Impersonate-Group' header values that are allowed by role set.

* Previous versions of the forwarding proxy ignored 'Deny' section of the roles
when applied to impersonation, this commit fixes that - roles with deny
kubernetes_users and kubernetes_groups section will not allow
impersonation of those users and groups.
This commit is contained in:
Alexander Klizhentas
2020-03-07 16:32:37 -08:00
committed by GitHub
parent 382628f479
commit 73ecb48232
25 changed files with 1172 additions and 544 deletions
+4
View File
@@ -445,6 +445,10 @@ const (
// allowed kubernetes groups
TraitKubeGroups = "kubernetes_groups"
// TraitKubeUsers is the name the role variable used to store
// allowed kubernetes users
TraitKubeUsers = "kubernetes_users"
// TraitInternalLoginsVariable is the variable used to store allowed
// logins for local accounts.
TraitInternalLoginsVariable = "{{internal.logins}}"
+1 -1
Submodule e updated: 1e87639954...0b124e0452
+83 -2
View File
@@ -1,5 +1,5 @@
/*
Copyright 2016-2019 Gravitational, Inc.
Copyright 2016-2020 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -155,6 +155,7 @@ func (s *KubeSuite) TestKubeExec(c *check.C) {
Allow: services.RoleConditions{
Logins: []string{username},
KubeGroups: []string{teleport.KubeSystemMasters},
KubeUsers: []string{"alice@example.com"},
},
})
t.AddUserWithRole(username, role)
@@ -166,7 +167,8 @@ func (s *KubeSuite) TestKubeExec(c *check.C) {
c.Assert(err, check.IsNil)
defer t.Stop(true)
// impersonating client requests will be denied
// impersonating client requests will be denied if the headers
// are referencing users or groups not allowed by the existing roles
impersonatingProxyClient, impersonatingProxyClientConfig, err := kubeProxyClient(kubeProxyConfig{
t: t,
username: username,
@@ -180,6 +182,23 @@ func (s *KubeSuite) TestKubeExec(c *check.C) {
})
c.Assert(err, check.NotNil)
// scoped client requests will be allowed, as long as the impersonation headers
// are referencing users and groups allowed by existing roles
scopedProxyClient, scopedProxyClientConfig, err := kubeProxyClient(kubeProxyConfig{
t: t,
username: username,
impersonation: &rest.ImpersonationConfig{
UserName: role.GetKubeUsers(services.Allow)[0],
Groups: role.GetKubeGroups(services.Allow),
},
})
c.Assert(err, check.IsNil)
_, err = scopedProxyClient.CoreV1().Pods(kubeSystemNamespace).List(metav1.ListOptions{
LabelSelector: kubeDNSLabels.AsSelector().String(),
})
c.Assert(err, check.IsNil)
// set up kube configuration using proxy
proxyClient, proxyClientConfig, err := kubeProxyClient(kubeProxyConfig{t: t, username: username})
c.Assert(err, check.IsNil)
@@ -266,6 +285,68 @@ loop:
})
c.Assert(err, check.NotNil)
c.Assert(err.Error(), check.Matches, ".*impersonation request has been denied.*")
// scoped kube exec is allowed, impersonation headers
// are allowed by the role
term = NewTerminal(250)
term.Type("\aecho hi\n\r\aexit\n\r\a")
out = &bytes.Buffer{}
err = kubeExec(scopedProxyClientConfig, kubeExecArgs{
podName: pod.Name,
podNamespace: pod.Namespace,
container: kubeDNSContainer,
command: []string{"/bin/sh"},
stdout: out,
tty: true,
stdin: &term,
})
c.Assert(err, check.IsNil)
}
// TestKubeDeny makes sure that deny rule conflicting with allow
// rule takes precendence
func (s *KubeSuite) TestKubeDeny(c *check.C) {
tconf := s.teleKubeConfig(Host)
t := NewInstance(InstanceConfig{
ClusterName: Site,
HostID: HostID,
NodeName: Host,
Ports: s.ports.PopIntSlice(5),
Priv: s.priv,
Pub: s.pub,
})
username := s.me.Username
role, err := services.NewRole("kubemaster", services.RoleSpecV3{
Allow: services.RoleConditions{
Logins: []string{username},
KubeGroups: []string{teleport.KubeSystemMasters},
KubeUsers: []string{"alice@example.com"},
},
Deny: services.RoleConditions{
KubeGroups: []string{teleport.KubeSystemMasters},
KubeUsers: []string{"alice@example.com"},
},
})
t.AddUserWithRole(username, role)
err = t.CreateEx(nil, tconf)
c.Assert(err, check.IsNil)
err = t.Start()
c.Assert(err, check.IsNil)
defer t.Stop(true)
// set up kube configuration using proxy
proxyClient, _, err := kubeProxyClient(kubeProxyConfig{t: t, username: username})
c.Assert(err, check.IsNil)
// try get request to fetch available pods
_, err = proxyClient.CoreV1().Pods(kubeSystemNamespace).List(metav1.ListOptions{
LabelSelector: kubeDNSLabels.AsSelector().String(),
})
c.Assert(err, check.NotNil)
}
// TestKubePortForward tests kubernetes port forwarding
+7 -4
View File
@@ -1,5 +1,5 @@
/*
Copyright 2017 Gravitational, Inc.
Copyright 2017-2020 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -289,9 +289,12 @@ type createUserParams struct {
// logins is the list of *nix logins.
logins []string
// kubeGroups is the list of Kubernetes this user belongs to.
// kubeGroups is the list of Kubernetes groups this user belongs to.
kubeGroups []string
// kubeUsers is the list of Kubernetes users this user belongs to.
kubeUsers []string
// roles is the list of roles this user is assigned to.
roles []string
@@ -309,14 +312,14 @@ func (s *AuthServer) calculateGithubUser(connector services.GithubConnector, cla
}
// Calculate logins, kubegroups, roles, and traits.
p.logins, p.kubeGroups = connector.MapClaims(*claims)
p.logins, p.kubeGroups, p.kubeUsers = connector.MapClaims(*claims)
if len(p.logins) == 0 {
return nil, trace.BadParameter(
"user %q does not belong to any teams configured in %q connector",
claims.Username, connector.GetName())
}
p.roles = modules.GetModules().RolesFromLogins(p.logins)
p.traits = modules.GetModules().TraitsFromLogins(p.logins, p.kubeGroups)
p.traits = modules.GetModules().TraitsFromLogins(p.logins, p.kubeGroups, p.kubeUsers)
// Pick smaller for role: session TTL from role or requested TTL.
roles, err := services.FetchRoles(p.roles, s.Access, p.traits)
+2 -1
View File
@@ -98,7 +98,7 @@ func (s *AuthServer) ProcessKubeCSR(req KubeCSR) (*KubeCSRResponse, error) {
// extract and encode the kubernetes groups of the authenticated
// user in the newly issued certificate
kubernetesGroups, err := roles.CheckKubeGroups(0)
kubernetesGroups, kubernetesUsers, err := roles.CheckKubeGroupsAndUsers(0)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -129,6 +129,7 @@ func (s *AuthServer) ProcessKubeCSR(req KubeCSR) (*KubeCSRResponse, error) {
// otherwise proxies can generate certs for any user.
Usage: []string{teleport.UsageKubeOnly},
KubernetesGroups: kubernetesGroups,
KubernetesUsers: kubernetesUsers,
}
subject, err := identity.Subject()
if err != nil {
+1
View File
@@ -261,6 +261,7 @@ func (a *AuthMiddleware) GetUser(r *http.Request) (IdentityGetter, error) {
Username: identity.Username,
Principals: identity.Principals,
KubernetesGroups: identity.KubernetesGroups,
KubernetesUsers: identity.KubernetesUsers,
RemoteRoles: identity.Groups,
Identity: *identity,
}, nil
+7 -2
View File
@@ -147,15 +147,17 @@ func (a *authorizer) authorizeRemoteUser(u RemoteUser) (*AuthContext, error) {
return nil, trace.AccessDenied("no roles mapped for remote user %q from cluster %q", u.Username, u.ClusterName)
}
// 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)
// passing exact logins, kubernetes groups and users 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,
teleport.TraitKubeUsers: u.KubernetesUsers,
}
log.Debugf("Mapped roles %v of remote user %q to local roles %v and traits %v.", u.RemoteRoles, u.Username, roleNames, traits)
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)
@@ -589,6 +591,9 @@ type RemoteUser struct {
// KubernetesGroups is a list of Kubernetes groups
KubernetesGroups []string `json:"kubernetes_groups"`
// KubernetesUsers is a list of Kubernetes users
KubernetesUsers []string `json:"kubernetes_users"`
// Identity is source x509 used to build this role
Identity tlsca.Identity
}
+110 -20
View File
@@ -221,7 +221,8 @@ func (f *Forwarder) Close() error {
// contains information about user, target cluster and authenticated groups
type authContext struct {
auth.AuthContext
kubeGroups []string
kubeGroups map[string]struct{}
kubeUsers map[string]struct{}
cluster cluster
clusterConfig services.ClusterConfig
// clientIdleTimeout sets information on client idle timeout
@@ -234,13 +235,13 @@ type authContext struct {
}
func (c authContext) String() string {
return fmt.Sprintf("user: %v, groups: %v, cluster: %v", c.User.GetName(), c.kubeGroups, c.cluster.GetName())
return fmt.Sprintf("user: %v, users: %v, groups: %v, cluster: %v", c.User.GetName(), c.kubeUsers, c.kubeGroups, c.cluster.GetName())
}
func (c *authContext) key() string {
// it is important that the context key contains user, kubernetes groups and certificate expiry,
// so that new logins with different parameters will not reuse this context
return fmt.Sprintf("%v:%v:%v:%v", c.cluster.GetName(), c.User.GetName(), c.kubeGroups, c.disconnectExpiredCert.UTC().Unix())
return fmt.Sprintf("%v:%v:%v:%v:%v", c.cluster.GetName(), c.User.GetName(), c.kubeUsers, c.kubeGroups, c.disconnectExpiredCert.UTC().Unix())
}
// cluster represents cluster information, name of the cluster
@@ -357,11 +358,17 @@ func (f *Forwarder) setupContext(ctx auth.AuthContext, req *http.Request, isRemo
sessionTTL := roles.AdjustSessionTTL(time.Hour)
// check signing TTL and return a list of allowed logins
kubeGroups, err := roles.CheckKubeGroups(sessionTTL)
kubeGroups, kubeUsers, err := roles.CheckKubeGroupsAndUsers(sessionTTL)
if err != nil {
return nil, trace.Wrap(err)
}
// By default, if no kubernetes_users is set (which will be a majority),
// user will impersonate themselves, which is the backwards-compatible behavior.
if len(kubeUsers) == 0 {
kubeUsers = append(kubeUsers, ctx.User.GetName())
}
// KubeSystemAuthenticated is a builtin group that allows
// any user to access common API methods, e.g. discovery methods
// required for initial client usage, without it, restricted user's
@@ -376,7 +383,6 @@ func (f *Forwarder) setupContext(ctx auth.AuthContext, req *http.Request, isRemo
return nil, trace.Wrap(err)
}
if ctx.Identity.RouteToCluster != "" {
f.Debugf("Client certificate of %v has requested routing to a cluster: %v.", ctx.User.GetName(), ctx.Identity.RouteToCluster)
targetCluster, err = f.Tunnel.GetSite(ctx.Identity.RouteToCluster)
if err != nil {
return nil, trace.Wrap(err)
@@ -411,7 +417,8 @@ func (f *Forwarder) setupContext(ctx auth.AuthContext, req *http.Request, isRemo
clientIdleTimeout: roles.AdjustClientIdleTimeout(clusterConfig.GetClientIdleTimeout()),
sessionTTL: sessionTTL,
AuthContext: ctx,
kubeGroups: kubeGroups,
kubeGroups: utils.StringsSet(kubeGroups),
kubeUsers: utils.StringsSet(kubeUsers),
clusterConfig: clusterConfig,
cluster: cluster{
remoteAddr: utils.NetAddr{AddrNetwork: "tcp", Addr: req.RemoteAddr},
@@ -634,11 +641,21 @@ func (f *Forwarder) portForward(ctx *authContext, w http.ResponseWriter, req *ht
return nil, nil
}
const (
// ImpersonateHeaderPrefix is K8s impersonation prefix for impersonation feature:
// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation
ImpersonateHeaderPrefix = "Impersonate-"
// ImpersonateUserHeader is impersonation header for users
ImpersonateUserHeader = "Impersonate-User"
// ImpersonateGroupHeader is K8s impersonation header for user
ImpersonateGroupHeader = "Impersonate-Group"
// ImpersonationRequestDeniedMessage is access denied message for impersonation
ImpersonationRequestDeniedMessage = "impersonation request has been denied"
)
func (f *Forwarder) setupForwardingHeaders(ctx *authContext, sess *clusterSession, req *http.Request) error {
for header := range req.Header {
if strings.HasPrefix(header, "Impersonate-") {
return trace.AccessDenied("impersonation request has been denied")
}
if err := setupImpersonationHeaders(f.Entry, ctx, req.Header, f.creds.cfg.BearerToken); err != nil {
return trace.Wrap(err)
}
// Setup scheme, override target URL to the destination address
@@ -652,16 +669,89 @@ func (f *Forwarder) setupForwardingHeaders(ctx *authContext, sess *clusterSessio
req.Header.Add("X-Forwarded-Host", req.Host)
req.Header.Add("X-Forwarded-Path", req.URL.Path)
if !ctx.cluster.isRemote {
req.Header.Add("Impersonate-User", ctx.User.GetName())
f.Debugf("Impersonate User: %v", ctx.User)
for _, group := range ctx.kubeGroups {
req.Header.Add("Impersonate-Group", group)
f.Debugf("Impersonate Group: %v", group)
return nil
}
// setupImpersonationHeaders sets up Impersonate-User and Impersonate-Group headers
func setupImpersonationHeaders(log log.FieldLogger, ctx *authContext, headers http.Header, bearerToken string) error {
var impersonateUser string
var impersonateGroups []string
for header, values := range headers {
if !strings.HasPrefix(header, "Impersonate-") {
continue
}
if f.creds.cfg.BearerToken != "" {
f.Debugf("Using Bearer Token Auth")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", f.creds.cfg.BearerToken))
switch header {
case ImpersonateUserHeader:
if impersonateUser != "" {
return trace.AccessDenied("%v, user already specified to %q", ImpersonationRequestDeniedMessage, impersonateUser)
}
if len(values) == 0 || len(values) > 1 {
return trace.AccessDenied("%v, invalid user header %q", ImpersonationRequestDeniedMessage, values)
}
impersonateUser = values[0]
if _, ok := ctx.kubeUsers[impersonateUser]; !ok {
return trace.AccessDenied("%v, user header %q is not allowed in roles", ImpersonationRequestDeniedMessage, impersonateUser)
}
case ImpersonateGroupHeader:
for _, group := range values {
if _, ok := ctx.kubeGroups[group]; !ok {
return trace.AccessDenied("%v, group header %q value is not allowed in roles", ImpersonationRequestDeniedMessage, group)
}
impersonateGroups = append(impersonateGroups, group)
}
default:
return trace.AccessDenied("%v, unsupported impersonation header %q", ImpersonationRequestDeniedMessage, header)
}
}
impersonateGroups = utils.Deduplicate(impersonateGroups)
// By default, if no kubernetes_users is set (which will be a majority),
// user will impersonate themselves, which is the backwards-compatible behavior.
//
// As long as at least one `kubernetes_users` is set, the forwarder will start
// limiting the list of users allowed by the client to impersonate.
//
// If the users' role set does not include actual user name, it will be rejected,
// otherwise there will be no way to exclude the user from the list).
//
// If the `kubernetes_users` role set includes only one user
// (quite frequently that's the real intent), teleport will default to it,
// otherwise it will refuse to select.
//
// This will enable the use case when `kubernetes_users` has just one field to
// link the user identity with the IAM role, for example `IAM#{{external.email}}`
//
if impersonateUser == "" {
switch len(ctx.kubeUsers) {
// this is currently not possible as kube users have at least one
// user (user name), but in case if someone breaks it, catch here
case 0:
return trace.AccessDenied("assumed at least one user to be present")
// if there is deterministic choice, make it to improve user experience
case 1:
for user := range ctx.kubeUsers {
impersonateUser = user
break
}
default:
return trace.AccessDenied(
"please select a user to impersonate, refusing to select a user due to several kuberenetes_users set up for this user")
}
}
if len(impersonateGroups) == 0 {
for group := range ctx.kubeGroups {
impersonateGroups = append(impersonateGroups, group)
}
}
if !ctx.cluster.isRemote {
headers.Add("Impersonate-User", impersonateUser)
for _, group := range impersonateGroups {
headers.Add("Impersonate-Group", group)
}
if bearerToken != "" {
headers.Set("Authorization", fmt.Sprintf("Bearer %v", bearerToken))
}
}
return nil
@@ -993,7 +1083,7 @@ func (f *Forwarder) requestCertificate(ctx authContext) (*bundle, error) {
csr := &x509.CertificateRequest{
Subject: pkix.Name{
CommonName: ctx.User.GetName(),
Organization: ctx.kubeGroups,
Organization: utils.StringsSliceFromSet(ctx.kubeGroups),
},
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, csr, privateKey)
+3 -12
View File
@@ -32,6 +32,7 @@ import (
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/trace"
log "github.com/sirupsen/logrus"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -189,18 +190,8 @@ func (s *SpdyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
header.Add(httpstream.HeaderConnection, httpstream.HeaderUpgrade)
header.Add(httpstream.HeaderUpgrade, streamspdy.HeaderSpdy31)
// impersonation for remote clusters is handled by remote proxies
if !s.authCtx.cluster.isRemote {
header.Add("Impersonate-User", s.authCtx.User.GetName())
log.Debugf("Impersonate User: %v", s.authCtx.User)
for _, group := range s.authCtx.kubeGroups {
header.Add("Impersonate-Group", group)
log.Debugf("Impersonate Group: %v", group)
}
if s.bearerToken != "" {
log.Debugf("Using Bearer Token Auth")
header.Set("Authorization", fmt.Sprintf("Bearer %v", s.bearerToken))
}
if err := setupImpersonationHeaders(log.StandardLogger(), &s.authCtx, header, s.bearerToken); err != nil {
return nil, trace.Wrap(err)
}
var (
+3 -2
View File
@@ -44,7 +44,7 @@ type Modules interface {
RolesFromLogins([]string) []string
// TraitsFromLogins returns traits for external user based on the logins
// and kubernetes groups extracted from the connector
TraitsFromLogins([]string, []string) map[string][]string
TraitsFromLogins(logins []string, kubeGroups []string, kubeUsers []string) map[string][]string
// SupportsKubernetes returns true if this cluster supports kubernetes
SupportsKubernetes() bool
// IsBoringBinary checks if the binary was compiled with BoringCrypto.
@@ -107,10 +107,11 @@ func (p *defaultModules) RolesFromLogins(logins []string) []string {
// extracted from the connector
//
// By default logins are treated as allowed logins user traits.
func (p *defaultModules) TraitsFromLogins(logins []string, kubeGroups []string) map[string][]string {
func (p *defaultModules) TraitsFromLogins(logins []string, kubeGroups, kubeUsers []string) map[string][]string {
return map[string][]string{
teleport.TraitLogins: logins,
teleport.TraitKubeGroups: kubeGroups,
teleport.TraitKubeUsers: kubeUsers,
}
}
+4 -3
View File
@@ -44,10 +44,11 @@ func (s *ModulesSuite) TestDefaultModules(c *check.C) {
roles := GetModules().RolesFromLogins([]string{"root"})
c.Assert(roles, check.DeepEquals, []string{teleport.AdminRoleName})
traits := GetModules().TraitsFromLogins([]string{"root"}, []string{"system:masters"})
traits := GetModules().TraitsFromLogins([]string{"root"}, []string{"system:masters"}, []string{"alice@example.com"})
c.Assert(traits, check.DeepEquals, map[string][]string{
teleport.TraitLogins: []string{"root"},
teleport.TraitKubeGroups: []string{"system:masters"},
teleport.TraitKubeUsers: []string{"alice@example.com"},
})
isBoring := GetModules().IsBoringBinary()
@@ -66,7 +67,7 @@ func (s *ModulesSuite) TestTestModules(c *check.C) {
roles := GetModules().RolesFromLogins([]string{"root"})
c.Assert(roles, check.DeepEquals, []string{"root"})
traits := GetModules().TraitsFromLogins([]string{"root"}, []string{"system:masters"})
traits := GetModules().TraitsFromLogins([]string{"root"}, []string{"system:masters"}, []string{"alice@example.com"})
c.Assert(traits, check.IsNil)
isBoring := GetModules().IsBoringBinary()
@@ -97,7 +98,7 @@ func (p *testModules) RolesFromLogins(logins []string) []string {
return logins
}
func (p *testModules) TraitsFromLogins(logins []string, kubeGroups []string) map[string][]string {
func (p *testModules) TraitsFromLogins(logins []string, kubeGroups []string, kubeUsers []string) map[string][]string {
return nil
}
+8 -4
View File
@@ -52,7 +52,7 @@ type GithubConnector interface {
SetTeamsToLogins([]TeamMapping)
// MapClaims returns the list of allows logins based on the retrieved claims
// returns list of logins and kubernetes groups
MapClaims(GithubClaims) ([]string, []string)
MapClaims(GithubClaims) (logins []string, kubeGroups []string, kubeUsers []string)
// GetDisplay returns the connector display name
GetDisplay() string
// SetDisplay sets the connector display name
@@ -110,6 +110,9 @@ type TeamMapping struct {
Logins []string `json:"logins,omitempty"`
// KubeGroups is a list of allowed kubernetes groups for this org/team
KubeGroups []string `json:"kubernetes_groups,omitempty"`
// KubeUsers is a list of allowed kubernetes users to impersonate for
// this org/team
KubeUsers []string `json:"kubernetes_users,omitempty"`
}
// GithubClaims represents Github user information obtained during OAuth2 flow
@@ -240,8 +243,8 @@ func (c *GithubConnectorV3) SetDisplay(display string) {
// MapClaims returns a list of logins based on the provided claims,
// returns a list of logins and list of kubernetes groups
func (c *GithubConnectorV3) MapClaims(claims GithubClaims) ([]string, []string) {
var logins, kubeGroups []string
func (c *GithubConnectorV3) MapClaims(claims GithubClaims) ([]string, []string, []string) {
var logins, kubeGroups, kubeUsers []string
for _, mapping := range c.GetTeamsToLogins() {
teams, ok := claims.OrganizationToTeams[mapping.Organization]
if !ok {
@@ -253,10 +256,11 @@ func (c *GithubConnectorV3) MapClaims(claims GithubClaims) ([]string, []string)
if team == mapping.Team {
logins = append(logins, mapping.Logins...)
kubeGroups = append(kubeGroups, mapping.KubeGroups...)
kubeUsers = append(kubeUsers, mapping.KubeUsers...)
}
}
}
return utils.Deduplicate(logins), utils.Deduplicate(kubeGroups)
return utils.Deduplicate(logins), utils.Deduplicate(kubeGroups), utils.Deduplicate(kubeUsers)
}
var githubConnectorMarshaler GithubConnectorMarshaler = &TeleportGithubConnectorMarshaler{}
+5 -3
View File
@@ -77,6 +77,7 @@ func (s *GithubSuite) TestMapClaims(c *check.C) {
Team: "admins",
Logins: []string{"admin", "dev"},
KubeGroups: []string{"system:masters", "kube-devs"},
KubeUsers: []string{"alice@example.com"},
},
{
Organization: "gravitational",
@@ -86,15 +87,16 @@ func (s *GithubSuite) TestMapClaims(c *check.C) {
},
},
})
logins, kubeGroups := connector.MapClaims(GithubClaims{
logins, kubeGroups, kubeUsers := connector.MapClaims(GithubClaims{
OrganizationToTeams: map[string][]string{
"gravitational": []string{"admins"},
},
})
c.Assert(logins, check.DeepEquals, []string{"admin", "dev"})
c.Assert(kubeGroups, check.DeepEquals, []string{"system:masters", "kube-devs"})
c.Assert(kubeUsers, check.DeepEquals, []string{"alice@example.com"})
logins, kubeGroups = connector.MapClaims(GithubClaims{
logins, kubeGroups, kubeUsers = connector.MapClaims(GithubClaims{
OrganizationToTeams: map[string][]string{
"gravitational": []string{"devs"},
},
@@ -102,7 +104,7 @@ func (s *GithubSuite) TestMapClaims(c *check.C) {
c.Assert(logins, check.DeepEquals, []string{"dev", "test"})
c.Assert(kubeGroups, check.DeepEquals, []string{"kube-devs"})
logins, kubeGroups = connector.MapClaims(GithubClaims{
logins, kubeGroups, kubeUsers = connector.MapClaims(GithubClaims{
OrganizationToTeams: map[string][]string{
"gravitational": []string{"admins", "devs"},
},
+76 -28
View File
@@ -272,6 +272,11 @@ type Role interface {
// SetKubeGroups sets kubernetes groups for allow or deny condition.
SetKubeGroups(RoleConditionType, []string)
// GetKubeUsers returns kubernetes users to impersonate
GetKubeUsers(RoleConditionType) []string
// SetKubeUsers sets kubernetes users to impersonate for allow or deny condition.
SetKubeUsers(RoleConditionType, []string)
// GetAccessRequestConditions gets allow/deny conditions for access requests.
GetAccessRequestConditions(RoleConditionType) AccessRequestConditions
// SetAccessRequestConditions sets allow/deny conditions for access requests.
@@ -323,6 +328,21 @@ func ApplyTraits(r Role, traits map[string][]string) Role {
}
r.SetKubeGroups(condition, utils.Deduplicate(outKubeGroups))
// apply templates to kubernetes users
inKubeUsers := r.GetKubeUsers(condition)
var outKubeUsers []string
for _, user := range inKubeUsers {
variableValues, err := applyValueTraits(user, traits)
if err != nil {
if !trace.IsNotFound(err) {
log.Debugf("Skipping kube user %v: %v.", user, err)
}
continue
}
outKubeUsers = append(outKubeUsers, variableValues...)
}
r.SetKubeUsers(condition, utils.Deduplicate(outKubeUsers))
inLabels := r.GetNodeLabels(condition)
// to avoid unnecessary allocations
if inLabels != nil {
@@ -362,9 +382,8 @@ func ApplyTraits(r Role, traits map[string][]string) Role {
// mapped list of values otherwise, the function guarantees to return
// at least one value in case if return value is nil
func applyValueTraits(val string, traits map[string][]string) ([]string, error) {
// Extract the variablePrefix and variableName from the role variable.
variablePrefix, variableName, err := parse.IsRoleVariable(val)
// Extract the variable from the role variable.
variable, err := parse.RoleVariable(val)
if err != nil {
if !trace.IsNotFound(err) {
return nil, trace.Wrap(err)
@@ -373,19 +392,21 @@ func applyValueTraits(val string, traits map[string][]string) ([]string, error)
}
// For internal traits, only internal.logins and internal.kubernetes_groups is supported at the moment.
if variablePrefix == teleport.TraitInternalPrefix {
if variableName != teleport.TraitLogins && variableName != teleport.TraitKubeGroups {
return nil, trace.BadParameter("unsupported variable %q", variableName)
if variable.Namespace() == teleport.TraitInternalPrefix {
if variable.Name() != teleport.TraitLogins && variable.Name() != teleport.TraitKubeGroups && variable.Name() != teleport.TraitKubeUsers {
return nil, trace.BadParameter("unsupported variable %q", variable.Name())
}
}
// If the variable is not found in the traits, skip it.
variableValues, ok := traits[variableName]
if !ok || len(variableValues) == 0 {
return nil, trace.NotFound("variable %q not found in traits", variableName)
interpolated, err := variable.Interpolate(traits)
if trace.IsNotFound(err) || len(interpolated) == 0 {
return nil, trace.NotFound("variable %q not found in traits", variable.Name())
}
return append([]string{}, variableValues...), nil
if err != nil {
return nil, trace.Wrap(err)
}
return interpolated, nil
}
// GetVersion returns resource version
@@ -527,6 +548,25 @@ func (r *RoleV3) SetKubeGroups(rct RoleConditionType, groups []string) {
}
}
// GetKubeUsers returns kubernetes users
func (r *RoleV3) GetKubeUsers(rct RoleConditionType) []string {
if rct == Allow {
return r.Spec.Allow.KubeUsers
}
return r.Spec.Deny.KubeUsers
}
// SetKubeUsers sets kubernetes user for allow or deny condition.
func (r *RoleV3) SetKubeUsers(rct RoleConditionType, users []string) {
lcopy := utils.CopyStrings(users)
if rct == Allow {
r.Spec.Allow.KubeUsers = lcopy
} else {
r.Spec.Deny.KubeUsers = lcopy
}
}
// GetAccessRequestConditions gets conditions for access requests.
func (r *RoleV3) GetAccessRequestConditions(rct RoleConditionType) AccessRequestConditions {
cond := r.Spec.Deny.Request
@@ -647,7 +687,7 @@ func (r *RoleV3) CheckAndSetDefaults() error {
for _, condition := range []RoleConditionType{Allow, Deny} {
for _, login := range r.GetLogins(condition) {
if strings.Contains(login, "{{") || strings.Contains(login, "}}") {
_, _, err := parse.IsRoleVariable(login)
_, err := parse.RoleVariable(login)
if err != nil {
return trace.BadParameter("invalid login found: %v", login)
}
@@ -1285,9 +1325,9 @@ type AccessChecker interface {
// returns a combined list of allowed logins.
CheckLoginDuration(ttl time.Duration) ([]string, error)
// CheckKubeGroups check if role can login into kubernetes
// and returns a combined list of allowed groups
CheckKubeGroups(ttl time.Duration) ([]string, error)
// CheckKubeGroupsAndUsers check if role can login into kubernetes
// and returns two lists of combined allowed groups and users
CheckKubeGroupsAndUsers(ttl time.Duration) (groups []string, users []string, err error)
// AdjustSessionTTL will reduce the requested ttl to lowest max allowed TTL
// for this role set, otherwise it returns ttl unchanged
@@ -1626,31 +1666,39 @@ func (set RoleSet) AdjustDisconnectExpiredCert(disconnect bool) bool {
return disconnect
}
// CheckKubeGroups check if role can login into kubernetes
// and returns a combined list of allowed groups
func (set RoleSet) CheckKubeGroups(ttl time.Duration) ([]string, error) {
groups := make(map[string]bool)
// CheckKubeGroupsAndUsers check if role can login into kubernetes
// and returns two lists of allowed groups and users
func (set RoleSet) CheckKubeGroupsAndUsers(ttl time.Duration) ([]string, []string, error) {
groups := make(map[string]struct{})
users := make(map[string]struct{})
var matchedTTL bool
for _, role := range set {
maxSessionTTL := role.GetOptions().MaxSessionTTL.Value()
if ttl <= maxSessionTTL && maxSessionTTL != 0 {
matchedTTL = true
for _, group := range role.GetKubeGroups(Allow) {
groups[group] = true
groups[group] = struct{}{}
}
for _, user := range role.GetKubeUsers(Allow) {
users[user] = struct{}{}
}
}
}
for _, role := range set {
for _, group := range role.GetKubeGroups(Deny) {
delete(groups, group)
}
for _, user := range role.GetKubeUsers(Deny) {
delete(users, user)
}
}
if !matchedTTL {
return nil, trace.AccessDenied("this user cannot request kubernetes access for %v", ttl)
return nil, nil, trace.AccessDenied("this user cannot request kubernetes access for %v", ttl)
}
if len(groups) == 0 {
return nil, trace.AccessDenied("this user cannot request kubernetes access, has no assigned groups")
if len(groups) == 0 && len(users) == 0 {
return nil, nil, trace.AccessDenied("this user cannot request kubernetes access, has no assigned groups or users")
}
out := make([]string, 0, len(groups))
for group := range groups {
out = append(out, group)
}
return out, nil
return utils.StringsSliceFromSet(groups), utils.StringsSliceFromSet(users), nil
}
// CheckLoginDuration checks if role set can login up to given duration and
+65 -4
View File
@@ -1128,6 +1128,8 @@ func (s *RoleSuite) TestApplyTraits(c *C) {
outLabels Labels
inKubeGroups []string
outKubeGroups []string
inKubeUsers []string
outKubeUsers []string
}
var tests = []struct {
comment string
@@ -1146,6 +1148,16 @@ func (s *RoleSuite) TestApplyTraits(c *C) {
outLogins: []string{"bar", "root"},
},
},
{
comment: "logins substitute in allow rule with function",
inTraits: map[string][]string{
"foo": []string{"Bar <bar@example.com>"},
},
allow: rule{
inLogins: []string{`{{email.local(external.foo)}}`, "root"},
outLogins: []string{"bar", "root"},
},
},
{
comment: "logins substitute in deny rule",
inTraits: map[string][]string{
@@ -1176,6 +1188,26 @@ func (s *RoleSuite) TestApplyTraits(c *C) {
outKubeGroups: []string{"bar", "root"},
},
},
{
comment: "kube user interpolation in allow rule",
inTraits: map[string][]string{
"foo": []string{"bar"},
},
allow: rule{
inKubeUsers: []string{`IAM#{{external.foo}};`},
outKubeUsers: []string{"IAM#bar;"},
},
},
{
comment: "kube users interpolation in deny rule",
inTraits: map[string][]string{
"foo": []string{"bar"},
},
deny: rule{
inKubeUsers: []string{`IAM#{{external.foo}};`},
outKubeUsers: []string{"IAM#bar;"},
},
},
{
comment: "no variable in logins",
inTraits: map[string][]string{
@@ -1186,15 +1218,40 @@ func (s *RoleSuite) TestApplyTraits(c *C) {
outLogins: []string{"root"},
},
},
{
comment: "invalid variable in logins gets passed along",
comment: "invalid variable in logins does not get passed along",
inTraits: map[string][]string{
"foo": []string{"bar"},
},
allow: rule{
inLogins: []string{`external.foo}}`},
outLogins: []string{`external.foo}}`},
inLogins: []string{`external.foo}}`},
},
},
{
comment: "invalid function call in logins does not get passed along",
inTraits: map[string][]string{
"foo": []string{"bar"},
},
allow: rule{
inLogins: []string{`{{email.local(external.foo, 1)}}`},
},
},
{
comment: "invalid function call in logins does not get passed along",
inTraits: map[string][]string{
"foo": []string{"bar"},
},
allow: rule{
inLogins: []string{`{{email.local()}}`},
},
},
{
comment: "invalid function call in logins does not get passed along",
inTraits: map[string][]string{
"foo": []string{"bar"},
},
allow: rule{
inLogins: []string{`{{email.local(email.local)}}`, `{{email.local(email.local())}}`},
},
},
{
@@ -1310,11 +1367,13 @@ func (s *RoleSuite) TestApplyTraits(c *C) {
Logins: tt.allow.inLogins,
NodeLabels: tt.allow.inLabels,
KubeGroups: tt.allow.inKubeGroups,
KubeUsers: tt.allow.inKubeUsers,
},
Deny: RoleConditions{
Logins: tt.deny.inLogins,
NodeLabels: tt.deny.inLabels,
KubeGroups: tt.deny.inKubeGroups,
KubeUsers: tt.deny.inKubeUsers,
},
},
}
@@ -1323,10 +1382,12 @@ func (s *RoleSuite) TestApplyTraits(c *C) {
c.Assert(outRole.GetLogins(Allow), DeepEquals, tt.allow.outLogins, comment)
c.Assert(outRole.GetNodeLabels(Allow), DeepEquals, tt.allow.outLabels, comment)
c.Assert(outRole.GetKubeGroups(Allow), DeepEquals, tt.allow.outKubeGroups, comment)
c.Assert(outRole.GetKubeUsers(Allow), DeepEquals, tt.allow.outKubeUsers, comment)
c.Assert(outRole.GetLogins(Deny), DeepEquals, tt.deny.outLogins, comment)
c.Assert(outRole.GetNodeLabels(Deny), DeepEquals, tt.deny.outLabels, comment)
c.Assert(outRole.GetKubeGroups(Deny), DeepEquals, tt.deny.outKubeGroups, comment)
c.Assert(outRole.GetKubeUsers(Deny), DeepEquals, tt.deny.outKubeUsers, comment)
}
}
+2 -48
View File
@@ -1,5 +1,5 @@
/*
Copyright 2015-2019 Gravitational, Inc.
Copyright 2015-2020 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -17,7 +17,6 @@ limitations under the License.
package services
import (
"bytes"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
@@ -26,7 +25,6 @@ import (
"io/ioutil"
"net/http"
"strings"
"text/template"
"time"
"github.com/gravitational/teleport"
@@ -192,7 +190,7 @@ func (*TeleportSAMLConnectorMarshaler) UnmarshalSAMLConnector(bytes []byte, opts
return nil, trace.BadParameter("SAML connector resource version %v is not supported", h.Version)
}
// MarshalUser marshals SAML connector into JSON
// MarshalSAMLConnector marshals SAML connector into JSON
func (*TeleportSAMLConnectorMarshaler) MarshalSAMLConnector(c SAMLConnector, opts ...MarshalOption) ([]byte, error) {
cfg, err := collectOptions(opts)
if err != nil {
@@ -507,50 +505,6 @@ func (o *SAMLConnectorV2) MapAttributes(assertionInfo saml2.AssertionInfo) []str
return utils.Deduplicate(roles)
}
// executeSAMLStringTemplate takes a raw template string and a map of
// assertions to execute a template and generate output. Because the data
// structure used to execute the template is a map, the format of the raw
// string is expected to be {{index . "key"}}. See
// https://golang.org/pkg/text/template/ for more details.
func executeSAMLStringTemplate(raw string, assertion map[string]string) (string, error) {
tmpl, err := template.New("dynamic-roles").Parse(raw)
if err != nil {
return "", trace.Wrap(err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, assertion)
if err != nil {
return "", trace.Wrap(err)
}
return buf.String(), nil
}
// executeSAMLStringTemplate takes raw template strings and a map of
// assertions to execute templates and generate a slice of output. Because the
// data structure used to execute the template is a map, the format of each raw
// string is expected to be {{index . "key"}}. See
// https://golang.org/pkg/text/template/ for more details.
func executeSAMLSliceTemplate(raw []string, assertion map[string]string) ([]string, error) {
var sl []string
for _, v := range raw {
tmpl, err := template.New("dynamic-roles").Parse(v)
if err != nil {
return nil, trace.Wrap(err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, assertion)
if err != nil {
return nil, trace.Wrap(err)
}
sl = append(sl, buf.String())
}
return sl, nil
}
// GetServiceProvider initialises service provider spec from settings
func (o *SAMLConnectorV2) GetServiceProvider(clock clockwork.Clock) (*saml2.SAMLServiceProvider, error) {
if o.Metadata.Name == "" {
+361 -307
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -566,6 +566,9 @@ message RoleConditions {
repeated string KubeGroups = 5 [(gogoproto.jsontag) = "kubernetes_groups,omitempty"];
AccessRequestConditions Request = 6 [(gogoproto.jsontag) = "request,omitempty"];
// KubeUsers is an optional kubernetes users to impersonate
repeated string KubeUsers = 7 [(gogoproto.jsontag) = "kubernetes_users,omitempty"];
}
// AccessRequestConditions is a matcher for allow/deny restrictions on access-requests.
+1 -1
View File
@@ -484,7 +484,7 @@ func (u *UserV1) Check() error {
return trace.BadParameter("user name cannot be empty")
}
for _, login := range u.AllowedLogins {
_, _, err := parse.IsRoleVariable(login)
_, err := parse.RoleVariable(login)
if err == nil {
return trace.BadParameter("role variables not allowed in allowed logins")
}
+74 -11
View File
@@ -21,6 +21,7 @@ import (
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"math/big"
"net"
@@ -77,6 +78,8 @@ type Identity struct {
Principals []string
// KubernetesGroups is a list of Kubernetes groups allowed
KubernetesGroups []string
// KubernetesUsers is a list of Kubernetes users allowed
KubernetesUsers []string
// Expires specifies whenever the session will expire
Expires time.Time
// RouteToCluster specifies the target cluster
@@ -97,6 +100,21 @@ func (i *Identity) CheckAndSetDefaults() error {
return nil
}
// Custom ranges are taken from this article
//
// https://serverfault.com/questions/551477/is-there-reserved-oid-space-for-internal-enterprise-cas
//
// http://oid-info.com/get/1.3.9999
//
// KubeUsersASN1ExtensionOID is an extension ID used when encoding/decoding
// license payload into certificates
var KubeUsersASN1ExtensionOID = asn1.ObjectIdentifier{1, 3, 9999, 1, 1}
// KubeGroupsASN1ExtensionOID is an extension ID used when encoding/decoding
// license payload into certificates
var KubeGroupsASN1ExtensionOID = asn1.ObjectIdentifier{1, 3, 9999, 1, 2}
// Subject converts identity to X.509 subject name
func (id *Identity) Subject() (pkix.Name, error) {
rawTraits, err := wrappers.MarshalTraits(&id.Traits)
@@ -110,37 +128,82 @@ func (id *Identity) Subject() (pkix.Name, error) {
subject.Organization = append([]string{}, id.Groups...)
subject.OrganizationalUnit = append([]string{}, id.Usage...)
subject.Locality = append([]string{}, id.Principals...)
// DELETE IN (5.0.0)
// Groups are marshaled to both ASN1 extension
// and old Province section, for backwards-compatibility,
// however begin migration to ASN1 extensions in the future
// for this and other properties
subject.Province = append([]string{}, id.KubernetesGroups...)
subject.StreetAddress = []string{id.RouteToCluster}
subject.PostalCode = []string{string(rawTraits)}
for i := range id.KubernetesUsers {
kubeUser := id.KubernetesUsers[i]
subject.ExtraNames = append(subject.ExtraNames,
pkix.AttributeTypeAndValue{
Type: KubeUsersASN1ExtensionOID,
Value: kubeUser,
})
}
for i := range id.KubernetesGroups {
kubeGroup := id.KubernetesGroups[i]
subject.ExtraNames = append(subject.ExtraNames,
pkix.AttributeTypeAndValue{
Type: KubeGroupsASN1ExtensionOID,
Value: kubeGroup,
})
}
return subject, nil
}
// FromSubject returns identity from subject name
func FromSubject(subject pkix.Name, expires time.Time) (*Identity, error) {
i := &Identity{
Username: subject.CommonName,
Groups: subject.Organization,
Usage: subject.OrganizationalUnit,
Principals: subject.Locality,
KubernetesGroups: subject.Province,
Expires: expires,
id := &Identity{
Username: subject.CommonName,
Groups: subject.Organization,
Usage: subject.OrganizationalUnit,
Principals: subject.Locality,
Expires: expires,
}
if len(subject.StreetAddress) > 0 {
i.RouteToCluster = subject.StreetAddress[0]
id.RouteToCluster = subject.StreetAddress[0]
}
if len(subject.PostalCode) > 0 {
err := wrappers.UnmarshalTraits([]byte(subject.PostalCode[0]), &i.Traits)
err := wrappers.UnmarshalTraits([]byte(subject.PostalCode[0]), &id.Traits)
if err != nil {
return nil, trace.Wrap(err)
}
}
if err := i.CheckAndSetDefaults(); err != nil {
for _, attr := range subject.Names {
switch {
case attr.Type.Equal(KubeUsersASN1ExtensionOID):
val, ok := attr.Value.(string)
if ok {
id.KubernetesUsers = append(id.KubernetesUsers, val)
}
case attr.Type.Equal(KubeGroupsASN1ExtensionOID):
val, ok := attr.Value.(string)
if ok {
id.KubernetesGroups = append(id.KubernetesGroups, val)
}
}
}
// DELETE IN(5.0.0): This logic is using Province field
// from subject in case if Kubernetes groups were not populated
// from ASN1 extension, after 5.0 Province field will be ignored
if len(id.KubernetesGroups) == 0 {
id.KubernetesGroups = subject.Province
}
if err := id.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
return i, nil
return id, nil
}
// CertificateRequest is a X.509 signing certificate request
+39
View File
@@ -71,3 +71,42 @@ func (s *TLSCASuite) TestPrincipals(c *check.C) {
}
c.Assert(certIPs, check.DeepEquals, ips)
}
// TestKubeExtensions test ASN1 subject kubernetes extensions
func (s *TLSCASuite) TestKubeExtensions(c *check.C) {
ca, err := New([]byte(fixtures.SigningCertPEM), []byte(fixtures.SigningKeyPEM))
c.Assert(err, check.IsNil)
privateKey, err := rsa.GenerateKey(rand.Reader, teleport.RSAKeySize)
c.Assert(err, check.IsNil)
expires := s.clock.Now().Add(time.Hour)
identity := Identity{
Username: "alice@example.com",
Groups: []string{"admin"},
// 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},
KubernetesGroups: []string{"system:masters", "admin"},
KubernetesUsers: []string{"IAM#alice@example.com"},
Expires: expires,
}
subj, err := identity.Subject()
c.Assert(err, check.IsNil)
certBytes, err := ca.GenerateCertificate(CertificateRequest{
Clock: s.clock,
PublicKey: privateKey.Public(),
Subject: subj,
NotAfter: expires,
})
c.Assert(err, check.IsNil)
cert, err := ParseCertificatePEM(certBytes)
c.Assert(err, check.IsNil)
out, err := FromSubject(cert.Subject, cert.NotAfter)
c.Assert(err, check.IsNil)
fixtures.DeepCompare(c, out, &identity)
}
+164 -27
View File
@@ -1,5 +1,5 @@
/*
Copyright 2017 Gravitational, Inc.
Copyright 2017-2020 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,87 +19,224 @@ package parse
import (
"go/ast"
"go/parser"
"net/mail"
"regexp"
"strconv"
"strings"
"unicode"
"github.com/gravitational/trace"
)
// IsRoleVariable checks if the passed in string matches the variable pattern
// Expression is an expression template
// that can interpolate to some variables
type Expression struct {
// namespace is expression namespace,
// e.g. internal.traits has a variable traits
// in internal namespace
namespace string
// variable is a variable name, e.g. trait name,
// e.g. internal.traits has variable name traits
variable string
// prefix is a prefix of the string
prefix string
// suffix is a suffix
suffix string
// transform is an optional transform function to call,
// currently email.local is the only supported function
transform func(in string) (string, error)
}
// EmailLocal returns local part of the email
func EmailLocal(in string) (string, error) {
if in == "" {
return "", trace.BadParameter("address is empty")
}
addr, err := mail.ParseAddress(in)
if err != nil {
return "", trace.BadParameter("failed to parse address %q: %q", in, err)
}
parts := strings.SplitN(addr.Address, "@", 2)
if len(parts) != 2 {
return "", trace.BadParameter("could not find local part in %q", addr.Address)
}
return parts[0], nil
}
// Namespace returns a variable namespace, e.g. external or internal
func (p *Expression) Namespace() string {
return p.namespace
}
// Name returns variable name
func (p *Expression) Name() string {
return p.variable
}
// Interpolate interpolates the variable adding prefix and suffix if present,
// returns trace.NotFound in case if the trait is not found, nil in case of
// success and BadParameter error otherwise
func (p *Expression) Interpolate(traits map[string][]string) ([]string, error) {
values, ok := traits[p.variable]
if !ok {
return nil, trace.NotFound("variable is not found")
}
out := make([]string, len(values))
for i := range values {
val := values[i]
var err error
if p.transform != nil {
val, err = p.transform(val)
if err != nil {
return nil, trace.Wrap(err)
}
}
out[i] = p.prefix + val + p.suffix
}
return out, nil
}
var reVariable = regexp.MustCompile(
// prefix is anyting that is not { or }
`^(?P<prefix>[^}{]*)` +
// variable is antything in brackets {{}} that is not { or }
`{{(?P<expression>\s*[^}{]*\s*)}}` +
// prefix is anyting that is not { or }
`(?P<suffix>[^}{]*)$`,
)
// RoleVariable checks if the passed in string matches the variable pattern
// {{external.foo}} or {{internal.bar}}. If it does, it returns the variable
// prefix and the variable name. In the previous example this would be
// "external" or "internal" for the variable prefix and "foo" or "bar" for the
// variable name. If no variable pattern is found, trace.NotFound is returned.
func IsRoleVariable(variable string) (string, string, error) {
// time whitespace around string if it exists
variable = strings.TrimSpace(variable)
// strip {{ and }} from the start and end of the variable
if !strings.HasPrefix(variable, "{{") || !strings.HasSuffix(variable, "}}") {
return "", "", trace.NotFound("no variable found: %v", variable)
func RoleVariable(variable string) (*Expression, error) {
match := reVariable.FindStringSubmatch(variable)
if len(match) == 0 {
if strings.Index(variable, "{{") != -1 || strings.Index(variable, "}}") != -1 {
return nil, trace.BadParameter(
"%q is using template brackets '{{' or '}}', however expression does not parse, make sure the format is {{variable}}",
variable)
}
return nil, trace.NotFound("no variable found in %q", variable)
}
variable = variable[2 : len(variable)-2]
prefix, variable, suffix := match[1], match[2], match[3]
// parse and get the ast of the expression
expr, err := parser.ParseExpr(variable)
if err != nil {
return "", "", trace.NotFound("no variable found: %v", variable)
return nil, trace.NotFound("no variable found in %q: %v", variable, err)
}
// walk the ast tree and gather the variable parts
variableParts, err := walk(expr)
result, err := walk(expr)
if err != nil {
return "", "", trace.NotFound("no variable found: %v", variable)
return nil, trace.Wrap(err)
}
// the variable must have two parts the prefix and the variable name itself
if len(variableParts) != 2 {
return "", "", trace.NotFound("no variable found: %v", variable)
if len(result.parts) != 2 {
return nil, trace.NotFound("no variable found: %v", variable)
}
return variableParts[0], variableParts[1], nil
return &Expression{
prefix: strings.TrimLeftFunc(prefix, unicode.IsSpace),
namespace: result.parts[0],
variable: result.parts[1],
suffix: strings.TrimRightFunc(suffix, unicode.IsSpace),
transform: result.transform,
}, nil
}
const (
// EmailNamespace is a function namespace for email functions
EmailNamespace = "email"
// EmailLocalFnName is a name for email.local function
EmailLocalFnName = "local"
)
// TransformFn is an optional transform function
// that can take in string and replace it with another value
type TransformFn func(in string) (string, error)
type walkResult struct {
parts []string
transform TransformFn
}
// walk will walk the ast tree and gather all the variable parts into a slice and return it.
func walk(node ast.Node) ([]string, error) {
var l []string
func walk(node ast.Node) (*walkResult, error) {
var result walkResult
switch n := node.(type) {
case *ast.CallExpr:
switch call := n.Fun.(type) {
case *ast.Ident:
return nil, trace.BadParameter("function %v is not supported", call.Name)
case *ast.SelectorExpr:
// Selector expression looks like email.local(parameter)
namespace, ok := call.X.(*ast.Ident)
if !ok {
return nil, trace.BadParameter("expected namespace, e.g. email.local, got %v", call.X)
}
// This is the part before the dot
if namespace.Name != EmailNamespace {
return nil, trace.BadParameter("unsupported namespace, e.g. email.local, got %v", call.X)
}
// This is a function name
if call.Sel.Name != EmailLocalFnName {
return nil, trace.BadParameter("unsupported function %v, supported functions are: email.local", call.Sel.Name)
}
// Because only one function is supported for now,
// this makes sure that the function call has exactly one argument
if len(n.Args) != 1 {
return nil, trace.BadParameter("expected 1 argument for email.local got %v", len(n.Args))
}
result.transform = EmailLocal
ret, err := walk(n.Args[0])
if err != nil {
return nil, trace.Wrap(err)
}
result.parts = ret.parts
return &result, nil
default:
return nil, trace.BadParameter("unsupported function %T", n.Fun)
}
case *ast.IndexExpr:
ret, err := walk(n.X)
if err != nil {
return nil, err
}
l = append(l, ret...)
result.parts = append(result.parts, ret.parts...)
ret, err = walk(n.Index)
if err != nil {
return nil, err
}
l = append(l, ret...)
result.parts = append(result.parts, ret.parts...)
return &result, nil
case *ast.SelectorExpr:
ret, err := walk(n.X)
if err != nil {
return nil, err
}
l = append(l, ret...)
result.parts = append(result.parts, ret.parts...)
ret, err = walk(n.Sel)
if err != nil {
return nil, err
}
l = append(l, ret...)
result.parts = append(result.parts, ret.parts...)
return &result, nil
case *ast.Ident:
return []string{n.Name}, nil
return &walkResult{parts: []string{n.Name}}, nil
case *ast.BasicLit:
value, err := strconv.Unquote(n.Value)
if err != nil {
return nil, err
}
return []string{value}, nil
return &walkResult{parts: []string{value}}, nil
default:
return nil, trace.BadParameter("unknown node type: %T", n)
}
return l, nil
}
+126 -62
View File
@@ -1,5 +1,5 @@
/*
Copyright 2017 Gravitational, Inc.
Copyright 2017-2020 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -40,94 +40,158 @@ func (s *ParseSuite) TearDownSuite(c *check.C) {}
func (s *ParseSuite) SetUpTest(c *check.C) {}
func (s *ParseSuite) TearDownTest(c *check.C) {}
func (s *ParseSuite) TestIsRoleVariable(c *check.C) {
// TestRoleVariable tests variable parsing
func (s *ParseSuite) TestRoleVariable(c *check.C) {
var tests = []struct {
inVariable string
outIsNotFound bool
outVariablePrefix string
outVariableName string
title string
in string
err error
out Expression
}{
// 0 - no curly bracket prefix
{
"external.foo}}",
true,
"",
"",
title: "no curly bracket prefix",
in: "external.foo}}",
err: trace.BadParameter(""),
},
// 1 - invalid syntax
{
`{{external.foo("bar")`,
true,
"",
"",
title: "invalid syntax",
in: `{{external.foo("bar")`,
err: trace.BadParameter(""),
},
// 2 - invalid sytnax
{
"{{internal.}}",
true,
"",
"",
title: "invalid variable syntax",
in: "{{internal.}}",
err: trace.BadParameter(""),
},
// 3 - invalid syntax
{
"{{external..foo}}",
true,
"",
"",
title: "invalid dot syntax",
in: "{{external..foo}}",
err: trace.BadParameter(""),
},
// 4 - invalid syntax
{
"{{}}",
true,
"",
"",
title: "empty variable",
in: "{{}}",
err: trace.BadParameter(""),
},
// 5 - invalid syntax
{
"{{internal.foo",
true,
"",
"",
title: "no curly bracket suffix",
in: "{{internal.foo",
err: trace.BadParameter(""),
},
// 6 - invalid syntax
{
"{{internal.foo.bar}}",
true,
"",
"",
title: "too many levels of nesting in the variable",
in: "{{internal.foo.bar}}",
err: trace.BadParameter(""),
},
// 7 - valid brackets
{
`{{internal["foo"]}}`,
false,
"internal",
"foo",
title: "valid with brackets",
in: `{{internal["foo"]}}`,
out: Expression{namespace: "internal", variable: "foo"},
},
// 8 - valid
{
"{{external.foo}}",
false,
"external",
"foo",
title: "external with no brackets",
in: "{{external.foo}}",
out: Expression{namespace: "external", variable: "foo"},
},
// 9 - valid
{
"{{internal.bar}}",
false,
"internal",
"bar",
title: "internal with no brackets",
in: "{{internal.bar}}",
out: Expression{namespace: "internal", variable: "bar"},
},
{
title: "internal with spaces removed",
in: " {{ internal.bar }} ",
out: Expression{namespace: "internal", variable: "bar"},
},
{
title: "variable with prefix and suffix",
in: " hello, {{ internal.bar }} there! ",
out: Expression{prefix: "hello, ", namespace: "internal", variable: "bar", suffix: " there!"},
},
{
title: "variable with local function",
in: "{{email.local(internal.bar)}}",
out: Expression{namespace: "internal", variable: "bar", transform: EmailLocal},
},
}
for i, tt := range tests {
comment := check.Commentf("Test %v", i)
comment := check.Commentf("Test(%v) %q", i, tt.title)
variablePrefix, variableName, err := IsRoleVariable(tt.inVariable)
if tt.outIsNotFound {
c.Assert(trace.IsNotFound(err), check.Equals, true, comment)
variable, err := RoleVariable(tt.in)
if tt.err != nil {
c.Assert(err, check.FitsTypeOf, tt.err, comment)
continue
}
c.Assert(variablePrefix, check.Equals, tt.outVariablePrefix, comment)
c.Assert(variableName, check.Equals, tt.outVariableName, comment)
c.Assert(err, check.IsNil, comment)
// functionns are not directly comparable, compare fields
// directly, except functions as a workaround
c.Assert(variable.prefix, check.Equals, tt.out.prefix, comment)
c.Assert(variable.variable, check.Equals, tt.out.variable, comment)
c.Assert(variable.suffix, check.Equals, tt.out.suffix, comment)
// functions are not comparable
if tt.out.transform == nil {
c.Assert(variable.transform, check.IsNil, comment)
} else {
c.Assert(variable.transform, check.NotNil, comment)
}
}
}
// TestInterpolate tests variable interpolation
func (s *ParseSuite) TestInterpolate(c *check.C) {
type result struct {
values []string
err error
}
var tests = []struct {
title string
in Expression
traits map[string][]string
res result
}{
{
title: "mapped traits",
in: Expression{variable: "foo"},
traits: map[string][]string{"foo": []string{"a", "b"}, "bar": []string{"c"}},
res: result{values: []string{"a", "b"}},
},
{
title: "mapped traits with email.local",
in: Expression{variable: "foo", transform: EmailLocal},
traits: map[string][]string{"foo": []string{"Alice <alice@example.com>", "bob@example.com"}, "bar": []string{"c"}},
res: result{values: []string{"alice", "bob"}},
},
{
title: "missed traits",
in: Expression{variable: "baz"},
traits: map[string][]string{"foo": []string{"a", "b"}, "bar": []string{"c"}},
res: result{err: trace.NotFound("not found"), values: []string{}},
},
{
title: "traits with prefix and suffix",
in: Expression{prefix: "IAM#", variable: "foo", suffix: ";"},
traits: map[string][]string{"foo": []string{"a", "b"}, "bar": []string{"c"}},
res: result{values: []string{"IAM#a;", "IAM#b;"}},
},
{
title: "error in mapping traits",
in: Expression{variable: "foo", transform: EmailLocal},
traits: map[string][]string{"foo": []string{"Alice <alice"}},
res: result{err: trace.BadParameter("")},
},
}
for i, tt := range tests {
comment := check.Commentf("Test(%v) %q", i, tt.title)
values, err := tt.in.Interpolate(tt.traits)
if tt.res.err != nil {
c.Assert(err, check.FitsTypeOf, tt.res.err, comment)
c.Assert(values, check.HasLen, 0)
continue
}
c.Assert(err, check.IsNil, comment)
c.Assert(values, check.DeepEquals, tt.res.values, comment)
}
}
+16 -2
View File
@@ -1,5 +1,5 @@
/*
Copyright 2015-2019 Gravitational, Inc.
Copyright 2015-2020 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -26,6 +26,7 @@ import (
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
@@ -178,11 +179,24 @@ func ParseAdvertiseAddr(advertiseIP string) (string, string, error) {
return host, port, nil
}
// StringsSliceFromSet returns a sorted strings slice from set
func StringsSliceFromSet(in map[string]struct{}) []string {
if in == nil {
return nil
}
out := make([]string, 0, len(in))
for key := range in {
out = append(out, key)
}
sort.Strings(out)
return out
}
// StringsSet creates set of string (map[string]struct{})
// from a list of strings
func StringsSet(in []string) map[string]struct{} {
if in == nil {
return nil
return map[string]struct{}{}
}
out := make(map[string]struct{})
for _, v := range in {
+7
View File
@@ -500,3 +500,10 @@ func (s *UtilsSuite) TestReadToken(c *check.C) {
c.Assert(err, check.IsNil)
c.Assert(tok, check.Equals, "shmoken")
}
// TestStringsSet makes sure that nil slice returns empty set (less error prone)
func (s *UtilsSuite) TestStringsSet(c *check.C) {
out := StringsSet(nil)
c.Assert(out, check.HasLen, 0)
c.Assert(out, check.NotNil)
}