ContainerImage.Pinniped/internal/registry/credentialrequest/rest.go
Monis Khan 5ff2be973c
credentialrequest: use safer approximation for ExpirationTimestamp
We want the value of time.Now() to be calculated before the call to
IssueClientCertPEM to prevent the ExpirationTimestamp from being
later than the notAfter timestamp on the issued certificate.

Signed-off-by: Monis Khan <mok@vmware.com>
2021-06-23 11:07:00 -04:00

224 lines
7.5 KiB
Go

// 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"
"fmt"
"time"
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/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, resource schema.GroupResource) *REST {
return &REST{
authenticator: authenticator,
issuer: issuer,
tableConvertor: rest.NewDefaultTableConvertor(resource),
}
}
type REST struct {
authenticator TokenCredentialRequestAuthenticator
issuer issuer.ClientCertIssuer
tableConvertor rest.TableConvertor
}
// 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: obj.GetObjectKind().GroupVersionKind().Kind,
})
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
}
// 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
}
traceSuccess(t, userInfo, true)
return &loginapi.TokenCredentialRequest{
Status: loginapi.TokenCredentialRequestStatus{
Credential: &loginapi.ClusterCredential{
ExpirationTimestamp: expires,
ClientCertificateData: string(certPEM),
ClientKeyData: string(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,
},
}
}