open-consul/agent/consul/authmethod/kubeauth/k8s.go
R.B. Boyer 3ac5a841ec
acl: refactor the authmethod.Validator interface (#7760)
This is a collection of refactors that make upcoming PRs easier to digest.

The main change is the introduction of the authmethod.Identity struct.
In the one and only current auth method (type=kubernetes) all of the
trusted identity attributes are both selectable and projectable, so they
were just passed around as a map[string]string.

When namespaces were added, this was slightly changed so that the
enterprise metadata can also come back from the login operation, so
login now returned two fields.

Now with some upcoming auth methods it won't be true that all identity
attributes will be both selectable and projectable, so rather than
update the login function to return 3 pieces of data it seemed worth it
to wrap those fields up and give them a proper name.
2020-05-01 17:35:28 -05:00

223 lines
6.3 KiB
Go

package kubeauth
import (
"context"
"errors"
"fmt"
"strings"
"github.com/hashicorp/consul/agent/consul/authmethod"
"github.com/hashicorp/consul/agent/structs"
cleanhttp "github.com/hashicorp/go-cleanhttp"
"github.com/hashicorp/go-hclog"
"gopkg.in/square/go-jose.v2/jwt"
authv1 "k8s.io/api/authentication/v1"
client_metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
k8s "k8s.io/client-go/kubernetes"
client_authv1 "k8s.io/client-go/kubernetes/typed/authentication/v1"
client_corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
client_rest "k8s.io/client-go/rest"
cert "k8s.io/client-go/util/cert"
)
func init() {
// register this as an available auth method type
authmethod.Register("kubernetes", func(logger hclog.Logger, method *structs.ACLAuthMethod) (authmethod.Validator, error) {
v, err := NewValidator(method)
if err != nil {
return nil, err
}
return v, nil
})
}
const (
serviceAccountNamespaceField = "serviceaccount.namespace"
serviceAccountNameField = "serviceaccount.name"
serviceAccountUIDField = "serviceaccount.uid"
serviceAccountServiceNameAnnotation = "consul.hashicorp.com/service-name"
)
type Config struct {
// Host must be a host string, a host:port pair, or a URL to the base of
// the Kubernetes API server.
Host string `json:",omitempty"`
// PEM encoded CA cert for use by the TLS client used to talk with the
// Kubernetes API. Every line must end with a newline: \n
CACert string `json:",omitempty"`
// A service account JWT used to access the TokenReview API to validate
// other JWTs during login. It also must be able to read ServiceAccount
// annotations.
ServiceAccountJWT string `json:",omitempty"`
enterpriseConfig `mapstructure:",squash"`
}
// Validator is the wrapper around the relevant portions of the Kubernetes API
// that also conforms to the authmethod.Validator interface.
type Validator struct {
name string
config *Config
saGetter client_corev1.ServiceAccountsGetter
trGetter client_authv1.TokenReviewsGetter
}
func NewValidator(method *structs.ACLAuthMethod) (*Validator, error) {
if method.Type != "kubernetes" {
return nil, fmt.Errorf("%q is not a kubernetes auth method", method.Name)
}
var config Config
if err := authmethod.ParseConfig(method.Config, &config); err != nil {
return nil, err
}
if config.Host == "" {
return nil, fmt.Errorf("Config.Host is required")
}
if config.CACert == "" {
return nil, fmt.Errorf("Config.CACert is required")
}
if _, err := cert.ParseCertsPEM([]byte(config.CACert)); err != nil {
return nil, fmt.Errorf("error parsing kubernetes ca cert: %v", err)
}
// This is the bearer token we give the apiserver to use the API.
if config.ServiceAccountJWT == "" {
return nil, fmt.Errorf("Config.ServiceAccountJWT is required")
}
if _, err := jwt.ParseSigned(config.ServiceAccountJWT); err != nil {
return nil, fmt.Errorf("Config.ServiceAccountJWT is not a valid JWT: %v", err)
}
transport := cleanhttp.DefaultTransport()
client, err := k8s.NewForConfig(&client_rest.Config{
Host: config.Host,
BearerToken: config.ServiceAccountJWT,
Dial: transport.DialContext,
TLSClientConfig: client_rest.TLSClientConfig{
CAData: []byte(config.CACert),
},
ContentConfig: client_rest.ContentConfig{
ContentType: "application/json",
},
})
if err != nil {
return nil, err
}
return &Validator{
name: method.Name,
config: &config,
saGetter: client.CoreV1(),
trGetter: client.AuthenticationV1(),
}, nil
}
func (v *Validator) Name() string { return v.name }
func (v *Validator) Stop() {}
func (v *Validator) ValidateLogin(ctx context.Context, loginToken string) (*authmethod.Identity, error) {
if _, err := jwt.ParseSigned(loginToken); err != nil {
return nil, fmt.Errorf("failed to parse and validate JWT: %v", err)
}
// Check TokenReview for the bulk of the work.
trResp, err := v.trGetter.TokenReviews().Create(&authv1.TokenReview{
Spec: authv1.TokenReviewSpec{
Token: loginToken,
},
})
if err != nil {
return nil, err
} else if trResp.Status.Error != "" {
return nil, fmt.Errorf("lookup failed: %s", trResp.Status.Error)
}
if !trResp.Status.Authenticated {
return nil, errors.New("lookup failed: service account jwt not valid")
}
// The username is of format: system:serviceaccount:(NAMESPACE):(SERVICEACCOUNT)
parts := strings.Split(trResp.Status.User.Username, ":")
if len(parts) != 4 {
return nil, errors.New("lookup failed: unexpected username format")
}
// Validate the user that comes back from token review is a service account
if parts[0] != "system" || parts[1] != "serviceaccount" {
return nil, errors.New("lookup failed: username returned is not a service account")
}
var (
saNamespace = parts[2]
saName = parts[3]
saUID = string(trResp.Status.User.UID)
)
// Check to see if there is an override name on the ServiceAccount object.
sa, err := v.saGetter.ServiceAccounts(saNamespace).Get(saName, client_metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("annotation lookup failed: %v", err)
}
annotations := sa.GetObjectMeta().GetAnnotations()
if serviceNameOverride, ok := annotations[serviceAccountServiceNameAnnotation]; ok {
saName = serviceNameOverride
}
fields := map[string]string{
serviceAccountNamespaceField: saNamespace,
serviceAccountNameField: saName,
serviceAccountUIDField: saUID,
}
id := v.NewIdentity()
id.SelectableFields = &k8sFieldDetails{
ServiceAccount: k8sFieldDetailsServiceAccount{
Namespace: fields[serviceAccountNamespaceField],
Name: fields[serviceAccountNameField],
UID: fields[serviceAccountUIDField],
},
}
for k, val := range fields {
id.ProjectedVars[k] = val
}
id.EnterpriseMeta = v.k8sEntMetaFromFields(fields)
return id, nil
}
func (v *Validator) NewIdentity() *authmethod.Identity {
id := &authmethod.Identity{
SelectableFields: &k8sFieldDetails{},
ProjectedVars: map[string]string{},
}
for _, f := range availableFields {
id.ProjectedVars[f] = ""
}
return id
}
var availableFields = []string{
serviceAccountNamespaceField,
serviceAccountNameField,
serviceAccountUIDField,
}
type k8sFieldDetails struct {
ServiceAccount k8sFieldDetailsServiceAccount `bexpr:"serviceaccount"`
}
type k8sFieldDetailsServiceAccount struct {
Namespace string `bexpr:"namespace"`
Name string `bexpr:"name"`
UID string `bexpr:"uid"`
}