// 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 }