ContainerImage.Pinniped/internal/controller/supervisorconfig/upstreamwatcher/ldap_upstream_watcher.go

379 lines
14 KiB
Go
Raw Normal View History

// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package upstreamwatcher
import (
"context"
"crypto/x509"
"encoding/base64"
"fmt"
"regexp"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
corev1informers "k8s.io/client-go/informers/core/v1"
"k8s.io/klog/v2/klogr"
"go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
pinnipedclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned"
idpinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1"
pinnipedcontroller "go.pinniped.dev/internal/controller"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/upstreamldap"
)
const (
ldapControllerName = "ldap-upstream-observer"
ldapBindAccountSecretType = corev1.SecretTypeBasicAuth
testLDAPConnectionTimeout = 90 * time.Second
// Constants related to conditions.
typeBindSecretValid = "BindSecretValid"
typeTLSConfigurationValid = "TLSConfigurationValid"
typeLDAPConnectionValid = "LDAPConnectionValid"
reasonLDAPConnectionError = "LDAPConnectionError"
reasonAuthenticationDryRunError = "AuthenticationDryRunError"
noTLSConfigurationMessage = "no TLS configuration provided"
loadedTLSConfigurationMessage = "loaded TLS configuration"
)
var (
secretVersionParser = regexp.MustCompile(` \[validated with Secret ".+" at version "(.+)"]`)
)
// UpstreamLDAPIdentityProviderICache is a thread safe cache that holds a list of validated upstream LDAP IDP configurations.
type UpstreamLDAPIdentityProviderICache interface {
SetLDAPIdentityProviders([]provider.UpstreamLDAPIdentityProviderI)
}
type ldapWatcherController struct {
cache UpstreamLDAPIdentityProviderICache
ldapDialer upstreamldap.LDAPDialer
client pinnipedclientset.Interface
ldapIdentityProviderInformer idpinformers.LDAPIdentityProviderInformer
secretInformer corev1informers.SecretInformer
}
// NewLDAPUpstreamWatcherController instantiates a new controllerlib.Controller which will populate the provided UpstreamLDAPIdentityProviderICache.
func NewLDAPUpstreamWatcherController(
2021-04-27 19:43:09 +00:00
idpCache UpstreamLDAPIdentityProviderICache,
client pinnipedclientset.Interface,
ldapIdentityProviderInformer idpinformers.LDAPIdentityProviderInformer,
secretInformer corev1informers.SecretInformer,
withInformer pinnipedcontroller.WithInformerOptionFunc,
) controllerlib.Controller {
// nil means to use a real production dialer when creating objects to add to the dynamicUpstreamIDPProvider cache.
return newInternal(idpCache, nil, client, ldapIdentityProviderInformer, secretInformer, withInformer)
}
func newInternal(
idpCache UpstreamLDAPIdentityProviderICache,
ldapDialer upstreamldap.LDAPDialer,
client pinnipedclientset.Interface,
ldapIdentityProviderInformer idpinformers.LDAPIdentityProviderInformer,
secretInformer corev1informers.SecretInformer,
withInformer pinnipedcontroller.WithInformerOptionFunc,
) controllerlib.Controller {
c := ldapWatcherController{
cache: idpCache,
ldapDialer: ldapDialer,
client: client,
ldapIdentityProviderInformer: ldapIdentityProviderInformer,
secretInformer: secretInformer,
}
return controllerlib.New(
controllerlib.Config{Name: ldapControllerName, Syncer: &c},
withInformer(
ldapIdentityProviderInformer,
pinnipedcontroller.MatchAnythingFilter(pinnipedcontroller.SingletonQueue()),
controllerlib.InformerOption{},
),
withInformer(
secretInformer,
pinnipedcontroller.MatchAnySecretOfTypeFilter(ldapBindAccountSecretType, pinnipedcontroller.SingletonQueue()),
controllerlib.InformerOption{},
),
)
}
// Sync implements controllerlib.Syncer.
func (c *ldapWatcherController) Sync(ctx controllerlib.Context) error {
actualUpstreams, err := c.ldapIdentityProviderInformer.Lister().List(labels.Everything())
if err != nil {
return fmt.Errorf("failed to list LDAPIdentityProviders: %w", err)
}
requeue := false
validatedUpstreams := make([]provider.UpstreamLDAPIdentityProviderI, 0, len(actualUpstreams))
for _, upstream := range actualUpstreams {
valid := c.validateUpstream(ctx.Context, upstream)
if valid == nil {
requeue = true
} else {
validatedUpstreams = append(validatedUpstreams, valid)
}
}
c.cache.SetLDAPIdentityProviders(validatedUpstreams)
if requeue {
return controllerlib.ErrSyntheticRequeue
}
return nil
}
func (c *ldapWatcherController) validateUpstream(ctx context.Context, upstream *v1alpha1.LDAPIdentityProvider) provider.UpstreamLDAPIdentityProviderI {
spec := upstream.Spec
config := &upstreamldap.ProviderConfig{
Name: upstream.Name,
Host: spec.Host,
UserSearch: upstreamldap.UserSearchConfig{
Base: spec.UserSearch.Base,
Filter: spec.UserSearch.Filter,
UsernameAttribute: spec.UserSearch.Attributes.Username,
2021-04-27 19:43:09 +00:00
UIDAttribute: spec.UserSearch.Attributes.UID,
},
Dialer: c.ldapDialer,
}
conditions := []*v1alpha1.Condition{}
secretValidCondition, currentSecretVersion := c.validateSecret(upstream, config)
tlsValidCondition := c.validateTLSConfig(upstream, config)
conditions = append(conditions, secretValidCondition, tlsValidCondition)
// No point in trying to connect to the server if the config was already determined to be invalid.
if secretValidCondition.Status == v1alpha1.ConditionTrue && tlsValidCondition.Status == v1alpha1.ConditionTrue {
finishedConfigCondition := c.validateFinishedConfig(ctx, upstream, config, currentSecretVersion)
// nil when there is no need to update this condition.
if finishedConfigCondition != nil {
conditions = append(conditions, finishedConfigCondition)
}
}
hadErrorCondition := c.updateStatus(ctx, upstream, conditions)
if hadErrorCondition {
return nil
}
return upstreamldap.New(*config)
}
func (c *ldapWatcherController) validateTLSConfig(upstream *v1alpha1.LDAPIdentityProvider, config *upstreamldap.ProviderConfig) *v1alpha1.Condition {
tlsSpec := upstream.Spec.TLS
if tlsSpec == nil {
return c.validTLSCondition(noTLSConfigurationMessage)
}
if len(tlsSpec.CertificateAuthorityData) == 0 {
return c.validTLSCondition(loadedTLSConfigurationMessage)
}
bundle, err := base64.StdEncoding.DecodeString(tlsSpec.CertificateAuthorityData)
if err != nil {
return c.invalidTLSCondition(fmt.Sprintf("certificateAuthorityData is invalid: %s", err.Error()))
}
ca := x509.NewCertPool()
ok := ca.AppendCertsFromPEM(bundle)
if !ok {
return c.invalidTLSCondition(fmt.Sprintf("certificateAuthorityData is invalid: %s", errNoCertificates))
}
config.CABundle = bundle
return c.validTLSCondition(loadedTLSConfigurationMessage)
}
func (c *ldapWatcherController) validateFinishedConfig(ctx context.Context, upstream *v1alpha1.LDAPIdentityProvider, config *upstreamldap.ProviderConfig, currentSecretVersion string) *v1alpha1.Condition {
ldapProvider := upstreamldap.New(*config)
if hasPreviousSuccessfulConditionForCurrentSpecGenerationAndSecretVersion(upstream, currentSecretVersion) {
return nil
}
testConnectionTimeout, cancelFunc := context.WithTimeout(ctx, testLDAPConnectionTimeout)
defer cancelFunc()
if len(upstream.Spec.DryRunAuthenticationUsername) > 0 {
return c.dryRunAuthentication(testConnectionTimeout, upstream, ldapProvider, currentSecretVersion)
}
return c.testConnection(testConnectionTimeout, upstream, config, ldapProvider, currentSecretVersion)
}
func (c *ldapWatcherController) testConnection(
ctx context.Context,
upstream *v1alpha1.LDAPIdentityProvider,
config *upstreamldap.ProviderConfig,
ldapProvider *upstreamldap.Provider,
currentSecretVersion string,
) *v1alpha1.Condition {
err := ldapProvider.TestConnection(ctx)
if err != nil {
return &v1alpha1.Condition{
Type: typeLDAPConnectionValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonLDAPConnectionError,
Message: fmt.Sprintf(`could not successfully connect to "%s" and bind as user "%s": %s`,
config.Host, config.BindUsername, err.Error()),
}
}
return &v1alpha1.Condition{
Type: typeLDAPConnectionValid,
Status: v1alpha1.ConditionTrue,
Reason: reasonSuccess,
Message: fmt.Sprintf(`successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`,
config.Host, config.BindUsername, upstream.Spec.Bind.SecretName, currentSecretVersion),
}
}
func (c *ldapWatcherController) dryRunAuthentication(
ctx context.Context,
upstream *v1alpha1.LDAPIdentityProvider,
ldapProvider *upstreamldap.Provider,
currentSecretVersion string,
) *v1alpha1.Condition {
authResponse, authenticated, err := ldapProvider.DryRunAuthenticateUser(ctx, upstream.Spec.DryRunAuthenticationUsername)
if err != nil {
return &v1alpha1.Condition{
Type: typeLDAPConnectionValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonAuthenticationDryRunError,
Message: fmt.Sprintf(`failed authentication dry run for end user "%s": %s`,
upstream.Spec.DryRunAuthenticationUsername, err.Error()),
}
}
if !authenticated {
// Since we aren't doing a real auth with a password that could be wrong, the only reason we should get
// an unauthenticated response without an error is when the username was wrong.
return &v1alpha1.Condition{
Type: typeLDAPConnectionValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonAuthenticationDryRunError,
Message: fmt.Sprintf(`failed authentication dry run for end user "%s": user not found`,
upstream.Spec.DryRunAuthenticationUsername),
}
}
return &v1alpha1.Condition{
Type: typeLDAPConnectionValid,
Status: v1alpha1.ConditionTrue,
Reason: reasonSuccess,
Message: fmt.Sprintf(
`successful authentication dry run for end user "%s": selected username "%s" and UID "%s" [validated with Secret "%s" at version "%s"]`,
upstream.Spec.DryRunAuthenticationUsername,
authResponse.User.GetName(), authResponse.User.GetUID(),
upstream.Spec.Bind.SecretName, currentSecretVersion),
}
}
func hasPreviousSuccessfulConditionForCurrentSpecGenerationAndSecretVersion(upstream *v1alpha1.LDAPIdentityProvider, currentSecretVersion string) bool {
currentGeneration := upstream.Generation
for _, c := range upstream.Status.Conditions {
if c.Type == typeLDAPConnectionValid && c.Status == v1alpha1.ConditionTrue && c.ObservedGeneration == currentGeneration {
// Found a previously successful condition for the current spec generation.
// Now figure out which version of the bind Secret was used during that previous validation.
matches := secretVersionParser.FindStringSubmatch(c.Message)
if len(matches) != 2 {
continue
}
validatedSecretVersion := matches[1]
if validatedSecretVersion == currentSecretVersion {
return true
}
}
}
return false
}
func (c *ldapWatcherController) validTLSCondition(message string) *v1alpha1.Condition {
return &v1alpha1.Condition{
Type: typeTLSConfigurationValid,
Status: v1alpha1.ConditionTrue,
Reason: reasonSuccess,
Message: message,
}
}
func (c *ldapWatcherController) invalidTLSCondition(message string) *v1alpha1.Condition {
return &v1alpha1.Condition{
Type: typeTLSConfigurationValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonInvalidTLSConfig,
Message: message,
}
}
func (c *ldapWatcherController) validateSecret(upstream *v1alpha1.LDAPIdentityProvider, config *upstreamldap.ProviderConfig) (*v1alpha1.Condition, string) {
secretName := upstream.Spec.Bind.SecretName
secret, err := c.secretInformer.Lister().Secrets(upstream.Namespace).Get(secretName)
if err != nil {
return &v1alpha1.Condition{
Type: typeBindSecretValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonNotFound,
Message: err.Error(),
}, ""
}
if secret.Type != corev1.SecretTypeBasicAuth {
return &v1alpha1.Condition{
Type: typeBindSecretValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonWrongType,
Message: fmt.Sprintf("referenced Secret %q has wrong type %q (should be %q)",
secretName, secret.Type, corev1.SecretTypeBasicAuth),
}, secret.ResourceVersion
}
config.BindUsername = string(secret.Data[corev1.BasicAuthUsernameKey])
config.BindPassword = string(secret.Data[corev1.BasicAuthPasswordKey])
if len(config.BindUsername) == 0 || len(config.BindPassword) == 0 {
return &v1alpha1.Condition{
Type: typeBindSecretValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonMissingKeys,
Message: fmt.Sprintf("referenced Secret %q is missing required keys %q",
secretName, []string{corev1.BasicAuthUsernameKey, corev1.BasicAuthPasswordKey}),
}, secret.ResourceVersion
}
return &v1alpha1.Condition{
Type: typeBindSecretValid,
Status: v1alpha1.ConditionTrue,
Reason: reasonSuccess,
Message: "loaded bind secret",
}, secret.ResourceVersion
}
func (c *ldapWatcherController) updateStatus(ctx context.Context, upstream *v1alpha1.LDAPIdentityProvider, conditions []*v1alpha1.Condition) bool {
log := klogr.New().WithValues("namespace", upstream.Namespace, "name", upstream.Name)
updated := upstream.DeepCopy()
hadErrorCondition := mergeConditions(conditions, upstream.Generation, &updated.Status.Conditions, log)
updated.Status.Phase = v1alpha1.LDAPPhaseReady
if hadErrorCondition {
updated.Status.Phase = v1alpha1.LDAPPhaseError
}
if equality.Semantic.DeepEqual(upstream, updated) {
return hadErrorCondition
}
_, err := c.client.
IDPV1alpha1().
LDAPIdentityProviders(upstream.Namespace).
UpdateStatus(ctx, updated, metav1.UpdateOptions{})
if err != nil {
log.Error(err, "failed to update status")
}
return hadErrorCondition
}