mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-01-05 21:15:26 +00:00
354 lines
11 KiB
Go
354 lines
11 KiB
Go
// Copyright 2020-2025 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/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"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"
|
|
utilvalidation "k8s.io/apimachinery/pkg/util/validation"
|
|
"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"
|
|
|
|
loginapi "go.pinniped.dev/generated/latest/apis/concierge/login"
|
|
"go.pinniped.dev/internal/auditevent"
|
|
"go.pinniped.dev/internal/clientcertissuer"
|
|
"go.pinniped.dev/internal/plog"
|
|
)
|
|
|
|
// 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 clientcertissuer.ClientCertIssuer,
|
|
resource schema.GroupResource,
|
|
auditLogger plog.AuditLogger,
|
|
) *REST {
|
|
return &REST{
|
|
authenticator: authenticator,
|
|
issuer: issuer,
|
|
tableConvertor: rest.NewDefaultTableConvertor(resource),
|
|
auditLogger: auditLogger,
|
|
}
|
|
}
|
|
|
|
type REST struct {
|
|
authenticator TokenCredentialRequestAuthenticator
|
|
issuer clientcertissuer.ClientCertIssuer
|
|
tableConvertor rest.TableConvertor
|
|
auditLogger plog.AuditLogger
|
|
}
|
|
|
|
// Assert that our *REST implements all the optional interfaces that we expect it to implement.
|
|
var _ interface {
|
|
rest.Creater //nolint:misspell // this name comes from a dependency
|
|
rest.NamespaceScopedStrategy
|
|
rest.Scoper
|
|
rest.Storage
|
|
rest.CategoriesProvider
|
|
rest.SingularNameProvider
|
|
rest.Lister
|
|
} = (*REST)(nil)
|
|
|
|
func (*REST) New() runtime.Object {
|
|
return &loginapi.TokenCredentialRequest{}
|
|
}
|
|
|
|
func (*REST) Destroy() {}
|
|
|
|
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 (*REST) GetSingularName() string {
|
|
return "tokencredentialrequest"
|
|
}
|
|
|
|
func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
|
credentialRequest, err := validateRequest(ctx, obj, createValidation, options)
|
|
if err != nil {
|
|
// Bad requests are not audit logged because the Kubernetes audit log will show the response's status error code.
|
|
plog.DebugErr("TokenCredentialRequest request object validation error", err)
|
|
return nil, err
|
|
}
|
|
|
|
// Allow cross-referencing the token with the Supervisor's audit logs.
|
|
r.auditLogger.Audit(auditevent.TokenCredentialRequestTokenReceived, &plog.AuditParams{
|
|
ReqCtx: ctx,
|
|
KeysAndValues: []any{
|
|
"tokenID", fmt.Sprintf("%x", sha256.Sum256([]byte(credentialRequest.Spec.Token))),
|
|
},
|
|
})
|
|
|
|
userInfo, err := r.authenticator.AuthenticateTokenCredentialRequest(ctx, credentialRequest)
|
|
if err != nil {
|
|
r.auditLogger.Audit(auditevent.TokenCredentialRequestUnexpectedError, &plog.AuditParams{
|
|
ReqCtx: ctx,
|
|
KeysAndValues: []any{
|
|
"reason", "authenticator returned an error",
|
|
"err", err.Error(),
|
|
"authenticator", credentialRequest.Spec.Authenticator,
|
|
},
|
|
})
|
|
return authenticationFailedResponse(), nil
|
|
}
|
|
|
|
if userInfo == nil {
|
|
r.auditLogger.Audit(auditevent.TokenCredentialRequestAuthenticationFailed, &plog.AuditParams{
|
|
ReqCtx: ctx,
|
|
KeysAndValues: []any{
|
|
"reason", "auth rejected by authenticator",
|
|
"authenticator", credentialRequest.Spec.Authenticator,
|
|
},
|
|
})
|
|
return authenticationFailedResponse(), nil
|
|
}
|
|
|
|
if err = validateUserInfo(userInfo); err != nil {
|
|
r.auditLogger.Audit(auditevent.TokenCredentialRequestUnsupportedUserInfo, &plog.AuditParams{
|
|
ReqCtx: ctx,
|
|
PIIKeysAndValues: []any{
|
|
"userInfoName", userInfo.GetName(),
|
|
"userInfoUID", userInfo.GetUID(),
|
|
},
|
|
KeysAndValues: []any{
|
|
"userInfoExtrasCount", len(userInfo.GetExtra()),
|
|
"reason", "unsupported value in userInfo returned by authenticator",
|
|
"err", err.Error(),
|
|
"authenticator", credentialRequest.Spec.Authenticator,
|
|
},
|
|
})
|
|
return authenticationFailedResponse(), nil
|
|
}
|
|
|
|
pem, err := r.issuer.IssueClientCertPEM(
|
|
userInfo.GetName(),
|
|
userInfo.GetGroups(),
|
|
extrasAsKeyValues(userInfo.GetExtra()),
|
|
clientCertificateTTL,
|
|
)
|
|
if err != nil {
|
|
r.auditLogger.Audit(auditevent.TokenCredentialRequestUnexpectedError, &plog.AuditParams{
|
|
ReqCtx: ctx,
|
|
KeysAndValues: []any{
|
|
"reason", "cert issuer returned an error",
|
|
"err", err.Error(),
|
|
"authenticator", credentialRequest.Spec.Authenticator,
|
|
},
|
|
})
|
|
return authenticationFailedResponse(), nil
|
|
}
|
|
|
|
notBefore := metav1.NewTime(pem.NotBefore)
|
|
notAfter := metav1.NewTime(pem.NotAfter)
|
|
|
|
r.auditLogger.Audit(auditevent.TokenCredentialRequestAuthenticatedUser, &plog.AuditParams{
|
|
ReqCtx: ctx,
|
|
PIIKeysAndValues: []any{
|
|
"username", userInfo.GetName(),
|
|
"groups", userInfo.GetGroups(),
|
|
"extras", userInfo.GetExtra(),
|
|
},
|
|
KeysAndValues: []any{
|
|
"issuedClientCert", map[string]string{
|
|
"notBefore": notBefore.Format(time.RFC3339),
|
|
"notAfter": notAfter.Format(time.RFC3339),
|
|
},
|
|
"authenticator", credentialRequest.Spec.Authenticator,
|
|
},
|
|
})
|
|
|
|
return &loginapi.TokenCredentialRequest{
|
|
Status: loginapi.TokenCredentialRequestStatus{
|
|
Credential: &loginapi.ClusterCredential{
|
|
ExpirationTimestamp: notAfter,
|
|
ClientCertificateData: string(pem.CertPEM),
|
|
ClientKeyData: string(pem.KeyPEM),
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func extrasAsKeyValues(extras map[string][]string) []string {
|
|
var kvExtras []string
|
|
for k, v := range extras {
|
|
for _, vv := range v {
|
|
// Note that this will result in a key getting repeated if it has multiple values.
|
|
kvExtras = append(kvExtras, fmt.Sprintf("%s=%s", k, vv))
|
|
}
|
|
}
|
|
slices.Sort(kvExtras)
|
|
return kvExtras
|
|
}
|
|
|
|
func validateRequest(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (*loginapi.TokenCredentialRequest, error) {
|
|
credentialRequest, ok := obj.(*loginapi.TokenCredentialRequest)
|
|
if !ok {
|
|
return nil, apierrors.NewBadRequest(fmt.Sprintf("not a TokenCredentialRequest: %#v", obj))
|
|
}
|
|
|
|
if len(credentialRequest.Spec.Token) == 0 {
|
|
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 {
|
|
errs := field.ErrorList{field.NotSupported(field.NewPath("dryRun"), options.DryRun, []string(nil))}
|
|
return nil, apierrors.NewInvalid(loginapi.Kind(credentialRequest.Kind), credentialRequest.Name, errs)
|
|
}
|
|
}
|
|
|
|
if namespace := genericapirequest.NamespaceValue(ctx); len(namespace) != 0 {
|
|
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
|
|
if createValidation != nil {
|
|
requestForValidation := obj.DeepCopyObject()
|
|
requestForValidation.(*loginapi.TokenCredentialRequest).Spec.Token = ""
|
|
if err := createValidation(ctx, requestForValidation); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return credentialRequest, nil
|
|
}
|
|
|
|
func validateUserInfo(userInfo user.Info) error {
|
|
if len(userInfo.GetName()) == 0 {
|
|
return errors.New("empty username is not allowed")
|
|
}
|
|
|
|
// certs cannot assert UID
|
|
if len(userInfo.GetUID()) != 0 {
|
|
return errors.New("UIDs are not supported")
|
|
}
|
|
|
|
allErrs := validateExtraKeys(userInfo.GetExtra())
|
|
if allErrs != nil {
|
|
return fmt.Errorf("authenticator returned illegal userInfo extra key(s): %w", allErrs.ToAggregate())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateExtraKeys(extras map[string][]string) field.ErrorList {
|
|
// Prevent WebhookAuthenticators from returning illegal extras.
|
|
//
|
|
// JWTAuthenticators are already effectively prevented from returning illegal extras because we validate
|
|
// the extra key names that are configured on the JWTAuthenticator CRD, but it shouldn't hurt to check again
|
|
// here for JWTAuthenticators too.
|
|
//
|
|
// These validations are inspired by those done in k8s.io/apiserver@v0.33.2/pkg/apis/apiserver/validation/validation.go.
|
|
//
|
|
// Keys must be a domain-prefix path (e.g. example.org/foo).
|
|
// All characters before the first "/" must be a valid subdomain as defined by RFC 1123.
|
|
// All characters trailing the first "/" must be valid HTTP Path characters as defined by RFC 3986.
|
|
// k8s.io, kubernetes.io and their subdomains are reserved for Kubernetes use and cannot be used.
|
|
// Keys must be lowercase.
|
|
var allErrs field.ErrorList
|
|
|
|
// Sort the keys for stable order of error messages.
|
|
keys := make([]string, 0, len(extras))
|
|
for k := range extras {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
for _, extraKey := range keys {
|
|
path := field.NewPath(fmt.Sprintf("userInfo extra key %q", extraKey))
|
|
|
|
// This is a special key that is always added by authenticators starting in K8s 1.32, so always allow it.
|
|
if extraKey == "authentication.kubernetes.io/credential-id" {
|
|
continue
|
|
}
|
|
|
|
// Noe that IsDomainPrefixedPath also checks for empty keys.
|
|
allErrs = append(allErrs, utilvalidation.IsDomainPrefixedPath(path, extraKey)...)
|
|
|
|
// Cannot use reserved prefixes.
|
|
if isKubernetesDomainPrefix(extraKey) {
|
|
allErrs = append(allErrs, field.Invalid(path, extraKey, "k8s.io, kubernetes.io and their subdomains are reserved for Kubernetes use"))
|
|
}
|
|
|
|
// We can't allow equals signs in the key name, because we need to be able to encode the key names and values
|
|
// into the client cert as OU "keyName=value".
|
|
if strings.Contains(extraKey, "=") {
|
|
allErrs = append(allErrs, field.Invalid(path, extraKey, "Pinniped does not allow extra key names to contain equals sign"))
|
|
}
|
|
}
|
|
|
|
return allErrs
|
|
}
|
|
|
|
func isKubernetesDomainPrefix(key string) bool {
|
|
domainPrefix := getDomainPrefix(key)
|
|
if domainPrefix == "kubernetes.io" || strings.HasSuffix(domainPrefix, ".kubernetes.io") {
|
|
return true
|
|
}
|
|
if domainPrefix == "k8s.io" || strings.HasSuffix(domainPrefix, ".k8s.io") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func getDomainPrefix(key string) string {
|
|
if parts := strings.SplitN(key, "/", 2); len(parts) == 2 {
|
|
return parts[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func authenticationFailedResponse() *loginapi.TokenCredentialRequest {
|
|
m := "authentication failed"
|
|
return &loginapi.TokenCredentialRequest{
|
|
Status: loginapi.TokenCredentialRequestStatus{
|
|
Credential: nil,
|
|
Message: &m,
|
|
},
|
|
}
|
|
}
|