ContainerImage.Pinniped/internal/controller/supervisorconfig/upstreamwatchers/upstream_watchers.go
2021-09-08 10:34:42 -07:00

355 lines
14 KiB
Go

// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package upstreamwatchers
import (
"context"
"crypto/x509"
"encoding/base64"
"fmt"
"time"
corev1 "k8s.io/api/core/v1"
corev1informers "k8s.io/client-go/informers/core/v1"
"go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
"go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/upstreamldap"
)
const (
ReasonNotFound = "SecretNotFound"
ReasonWrongType = "SecretWrongType"
ReasonMissingKeys = "SecretMissingKeys"
ReasonSuccess = "Success"
ReasonInvalidTLSConfig = "InvalidTLSConfig"
ErrNoCertificates = constable.Error("no certificates found")
LDAPBindAccountSecretType = corev1.SecretTypeBasicAuth
probeLDAPTimeout = 90 * time.Second
// Constants related to conditions.
typeBindSecretValid = "BindSecretValid"
typeTLSConfigurationValid = "TLSConfigurationValid"
typeLDAPConnectionValid = "LDAPConnectionValid"
TypeSearchBaseFound = "SearchBaseFound"
reasonLDAPConnectionError = "LDAPConnectionError"
noTLSConfigurationMessage = "no TLS configuration provided"
loadedTLSConfigurationMessage = "loaded TLS configuration"
ReasonUsingConfigurationFromSpec = "UsingConfigurationFromSpec"
ReasonErrorFetchingSearchBase = "ErrorFetchingSearchBase"
)
// An in-memory cache with an entry for each ActiveDirectoryIdentityProvider, to keep track of which ResourceVersion
// of the bind Secret, which TLS/StartTLS setting was used and which search base was found during the most recent successful validation.
type SecretVersionCacheI interface {
Get(upstreamName, resourceVersion string, generation int64) (ValidatedSettings, bool)
Set(upstreamName, resourceVersion string, generation int64, settings ValidatedSettings)
}
type SecretVersionCache struct {
ValidatedSettingsByName map[string]ValidatedSettings
}
func (s *SecretVersionCache) Get(upstreamName, resourceVersion string, generation int64) (ValidatedSettings, bool) {
validatedSettings := s.ValidatedSettingsByName[upstreamName]
if validatedSettings.BindSecretResourceVersion == resourceVersion &&
validatedSettings.Generation == generation && validatedSettings.UserSearchBase != "" &&
validatedSettings.GroupSearchBase != "" && validatedSettings.LDAPConnectionProtocol != "" {
return validatedSettings, true
}
return ValidatedSettings{}, false
}
func (s *SecretVersionCache) Set(upstreamName, resourceVersion string, generation int64, settings ValidatedSettings) {
s.ValidatedSettingsByName[upstreamName] = settings
}
type ValidatedSettings struct {
Generation int64
BindSecretResourceVersion string
LDAPConnectionProtocol upstreamldap.LDAPConnectionProtocol
UserSearchBase string
GroupSearchBase string
}
func NewSecretVersionCache() SecretVersionCacheI {
cache := SecretVersionCache{ValidatedSettingsByName: map[string]ValidatedSettings{}}
return &cache
}
// read only interface for sharing between ldap and active directory.
type UpstreamGenericLDAPIDP interface {
Spec() UpstreamGenericLDAPSpec
Name() string
Namespace() string
Generation() int64
Status() UpstreamGenericLDAPStatus
}
type UpstreamGenericLDAPSpec interface {
Host() string
TLSSpec() *v1alpha1.TLSSpec
BindSecretName() string
UserSearch() UpstreamGenericLDAPUserSearch
GroupSearch() UpstreamGenericLDAPGroupSearch
DetectAndSetSearchBase(ctx context.Context, config *upstreamldap.ProviderConfig) *v1alpha1.Condition
}
type UpstreamGenericLDAPUserSearch interface {
Base() string
Filter() string
UsernameAttribute() string
UIDAttribute() string
}
type UpstreamGenericLDAPGroupSearch interface {
Base() string
Filter() string
GroupNameAttribute() string
}
type UpstreamGenericLDAPStatus interface {
Conditions() []v1alpha1.Condition
}
func ValidateTLSConfig(tlsSpec *v1alpha1.TLSSpec, config *upstreamldap.ProviderConfig) *v1alpha1.Condition {
if tlsSpec == nil {
return validTLSCondition(noTLSConfigurationMessage)
}
if len(tlsSpec.CertificateAuthorityData) == 0 {
return validTLSCondition(loadedTLSConfigurationMessage)
}
bundle, err := base64.StdEncoding.DecodeString(tlsSpec.CertificateAuthorityData)
if err != nil {
return invalidTLSCondition(fmt.Sprintf("certificateAuthorityData is invalid: %s", err.Error()))
}
ca := x509.NewCertPool()
ok := ca.AppendCertsFromPEM(bundle)
if !ok {
return invalidTLSCondition(fmt.Sprintf("certificateAuthorityData is invalid: %s", ErrNoCertificates))
}
config.CABundle = bundle
return validTLSCondition(loadedTLSConfigurationMessage)
}
func TestConnection(
ctx context.Context,
bindSecretName string,
config *upstreamldap.ProviderConfig,
currentSecretVersion string,
) *v1alpha1.Condition {
// First try using TLS.
config.ConnectionProtocol = upstreamldap.TLS
tlsLDAPProvider := upstreamldap.New(*config)
err := tlsLDAPProvider.TestConnection(ctx)
if err != nil {
plog.InfoErr("testing LDAP connection using TLS failed, so trying again with StartTLS", err, "host", config.Host)
// If there was any error, try again with StartTLS instead.
config.ConnectionProtocol = upstreamldap.StartTLS
startTLSLDAPProvider := upstreamldap.New(*config)
startTLSErr := startTLSLDAPProvider.TestConnection(ctx)
if startTLSErr == nil {
plog.Info("testing LDAP connection using StartTLS succeeded", "host", config.Host)
// Successfully able to fall back to using StartTLS, so clear the original
// error and consider the connection test to be successful.
err = nil
} else {
plog.InfoErr("testing LDAP connection using StartTLS also failed", err, "host", config.Host)
// Falling back to StartTLS also failed, so put TLS back into the config
// and consider the connection test to be failed.
config.ConnectionProtocol = upstreamldap.TLS
}
}
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, bindSecretName, currentSecretVersion),
}
}
func validTLSCondition(message string) *v1alpha1.Condition {
return &v1alpha1.Condition{
Type: typeTLSConfigurationValid,
Status: v1alpha1.ConditionTrue,
Reason: ReasonSuccess,
Message: message,
}
}
func invalidTLSCondition(message string) *v1alpha1.Condition {
return &v1alpha1.Condition{
Type: typeTLSConfigurationValid,
Status: v1alpha1.ConditionFalse,
Reason: ReasonInvalidTLSConfig,
Message: message,
}
}
func ValidateSecret(secretInformer corev1informers.SecretInformer, secretName string, secretNamespace string, config *upstreamldap.ProviderConfig) (*v1alpha1.Condition, string) {
secret, err := secretInformer.Lister().Secrets(secretNamespace).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
}
type GradatedConditions struct {
gradatedConditions []GradatedCondition
}
func (g *GradatedConditions) Conditions() []*v1alpha1.Condition {
conditions := []*v1alpha1.Condition{}
for _, gc := range g.gradatedConditions {
conditions = append(conditions, gc.condition)
}
return conditions
}
func (g *GradatedConditions) Append(condition *v1alpha1.Condition, isFatal bool) {
g.gradatedConditions = append(g.gradatedConditions, GradatedCondition{condition: condition, isFatal: isFatal})
}
// A condition and a boolean that tells you whether it's fatal or just a warning.
type GradatedCondition struct {
condition *v1alpha1.Condition
isFatal bool
}
func ValidateGenericLDAP(ctx context.Context, upstream UpstreamGenericLDAPIDP, secretInformer corev1informers.SecretInformer, validatedSecretVersionsCache SecretVersionCacheI, config *upstreamldap.ProviderConfig) GradatedConditions {
conditions := GradatedConditions{}
secretValidCondition, currentSecretVersion := ValidateSecret(secretInformer, upstream.Spec().BindSecretName(), upstream.Namespace(), config)
conditions.Append(secretValidCondition, true)
tlsValidCondition := ValidateTLSConfig(upstream.Spec().TLSSpec(), config)
conditions.Append(tlsValidCondition, true)
// No point in trying to connect to the server if the config was already determined to be invalid.
var ldapConnectionValidCondition *v1alpha1.Condition
var searchBaseFoundCondition *v1alpha1.Condition
if secretValidCondition.Status == v1alpha1.ConditionTrue && tlsValidCondition.Status == v1alpha1.ConditionTrue {
ldapConnectionValidCondition, searchBaseFoundCondition = validateAndSetLDAPServerConnectivityAndSearchBase(ctx, validatedSecretVersionsCache, upstream, config, currentSecretVersion)
if ldapConnectionValidCondition != nil {
conditions.Append(ldapConnectionValidCondition, false)
}
if searchBaseFoundCondition != nil {
conditions.Append(searchBaseFoundCondition, true)
}
}
return conditions
}
func validateAndSetLDAPServerConnectivityAndSearchBase(ctx context.Context, validatedSecretVersionsCache SecretVersionCacheI, upstream UpstreamGenericLDAPIDP, config *upstreamldap.ProviderConfig, currentSecretVersion string) (*v1alpha1.Condition, *v1alpha1.Condition) {
// previouslyValidatedSecretVersion := validatedSecretVersionsCache.ValidatedSettingsByName[upstream.Name()].BindSecretResourceVersion
// doesn't have an existing entry for ValidatedSettingsByName with this secret version ->
// lets double check tls connection
// if we can connect, put it in the secret cache
// also we KNOW we need to recheck the search base stuff too... so they should all be one function?
// but if tls validation fails no need to also try to get search base stuff?
validatedSettings, hasPreviousValidatedSettings := validatedSecretVersionsCache.Get(upstream.Name(), currentSecretVersion, upstream.Generation())
var ldapConnectionValidCondition, searchBaseFoundCondition *v1alpha1.Condition
if !hasPreviousValidatedSettings {
testConnectionTimeout, cancelFunc := context.WithTimeout(ctx, probeLDAPTimeout)
defer cancelFunc()
ldapConnectionValidCondition = TestConnection(testConnectionTimeout, upstream.Spec().BindSecretName(), config, currentSecretVersion)
searchBaseTimeout, cancelFunc := context.WithTimeout(ctx, probeLDAPTimeout)
defer cancelFunc()
searchBaseFoundCondition = upstream.Spec().DetectAndSetSearchBase(searchBaseTimeout, config)
if ldapConnectionValidCondition.Status == v1alpha1.ConditionTrue {
// if it's nil, don't worry about the search base condition. But if it exists make sure the status is true.
if searchBaseFoundCondition == nil || (searchBaseFoundCondition.Status == v1alpha1.ConditionTrue) {
// Remember (in-memory for this pod) that the controller has successfully validated the LDAP provider
// using this version of the Secret. This is for performance reasons, to avoid attempting to connect to
// the LDAP server more than is needed. If the pod restarts, it will attempt this validation again.
validatedSettings.LDAPConnectionProtocol = config.ConnectionProtocol
validatedSettings.BindSecretResourceVersion = currentSecretVersion
validatedSettings.Generation = upstream.Generation()
validatedSettings.UserSearchBase = config.UserSearch.Base
validatedSettings.GroupSearchBase = config.GroupSearch.Base
validatedSecretVersionsCache.Set(upstream.Name(), currentSecretVersion, upstream.Generation(), validatedSettings)
}
}
} else {
config.ConnectionProtocol = validatedSettings.LDAPConnectionProtocol
config.UserSearch.Base = validatedSettings.UserSearchBase
config.GroupSearch.Base = validatedSettings.GroupSearchBase
}
return ldapConnectionValidCondition, searchBaseFoundCondition
}
func EvaluateConditions(conditions GradatedConditions, config *upstreamldap.ProviderConfig) (provider.UpstreamLDAPIdentityProviderI, bool) {
for _, gradatedCondition := range conditions.gradatedConditions {
if gradatedCondition.condition.Status != v1alpha1.ConditionTrue && gradatedCondition.isFatal {
// Invalid provider, so do not load it into the cache.
return nil, true
}
}
for _, gradatedCondition := range conditions.gradatedConditions {
if gradatedCondition.condition.Status != v1alpha1.ConditionTrue && !gradatedCondition.isFatal {
// Error but load it into the cache anyway, treating this condition failure more like a warning.
// Try again hoping that the condition will improve.
return upstreamldap.New(*config), true
}
}
// Fully validated provider, so load it into the cache.
return upstreamldap.New(*config), false
}