ContainerImage.Pinniped/internal/registry/credentialrequest/rest.go

330 lines
11 KiB
Go
Raw Normal View History

2021-01-07 22:58:09 +00:00
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package credentialrequest provides REST functionality for the CredentialRequest resource.
package credentialrequest
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"time"
certificatesv1 "k8s.io/api/certificates/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/authentication/user"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/util/cert"
"k8s.io/client-go/util/certificate/csr"
"k8s.io/client-go/util/keyutil"
"k8s.io/utils/trace"
loginapi "go.pinniped.dev/generated/latest/apis/concierge/login"
"go.pinniped.dev/internal/issuer"
)
// clientCertificateTTL is the TTL for short-lived client certificates returned by this API.
const clientCertificateTTL = 5 * time.Minute
type TokenCredentialRequestAuthenticator interface {
AuthenticateTokenCredentialRequest(ctx context.Context, req *loginapi.TokenCredentialRequest) (user.Info, error)
}
func NewREST(authenticator TokenCredentialRequestAuthenticator, issuer issuer.ClientCertIssuer, kubeClientWithoutLeaderElection kubernetes.Interface, resource schema.GroupResource) *REST {
return &REST{
authenticator: authenticator,
issuer: issuer,
tableConvertor: rest.NewDefaultTableConvertor(resource),
kubeClientWithoutLeaderElection: kubeClientWithoutLeaderElection,
}
}
type REST struct {
authenticator TokenCredentialRequestAuthenticator
issuer issuer.ClientCertIssuer
tableConvertor rest.TableConvertor
kubeClientWithoutLeaderElection kubernetes.Interface
}
// Assert that our *REST implements all the optional interfaces that we expect it to implement.
var _ interface {
rest.Creater
rest.NamespaceScopedStrategy
rest.Scoper
rest.Storage
rest.CategoriesProvider
rest.Lister
} = (*REST)(nil)
func (*REST) New() runtime.Object {
return &loginapi.TokenCredentialRequest{}
}
func (*REST) NewList() runtime.Object {
return &loginapi.TokenCredentialRequestList{}
}
func (*REST) List(_ context.Context, _ *metainternalversion.ListOptions) (runtime.Object, error) {
return &loginapi.TokenCredentialRequestList{
ListMeta: metav1.ListMeta{
ResourceVersion: "0", // this resource version means "from the API server cache"
},
Items: []loginapi.TokenCredentialRequest{}, // avoid sending nil items list
}, nil
}
func (r *REST) ConvertToTable(ctx context.Context, obj runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return r.tableConvertor.ConvertToTable(ctx, obj, tableOptions)
}
func (*REST) NamespaceScoped() bool {
return false
}
func (*REST) Categories() []string {
return []string{"pinniped"}
}
func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
t := trace.FromContext(ctx).Nest("create", trace.Field{
Key: "kind",
Value: "TokenCredentialRequest",
})
defer t.Log()
credentialRequest, err := validateRequest(ctx, obj, createValidation, options, t)
if err != nil {
return nil, err
}
userInfo, err := r.authenticator.AuthenticateTokenCredentialRequest(ctx, credentialRequest)
if err != nil {
traceFailureWithError(t, "token authentication", err)
return failureResponse(), nil
}
if ok := isUserInfoValid(userInfo); !ok {
traceSuccess(t, userInfo, false)
return failureResponse(), nil
}
// By commenting out this code for the spike, we prevent the usual kube cert agent and impersonation proxy
// strategies from getting involved in creating client certs. Instead, we will use the Kube CSR APIs below.
//// this timestamp should be returned from IssueClientCertPEM but this is a safe approximation
//expires := metav1.NewTime(time.Now().UTC().Add(clientCertificateTTL))
//certPEM, keyPEM, err := r.issuer.IssueClientCertPEM(userInfo.GetName(), userInfo.GetGroups(), clientCertificateTTL)
//if err != nil {
// traceFailureWithError(t, "cert issuer", err)
// return failureResponse(), nil
//}
expires, certPEM, keyPEM, err := getCertFromCSR(ctx, r.kubeClientWithoutLeaderElection, userInfo)
if err != nil {
return nil, apierrors.NewInternalError(err) // TODO better error handling, but this is good enough for a spike
}
traceSuccess(t, userInfo, true)
return &loginapi.TokenCredentialRequest{
Status: loginapi.TokenCredentialRequestStatus{
Credential: &loginapi.ClusterCredential{
ExpirationTimestamp: expires,
ClientCertificateData: string(certPEM),
ClientKeyData: string(keyPEM),
},
},
}, nil
}
func getCertFromCSR(
ctx context.Context,
kubeClient kubernetes.Interface,
userInfo user.Info,
) (expires metav1.Time, certPEM []byte, keyPEM []byte, err error) {
// Make a private key.
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return metav1.Time{}, nil, nil, err
}
der, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
return metav1.Time{}, nil, nil, err
}
keyPEM = pem.EncodeToMemory(&pem.Block{Type: keyutil.ECPrivateKeyBlockType, Bytes: der})
// Make a CSR.
csrPEM, err := cert.MakeCSR(privateKey, &pkix.Name{
CommonName: userInfo.GetName(),
Organization: userInfo.GetGroups(),
}, nil, nil)
if err != nil {
return metav1.Time{}, nil, nil, err
}
// Docs say that 600 seconds is the smallest allowed duration.
// This should result in a cert which is valid from 5 minutes ago
// until 10 minutes in the future.
minimumAllowedDuration := time.Second * 600
// Use the CSR API to request a client cert for the API server.
csrName, csrUID, err := csr.RequestCertificate(
kubeClient,
csrPEM,
"", // empty means auto-generate a random name
certificatesv1.KubeAPIServerClientSignerName,
&minimumAllowedDuration,
[]certificatesv1.KeyUsage{certificatesv1.UsageClientAuth},
privateKey,
)
if err != nil {
return metav1.Time{}, nil, nil, err
}
// These CSRs are not auto-approved, so approve our own request.
_, err = kubeClient.CertificatesV1().CertificateSigningRequests().UpdateApproval(ctx, csrName, &certificatesv1.CertificateSigningRequest{
ObjectMeta: metav1.ObjectMeta{
Name: csrName,
},
Status: certificatesv1.CertificateSigningRequestStatus{
Conditions: []certificatesv1.CertificateSigningRequestCondition{
{
Type: certificatesv1.CertificateApproved,
Status: corev1.ConditionTrue,
Reason: "TokenCredentialRequest",
},
},
},
}, metav1.UpdateOptions{})
if err != nil {
return metav1.Time{}, nil, nil, err
}
// Wait for the cert to be issued by the signer, or error after a reasonably long timeout.
timeoutCtx, cancelFunc := context.WithTimeout(ctx, 90*time.Second)
defer cancelFunc()
certPEM, err = csr.WaitForCertificate(timeoutCtx, kubeClient, csrName, csrUID)
if err != nil {
return metav1.Time{}, nil, nil, err
}
// This feels awkward to need to decode the cert to find out when it expires,
// but the CSR API only returns the encoded cert. It might be nice if it also
// returned the cert's expiration time as a separate field?
decodedCertPEMBlock, _ := pem.Decode(certPEM)
parsedCertPEM, err := x509.ParseCertificate(decodedCertPEMBlock.Bytes)
if err != nil {
return metav1.Time{}, nil, nil, err
}
// TODO maybe return an error unless the signer honored our 600 second duration request
expires = metav1.NewTime(parsedCertPEM.NotAfter)
return expires, certPEM, keyPEM, nil
}
func validateRequest(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions, t *trace.Trace) (*loginapi.TokenCredentialRequest, error) {
credentialRequest, ok := obj.(*loginapi.TokenCredentialRequest)
if !ok {
traceValidationFailure(t, "not a TokenCredentialRequest")
return nil, apierrors.NewBadRequest(fmt.Sprintf("not a TokenCredentialRequest: %#v", obj))
}
if len(credentialRequest.Spec.Token) == 0 {
traceValidationFailure(t, "token must be supplied")
errs := field.ErrorList{field.Required(field.NewPath("spec", "token", "value"), "token must be supplied")}
return nil, apierrors.NewInvalid(loginapi.Kind(credentialRequest.Kind), credentialRequest.Name, errs)
}
// just a sanity check, not sure how to honor a dry run on a virtual API
if options != nil {
if len(options.DryRun) != 0 {
traceValidationFailure(t, "dryRun not supported")
errs := field.ErrorList{field.NotSupported(field.NewPath("dryRun"), options.DryRun, nil)}
return nil, apierrors.NewInvalid(loginapi.Kind(credentialRequest.Kind), credentialRequest.Name, errs)
}
}
if namespace := genericapirequest.NamespaceValue(ctx); len(namespace) != 0 {
traceValidationFailure(t, "namespace is not allowed")
return nil, apierrors.NewBadRequest(fmt.Sprintf("namespace is not allowed on TokenCredentialRequest: %v", namespace))
}
// let dynamic admission webhooks have a chance to validate (but not mutate) as well
// TODO Since we are an aggregated API, we should investigate to see if the kube API server is already invoking admission hooks for us.
// Even if it is, its okay to call it again here. However, if the kube API server is already calling the webhooks and passing
// the token, then there is probably no reason for us to avoid passing the token when we call the webhooks here, since
// they already got the token.
if createValidation != nil {
requestForValidation := obj.DeepCopyObject()
requestForValidation.(*loginapi.TokenCredentialRequest).Spec.Token = ""
if err := createValidation(ctx, requestForValidation); err != nil {
traceFailureWithError(t, "validation webhook", err)
return nil, err
}
}
return credentialRequest, nil
}
func isUserInfoValid(userInfo user.Info) bool {
switch {
case userInfo == nil, // must be non-nil
len(userInfo.GetName()) == 0, // must have a username, groups are optional
len(userInfo.GetUID()) != 0, // certs cannot assert UID
len(userInfo.GetExtra()) != 0: // certs cannot assert extra
return false
default:
return true
}
}
func traceSuccess(t *trace.Trace, userInfo user.Info, authenticated bool) {
userID := "<none>"
hasExtra := false
if userInfo != nil {
userID = userInfo.GetUID()
hasExtra = len(userInfo.GetExtra()) > 0
}
t.Step("success",
trace.Field{Key: "userID", Value: userID},
trace.Field{Key: "hasExtra", Value: hasExtra},
trace.Field{Key: "authenticated", Value: authenticated},
)
}
func traceValidationFailure(t *trace.Trace, msg string) {
t.Step("failure",
trace.Field{Key: "failureType", Value: "request validation"},
trace.Field{Key: "msg", Value: msg},
)
}
func traceFailureWithError(t *trace.Trace, failureType string, err error) {
t.Step("failure",
trace.Field{Key: "failureType", Value: failureType},
trace.Field{Key: "msg", Value: err.Error()},
)
}
func failureResponse() *loginapi.TokenCredentialRequest {
m := "authentication failed"
return &loginapi.TokenCredentialRequest{
Status: loginapi.TokenCredentialRequestStatus{
Credential: nil,
Message: &m,
},
}
}