880 lines
38 KiB
Go
880 lines
38 KiB
Go
// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package supervisorconfig
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/equality"
|
|
"k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/labels"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
errorsutil "k8s.io/apimachinery/pkg/util/errors"
|
|
"k8s.io/apimachinery/pkg/util/sets"
|
|
"k8s.io/utils/clock"
|
|
|
|
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
|
|
pinnipedclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned"
|
|
configinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions/config/v1alpha1"
|
|
idpinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1"
|
|
"go.pinniped.dev/internal/celtransformer"
|
|
pinnipedcontroller "go.pinniped.dev/internal/controller"
|
|
"go.pinniped.dev/internal/controller/conditionsutil"
|
|
"go.pinniped.dev/internal/controllerlib"
|
|
"go.pinniped.dev/internal/federationdomain/federationdomainproviders"
|
|
"go.pinniped.dev/internal/idtransform"
|
|
"go.pinniped.dev/internal/plog"
|
|
)
|
|
|
|
const (
|
|
typeReady = "Ready"
|
|
typeIssuerURLValid = "IssuerURLValid"
|
|
typeOneTLSSecretPerIssuerHostname = "OneTLSSecretPerIssuerHostname"
|
|
typeIssuerIsUnique = "IssuerIsUnique"
|
|
typeIdentityProvidersFound = "IdentityProvidersFound"
|
|
typeIdentityProvidersDisplayNamesUnique = "IdentityProvidersDisplayNamesUnique"
|
|
typeIdentityProvidersAPIGroupSuffixValid = "IdentityProvidersObjectRefAPIGroupSuffixValid"
|
|
typeIdentityProvidersObjectRefKindValid = "IdentityProvidersObjectRefKindValid"
|
|
typeTransformsConstantsNamesUnique = "TransformsConstantsNamesUnique"
|
|
|
|
reasonSuccess = "Success"
|
|
reasonNotReady = "NotReady"
|
|
reasonUnableToValidate = "UnableToValidate"
|
|
reasonInvalidIssuerURL = "InvalidIssuerURL"
|
|
reasonDuplicateIssuer = "DuplicateIssuer"
|
|
reasonDifferentSecretRefsFound = "DifferentSecretRefsFound"
|
|
reasonLegacyConfigurationSuccess = "LegacyConfigurationSuccess"
|
|
reasonLegacyConfigurationIdentityProviderNotFound = "LegacyConfigurationIdentityProviderNotFound"
|
|
reasonIdentityProvidersObjectRefsNotFound = "IdentityProvidersObjectRefsNotFound"
|
|
reasonIdentityProviderNotSpecified = "IdentityProviderNotSpecified"
|
|
reasonDuplicateDisplayNames = "DuplicateDisplayNames"
|
|
reasonAPIGroupNameUnrecognized = "APIGroupUnrecognized"
|
|
reasonKindUnrecognized = "KindUnrecognized"
|
|
reasonDuplicateConstantsNames = "DuplicateConstantsNames"
|
|
|
|
kindLDAPIdentityProvider = "LDAPIdentityProvider"
|
|
kindOIDCIdentityProvider = "OIDCIdentityProvider"
|
|
kindActiveDirectoryIdentityProvider = "ActiveDirectoryIdentityProvider"
|
|
|
|
celTransformerMaxExpressionRuntime = 5 * time.Second
|
|
)
|
|
|
|
// FederationDomainsSetter can be notified of all known valid providers with its SetFederationDomains function.
|
|
// If there are no longer any valid issuers, then it can be called with no arguments.
|
|
// Implementations of this type should be thread-safe to support calls from multiple goroutines.
|
|
type FederationDomainsSetter interface {
|
|
SetFederationDomains(federationDomains ...*federationdomainproviders.FederationDomainIssuer)
|
|
}
|
|
|
|
type federationDomainWatcherController struct {
|
|
federationDomainsSetter FederationDomainsSetter
|
|
apiGroup string
|
|
clock clock.Clock
|
|
client pinnipedclientset.Interface
|
|
|
|
federationDomainInformer configinformers.FederationDomainInformer
|
|
oidcIdentityProviderInformer idpinformers.OIDCIdentityProviderInformer
|
|
ldapIdentityProviderInformer idpinformers.LDAPIdentityProviderInformer
|
|
activeDirectoryIdentityProviderInformer idpinformers.ActiveDirectoryIdentityProviderInformer
|
|
|
|
celTransformer *celtransformer.CELTransformer
|
|
allowedKinds sets.Set[string]
|
|
}
|
|
|
|
// NewFederationDomainWatcherController creates a controllerlib.Controller that watches
|
|
// FederationDomain objects and notifies a callback object of the collection of provider configs.
|
|
func NewFederationDomainWatcherController(
|
|
federationDomainsSetter FederationDomainsSetter,
|
|
apiGroupSuffix string,
|
|
clock clock.Clock,
|
|
client pinnipedclientset.Interface,
|
|
federationDomainInformer configinformers.FederationDomainInformer,
|
|
oidcIdentityProviderInformer idpinformers.OIDCIdentityProviderInformer,
|
|
ldapIdentityProviderInformer idpinformers.LDAPIdentityProviderInformer,
|
|
activeDirectoryIdentityProviderInformer idpinformers.ActiveDirectoryIdentityProviderInformer,
|
|
withInformer pinnipedcontroller.WithInformerOptionFunc,
|
|
) controllerlib.Controller {
|
|
allowedKinds := sets.New(kindActiveDirectoryIdentityProvider, kindLDAPIdentityProvider, kindOIDCIdentityProvider)
|
|
return controllerlib.New(
|
|
controllerlib.Config{
|
|
Name: "FederationDomainWatcherController",
|
|
Syncer: &federationDomainWatcherController{
|
|
federationDomainsSetter: federationDomainsSetter,
|
|
apiGroup: fmt.Sprintf("idp.supervisor.%s", apiGroupSuffix),
|
|
clock: clock,
|
|
client: client,
|
|
federationDomainInformer: federationDomainInformer,
|
|
oidcIdentityProviderInformer: oidcIdentityProviderInformer,
|
|
ldapIdentityProviderInformer: ldapIdentityProviderInformer,
|
|
activeDirectoryIdentityProviderInformer: activeDirectoryIdentityProviderInformer,
|
|
allowedKinds: allowedKinds,
|
|
},
|
|
},
|
|
withInformer(
|
|
federationDomainInformer,
|
|
pinnipedcontroller.MatchAnythingFilter(pinnipedcontroller.SingletonQueue()),
|
|
controllerlib.InformerOption{},
|
|
),
|
|
withInformer(
|
|
oidcIdentityProviderInformer,
|
|
// Since this controller only cares about IDP metadata names and UIDs (immutable fields),
|
|
// we only need to trigger Sync on creates and deletes.
|
|
pinnipedcontroller.MatchAnythingIgnoringUpdatesFilter(pinnipedcontroller.SingletonQueue()),
|
|
controllerlib.InformerOption{},
|
|
),
|
|
withInformer(
|
|
ldapIdentityProviderInformer,
|
|
// Since this controller only cares about IDP metadata names and UIDs (immutable fields),
|
|
// we only need to trigger Sync on creates and deletes.
|
|
pinnipedcontroller.MatchAnythingIgnoringUpdatesFilter(pinnipedcontroller.SingletonQueue()),
|
|
controllerlib.InformerOption{},
|
|
),
|
|
withInformer(
|
|
activeDirectoryIdentityProviderInformer,
|
|
// Since this controller only cares about IDP metadata names and UIDs (immutable fields),
|
|
// we only need to trigger Sync on creates and deletes.
|
|
pinnipedcontroller.MatchAnythingIgnoringUpdatesFilter(pinnipedcontroller.SingletonQueue()),
|
|
controllerlib.InformerOption{},
|
|
),
|
|
)
|
|
}
|
|
|
|
// Sync implements controllerlib.Syncer.
|
|
func (c *federationDomainWatcherController) Sync(ctx controllerlib.Context) error {
|
|
federationDomains, err := c.federationDomainInformer.Lister().List(labels.Everything())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if c.celTransformer == nil {
|
|
c.celTransformer, err = celtransformer.NewCELTransformer(celTransformerMaxExpressionRuntime)
|
|
if err != nil {
|
|
return err // shouldn't really happen
|
|
}
|
|
}
|
|
|
|
// Process each FederationDomain to validate its spec and to turn it into a FederationDomainIssuer.
|
|
federationDomainIssuers, fdToConditionsMap, err := c.processAllFederationDomains(federationDomains)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Load the endpoints of every valid FederationDomain. Removes the endpoints of any
|
|
// previous FederationDomains which no longer exist or are no longer valid.
|
|
c.federationDomainsSetter.SetFederationDomains(federationDomainIssuers...)
|
|
|
|
// Now that the endpoints of every valid FederationDomain are available, update the
|
|
// statuses. This allows clients to wait for Ready without any race conditions in the
|
|
// endpoints being available.
|
|
var errs []error
|
|
for federationDomain, conditions := range fdToConditionsMap {
|
|
if err = c.updateStatus(ctx.Context, federationDomain, conditions); err != nil {
|
|
errs = append(errs, fmt.Errorf("could not update status: %w", err))
|
|
}
|
|
}
|
|
|
|
return errorsutil.NewAggregate(errs)
|
|
}
|
|
|
|
func (c *federationDomainWatcherController) processAllFederationDomains(
|
|
federationDomains []*configv1alpha1.FederationDomain,
|
|
) ([]*federationdomainproviders.FederationDomainIssuer, map[*configv1alpha1.FederationDomain][]*configv1alpha1.Condition, error) {
|
|
federationDomainIssuers := make([]*federationdomainproviders.FederationDomainIssuer, 0)
|
|
fdToConditionsMap := map[*configv1alpha1.FederationDomain][]*configv1alpha1.Condition{}
|
|
crossDomainConfigValidator := newCrossFederationDomainConfigValidator(federationDomains)
|
|
|
|
for _, federationDomain := range federationDomains {
|
|
conditions := make([]*configv1alpha1.Condition, 0)
|
|
|
|
conditions = crossDomainConfigValidator.Validate(federationDomain, conditions)
|
|
|
|
federationDomainIssuer, conditions, err := c.makeFederationDomainIssuer(federationDomain, conditions)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Now that we have determined the conditions, save them for after the loop.
|
|
// For a valid FederationDomain, want to update the conditions after we have
|
|
// made the FederationDomain's endpoints available.
|
|
fdToConditionsMap[federationDomain] = conditions
|
|
|
|
if !hadErrorCondition(conditions) {
|
|
// Successfully validated the FederationDomain, so allow it to be loaded.
|
|
federationDomainIssuers = append(federationDomainIssuers, federationDomainIssuer)
|
|
}
|
|
}
|
|
|
|
return federationDomainIssuers, fdToConditionsMap, nil
|
|
}
|
|
|
|
func (c *federationDomainWatcherController) makeFederationDomainIssuer(
|
|
federationDomain *configv1alpha1.FederationDomain,
|
|
conditions []*configv1alpha1.Condition,
|
|
) (*federationdomainproviders.FederationDomainIssuer, []*configv1alpha1.Condition, error) {
|
|
var err error
|
|
// Create the list of IDPs for this FederationDomain.
|
|
// Don't worry if the IDP CRs themselves is phase=Ready because those which are not ready will not be loaded
|
|
// into the provider cache, so they cannot actually be used to authenticate.
|
|
var federationDomainIssuer *federationdomainproviders.FederationDomainIssuer
|
|
if len(federationDomain.Spec.IdentityProviders) == 0 {
|
|
federationDomainIssuer, conditions, err = c.makeLegacyFederationDomainIssuer(federationDomain, conditions)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
} else {
|
|
federationDomainIssuer, conditions, err = c.makeFederationDomainIssuerWithExplicitIDPs(federationDomain, conditions)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
return federationDomainIssuer, conditions, nil
|
|
}
|
|
|
|
func (c *federationDomainWatcherController) makeLegacyFederationDomainIssuer(
|
|
federationDomain *configv1alpha1.FederationDomain,
|
|
conditions []*configv1alpha1.Condition,
|
|
) (*federationdomainproviders.FederationDomainIssuer, []*configv1alpha1.Condition, error) {
|
|
var defaultFederationDomainIdentityProvider *federationdomainproviders.FederationDomainIdentityProvider
|
|
|
|
// When the FederationDomain does not list any IDPs, then we might be in backwards compatibility mode.
|
|
oidcIdentityProviders, err := c.oidcIdentityProviderInformer.Lister().List(labels.Everything())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
ldapIdentityProviders, err := c.ldapIdentityProviderInformer.Lister().List(labels.Everything())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
activeDirectoryIdentityProviders, err := c.activeDirectoryIdentityProviderInformer.Lister().List(labels.Everything())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Check if that there is exactly one IDP defined in the Supervisor namespace of any IDP CRD type.
|
|
idpCRsCount := len(oidcIdentityProviders) + len(ldapIdentityProviders) + len(activeDirectoryIdentityProviders)
|
|
|
|
switch {
|
|
case idpCRsCount == 1:
|
|
foundIDPName := ""
|
|
// If so, default that IDP's DisplayName to be the same as its resource Name.
|
|
defaultFederationDomainIdentityProvider = &federationdomainproviders.FederationDomainIdentityProvider{}
|
|
switch {
|
|
case len(oidcIdentityProviders) == 1:
|
|
defaultFederationDomainIdentityProvider.DisplayName = oidcIdentityProviders[0].Name
|
|
defaultFederationDomainIdentityProvider.UID = oidcIdentityProviders[0].UID
|
|
foundIDPName = oidcIdentityProviders[0].Name
|
|
case len(ldapIdentityProviders) == 1:
|
|
defaultFederationDomainIdentityProvider.DisplayName = ldapIdentityProviders[0].Name
|
|
defaultFederationDomainIdentityProvider.UID = ldapIdentityProviders[0].UID
|
|
foundIDPName = ldapIdentityProviders[0].Name
|
|
case len(activeDirectoryIdentityProviders) == 1:
|
|
defaultFederationDomainIdentityProvider.DisplayName = activeDirectoryIdentityProviders[0].Name
|
|
defaultFederationDomainIdentityProvider.UID = activeDirectoryIdentityProviders[0].UID
|
|
foundIDPName = activeDirectoryIdentityProviders[0].Name
|
|
}
|
|
// Backwards compatibility mode always uses an empty identity transformation pipeline since no
|
|
// transformations are defined on the FederationDomain.
|
|
defaultFederationDomainIdentityProvider.Transforms = idtransform.NewTransformationPipeline()
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIdentityProvidersFound,
|
|
Status: configv1alpha1.ConditionTrue,
|
|
Reason: reasonLegacyConfigurationSuccess,
|
|
Message: fmt.Sprintf("no resources were specified by .spec.identityProviders[].objectRef but exactly one "+
|
|
"identity provider resource has been found: using %q as "+
|
|
"identity provider: please explicitly list identity providers in .spec.identityProviders "+
|
|
"(this legacy configuration mode may be removed in a future version of Pinniped)", foundIDPName),
|
|
})
|
|
case idpCRsCount > 1:
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIdentityProvidersFound,
|
|
Status: configv1alpha1.ConditionFalse,
|
|
Reason: reasonIdentityProviderNotSpecified, // vs LegacyConfigurationIdentityProviderNotFound as this is more specific
|
|
Message: fmt.Sprintf("no resources were specified by .spec.identityProviders[].objectRef "+
|
|
"and %q identity provider resources have been found: "+
|
|
"please update .spec.identityProviders to specify which identity providers "+
|
|
"this federation domain should use", idpCRsCount),
|
|
})
|
|
default:
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIdentityProvidersFound,
|
|
Status: configv1alpha1.ConditionFalse,
|
|
Reason: reasonLegacyConfigurationIdentityProviderNotFound,
|
|
Message: "no resources were specified by .spec.identityProviders[].objectRef and no identity provider " +
|
|
"resources have been found: please create an identity provider resource",
|
|
})
|
|
}
|
|
|
|
// This is the constructor for the backwards compatibility mode.
|
|
federationDomainIssuer, err := federationdomainproviders.NewFederationDomainIssuerWithDefaultIDP(federationDomain.Spec.Issuer, defaultFederationDomainIdentityProvider)
|
|
conditions = appendIssuerURLValidCondition(err, conditions)
|
|
|
|
conditions = appendIdentityProviderDuplicateDisplayNamesCondition(sets.Set[string]{}, conditions)
|
|
conditions = appendIdentityProviderObjectRefAPIGroupSuffixCondition(c.apiGroup, []string{}, conditions)
|
|
conditions = appendIdentityProviderObjectRefKindCondition(c.sortedAllowedKinds(), []string{}, conditions)
|
|
conditions = appendTransformsConstantsNamesUniqueCondition(sets.Set[string]{}, conditions)
|
|
|
|
return federationDomainIssuer, conditions, nil
|
|
}
|
|
|
|
func (c *federationDomainWatcherController) makeFederationDomainIssuerWithExplicitIDPs(
|
|
federationDomain *configv1alpha1.FederationDomain,
|
|
conditions []*configv1alpha1.Condition,
|
|
) (*federationdomainproviders.FederationDomainIssuer, []*configv1alpha1.Condition, error) {
|
|
federationDomainIdentityProviders := []*federationdomainproviders.FederationDomainIdentityProvider{}
|
|
idpNotFoundIndices := []int{}
|
|
displayNames := sets.Set[string]{}
|
|
duplicateDisplayNames := sets.Set[string]{}
|
|
badAPIGroupNames := []string{}
|
|
badKinds := []string{}
|
|
|
|
for index, idp := range federationDomain.Spec.IdentityProviders {
|
|
// The CRD requires the displayName field, and validates that it has at least one character,
|
|
// so here we only need to validate that they are unique.
|
|
if displayNames.Has(idp.DisplayName) {
|
|
duplicateDisplayNames.Insert(idp.DisplayName)
|
|
}
|
|
displayNames.Insert(idp.DisplayName)
|
|
|
|
// The objectRef is a required field in the CRD, so it will always exist in practice.
|
|
// objectRef.name and objectRef.kind are required, but may be empty strings.
|
|
// objectRef.apiGroup is not required, however, so it may be nil or empty string.
|
|
canTryToFindIDP := true
|
|
apiGroup := ""
|
|
if idp.ObjectRef.APIGroup != nil {
|
|
apiGroup = *idp.ObjectRef.APIGroup
|
|
}
|
|
if apiGroup != c.apiGroup {
|
|
badAPIGroupNames = append(badAPIGroupNames, apiGroup)
|
|
canTryToFindIDP = false
|
|
}
|
|
if !c.allowedKinds.Has(idp.ObjectRef.Kind) {
|
|
badKinds = append(badKinds, idp.ObjectRef.Kind)
|
|
canTryToFindIDP = false
|
|
}
|
|
|
|
var idpResourceUID types.UID
|
|
idpWasFound := false
|
|
if canTryToFindIDP {
|
|
var err error
|
|
// Validate that each objectRef resolves to an existing IDP. It does not matter if the IDP itself
|
|
// is phase=Ready, because it will not be loaded into the cache if not ready. For each objectRef
|
|
// that does not resolve, put an error on the FederationDomain status.
|
|
idpResourceUID, idpWasFound, err = c.findIDPsUIDByObjectRef(idp.ObjectRef, federationDomain.Namespace)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
if !canTryToFindIDP || !idpWasFound {
|
|
idpNotFoundIndices = append(idpNotFoundIndices, index)
|
|
}
|
|
|
|
var err error
|
|
var pipeline *idtransform.TransformationPipeline
|
|
pipeline, conditions, err = c.makeTransformationPipelineForIdentityProvider(idp, federationDomain.Name, conditions)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// For each valid IDP (unique displayName, valid objectRef + valid transforms), add it to the list.
|
|
federationDomainIdentityProviders = append(federationDomainIdentityProviders, &federationdomainproviders.FederationDomainIdentityProvider{
|
|
DisplayName: idp.DisplayName,
|
|
UID: idpResourceUID,
|
|
Transforms: pipeline,
|
|
})
|
|
plog.Debug("loaded FederationDomain identity provider",
|
|
"federationDomain", federationDomain.Name,
|
|
"identityProviderDisplayName", idp.DisplayName,
|
|
"identityProviderResourceUID", idpResourceUID,
|
|
)
|
|
}
|
|
|
|
if len(idpNotFoundIndices) != 0 {
|
|
msgs := []string{}
|
|
for _, idpNotFoundIndex := range idpNotFoundIndices {
|
|
msgs = append(msgs, fmt.Sprintf(".spec.identityProviders[%d] with displayName %q", idpNotFoundIndex,
|
|
federationDomain.Spec.IdentityProviders[idpNotFoundIndex].DisplayName))
|
|
}
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIdentityProvidersFound,
|
|
Status: configv1alpha1.ConditionFalse,
|
|
Reason: reasonIdentityProvidersObjectRefsNotFound,
|
|
Message: fmt.Sprintf(".spec.identityProviders[].objectRef identifies resource(s) that cannot be found: %s",
|
|
strings.Join(msgs, ", ")),
|
|
})
|
|
} else if len(federationDomain.Spec.IdentityProviders) != 0 {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIdentityProvidersFound,
|
|
Status: configv1alpha1.ConditionTrue,
|
|
Reason: reasonSuccess,
|
|
Message: "the resources specified by .spec.identityProviders[].objectRef were found",
|
|
})
|
|
}
|
|
|
|
// This is the constructor for any case other than the legacy case, including when there is an empty list of IDPs.
|
|
federationDomainIssuer, err := federationdomainproviders.NewFederationDomainIssuer(federationDomain.Spec.Issuer, federationDomainIdentityProviders)
|
|
conditions = appendIssuerURLValidCondition(err, conditions)
|
|
|
|
conditions = appendIdentityProviderDuplicateDisplayNamesCondition(duplicateDisplayNames, conditions)
|
|
conditions = appendIdentityProviderObjectRefAPIGroupSuffixCondition(c.apiGroup, badAPIGroupNames, conditions)
|
|
conditions = appendIdentityProviderObjectRefKindCondition(c.sortedAllowedKinds(), badKinds, conditions)
|
|
|
|
return federationDomainIssuer, conditions, nil
|
|
}
|
|
func (c *federationDomainWatcherController) findIDPsUIDByObjectRef(objectRef corev1.TypedLocalObjectReference, namespace string) (types.UID, bool, error) {
|
|
var idpResourceUID types.UID
|
|
var foundIDP metav1.Object
|
|
var err error
|
|
|
|
switch objectRef.Kind {
|
|
case kindLDAPIdentityProvider:
|
|
foundIDP, err = c.ldapIdentityProviderInformer.Lister().LDAPIdentityProviders(namespace).Get(objectRef.Name)
|
|
case kindActiveDirectoryIdentityProvider:
|
|
foundIDP, err = c.activeDirectoryIdentityProviderInformer.Lister().ActiveDirectoryIdentityProviders(namespace).Get(objectRef.Name)
|
|
case kindOIDCIdentityProvider:
|
|
foundIDP, err = c.oidcIdentityProviderInformer.Lister().OIDCIdentityProviders(namespace).Get(objectRef.Name)
|
|
default:
|
|
// This shouldn't happen because this helper function is not called when the kind is invalid.
|
|
return "", false, fmt.Errorf("unexpected kind: %s", objectRef.Kind)
|
|
}
|
|
|
|
switch {
|
|
case err == nil:
|
|
idpResourceUID = foundIDP.GetUID()
|
|
case errors.IsNotFound(err):
|
|
return "", false, nil
|
|
default:
|
|
return "", false, err // unexpected error from the informer
|
|
}
|
|
return idpResourceUID, true, nil
|
|
}
|
|
|
|
func (c *federationDomainWatcherController) makeTransformationPipelineForIdentityProvider(
|
|
idp configv1alpha1.FederationDomainIdentityProvider,
|
|
federationDomainName string,
|
|
conditions []*configv1alpha1.Condition,
|
|
) (*idtransform.TransformationPipeline, []*configv1alpha1.Condition, error) {
|
|
pipeline := idtransform.NewTransformationPipeline()
|
|
consts := &celtransformer.TransformationConstants{
|
|
StringConstants: map[string]string{},
|
|
StringListConstants: map[string][]string{},
|
|
}
|
|
constNames := sets.Set[string]{}
|
|
duplicateConstNames := sets.Set[string]{}
|
|
|
|
// Read all the declared constants.
|
|
for _, constant := range idp.Transforms.Constants {
|
|
// The CRD requires the name field, and validates that it has at least one character,
|
|
// so here we only need to validate that they are unique.
|
|
if constNames.Has(constant.Name) {
|
|
duplicateConstNames.Insert(constant.Name)
|
|
}
|
|
constNames.Insert(constant.Name)
|
|
switch constant.Type {
|
|
case "string":
|
|
consts.StringConstants[constant.Name] = constant.StringValue
|
|
case "stringList":
|
|
consts.StringListConstants[constant.Name] = constant.StringListValue
|
|
default:
|
|
// This shouldn't really happen since the CRD validates it, but handle it as an error.
|
|
return nil, nil, fmt.Errorf("one of spec.identityProvider[].transforms.constants[].type is invalid: %q", constant.Type)
|
|
}
|
|
}
|
|
conditions = appendTransformsConstantsNamesUniqueCondition(duplicateConstNames, conditions)
|
|
|
|
// Compile all the expressions and add them to the pipeline.
|
|
for idx, expr := range idp.Transforms.Expressions {
|
|
var rawTransform celtransformer.CELTransformation
|
|
switch expr.Type {
|
|
case "username/v1":
|
|
rawTransform = &celtransformer.UsernameTransformation{Expression: expr.Expression}
|
|
case "groups/v1":
|
|
rawTransform = &celtransformer.GroupsTransformation{Expression: expr.Expression}
|
|
case "policy/v1":
|
|
rawTransform = &celtransformer.AllowAuthenticationPolicy{
|
|
Expression: expr.Expression,
|
|
RejectedAuthenticationMessage: expr.Message,
|
|
}
|
|
default:
|
|
// This shouldn't really happen since the CRD validates it, but handle it as an error.
|
|
return nil, nil, fmt.Errorf("one of spec.identityProvider[].transforms.expressions[].type is invalid: %q", expr.Type)
|
|
}
|
|
compiledTransform, err := c.celTransformer.CompileTransformation(rawTransform, consts)
|
|
if err != nil {
|
|
// TODO: handle compile err
|
|
plog.Error("error compiling identity transformation", err,
|
|
"federationDomain", federationDomainName,
|
|
"idpDisplayName", idp.DisplayName,
|
|
"transformationIndex", idx,
|
|
"transformationType", expr.Type,
|
|
"transformationExpression", expr.Expression,
|
|
)
|
|
}
|
|
pipeline.AppendTransformation(compiledTransform)
|
|
plog.Debug("successfully compiled identity transformation expression",
|
|
"type", expr.Type,
|
|
"expr", expr.Expression,
|
|
"policyMessage", expr.Message,
|
|
)
|
|
}
|
|
|
|
// Run all the provided transform examples. If any fail, put errors on the FederationDomain status.
|
|
for idx, e := range idp.Transforms.Examples {
|
|
// TODO: use a real context param below
|
|
result, _ := pipeline.Evaluate(context.TODO(), e.Username, e.Groups)
|
|
// TODO: handle err
|
|
resultWasAuthRejected := !result.AuthenticationAllowed
|
|
if e.Expects.Rejected && !resultWasAuthRejected { //nolint:gocritic,nestif
|
|
// TODO: handle this failed example
|
|
plog.Warning("FederationDomain identity provider transformations example failed: expected authentication to be rejected but it was not",
|
|
"federationDomain", federationDomainName,
|
|
"idpDisplayName", idp.DisplayName,
|
|
"exampleIndex", idx,
|
|
"expectedRejected", e.Expects.Rejected,
|
|
"actualRejectedResult", resultWasAuthRejected,
|
|
"expectedMessage", e.Expects.Message,
|
|
"actualMessageResult", result.RejectedAuthenticationMessage,
|
|
)
|
|
} else if !e.Expects.Rejected && resultWasAuthRejected {
|
|
// TODO: handle this failed example
|
|
plog.Warning("FederationDomain identity provider transformations example failed: expected authentication not to be rejected but it was rejected",
|
|
"federationDomain", federationDomainName,
|
|
"idpDisplayName", idp.DisplayName,
|
|
"exampleIndex", idx,
|
|
"expectedRejected", e.Expects.Rejected,
|
|
"actualRejectedResult", resultWasAuthRejected,
|
|
"expectedMessage", e.Expects.Message,
|
|
"actualMessageResult", result.RejectedAuthenticationMessage,
|
|
)
|
|
} else if e.Expects.Rejected && resultWasAuthRejected && e.Expects.Message != result.RejectedAuthenticationMessage {
|
|
// TODO: when expected message is blank, then treat it like it expects the default message
|
|
// TODO: handle this failed example
|
|
plog.Warning("FederationDomain identity provider transformations example failed: expected a different authentication rejection message",
|
|
"federationDomain", federationDomainName,
|
|
"idpDisplayName", idp.DisplayName,
|
|
"exampleIndex", idx,
|
|
"expectedRejected", e.Expects.Rejected,
|
|
"actualRejectedResult", resultWasAuthRejected,
|
|
"expectedMessage", e.Expects.Message,
|
|
"actualMessageResult", result.RejectedAuthenticationMessage,
|
|
)
|
|
} else if result.AuthenticationAllowed {
|
|
// In the case where the user expected the auth to be allowed and it was allowed, then compare
|
|
// the expected username and group names to the actual username and group names.
|
|
// TODO: when both of these fail, put both errors onto the status (not just the first one)
|
|
if e.Expects.Username != result.Username {
|
|
// TODO: handle this failed example
|
|
plog.Warning("FederationDomain identity provider transformations example failed: expected a different transformed username",
|
|
"federationDomain", federationDomainName,
|
|
"idpDisplayName", idp.DisplayName,
|
|
"exampleIndex", idx,
|
|
"expectedUsername", e.Expects.Username,
|
|
"actualUsernameResult", result.Username,
|
|
)
|
|
}
|
|
if !stringSlicesEqual(e.Expects.Groups, result.Groups) {
|
|
// TODO: Do we need to make this insensitive to ordering, or should the transformations evaluator be changed to always return sorted group names at the end of the pipeline?
|
|
// TODO: What happens if the user did not write any group expectation? Treat it like expecting an empty list of groups?
|
|
// TODO: handle this failed example
|
|
plog.Warning("FederationDomain identity provider transformations example failed: expected a different transformed groups list",
|
|
"federationDomain", federationDomainName,
|
|
"idpDisplayName", idp.DisplayName,
|
|
"exampleIndex", idx,
|
|
"expectedGroups", e.Expects.Groups,
|
|
"actualGroupsResult", result.Groups,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return pipeline, conditions, nil
|
|
}
|
|
|
|
func (c *federationDomainWatcherController) sortedAllowedKinds() []string {
|
|
return sortAndQuote(c.allowedKinds.UnsortedList())
|
|
}
|
|
|
|
func appendIdentityProviderObjectRefKindCondition(expectedKinds []string, badSuffixNames []string, conditions []*configv1alpha1.Condition) []*configv1alpha1.Condition {
|
|
if len(badSuffixNames) > 0 {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIdentityProvidersObjectRefKindValid,
|
|
Status: configv1alpha1.ConditionFalse,
|
|
Reason: reasonKindUnrecognized,
|
|
Message: fmt.Sprintf("the kinds specified by .spec.identityProviders[].objectRef.kind are not recognized (should be one of %s): %s",
|
|
strings.Join(expectedKinds, ", "), strings.Join(sortAndQuote(badSuffixNames), ", ")),
|
|
})
|
|
} else {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIdentityProvidersObjectRefKindValid,
|
|
Status: configv1alpha1.ConditionTrue,
|
|
Reason: reasonSuccess,
|
|
Message: "the kinds specified by .spec.identityProviders[].objectRef.kind are recognized",
|
|
})
|
|
}
|
|
return conditions
|
|
}
|
|
|
|
func appendIdentityProviderObjectRefAPIGroupSuffixCondition(expectedSuffixName string, badSuffixNames []string, conditions []*configv1alpha1.Condition) []*configv1alpha1.Condition {
|
|
if len(badSuffixNames) > 0 {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIdentityProvidersAPIGroupSuffixValid,
|
|
Status: configv1alpha1.ConditionFalse,
|
|
Reason: reasonAPIGroupNameUnrecognized,
|
|
Message: fmt.Sprintf("the API groups specified by .spec.identityProviders[].objectRef.apiGroup are not recognized (should be %q): %s",
|
|
expectedSuffixName, strings.Join(sortAndQuote(badSuffixNames), ", ")),
|
|
})
|
|
} else {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIdentityProvidersAPIGroupSuffixValid,
|
|
Status: configv1alpha1.ConditionTrue,
|
|
Reason: reasonSuccess,
|
|
Message: "the API groups specified by .spec.identityProviders[].objectRef.apiGroup are recognized",
|
|
})
|
|
}
|
|
return conditions
|
|
}
|
|
|
|
func appendIdentityProviderDuplicateDisplayNamesCondition(duplicateDisplayNames sets.Set[string], conditions []*configv1alpha1.Condition) []*configv1alpha1.Condition {
|
|
if duplicateDisplayNames.Len() > 0 {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIdentityProvidersDisplayNamesUnique,
|
|
Status: configv1alpha1.ConditionFalse,
|
|
Reason: reasonDuplicateDisplayNames,
|
|
Message: fmt.Sprintf("the names specified by .spec.identityProviders[].displayName contain duplicates: %s",
|
|
strings.Join(sortAndQuote(duplicateDisplayNames.UnsortedList()), ", ")),
|
|
})
|
|
} else {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIdentityProvidersDisplayNamesUnique,
|
|
Status: configv1alpha1.ConditionTrue,
|
|
Reason: reasonSuccess,
|
|
Message: "the names specified by .spec.identityProviders[].displayName are unique",
|
|
})
|
|
}
|
|
return conditions
|
|
}
|
|
|
|
func appendTransformsConstantsNamesUniqueCondition(duplicateConstNames sets.Set[string], conditions []*configv1alpha1.Condition) []*configv1alpha1.Condition {
|
|
if duplicateConstNames.Len() > 0 {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeTransformsConstantsNamesUnique,
|
|
Status: configv1alpha1.ConditionFalse,
|
|
Reason: reasonDuplicateConstantsNames,
|
|
Message: fmt.Sprintf("the names specified by .spec.identityProviders[].transforms.constants[].name contain duplicates: %s",
|
|
strings.Join(sortAndQuote(duplicateConstNames.UnsortedList()), ", ")),
|
|
})
|
|
} else {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeTransformsConstantsNamesUnique,
|
|
Status: configv1alpha1.ConditionTrue,
|
|
Reason: reasonSuccess,
|
|
Message: "the names specified by .spec.identityProviders[].transforms.constants[].name are unique",
|
|
})
|
|
}
|
|
return conditions
|
|
}
|
|
|
|
func appendIssuerURLValidCondition(err error, conditions []*configv1alpha1.Condition) []*configv1alpha1.Condition {
|
|
if err != nil {
|
|
// Note that the FederationDomainIssuer constructors only validate the Issuer URL,
|
|
// so these are always issuer URL validation errors.
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIssuerURLValid,
|
|
Status: configv1alpha1.ConditionFalse,
|
|
Reason: reasonInvalidIssuerURL,
|
|
Message: err.Error(),
|
|
})
|
|
} else {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIssuerURLValid,
|
|
Status: configv1alpha1.ConditionTrue,
|
|
Reason: reasonSuccess,
|
|
Message: "spec.issuer is a valid URL",
|
|
})
|
|
}
|
|
return conditions
|
|
}
|
|
|
|
func sortAndQuote(strs []string) []string {
|
|
quoted := make([]string, 0, len(strs))
|
|
for _, s := range strs {
|
|
quoted = append(quoted, fmt.Sprintf("%q", s))
|
|
}
|
|
sort.Strings(quoted)
|
|
return quoted
|
|
}
|
|
|
|
func (c *federationDomainWatcherController) updateStatus(
|
|
ctx context.Context,
|
|
federationDomain *configv1alpha1.FederationDomain,
|
|
conditions []*configv1alpha1.Condition,
|
|
) error {
|
|
updated := federationDomain.DeepCopy()
|
|
|
|
if hadErrorCondition(conditions) {
|
|
updated.Status.Phase = configv1alpha1.FederationDomainPhaseError
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeReady,
|
|
Status: configv1alpha1.ConditionFalse,
|
|
Reason: reasonNotReady,
|
|
Message: "the FederationDomain is not ready: see other conditions for details",
|
|
})
|
|
} else {
|
|
updated.Status.Phase = configv1alpha1.FederationDomainPhaseReady
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeReady,
|
|
Status: configv1alpha1.ConditionTrue,
|
|
Reason: reasonSuccess,
|
|
Message: fmt.Sprintf("the FederationDomain is ready and its endpoints are available: "+
|
|
"the discovery endpoint is %s/.well-known/openid-configuration", federationDomain.Spec.Issuer),
|
|
})
|
|
}
|
|
|
|
_ = conditionsutil.MergeConfigConditions(conditions,
|
|
federationDomain.Generation, &updated.Status.Conditions, plog.New(), metav1.NewTime(c.clock.Now()))
|
|
|
|
if equality.Semantic.DeepEqual(federationDomain, updated) {
|
|
return nil
|
|
}
|
|
|
|
_, err := c.client.
|
|
ConfigV1alpha1().
|
|
FederationDomains(federationDomain.Namespace).
|
|
UpdateStatus(ctx, updated, metav1.UpdateOptions{})
|
|
return err
|
|
}
|
|
|
|
type crossFederationDomainConfigValidator struct {
|
|
issuerCounts map[string]int
|
|
uniqueSecretNamesPerIssuerAddress map[string]map[string]bool
|
|
}
|
|
|
|
func issuerURLToHostnameKey(issuerURL *url.URL) string {
|
|
return lowercaseHostWithoutPort(issuerURL)
|
|
}
|
|
|
|
func issuerURLToIssuerKey(issuerURL *url.URL) string {
|
|
return fmt.Sprintf("%s://%s%s", issuerURL.Scheme, strings.ToLower(issuerURL.Host), issuerURL.Path)
|
|
}
|
|
|
|
func (v *crossFederationDomainConfigValidator) Validate(federationDomain *configv1alpha1.FederationDomain, conditions []*configv1alpha1.Condition) []*configv1alpha1.Condition {
|
|
issuerURL, urlParseErr := url.Parse(federationDomain.Spec.Issuer)
|
|
|
|
if urlParseErr != nil {
|
|
// Don't write a condition about the issuer URL being invalid because that is added elsewhere in the controller.
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIssuerIsUnique,
|
|
Status: configv1alpha1.ConditionUnknown,
|
|
Reason: reasonUnableToValidate,
|
|
Message: "unable to check if spec.issuer is unique among all FederationDomains because URL cannot be parsed",
|
|
})
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeOneTLSSecretPerIssuerHostname,
|
|
Status: configv1alpha1.ConditionUnknown,
|
|
Reason: reasonUnableToValidate,
|
|
Message: "unable to check if all FederationDomains are using the same TLS secret when using the same hostname in the spec.issuer URL because URL cannot be parsed",
|
|
})
|
|
return conditions
|
|
}
|
|
|
|
if issuerCount := v.issuerCounts[issuerURLToIssuerKey(issuerURL)]; issuerCount > 1 {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIssuerIsUnique,
|
|
Status: configv1alpha1.ConditionFalse,
|
|
Reason: reasonDuplicateIssuer,
|
|
Message: "multiple FederationDomains have the same spec.issuer URL: these URLs must be unique (can use different hosts or paths)",
|
|
})
|
|
} else {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeIssuerIsUnique,
|
|
Status: configv1alpha1.ConditionTrue,
|
|
Reason: reasonSuccess,
|
|
Message: "spec.issuer is unique among all FederationDomains",
|
|
})
|
|
}
|
|
|
|
if len(v.uniqueSecretNamesPerIssuerAddress[issuerURLToHostnameKey(issuerURL)]) > 1 {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeOneTLSSecretPerIssuerHostname,
|
|
Status: configv1alpha1.ConditionFalse,
|
|
Reason: reasonDifferentSecretRefsFound,
|
|
Message: "when different FederationDomains are using the same hostname in the spec.issuer URL then they must also use the same TLS secretRef: different secretRefs found",
|
|
})
|
|
} else {
|
|
conditions = append(conditions, &configv1alpha1.Condition{
|
|
Type: typeOneTLSSecretPerIssuerHostname,
|
|
Status: configv1alpha1.ConditionTrue,
|
|
Reason: reasonSuccess,
|
|
Message: "all FederationDomains are using the same TLS secret when using the same hostname in the spec.issuer URL",
|
|
})
|
|
}
|
|
|
|
return conditions
|
|
}
|
|
|
|
func newCrossFederationDomainConfigValidator(federationDomains []*configv1alpha1.FederationDomain) *crossFederationDomainConfigValidator {
|
|
// Make a map of issuer strings -> count of how many times we saw that issuer string.
|
|
// This will help us complain when there are duplicate issuer strings.
|
|
// Also make a helper function for forming keys into this map.
|
|
issuerCounts := make(map[string]int)
|
|
|
|
// Make a map of issuer hostnames -> set of unique secret names. This will help us complain when
|
|
// multiple FederationDomains have the same issuer hostname (excluding port) but specify
|
|
// different TLS serving Secrets. Doesn't make sense to have the one address use more than one
|
|
// TLS cert. Ignore ports because SNI information on the incoming requests is not going to include
|
|
// port numbers. Also make a helper function for forming keys into this map.
|
|
uniqueSecretNamesPerIssuerAddress := make(map[string]map[string]bool)
|
|
|
|
for _, federationDomain := range federationDomains {
|
|
issuerURL, err := url.Parse(federationDomain.Spec.Issuer)
|
|
if err != nil {
|
|
continue // Skip url parse errors because they will be handled in the Validate function.
|
|
}
|
|
|
|
issuerCounts[issuerURLToIssuerKey(issuerURL)]++
|
|
|
|
setOfSecretNames := uniqueSecretNamesPerIssuerAddress[issuerURLToHostnameKey(issuerURL)]
|
|
if setOfSecretNames == nil {
|
|
setOfSecretNames = make(map[string]bool)
|
|
uniqueSecretNamesPerIssuerAddress[issuerURLToHostnameKey(issuerURL)] = setOfSecretNames
|
|
}
|
|
if federationDomain.Spec.TLS != nil {
|
|
setOfSecretNames[federationDomain.Spec.TLS.SecretName] = true
|
|
}
|
|
}
|
|
|
|
return &crossFederationDomainConfigValidator{
|
|
issuerCounts: issuerCounts,
|
|
uniqueSecretNamesPerIssuerAddress: uniqueSecretNamesPerIssuerAddress,
|
|
}
|
|
}
|
|
|
|
func hadErrorCondition(conditions []*configv1alpha1.Condition) bool {
|
|
for _, c := range conditions {
|
|
if c.Status != configv1alpha1.ConditionTrue {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func stringSlicesEqual(a []string, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i, itemFromA := range a {
|
|
if b[i] != itemFromA {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|